├── .nvmrc ├── setupJest.js ├── .coveralls.yml ├── docs ├── data flow.png ├── README.md ├── 7-Contributing.md ├── 6-How-it-works.md ├── 4-Chaining-Events.md ├── 2-HTTP-Events.md ├── 1-Basic-Usage.md ├── 3-Advanced-Usage.md └── 5-API-docs.md ├── .prettierrc ├── labcodes-github-banner.jpg ├── .travis.yml ├── .editorconfig ├── .eslintrc.js ├── .babelrc ├── LICENSE ├── .gitignore ├── index.js ├── package.json ├── lib ├── helpers.js └── events.js ├── dist ├── helpers.js └── events.js ├── __tests__ ├── helpers.test.js └── events.test.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /setupJest.js: -------------------------------------------------------------------------------- 1 | global.fetch = require('jest-fetch-mock'); 2 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: AcGQUiDQL1NAcgkU5YCiApuPhpUGv9y6v 2 | -------------------------------------------------------------------------------- /docs/data flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labcodes/rel-events/HEAD/docs/data flow.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /labcodes-github-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labcodes/rel-events/HEAD/labcodes-github-banner.jpg -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "lts/*" 4 | script: 5 | - npm run test 6 | before_script: 7 | - yarn run lint 8 | after_script: 9 | - yarn run coveralls 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-base', 'prettier'], 3 | plugins: ['prettier'], 4 | parser: 'babel-eslint', 5 | rules: { 6 | 'prettier/prettier': 'error', 7 | 'arrow-body-style': ['error', 'as-needed'], 8 | 'no-plusplus': 'off', 9 | 'no-param-reassign': 'off', 10 | 'no-prototype-builtins': 'off', 11 | 'no-restricted-properties': 'off', 12 | 'no-underscore-dangle': 'off', 13 | 'array-callback-return': 'off', 14 | 'camelcase': 'off', 15 | 'no-console': ['error', { allow: ['error'] }], 16 | 'class-methods-use-this': 'off' 17 | }, 18 | env: { 19 | es6: true, 20 | jest: true, 21 | browser: true, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": "IE 11" 8 | } 9 | } 10 | ] 11 | ], 12 | "env": { 13 | "production": { 14 | "plugins": [ 15 | "@babel/plugin-transform-runtime", 16 | [ 17 | "@babel/plugin-proposal-class-properties", 18 | { 19 | "loose": true 20 | } 21 | ] 22 | ] 23 | }, 24 | "test": { 25 | "plugins": [ 26 | "@babel/plugin-transform-runtime", 27 | "rewire", 28 | [ 29 | "@babel/plugin-proposal-class-properties", 30 | { 31 | "loose": true 32 | } 33 | ] 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # rel-events docs 2 | 3 | This folder contains all the docs on the current API and how to use the `rel-events` library :D 4 | 5 | - [1: Basic Usage](https://github.com/labcodes/rel-events/tree/master/docs/1-Basic-Usage.md) 6 | - [2: HTTP Events](https://github.com/labcodes/rel-events/tree/master/docs/2-HTTP-Events.md) 7 | - [3: Advanced Usage](https://github.com/labcodes/rel-events/tree/master/docs/3-Advanced-Usage.md) 8 | - [4: Chaining Events](https://github.com/labcodes/rel-events/tree/master/docs/4-Chaining-Events.md) 9 | - [5: API docs](https://github.com/labcodes/rel-events/tree/master/docs/5-API-docs.md) 10 | - [6: How it works](https://github.com/labcodes/rel-events/tree/master/docs/6-How-it-works.md) 11 | - [7: Contributing](https://github.com/labcodes/rel-events/tree/master/docs/7-Contributing.md) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Luciano Ratamero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # VS Code settings 15 | .vscode 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | yarn.lock 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | Object.defineProperty(exports, "eventsMiddleware", { 7 | enumerable: true, 8 | get: function get() { 9 | return _middleware.default; 10 | } 11 | }); 12 | Object.defineProperty(exports, "fetchFromApi", { 13 | enumerable: true, 14 | get: function get() { 15 | return _api.default; 16 | } 17 | }); 18 | Object.defineProperty(exports, "Event", { 19 | enumerable: true, 20 | get: function get() { 21 | return _events.Event; 22 | } 23 | }); 24 | Object.defineProperty(exports, "HTTPEvent", { 25 | enumerable: true, 26 | get: function get() { 27 | return _events.HTTPEvent; 28 | } 29 | }); 30 | Object.defineProperty(exports, "getCurrentStateFromEvent", { 31 | enumerable: true, 32 | get: function get() { 33 | return _helpers.getCurrentStateFromEvent; 34 | } 35 | }); 36 | Object.defineProperty(exports, "dispatchEvent", { 37 | enumerable: true, 38 | get: function get() { 39 | return _helpers.dispatchEvent; 40 | } 41 | }); 42 | Object.defineProperty(exports, "combineEventReducers", { 43 | enumerable: true, 44 | get: function get() { 45 | return _helpers.combineEventReducers; 46 | } 47 | }); 48 | 49 | var _middleware = _interopRequireDefault(require("react-redux-api-tools/dist/middleware")); 50 | 51 | var _api = _interopRequireDefault(require("react-redux-api-tools/dist/api")); 52 | 53 | var _events = require("./dist/events"); 54 | 55 | var _helpers = require("./dist/helpers"); 56 | 57 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 58 | -------------------------------------------------------------------------------- /docs/7-Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thanks for wanting to contribute with rel-events! :) 4 | 5 | Before I go into the details, I need to make the standard disclaimers: 6 | - if you would like a new feature, it would be nice to discuss it before we accept any incoming PRs. We reserve ourselves the right to reject a feature that was not discussed or that will impact the code or API in a meaningful way. In that case, open an issue so we can discuss it thoroughly. 7 | - be nice to each other, please! No misogyny, racism, ableism or any kind of discrimination will be tolerated here. 8 | 9 | With that said, let's start! 10 | 11 | ### How do I contribute? 12 | 13 | The first step to contribute is to fork the project to your github account. You can do that by clicking the 'Fork' button at the top of the page (up there ^). 14 | 15 | With that done, you'll have your copy of the project to play around. You then need to clone your fork, by running `git clone ` on your terminal. 16 | 17 | Then, with your branch cloned locally, go inside the project folder (`cd` into it) and install the development dependencies. For that, you'll need to have [node and npm set up](https://nodejs.org/en/download/current/). We recommend getting the "Current" version or, even better, the version described inside the `.nvmrc` file on the root folder. 18 | 19 | Now you should have the `npm` command on your terminal. Inside the project's folder, run `npm install --dev` to install the project's dependencies. After it runs, you'll have a `node_modules` folder. Run `npm test` just to be sure everything installed correctly. 20 | 21 | You're ready to go! Create yourself a new branch for your feature/bugfix and commit what you need. We added a git hook that will prevent you from commiting if the tests and linting fail, though, so be aware of that. I personally like to run `npm test` and `npm run lint` before every commit. When it's ready to be merged, push your feature/bugfix branch to your fork and open a Pull Request on github, targeting our `master` branch. 22 | 23 | That's about it! Any questions, don't hesitate to contact me at `luciano@labcodes.com.br`. Thanks, and hope it all goes well :D 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rel-events", 3 | "version": "0.4.6", 4 | "description": "The relevant React Events Library. Events framework based on redux to decouple our from business logic and make state management easy.", 5 | "main": "index.js", 6 | "scripts": { 7 | "coveralls": "npm run test && cat ./coverage/lcov.info | coveralls", 8 | "lint": "NODE_ENV=production eslint lib/** __tests__/**", 9 | "test": "NODE_ENV=test npm run dist && jest --coverage", 10 | "jest": "NODE_ENV=test npm run dist && jest", 11 | "dist": "NODE_ENV=production ./node_modules/.bin/babel lib -d dist", 12 | "test_debug": "NODE_ENV=test node --inspect node_modules/.bin/jest --watch" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/labcodes/rel-event.git" 17 | }, 18 | "keywords": [ 19 | "react", 20 | "redux", 21 | "api", 22 | "tools", 23 | "middleware" 24 | ], 25 | "author": "Luciano Ratamero", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/labcodes/rel-event/issues" 29 | }, 30 | "homepage": "https://github.com/labcodes/rel-event#readme", 31 | "devDependencies": { 32 | "@babel/cli": "^7.8.4", 33 | "@babel/core": "^7.9.6", 34 | "@babel/plugin-proposal-class-properties": "^7.8.3", 35 | "@babel/plugin-transform-runtime": "^7.9.6", 36 | "@babel/preset-env": "^7.9.6", 37 | "@babel/runtime": "^7.9.6", 38 | "babel-eslint": "^10.1.0", 39 | "babel-plugin-rewire": "^1.2.0", 40 | "coveralls": "^3.1.0", 41 | "eslint": "^6.0.1", 42 | "eslint-config-airbnb-base": "^13.2.0", 43 | "eslint-config-prettier": "^6.11.0", 44 | "eslint-plugin-import": "^2.20.2", 45 | "eslint-plugin-prettier": "^3.1.3", 46 | "husky": "^3.0.1", 47 | "jest": "^26.0.1", 48 | "jest-fetch-mock": "^2.1.2", 49 | "prettier": "^1.18.2", 50 | "react": "^16.13.1", 51 | "react-dom": "^16.13.1", 52 | "react-redux": "^5", 53 | "redux": "^4.0.4" 54 | }, 55 | "jest": { 56 | "automock": false, 57 | "setupFiles": [ 58 | "/setupJest.js" 59 | ], 60 | "coverageThreshold": { 61 | "global": { 62 | "branches": 100, 63 | "functions": 100, 64 | "lines": 100, 65 | "statements": 100 66 | } 67 | } 68 | }, 69 | "husky": { 70 | "hooks": { 71 | "pre-commit": "npm run lint && npm run test && git add dist" 72 | } 73 | }, 74 | "peerDependencies": { 75 | "react-redux": "~5" 76 | }, 77 | "dependencies": { 78 | "lodash.debounce": "^4.0.8", 79 | "react-redux-api-tools": "^2.1.1" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | export function _combineConflictingReducers(reducers = []) { 2 | // needed for compatibility 3 | const reducersArray = reducers.map(reducer => Object.values(reducer)[0]); 4 | 5 | return (state, action) => { 6 | for (let i = 0; i < reducersArray.length; i++) { 7 | const reducer = reducersArray[i]; 8 | state = reducer(state, action); 9 | } 10 | return state; 11 | }; 12 | } 13 | 14 | export function combineEventReducers(events = []) { 15 | const conflictingEventsAndKeys = {}; 16 | const combinedReducers = {}; 17 | 18 | events.forEach(event => { 19 | if (event.useDataFrom) { 20 | if (!conflictingEventsAndKeys[event.useDataFrom]) { 21 | conflictingEventsAndKeys[event.useDataFrom] = []; 22 | } 23 | conflictingEventsAndKeys[event.useDataFrom].push(event); 24 | } else { 25 | // eslint-disable-next-line prefer-destructuring 26 | combinedReducers[event.name] = Object.values(event.createReducers())[0]; 27 | } 28 | }); 29 | 30 | Object.keys(conflictingEventsAndKeys).forEach(eventName => { 31 | let baseEvent = events.filter(event => event.name === eventName); 32 | 33 | if (!baseEvent.length) { 34 | throw new Error(`Event with ${eventName} name not found.`); 35 | } 36 | 37 | // eslint-disable-next-line prefer-destructuring 38 | baseEvent = baseEvent[0]; 39 | 40 | combinedReducers[eventName] = _combineConflictingReducers([ 41 | baseEvent.createReducers(), 42 | ...conflictingEventsAndKeys[eventName].map(event => event.createReducers()), 43 | ]); 44 | }); 45 | 46 | return combinedReducers; 47 | } 48 | 49 | export function dispatchEvent({ event, store, data }) { 50 | if (!event) { 51 | throw new Error('You need to pass an event.'); 52 | } else if (typeof event.toRedux !== 'function') { 53 | throw new Error( 54 | 'The event you passed needs to have a `toRedux` method. Are you sure you instantiated and passed the correct event?', 55 | ); 56 | } 57 | 58 | if (!store) { 59 | throw new Error('You need to pass your redux store.'); 60 | } else if (typeof store.dispatch !== 'function') { 61 | throw new Error( 62 | 'The store you passed does not have a `dispatch` method. Are you sure you passed the correct variable as the store?', 63 | ); 64 | } 65 | 66 | return store.dispatch(event.toRedux(data)); 67 | } 68 | 69 | export function getCurrentStateFromEvent({ appState, event }) { 70 | if (!event) { 71 | throw new Error('You need to pass an event.'); 72 | } 73 | 74 | if (!appState) { 75 | throw new Error( 76 | 'You need to pass your app state. This is only available inside `shouldDispatch` methods or imported manually (not recommended).', 77 | ); 78 | } 79 | 80 | return appState[event.name]; 81 | } 82 | -------------------------------------------------------------------------------- /docs/6-How-it-works.md: -------------------------------------------------------------------------------- 1 | # How it works 2 | 3 |
4 | 5 | `rel-events` is a small, thin layer over `redux`, and aims to present a more compelling, simple API to deal with data on `react` apps. We at Labcodes value code readability and low cognitive burden, and we believe that code needs to be as simple as it gets. 6 | 7 | To do that, both the Event and the HTTPEvent classes, together with `react-redux-api-tools`' middleware, contain all the repetitive and verbose logic from redux. 8 | 9 | When we create a new Event, it gets ready to give redux what it needs, when it needs it: 10 | 11 | - first, it offers the `createReducers` method, which gives redux the *store name*, the *action names* and the *reducer handlers* for a specific Event, so you don't need to repeat reducers names anywhere; 12 | - then, it offers the `fetchFromApi` helper and the `apiMiddleware` from `react-redux-api-tools` as its own, so you don't need to know about multiple packages. The middleware is the one that does all the heavy lifting inside redux for it to do that it needs to do when dealing with HTTP requests; 13 | - finally, we wrap redux's `connect` inside the `register` method, so people stop being confused with weird and repetitive declarations from `mapStateToProps`, `mapDispatchToProps`, `bindActionCreators`, etc. 14 | 15 | 16 | So when you trigger an Event inside your Component, the following occurs: 17 | 18 | - we pass the data you passed to it to the Event's `toRedux` method; 19 | - the return is passed to redux's `dispatch`, which triggers redux's flow (this is the same as dispatching an action on redux); 20 | - redux will trigger the reducer with the correct name, since we mapped the Event inside redux's reducers. That means that, as soon as the event is dispatched, the `onDispatch` method will be called; 21 | - if the Event Manager has an `afterDispatch` method, it's async called; 22 | - the store is updated with the new data. 23 | 24 | If we're dealing with a HTTPEvent, the flow is a little bit more complicated: 25 | 26 | - it executes the event as described above, (so it calls the `onDispatch` method and persists it's data inside redux's store); 27 | - then, it triggers the `call` method to begin the request. Internally, it saves the request promise on memory and awaits for it. This is done entirely by `react-redux-api-tools`' middleware; 28 | - as soon as the request is done, the request promise is evaluated. If it's resolved, the `onSuccess` reducer is called - if not, `onFailure` is called; 29 | - with the new data at hand, it checks if there are any `afterSuccess` or `afterFailure` methods on the Event Manager. If so, it async calls them accordingly; 30 | - finally, each event checks if they are listening to this specific reducer and, if so, it async dispatches itself, passing the new data. The new data then is saved on redux's store. 31 | 32 | The final flow is something like this. The middle bubbles are, well, the middleware: 33 | 34 | 35 | 36 | If you still want to better understand this lib, I really invite you to check out the source code from the Event and HTTPEvent so you understand better the pros and cons to this approach. :) 37 | 38 | And if you want to understand the whys, take a look at [our blog post about that](https://labcodes.com.br/blog/en/decoupling-logic-from-react-components.html)! Thanks <3 39 | -------------------------------------------------------------------------------- /dist/helpers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports._combineConflictingReducers = _combineConflictingReducers; 9 | exports.combineEventReducers = combineEventReducers; 10 | exports.dispatchEvent = dispatchEvent; 11 | exports.getCurrentStateFromEvent = getCurrentStateFromEvent; 12 | 13 | var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray")); 14 | 15 | function _combineConflictingReducers() { 16 | var reducers = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; 17 | // needed for compatibility 18 | var reducersArray = reducers.map(function (reducer) { 19 | return Object.values(reducer)[0]; 20 | }); 21 | return function (state, action) { 22 | for (var i = 0; i < reducersArray.length; i++) { 23 | var reducer = reducersArray[i]; 24 | state = reducer(state, action); 25 | } 26 | 27 | return state; 28 | }; 29 | } 30 | 31 | function combineEventReducers() { 32 | var events = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; 33 | var conflictingEventsAndKeys = {}; 34 | var combinedReducers = {}; 35 | events.forEach(function (event) { 36 | if (event.useDataFrom) { 37 | if (!conflictingEventsAndKeys[event.useDataFrom]) { 38 | conflictingEventsAndKeys[event.useDataFrom] = []; 39 | } 40 | 41 | conflictingEventsAndKeys[event.useDataFrom].push(event); 42 | } else { 43 | // eslint-disable-next-line prefer-destructuring 44 | combinedReducers[event.name] = Object.values(event.createReducers())[0]; 45 | } 46 | }); 47 | Object.keys(conflictingEventsAndKeys).forEach(function (eventName) { 48 | var baseEvent = events.filter(function (event) { 49 | return event.name === eventName; 50 | }); 51 | 52 | if (!baseEvent.length) { 53 | throw new Error("Event with ".concat(eventName, " name not found.")); 54 | } // eslint-disable-next-line prefer-destructuring 55 | 56 | 57 | baseEvent = baseEvent[0]; 58 | combinedReducers[eventName] = _combineConflictingReducers([baseEvent.createReducers()].concat((0, _toConsumableArray2.default)(conflictingEventsAndKeys[eventName].map(function (event) { 59 | return event.createReducers(); 60 | })))); 61 | }); 62 | return combinedReducers; 63 | } 64 | 65 | function dispatchEvent(_ref) { 66 | var event = _ref.event, 67 | store = _ref.store, 68 | data = _ref.data; 69 | 70 | if (!event) { 71 | throw new Error('You need to pass an event.'); 72 | } else if (typeof event.toRedux !== 'function') { 73 | throw new Error('The event you passed needs to have a `toRedux` method. Are you sure you instantiated and passed the correct event?'); 74 | } 75 | 76 | if (!store) { 77 | throw new Error('You need to pass your redux store.'); 78 | } else if (typeof store.dispatch !== 'function') { 79 | throw new Error('The store you passed does not have a `dispatch` method. Are you sure you passed the correct variable as the store?'); 80 | } 81 | 82 | return store.dispatch(event.toRedux(data)); 83 | } 84 | 85 | function getCurrentStateFromEvent(_ref2) { 86 | var appState = _ref2.appState, 87 | event = _ref2.event; 88 | 89 | if (!event) { 90 | throw new Error('You need to pass an event.'); 91 | } 92 | 93 | if (!appState) { 94 | throw new Error('You need to pass your app state. This is only available inside `shouldDispatch` methods or imported manually (not recommended).'); 95 | } 96 | 97 | return appState[event.name]; 98 | } -------------------------------------------------------------------------------- /docs/4-Chaining-Events.md: -------------------------------------------------------------------------------- 1 | 2 | ### Event Chaining - Making Events listen to Events 3 | 4 | Sometimes, we want to trigger an Event at the completion of another. Let's imagine that, after logging in with an user, we need to fetch the user's data from the API. 5 | 6 | Assuming you've implemented the `LoginHTTPEvent`, you'll need to implement your new Event and EventManager to fetch the user's data. After implementing, you just need to pass the `listenTo` key to your new event. 7 | 8 | ```js 9 | // on events.js 10 | import { HTTPEvent } from 'rel-events'; 11 | import { LoginHTTPEventManager, FetchUserDataHTTPEventManager } from './eventManagers.js'; 12 | 13 | export const LoginHTTPEvent = new HTTPEvent({ 14 | name: 'login', 15 | manager: new LoginHTTPEventManager(), 16 | }); 17 | 18 | // to chain an event to another, declare the `listenTo` key. 19 | // and, yes, you may make an event listen to multiple events. 20 | // be careful not to pass the direct reference to LoginHTTPEvent; 21 | // pass a function that returns it instead. 22 | export const FetchUserDataHTTPEvent = new HTTPEvent({ 23 | name: 'fetchUserData', 24 | manager: new FetchUserDataHTTPEventManager(), 25 | listenTo: [ 26 | { event: () => LoginHTTPEvent, triggerOn: 'success' }, 27 | ] 28 | }); 29 | ``` 30 | 31 | That means that, whenever the login is successful, `fetchUserData` will be triggered by calling `FetchUserDataHTTPEventManager.call` passing the data from the `LoginHTTPEvent.onSuccess` return. 32 | 33 | **One caveat** is that the `event` value is **not** a direct reference to the Event that will be listened to. Instead, it's a function that returns the reference. That's needed because we could be using multiple files for multiple Events, and, if we do, we can't guarantee that `FetchUserDataHTTPEvent` will be loaded into memory before `LoginHTTPEvent`. If that happened, the `event` value would be `undefined`, so we chose to receive a function that returnsthe reference instead. 34 | 35 | ### Autocomplete Event calls using cached arguments 36 | 37 | There may be times when you may have an Event listening to multiple Events. Let's say, for example, that you want to get a client's profile whenever you search for their name, but you want to fetch it again if you change dates on a datepicker (or change the selected client, of course). You'll then have three Events: the datepicker one, the client search one and the client profile one, as follows: 38 | 39 | ```js 40 | // on events.js 41 | import { Event, HTTPEvent } from 'rel-events'; 42 | import { 43 | ChooseDateRangeEventManager, 44 | SearchByClientHTTPEventManager, 45 | GetClientProfileHTTPEventManager, 46 | } from './eventManagers.js'; 47 | 48 | export const ChooseDateRangeEvent = new Event({ 49 | name: 'chooseDateRange', 50 | manager: new ChooseDateRangeEventManager(), 51 | }); 52 | 53 | export const SearchByClientHTTPEvent = new HTTPEvent({ 54 | name: 'searchByClient', 55 | manager: new SearchByClientHTTPEventManager(), 56 | }); 57 | 58 | export const GetClientProfileHTTPEvent = new HTTPEvent({ 59 | name: 'getClientProfile', 60 | manager: new GetClientProfileHTTPEventManager(), 61 | listenTo: [ 62 | { event: () => ChooseDateRangeEvent, triggerOn: 'dispatch' }, 63 | { event: () => SearchByClientHTTPEvent, triggerOn: 'success' }, 64 | ] 65 | }); 66 | ``` 67 | 68 | Since each Event has its own data (and we want to keep it that way), we now have a problem: since getting the client profile depends on both the daterange and the selected client on the search, whenever one of the Events triggers the other, it will never pass the whole data the other needs to call itself. You may be tempted to replicate data on multiple Events just for that case. 69 | 70 | Instead, you may want an Event to be able to autocomplete new call arguments with the ones from the last call, so it will always have the full picture. That way, **whenever new dates are passed to the `GetClientProfileHTTPEvent`, it will remember which client was selected previously, and vice versa**. To enable that, just pass the `autocompleteCallArgs` as true when listening to an Event: 71 | 72 | ```js 73 | // on events.js 74 | 75 | // ... 76 | 77 | export const GetClientProfileHTTPEvent = new HTTPEvent({ 78 | name: 'getClientProfile', 79 | manager: new GetClientProfileHTTPEventManager(), 80 | listenTo: [ 81 | { event: () => ChooseDateRangeEvent, triggerOn: 'dispatch', autocompleteCallArgs: true }, 82 | { event: () => SearchByClientHTTPEvent, triggerOn: 'success', autocompleteCallArgs: true }, 83 | ] 84 | }); 85 | ``` 86 | 87 | **NOTE:** We will probably favor this behavior as default on next major releases. 88 | -------------------------------------------------------------------------------- /docs/2-HTTP-Events.md: -------------------------------------------------------------------------------- 1 | ## Creating a HTTPEvent 2 | 3 | The idea behind this library is to make data management easy and semantic, so we thought it would be best to include a special type of Event for making HTTP requests. 4 | 5 | First, if you want to use HTTPEvents, be sure to add the `eventsMiddleware` to your app's redux store when creating it. It does require you to have `redux` and `redux-thunk` installed. 6 | 7 | ```js 8 | import thunk from 'redux-thunk'; 9 | import { eventsMiddleware } from 'rel-events'; 10 | import { createStore, applyMiddleware } from 'redux'; 11 | 12 | import rootReducer from './myAppRootReducers'; 13 | 14 | export const store = createStore( 15 | rootReducer, 16 | applyMiddleware(thunk, eventsMiddleware), 17 | ); 18 | ``` 19 | 20 | A `HTTPEvent` has the very same API as the basic `Event`, but instead of having a manager with only an `onDispatch` method, we'll need to implement 4 methods: `onDispatch`. `onSuccess`, `onFailure` and `call`. 21 | 22 | ```js 23 | // on eventManagers.js 24 | import { fetchFromApi } from 'rel-events'; 25 | 26 | export class LoginHTTPEventManager { 27 | initialState = { isLoading: false, username: 'Anonymous' }; 28 | 29 | call = (user) => { 30 | return () => fetchFromApi( 31 | '/api/login', 32 | { method: 'POST', body: JSON.stringify(user) } 33 | ); 34 | } 35 | 36 | onDispatch = (state, event) => ({ 37 | ...state, 38 | isLoading: true, 39 | username: this.initialState.username 40 | }) 41 | 42 | onSuccess = (state, event) => ({ 43 | ...state, 44 | isLoading: false, 45 | username: event.response.data.username, 46 | }) 47 | 48 | onFailure = (state, event) => ({ 49 | ...state, 50 | isLoading: false, 51 | username: this.initialState.username, 52 | error: event.error.data, 53 | }) 54 | } 55 | 56 | // on events.js 57 | import { HTTPEvent } from 'rel-events'; 58 | import { LoginHTTPEventManager } from './eventManagers.js'; 59 | 60 | // notice we're using a HTTPEvent instead of a regular Event 61 | export const LoginHTTPEvent = new HTTPEvent({ 62 | name: 'login', 63 | manager: new LoginHTTPEventManager(), 64 | }); 65 | ``` 66 | 67 | Just a couple of notes about the `call` method: 68 | 69 | Since this is a `HTTPEvent`, `call` needs to return a function, which will be called by our middleware to fetch the data. We use the middleware and fetch helper from [`react-redux-api-tools`](https://github.com/labcodes/react-redux-api-tools), so I'd suggest you at least take a look at their docs if you want to use `HTTPEvent`s. If, for example, you prefer to use `axios`, just remove `fetchFromApi` and replace it with your `axios` code. 70 | 71 | **Disclaimer:** If you're going to use fetch, please use the `fetchFromApi` helper, or all `4xx` responses will trigger the `onSuccess` handler ([because fetch does not reject 4xx requests by default](https://www.tjvantoll.com/2015/09/13/fetch-and-errors/)). 72 | 73 | And remember to hook up the event on redux and register your components! 74 | 75 | ```js 76 | // on myAppRootReducers.js 77 | import { combineReducers } from 'redux'; 78 | import { combineEventReducers } from 'rel-events'; 79 | import { ChooseDateRangeEvent, LoginHTTPEvent } from './events.js'; 80 | 81 | export default combineReducers({ 82 | ...combineEventReducers([ ChooseDateRangeEvent, LoginHTTPEvent ]), 83 | }); 84 | 85 | // on LoginComponent.js 86 | import { LoginHTTPEvent } from './events.js'; 87 | 88 | class LoginComponent extends React.Component { 89 | //... 90 | handleFormSubmit = ({ email, username }) => this.props.login({ email, username }); 91 | // ... 92 | } 93 | 94 | export default LoginHTTPEvent.register({ Component: LoginComponent }); 95 | ``` 96 | 97 | #### Making multiple requests at the same time 98 | 99 | If you want to make multiple requests in the same call, instead of returning a single `fetchFromApi` call, you may use [`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) passing a list of `fetchFromApi` calls: 100 | 101 | ```js 102 | // on eventManagers.js 103 | import { fetchFromApi } from 'rel-events'; 104 | 105 | export class GetProductsHTTPEventManager { 106 | // ... 107 | 108 | call = () => { 109 | return () => Promise.all([ fetchFromApi('/api/products/1'), fetchFromApi('/api/products/2'), ]); 110 | } 111 | 112 | // ... 113 | } 114 | ``` 115 | 116 | When all of them are successful, the `onSuccess` handler will be called, and the `event.response` will be an array of `Response` objects. If any of them fails, the `onFailure` handler will be called passing the `Error` instance (most probably a `TypeError: Failed to fetch` error). 117 | 118 | --------------- 119 | 120 | Now that we're done here, maybe you should take a look at some other goodies we have on the Advanced Usage section :) 121 | -------------------------------------------------------------------------------- /__tests__/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | dispatchEvent, 3 | getCurrentStateFromEvent, 4 | combineEventReducers, 5 | _combineConflictingReducers, 6 | // eslint-disable-next-line import/named 7 | __RewireAPI__, 8 | } from '../lib/helpers'; 9 | 10 | describe('getCurrentStateFromEvent', () => { 11 | it('should throw if we do not pass an event', async () => { 12 | expect(() => getCurrentStateFromEvent({})).toThrow('You need to pass an event.'); 13 | }); 14 | 15 | it('should throw if we do not pass the appState', async () => { 16 | expect(() => getCurrentStateFromEvent({ event: {} })).toThrow( 17 | 'You need to pass your app state. This is only available inside `shouldDispatch` methods or imported manually (not recommended).', 18 | ); 19 | }); 20 | 21 | it('should return correct data', async () => { 22 | expect( 23 | getCurrentStateFromEvent({ event: { name: 'testEvent' }, appState: { testEvent: 'data' } }), 24 | ).toEqual('data'); 25 | }); 26 | }); 27 | 28 | describe('dispatchEvent', () => { 29 | it('should throw if we do not pass an event', async () => { 30 | expect(() => dispatchEvent({})).toThrow('You need to pass an event.'); 31 | }); 32 | 33 | it('should throw if we do not pass an event with a toRedux method', async () => { 34 | expect(() => dispatchEvent({ event: {} })).toThrow( 35 | 'The event you passed needs to have a `toRedux` method. Are you sure you instantiated and passed the correct event?', 36 | ); 37 | }); 38 | 39 | it('should throw if we do not pass a store', async () => { 40 | expect(() => dispatchEvent({ event: { toRedux: () => {} } })).toThrow( 41 | 'You need to pass your redux store.', 42 | ); 43 | }); 44 | 45 | it('should throw if we do not pass a store with a dispatch method', async () => { 46 | expect(() => dispatchEvent({ event: { toRedux: () => {} }, store: {} })).toThrow( 47 | 'The store you passed does not have a `dispatch` method. Are you sure you passed the correct variable as the store?', 48 | ); 49 | }); 50 | 51 | it('should call store.dispatch passing event.redux with data', async () => { 52 | const event = { 53 | toRedux: jest.fn(), 54 | }; 55 | const store = { 56 | dispatch: jest.fn(), 57 | }; 58 | 59 | dispatchEvent({ event, store, data: { test: 'data' } }); 60 | 61 | expect(event.toRedux).toHaveBeenCalledWith({ test: 'data' }); 62 | expect(store.dispatch).toHaveBeenCalledWith(event.toRedux({ test: 'data' })); 63 | }); 64 | }); 65 | 66 | describe('combineEventReducers', () => { 67 | it('should call return empty object if passed nothing', async () => { 68 | expect(combineEventReducers()).toEqual({}); 69 | expect(combineEventReducers([])).toEqual({}); 70 | }); 71 | 72 | it('should return object with reducers if passed events', async () => { 73 | const dummyEvents = [ 74 | { name: 'event1', createReducers: () => ({ event1: 'called1' }) }, 75 | { name: 'event2', createReducers: () => ({ event2: 'called2' }) }, 76 | ]; 77 | expect(combineEventReducers(dummyEvents)).toEqual({ 78 | event1: 'called1', 79 | event2: 'called2', 80 | }); 81 | }); 82 | 83 | it('should throw if passed `useDataFrom` key pointing at unexisting event', async () => { 84 | const dummyEvents = [ 85 | { name: 'event1', createReducers: () => ({ event1: 'called1' }) }, 86 | { 87 | name: 'event2', 88 | useDataFrom: 'unexistingEvent', 89 | createReducers: () => ({ event2: 'called2' }), 90 | }, 91 | ]; 92 | expect(() => combineEventReducers(dummyEvents)).toThrow( 93 | 'Event with unexistingEvent name not found.', 94 | ); 95 | }); 96 | 97 | it('should call _combineConflictingReducers if `useDataFrom` key exists in an event', async () => { 98 | const dummyEvents = [ 99 | { name: 'event1', createReducers: () => ({ event1: 'called1' }) }, 100 | { 101 | name: 'event2', 102 | useDataFrom: 'event1', 103 | createReducers: () => ({ event2: 'called2' }), 104 | }, 105 | { 106 | name: 'event3', 107 | useDataFrom: 'event1', 108 | createReducers: () => ({ event3: 'called3' }), 109 | }, 110 | ]; 111 | 112 | const mockedCombineConflictingReducers = jest.fn(() => ({ test: 'return' })); 113 | __RewireAPI__.__Rewire__('_combineConflictingReducers', mockedCombineConflictingReducers); 114 | 115 | const returnedData = combineEventReducers(dummyEvents); 116 | 117 | expect(mockedCombineConflictingReducers).toHaveBeenCalledWith([ 118 | { event1: 'called1' }, 119 | { event2: 'called2' }, 120 | { event3: 'called3' }, 121 | ]); 122 | expect(returnedData).toEqual({ 123 | event1: { test: 'return' }, 124 | }); 125 | }); 126 | }); 127 | 128 | describe('_combineConflictingReducers', () => { 129 | it('returns function that returns same state if passed nothing', async () => { 130 | const returnedFunction = _combineConflictingReducers(); 131 | expect(returnedFunction({ test: 'state' })).toEqual({ test: 'state' }); 132 | }); 133 | 134 | it('returns function that combines alterations of the same state', async () => { 135 | const dummyReducers = [ 136 | { 137 | event1: state => { 138 | state.event1 = 'test1'; 139 | return state; 140 | }, 141 | }, 142 | { 143 | event2: state => { 144 | state.event2 = 'test2'; 145 | return state; 146 | }, 147 | }, 148 | ]; 149 | 150 | const returnedFunction = _combineConflictingReducers(dummyReducers); 151 | 152 | expect(returnedFunction({ test: 'state' })).toEqual({ 153 | test: 'state', 154 | event1: 'test1', 155 | event2: 'test2', 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /docs/1-Basic-Usage.md: -------------------------------------------------------------------------------- 1 | 2 | # Basic Usage 3 | 4 | ### Installing 5 | 6 | To install `rel-events`, just run `npm i --save rel-events`. 7 | 8 | If you wish to use Events to make HTTP Requests (which you probably do), there's another step to set things up. Follow our [HTTPEvents Guide](https://github.com/labcodes/rel-events/tree/master/docs/2-HTTP-Events.md) to have everything ready. :) 9 | 10 | With that done, you may start to create some events! 11 | 12 | ### Creating a basic Event 13 | 14 | Let's say you want to pass a range of dates from `DatePickerComponent` to `CalendarComponent`. Instead of creating actions and reducers, forget everything about redux; create an Event instead. 15 | 16 | To do that, you need to initialize a new Event. It's recommended you create it in a new file (`events.js`). 17 | 18 | ```js 19 | // on events.js 20 | import { Event } from 'rel-events'; 21 | 22 | export const ChooseDateRangeEvent = new Event({ 23 | name: 'chooseDateRange', 24 | manager: { 25 | initialState: {}, 26 | onDispatch: (state, event) => { 27 | return { 28 | ...state, 29 | startDate: event.startDate, 30 | endDate: event.endDate, 31 | } 32 | } 33 | } 34 | }); 35 | ``` 36 | 37 | Let's break step-by-step what this code means: 38 | 39 | First, you import the Event class from our lib, then instantiate a new event. This Event receives an object with two required keys: `name` and `manager`. While `name` is self-explanatory, `manager` is not. 40 | 41 | For default Events, an Event Manager should have an `initialState` and implement an `onDispatch` method, which will be called whenever the event is dispatched. This is the alternative to the reducer part of the default redux flow. 42 | 43 | We recommend using classes for your EventManagers as well, since we can decouple Events from their managers. 44 | 45 | ```js 46 | // on eventManagers.js 47 | export class ChooseDateRangeEventManager { 48 | initialState = {}; 49 | 50 | onDispatch = (state, event) => { 51 | return { 52 | ...state, 53 | startDate: event.startDate, 54 | endDate: event.endDate, 55 | } 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | Then: 62 | 63 | ```js 64 | // on events.js 65 | import { Event } from 'rel-events'; 66 | import { ChooseDateRangeEventManager } from './eventManagers.js'; 67 | 68 | export const ChooseDateRangeEvent = new Event({ 69 | name: 'chooseDateRange', 70 | manager: new ChooseDateRangeEventManager(), 71 | ); 72 | ``` 73 | 74 | ### Hooking it up with redux 75 | 76 | With the event instantiated, you need to hook it up to redux so it can be dispatched and save data. When creating your root reducer, you should import the Event and initialize its reducers. 77 | 78 | ```js 79 | // on myAppRootReducers.js 80 | import { combineReducers } from 'redux'; 81 | import { combineEventReducers } from 'rel-events'; 82 | import { ChooseDateRangeEvent } from './events.js'; 83 | 84 | // remember to use object spread, so it's set up correctly 85 | export default combineReducers({ 86 | ...combineEventReducers([ ChooseDateRangeEvent ]), 87 | }); 88 | ``` 89 | 90 | Notice the store names and reducers aren't declared anymore; you don't need to. Any Event object will deal with anything and everything redux related. To be able to do that, you only need to hook it to redux as the example above. To see more on how this works, read our [how it works docs](https://github.com/labcodes/rel-events/tree/master/docs/5-How-it-works.md). 91 | 92 | Now you have our Event ready to go! Now, you just need to register it to a Component, which can trigger it and/or listen to it. 93 | 94 | ### Registering components to Events 95 | 96 | Let's say you have a component called `DatePickerComponent` that knows how to render a beautiful date picker. It has a `handleDatesChange` method to update the state with the new dates. 97 | 98 | ```jsx 99 | export default class DatePickerComponent extends React.Component { 100 | //... 101 | handleDatesChange = (startDate, endDate) => { 102 | this.setState({ startDate, endDate }); 103 | } 104 | //... 105 | } 106 | ``` 107 | 108 | To be able to send data from this component to the `CalendarComponent`, you may register both Components to your Event. Whenever you register a Component to an Event, you automatically receive a function to trigger the event as a prop. The function's name is the same as the event name you passed when initializing the event. 109 | 110 | ```jsx 111 | import { ChooseDateRangeEvent } from './events.js'; 112 | 113 | // you won't export the component directly anymore 114 | class DatePickerComponent extends React.Component { 115 | //... 116 | handleDatesChange = (startDate, endDate) => { 117 | // here, the event passing the new dates is triggered 118 | // after setState is done 119 | this.setState( 120 | { startDate, endDate }, 121 | () => this.props.chooseDateRange({ startDate, endDate }) 122 | ); 123 | } 124 | //... 125 | } 126 | 127 | // and here, you register the component to the event. 128 | // since Components are mostly named with CamelCase, 129 | // we preferred to name the key like that as well 130 | export default ChooseDateRangeEvent.register({ 131 | Component: DatePickerComponent, 132 | }); 133 | 134 | // you may as well register a Component to multiple events, no worries; 135 | // just remember to only export after you're done registering the Component to your events 136 | ``` 137 | 138 | Then, you may register your `CalendarComponent` as well, but passing a new `props` key: 139 | 140 | ```jsx 141 | import { ChooseDateRangeEvent } from './events.js'; 142 | 143 | class CalendarComponent extends React.Component { 144 | //... 145 | render(){ 146 | const { startDate, endDate } = this.props; 147 | 148 | return

The dates are: {startDate}, {endDate}

149 | } 150 | } 151 | 152 | // and here, you get the props from the event 153 | export default ChooseDateRangeEvent.register({ 154 | Component: CalendarComponent, 155 | props: ['startDate', 'endDate'], 156 | }) 157 | ``` 158 | 159 | And that's it! We still have a lot of other features to discuss, but I'll talk about those later. Before that, let's talk about [using events to make HTTP requests](https://github.com/labcodes/rel-events/tree/master/docs/2-HTTP-Events.md). 160 | -------------------------------------------------------------------------------- /docs/3-Advanced-Usage.md: -------------------------------------------------------------------------------- 1 | 2 | ## Advanced Usage: Other features and goodies 3 | 4 | ### Using `extraData` on Event Managers 5 | 6 | Whenever you call an Event/HTTPEvent, data that is passed to its 'call' or 'onDispatch' method is automatically added to the event instance in the `extraData` key. 7 | 8 | That means that if, for example, you want to persist some data on an Event, you may just do this: 9 | 10 | ```js 11 | // on eventManagers.js 12 | 13 | export class ExampleEventManager { 14 | name: 'login', 15 | manager: { 16 | initialState: {}, 17 | // just set a new key passing the event.extraData value 18 | onDispatch: (state, event) => ({ ...state, data: event.extraData }) 19 | }, 20 | } 21 | 22 | ``` 23 | 24 | ### Debouncing Events 25 | 26 | It's not unusual to have a use case in which you don't really want to trigger the Event right away. When the user is typing some data into a text input, for example, we may want to wait for a certein amount of time so that the user has finished typing, and only then trigger the Event with the latest data. 27 | 28 | Doing that inside a component may give you some undesirable effects. First of all the Component will need to implement the debouncing itself, and more code is more windows for errors. The redux flow will be oh so slightly off from the input change as well, which may lead to rendering issues when presenting the loading spinner, for example. 29 | 30 | To deal with those cases, we provide an optional `debounce` and `debounceDelay` configurations. When instantiating the Event, you are able to do something like this: 31 | 32 | ```js 33 | // on events.js 34 | import { HTTPEvent } from 'rel-events'; 35 | import { SearchByUsernameHTTPEventManager } from './eventManagers.js'; 36 | 37 | export const SearchByUsernameHTTPEvent = new HTTPEvent({ 38 | name: 'searchByUsername', 39 | manager: new SearchByUsernameHTTPEventManager(), 40 | // we set debounce as true and optionally pass a custom delay in ms 41 | debounce: true, 42 | debounceDelay: 500, // defaults to 300 43 | }); 44 | ``` 45 | 46 | Then, just trigger the Event as you would before and the Event will wait for that amount of time before dispatching itself: 47 | 48 | 49 | ```js 50 | // on SearchByUsernameComponent.js 51 | import { SearchByUsernameHTTPEvent } from './events.js'; 52 | 53 | class SearchByUsernameComponent extends React.Component { 54 | //... 55 | 56 | // even though the Event will be triggered whenever something is typed, 57 | // it will only be dispatched after the user stopped typing 58 | // and 500ms has passed since the last edit 59 | render() { 60 | return this.props.searchByUsername({ username: e.target.value })} /> 61 | }; 62 | // ... 63 | } 64 | 65 | export default LoginHTTPEvent.register({ Component: LoginComponent }); 66 | ``` 67 | 68 | The debounce function is provided straight from [lodash](https://lodash.com/docs/4.17.15#debounce). 69 | 70 | ### `useDataFrom` optional Event parameter 71 | 72 | If you want, for example, to clear data from an Event, you'll notice there isn't a way inside the same Event to do so. Instead, you must create a new Event that writes data to the first Event's state. 73 | 74 | ```js 75 | // on events.js 76 | import { Event, HTTPEvent } from 'rel-events'; 77 | import { LoginHTTPEventManager, FetchUserDataHTTPEventManager } from './eventManagers.js'; 78 | 79 | export const LoginHTTPEvent = new HTTPEvent({ 80 | name: 'login', 81 | manager: new LoginHTTPEventManager(), 82 | }); 83 | 84 | export const ClearLoginDataEvent = new Event({ 85 | name: 'clearLoginData', 86 | useDataFrom: 'login', // <-- we use the LoginHTTPEvent's name to link it's data to this new Event 87 | manager: { 88 | initialState: { isAuthenticated: false }, 89 | onDispatch: () => initialState, 90 | } 91 | }); 92 | ``` 93 | 94 | You may use this optional param with HTTPEvents as well. 95 | 96 | One thing to keep in mind: since this second Event uses the data from another, it can't be registered to a component passing a `props` key, since it doesn't have data of it's own. Don't worry, we'll warn if you if that happens. :) 97 | 98 | ### `shouldDispatch` optional method - helped by the `getCurrentStateFromEvent` helper 99 | 100 | If you're dealing with a situation in which you don't want to dispatch an event based on certain conditions, you should probably implement a `shouldDispatch` method on your event manager. 101 | 102 | Imagine a scenario in which you don't want to dispatch an event if the data is the same it was before. I'll be using the `ChooseDateRangeEvent` (seen above) as an example. 103 | 104 | Let's say that you don't want `ChooseDateRangeEvent` to be dispatched if the dates are the same. An example on how we would achieve that would be as follows: 105 | 106 | ```js 107 | // on eventManagers.js 108 | import { getCurrentStateFromEvent } from 'rel-events'; 109 | import { ChooseDateRangeEvent } from './events'; 110 | 111 | class ChooseDateRangeEventManager { 112 | 113 | //... 114 | 115 | shouldDispatch = (appState, event) => { 116 | const currentState = getCurrentStateFromEvent({ 117 | event: ChooseDateRangeEvent, 118 | appState: appState, 119 | }); 120 | // returns { startDate, endDate } 121 | 122 | return ( 123 | currentState.startDate !== event.startDate 124 | && currentState.endDate !== event.endDate 125 | ); 126 | } 127 | 128 | // ... 129 | 130 | } 131 | ``` 132 | 133 | The `shouldDispatch` method receives the whole app state (containing data from all events) and the event that would be dispatched. If `shouldDispatch` returns `true`, the event is dispatched. 134 | 135 | Since we don't want to leak the implementation details from the reducer layer, we provide the `getCurrentStateFromEvent` helper. It returns all the relevant data from an Event, so you can compare it to the event that will be dispatched. 136 | 137 | And yes, you may use `shouldDispatch` in any way you want. You may want to check a cookie, or data from other events, or localStorage, or the value of a variable, whatever; it only cares that you return a truthy or falsy value. By default, it always returns `true`. 138 | 139 | ### `afterDispatch`, `afterSuccess` and `afterFailure` 140 | 141 | Sometimes, all we want is to run some code after the new state from an event is set. For these cases, you may want to implement an `afterDispatch` method in your manager (for regular Events) or `afterSuccess`/`afterFailure` (for HTTPEvents). 142 | 143 | ```js 144 | // on eventManagers.js 145 | import * as Sentry from '@sentry.browser'; 146 | 147 | class ChooseDateRangeEventManager { 148 | 149 | //... 150 | 151 | afterDispatch = (previousState, newState) => { 152 | if (previoustState.isValid && newState.isInvalid) { 153 | Sentry.captureMessage('Something went wrong'); 154 | } 155 | } 156 | 157 | // ... 158 | 159 | } 160 | ``` 161 | -------------------------------------------------------------------------------- /lib/events.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | const _debounce = require('lodash.debounce'); 4 | 5 | const isNumber = n => !window.isNaN(parseFloat(n)) && !window.isNaN(n - 0); 6 | 7 | export class Event { 8 | constructor({ 9 | name, 10 | manager, 11 | useDataFrom, 12 | debounce = false, 13 | debounceDelay = 300, 14 | listenTo = [], 15 | } = {}) { 16 | if (arguments.length === 0) { 17 | throw new Error('An Event should not be initialized without parameters.'); 18 | } else { 19 | if (!name) { 20 | throw new Error('An Event should be initialized with an event name.'); 21 | } 22 | 23 | if (!manager) { 24 | throw new Error('An Event should be initialized with an EventManager.'); 25 | } 26 | 27 | if ( 28 | !Array.isArray(listenTo) || 29 | !listenTo.every( 30 | obj => 31 | obj.hasOwnProperty('event') && 32 | obj.hasOwnProperty('triggerOn') && 33 | typeof obj.event === 'function', 34 | ) 35 | ) { 36 | throw new Error( 37 | 'ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.', 38 | ); 39 | } 40 | } 41 | 42 | if (debounce && !isNumber(debounceDelay)) { 43 | throw new Error('When debounce is true, debounceDelay needs to be a Number.'); 44 | } 45 | 46 | this.name = name; 47 | this.manager = manager; 48 | this.debounce = debounce; 49 | this.debounceDelay = debounceDelay; 50 | this.listenTo = listenTo; 51 | this.useDataFrom = useDataFrom; 52 | this.__UNSAFE_state = manager.initialState; 53 | this.reducerName = this._formatReducerName(this.name); 54 | 55 | if (this.debounce) { 56 | this._callRedux = _debounce(this._callRedux, this.debounceDelay); 57 | } 58 | } 59 | 60 | createReducers = () => { 61 | const reducers = {}; 62 | 63 | if (this.useDataFrom) { 64 | reducers[this.useDataFrom] = this._createReducersTo(); 65 | } else { 66 | reducers[this.name] = this._createReducersTo(); 67 | } 68 | 69 | return reducers; 70 | }; 71 | 72 | register = ({ Component, props = [] }) => { 73 | if (!Component) { 74 | throw new Error( 75 | 'You must pass a Component inside the Component key when registering it to an Event.', 76 | ); 77 | } 78 | 79 | return connect(this._bindDataToProps(props), this._bindDispatchToProps)(Component); 80 | }; 81 | 82 | _createReducersTo = () => (state = this.manager.initialState, action) => { 83 | if (action.type === this.reducerName) { 84 | const newState = this.manager.onDispatch(state, action); 85 | 86 | if (this.manager.afterDispatch) { 87 | setTimeout(() => this.manager.afterDispatch(state, newState), 0); 88 | } 89 | 90 | this.__UNSAFE_state = newState; 91 | this._chainEvents(action); 92 | 93 | return newState; 94 | } 95 | this._chainEvents(action); 96 | return state; 97 | }; 98 | 99 | _chainEvents = action => { 100 | const { listenTo, _formatToRedux, __UNSAFE_cachedArgs: cachedArgs } = this; 101 | 102 | if (listenTo.length) { 103 | listenTo.map(({ event, triggerOn, autocompleteCallArgs }) => { 104 | event = event(); 105 | const reducer = event.reducerName ? event.reducerName : event.reducers[triggerOn]; 106 | 107 | if (action.type === reducer) { 108 | setTimeout(() => { 109 | const dispatchData = autocompleteCallArgs 110 | ? { ...cachedArgs, ...event.__UNSAFE_state } 111 | : event.__UNSAFE_state; 112 | 113 | action.__UNSAFE_dispatch(_formatToRedux(dispatchData)); 114 | }); 115 | } 116 | }); 117 | } 118 | }; 119 | 120 | _callRedux = dispatchData => this.__UNSAFE_reduxDispatch(this._formatToRedux(dispatchData)); 121 | 122 | _formatToRedux = dispatchData => { 123 | this.__UNSAFE_cachedArgs = dispatchData; 124 | return { 125 | type: this.reducerName, 126 | shouldDispatch: this.manager.shouldDispatch || (() => true), 127 | extraData: dispatchData, 128 | ...dispatchData, 129 | }; 130 | }; 131 | 132 | _bindDataToProps = props => { 133 | if (this.useDataFrom && props.length) { 134 | throw new Error( 135 | `When configuring 'useDataFrom', you will end up with an empty state. Listen to the event with the name described in the 'useDataFrom' key instead.`, 136 | ); 137 | } 138 | const { name } = this; 139 | 140 | return state => { 141 | const data = {}; 142 | data[`_event_${name}`] = this; 143 | 144 | props.map(key => { 145 | data[key] = state[name][key]; 146 | return null; 147 | }); 148 | 149 | return data; 150 | }; 151 | }; 152 | 153 | _bindDispatchToProps = reduxDispatch => { 154 | this.__UNSAFE_reduxDispatch = reduxDispatch; 155 | const actions = {}; 156 | actions[this.name] = this._callRedux; 157 | return actions; 158 | }; 159 | 160 | _formatReducerName = name => 161 | name 162 | .replace(/\.?([A-Z])/g, (_x, y) => `_${y.toLowerCase()}`) 163 | .replace(/^_/, '') 164 | .toUpperCase(); 165 | } 166 | 167 | export class HTTPEvent extends Event { 168 | constructor({ name, ...rest } = {}) { 169 | super({ name, ...rest }); 170 | 171 | delete this.reducerName; 172 | 173 | this.reducers = { 174 | request: `${this._formatReducerName(this.name)}_REQUEST`, 175 | success: `${this._formatReducerName(this.name)}_SUCCESS`, 176 | failure: `${this._formatReducerName(this.name)}_FAILURE`, 177 | }; 178 | } 179 | 180 | _formatToRedux = dispatchData => { 181 | this.__UNSAFE_cachedArgs = dispatchData; 182 | const { shouldDispatch } = this.manager; 183 | return { 184 | types: this.reducers, 185 | extraData: dispatchData, 186 | apiCallFunction: this.manager.call(dispatchData), 187 | shouldDispatch: shouldDispatch || (() => true), 188 | }; 189 | }; 190 | 191 | _createReducersTo = () => (state = this.manager.initialState, action) => { 192 | let newState = state; 193 | 194 | if (action.type === this.reducers.request) { 195 | newState = this.manager.onDispatch(state, action); 196 | } 197 | 198 | if (action.type === this.reducers.success) { 199 | newState = this.manager.onSuccess(state, action); 200 | if (this.manager.afterSuccess) { 201 | setTimeout(() => this.manager.afterSuccess(state, newState), 0); 202 | } 203 | } 204 | 205 | if (action.type === this.reducers.failure) { 206 | newState = this.manager.onFailure(state, action); 207 | if (this.manager.afterFailure) { 208 | setTimeout(() => this.manager.afterFailure(state, newState), 0); 209 | } 210 | } 211 | 212 | this.__UNSAFE_state = newState; 213 | this._chainEvents(action); 214 | 215 | return newState; 216 | }; 217 | } 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rel-events 2 | 3 | [![Build Status](https://travis-ci.org/labcodes/rel-events.svg?branch=master)](https://travis-ci.org/labcodes/rel-events) [![Coverage Status](https://coveralls.io/repos/github/labcodes/rel-events/badge.svg?branch=master)](https://coveralls.io/github/labcodes/rel-events?branch=master) 4 | 5 | *To read the docs, [go to our docs folder](https://github.com/labcodes/rel-events/tree/master/docs). :)* 6 | *To see the app demo, [go to our codesandbox page](https://codesandbox.io/s/rel-events-example-w6yji?fontsize=12)!* 7 | 8 | Welcome to the `rel-events` github repo! 9 | 10 | `rel-events` is an awesome events library for react. It uses `redux` behind the scenes to offer a more convenient, simple and relevant API for dealing with data flows. 11 | 12 | # Basic Usage 13 | 14 | ### Installing 15 | 16 | To install `rel-events`, just run `npm i --save rel-events`. 17 | 18 | If you wish to use Events to make HTTP Requests (which you probably do), there's another step to set things up. Follow our [HTTPEvents Guide](https://github.com/labcodes/rel-events/tree/master/docs/2-HTTP-Events.md) to have everything ready. :) 19 | 20 | With that done, you may start to create some events! 21 | 22 | ### Creating a basic Event 23 | 24 | Let's say you want to pass a range of dates from `DatePickerComponent` to `CalendarComponent`. Instead of creating actions and reducers, forget everything about redux; create an Event instead. 25 | 26 | To do that, you need to initialize a new Event. It's recommended you create it in a new file (`events.js`). 27 | 28 | ```js 29 | // on events.js 30 | import { Event } from 'rel-events'; 31 | 32 | export const ChooseDateRangeEvent = new Event({ 33 | name: 'chooseDateRange', 34 | manager: { 35 | initialState: {}, 36 | onDispatch: (state, event) => { 37 | return { 38 | ...state, 39 | startDate: event.startDate, 40 | endDate: event.endDate, 41 | } 42 | } 43 | } 44 | }); 45 | ``` 46 | 47 | Let's break step-by-step what this code means: 48 | 49 | First, you import the Event class from our lib, then instantiate a new event. This Event receives an object with two required keys: `name` and `manager`. While `name` is self-explanatory, `manager` is not. 50 | 51 | For default Events, an Event Manager should have an `initialState` and implement an `onDispatch` method, which will be called whenever the event is dispatched. This is the alternative to the reducer part of the default redux flow. 52 | 53 | We recommend using classes for your EventManagers as well, since we can decouple Events from their managers. 54 | 55 | ```js 56 | // on eventManagers.js 57 | export class ChooseDateRangeEventManager { 58 | initialState = {}; 59 | 60 | onDispatch = (state, event) => { 61 | return { 62 | ...state, 63 | startDate: event.startDate, 64 | endDate: event.endDate, 65 | } 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | Then: 72 | 73 | ```js 74 | // on events.js 75 | import { Event } from 'rel-events'; 76 | import { ChooseDateRangeEventManager } from './eventManagers.js'; 77 | 78 | export const ChooseDateRangeEvent = new Event({ 79 | name: 'chooseDateRange', 80 | manager: new ChooseDateRangeEventManager(), 81 | ); 82 | ``` 83 | 84 | ### Hooking it up with redux 85 | 86 | With the event instantiated, you need to hook it up to redux so it can be dispatched and save data. When creating your root reducer, you should import the Event and initialize its reducers. 87 | 88 | ```js 89 | // on myAppRootReducers.js 90 | import { combineReducers } from 'redux'; 91 | import { combineEventReducers } from 'rel-events'; 92 | import { ChooseDateRangeEvent } from './events.js'; 93 | 94 | // remember to use object spread, so it's set up correctly 95 | export default combineReducers({ 96 | ...combineEventReducers([ ChooseDateRangeEvent ]), 97 | }); 98 | ``` 99 | 100 | Notice the store names and reducers aren't declared anymore; you don't need to. Any Event object will deal with anything and everything redux related. To be able to do that, you only need to hook it to redux as the example above. To see more on how this works, read our [how it works docs](https://github.com/labcodes/rel-events/tree/master/docs/5-How-it-works.md). 101 | 102 | Now you have our Event ready to go! Now, you just need to register it to a Component, which can trigger it and/or listen to it. 103 | 104 | ### Registering components to Events 105 | 106 | Let's say you have a component called `DatePickerComponent` that knows how to render a beautiful date picker. It has a `handleDatesChange` method to update the state with the new dates. 107 | 108 | ```jsx 109 | export default class DatePickerComponent extends React.Component { 110 | //... 111 | handleDatesChange = (startDate, endDate) => { 112 | this.setState({ startDate, endDate }); 113 | } 114 | //... 115 | } 116 | ``` 117 | 118 | To be able to send data from this component to the `CalendarComponent`, you may register both Components to your Event. Whenever you register a Component to an Event, you automatically receive a function to trigger the event as a prop. The function's name is the same as the event name you passed when initializing the event. 119 | 120 | ```jsx 121 | import { ChooseDateRangeEvent } from './events.js'; 122 | 123 | // you won't export the component directly anymore 124 | class DatePickerComponent extends React.Component { 125 | //... 126 | handleDatesChange = (startDate, endDate) => { 127 | // here, the event passing the new dates is triggered 128 | // after setState is done 129 | this.setState( 130 | { startDate, endDate }, 131 | () => this.props.chooseDateRange({ startDate, endDate }) 132 | ); 133 | } 134 | //... 135 | } 136 | 137 | // and here, you register the component to the event. 138 | // since Components are mostly named with CamelCase, 139 | // we preferred to name the key like that as well 140 | export default ChooseDateRangeEvent.register({ 141 | Component: DatePickerComponent, 142 | }); 143 | 144 | // you may as well register a Component to multiple events, no worries; 145 | // just remember to only export after you're done registering the Component to your events 146 | ``` 147 | 148 | Then, you may register your `CalendarComponent` as well, but passing a new `props` key: 149 | 150 | ```jsx 151 | import { ChooseDateRangeEvent } from './events.js'; 152 | 153 | class CalendarComponent extends React.Component { 154 | //... 155 | render(){ 156 | const { startDate, endDate } = this.props; 157 | 158 | return

The dates are: {startDate}, {endDate}

159 | } 160 | } 161 | 162 | // and here, you get the props from the event 163 | export default ChooseDateRangeEvent.register({ 164 | Component: CalendarComponent, 165 | props: ['startDate', 'endDate'], 166 | }) 167 | ``` 168 | 169 | And that's it! We still have a lot of other features to discuss, but I'll talk about those later. Before that, let's talk about [using events to make HTTP requests](https://github.com/labcodes/rel-events/tree/master/docs/2-HTTP-Events.md). 170 | 171 | ### Contributing 172 | 173 | Thanks for wanting to contribute! Please, read our [Contributing guide](https://github.com/labcodes/rel-events/tree/master/docs/6-Contributing.md) carefully before opening PRs, though. We do enjoy PRs, but any PRs that don't follow our contributing guidelines will probably be rejected :/ 174 | 175 | ### Thanks 176 | 177 | Thanks for everyone at Labcodes for giving me the structure and time for me to work on this pet project of mine and giving me lots of awesome feedback! Bernardo Fontes, Luan Fonseca, Thulio Philipe, Mariana Bedran, Breno Chamie and Renato Oliveira, I'm really, really grateful for your input! <3 178 | 179 | [![labcodes github banner](labcodes-github-banner.jpg)](https://labcodes.com.br/?utm_source=github&utm_medium=cpc&utm_campaign=rel_events) 180 | -------------------------------------------------------------------------------- /docs/5-API-docs.md: -------------------------------------------------------------------------------- 1 | 2 | # API docs 3 | 4 | Here, we have the API docs for our public API. 5 | 6 | - [`Event` and `HTTPEvent` API docs](#event-and-httpevent-api-docs) 7 | - [`EventManager` API docs](#eventmanager-api-docs) 8 | - [`getCurrentStateFromEvent` API docs](#getcurrentstatefromevent-api-docs) 9 | 10 | --------------------------------------- 11 | 12 | ## `Event` and `HTTPEvent` API docs 13 | 14 | These API docs are literally the same for `Event` and `HTTPEvent` classes. The only parameter that needs to change is the EventManager. For more info on EventManagers, take a look at the [EventManager docs](https://github.com/labcodes/rel-events/tree/master/docs/4-EventManager-API-docs.md). 15 | 16 | ### Event initialization 17 | 18 | Initializes a new Event/HTTPEvent instance. 19 | 20 | ```js 21 | new Event({ 22 | name: String.isRequired, 23 | manager: Object.isRequired, 24 | useDataFrom: String, 25 | listenTo: Array.of(Object), 26 | debounce: Boolean, 27 | debounceDelay: Number, 28 | }); 29 | 30 | // example: 31 | const ChooseDateRangeEvent = new Event({ 32 | name: 'chooseDateRange', 33 | useDataFrom: 'otherEvent', 34 | manager: { 35 | // refer to EventManager API docs 36 | }, 37 | listenTo: [ 38 | { 39 | event: () => Event/HTTPEvent instance, 40 | triggerOn: String(depends on the event being listened: 'dispatch' for normal Events, 'dispatch'/'success'/'failure' for HTTPEvents), 41 | autocompleteCallArgs: true, 42 | } 43 | ], 44 | debounce: true, 45 | debounceDelay: 500, 46 | }); 47 | ``` 48 | 49 | ### Event.register 50 | 51 | Registers a Component to an Event/HTTPEvent, injecting data defined on the 'props' key and a function of the event.name to trigger the event. 52 | 53 | ```js 54 | // returns a wrapped component, same as redux's connect() 55 | EventInstance.register({ 56 | Component: React.Component, 57 | props: Array.of(String) 58 | }); 59 | 60 | // example: 61 | 62 | // on events.js 63 | import { HTTPEvent } from 'rel-events'; 64 | import { LoginHTTPEventManager } from './eventManagers.js'; 65 | 66 | export const LoginHTTPEvent = new HTTPEvent({ 67 | name: 'login', 68 | manager: new LoginHTTPEventManager(), 69 | }); 70 | 71 | // on LoginComponent.js 72 | import { LoginHTTPEvent } from './events'; 73 | 74 | class LoginComponent extends React.Component { 75 | //... 76 | handleFormSubmit = ({ email, username }) => this.props.login({ email, username }); 77 | // ... 78 | render(){ 79 | const { username, error } = this.props; 80 | 81 | if (error){ 82 | // renders error 83 | } 84 | 85 | return

Hi {username}!

86 | } 87 | } 88 | 89 | export default LoginHTTPEvent.register({ 90 | Component: LoginComponent, 91 | props: ['username', 'error'] 92 | }); 93 | ``` 94 | 95 | ### Event.createReducers 96 | 97 | Binds an Event/HTTPEvent to redux via reducers. Returns an object like `{ eventName(String): reducers(Function) }`. 98 | 99 | ```js 100 | EventInstance.createReducers(); 101 | 102 | // example: 103 | import { combineReducers } from 'redux'; 104 | import { ChooseDateRangeEvent } from './events.js'; 105 | 106 | // remember to use object spread, so it's set up correctly 107 | export default combineReducers({ 108 | ...ChooseDateRangeEvent.createReducers(), 109 | // returns { 'chooseDateRange': (state, action) => { ... } } 110 | }); 111 | ``` 112 | 113 | --------------------------------------- 114 | 115 | ## EventManager API docs 116 | 117 | EventManagers are implementations of how an event will behave through its lifecycle. They implement different methods based on the type of event it's bound to and on the needs of the use case. 118 | 119 | ### For regular Events 120 | 121 | For an Event, the EventManager is required to implement an `onDispatch` method, as well as an `initialState` for the Event. 122 | 123 | #### EventManager.onDispatch 124 | 125 | Receives the current state of the Event and the new dispatched event. Returns a new Event state. It's basically a mirror of a reducer. 126 | 127 | ```js 128 | class ChooseDateRangeEventManager { 129 | initialState = { 130 | startDate: new Date(), 131 | endDate: new Date(), 132 | } 133 | 134 | onDispatch = (state, event) => ({ 135 | ...state, 136 | startDate: event.startDate, 137 | endDate: event.endDate 138 | }) 139 | } 140 | ``` 141 | 142 | ### For HTTPEvents 143 | 144 | For a HTTPEvent, the EventManager is required to have an `initialState` and implement 4 methods: `onDispatch`, `onSuccess`, `onFailure` and `call`. 145 | 146 | #### EventManager.call 147 | 148 | Called whenever an event is triggered. Receives an object with data. Returns a function that, when called, makes a HTTP request. The HTTP function needs to return a Promise. 149 | 150 | ```js 151 | import { fetchFromApi } from 'rel-events'; 152 | 153 | class LoginEventManager { 154 | // ... 155 | call = ({ username, password }) => () => fetchFromApi( 156 | '/api/login', 157 | { method: 'POST', body: JSON.stringify({ username, password }) } 158 | ); 159 | // ... 160 | } 161 | ``` 162 | 163 | #### EventManager.onDispatch, EventManager.onSuccess, EventManager.onFailure 164 | 165 | These three methods receive the same data (state, event), but are called at different times. 166 | 167 | - `onDispatch`: called as soon as the request starts. Useful for rendering intermediate states, like loading spinners; 168 | - `onSuccess`: called when the request promise is successfully resolved; 169 | - `onFailure`: called when the request promise is rejected. 170 | 171 | ```js 172 | export class LoginHTTPEventManager { 173 | initialState = { isLoading: false, username: 'Anonymous' }; 174 | 175 | onDispatch = (state, event) => ({ 176 | ...state, 177 | isLoading: true, 178 | username: this.initialState.username 179 | }) 180 | 181 | onSuccess = (state, event) => ({ 182 | ...state, 183 | isLoading: false, 184 | username: event.response.data.username, 185 | }) 186 | 187 | onFailure = (state, event) => ({ 188 | ...state, 189 | isLoading: false, 190 | username: this.initialState.username, 191 | error: event.error.data, 192 | }) 193 | } 194 | ``` 195 | 196 | For more info on how the response objects work, refer to [`react-redux-api-tools` docs](https://github.com/labcodes/react-redux-api-tools). 197 | 198 | ### Optional methods 199 | 200 | #### EventManager.shouldDispatch 201 | 202 | `shouldDispatch` is called before an event is dispatched. If it returns `true`, the event is dispatched. Receives the whole appState and the event to be dispatched. Returns a Boolean. Mostly used together with `getCurrentStateFromEvent`. 203 | 204 | ```js 205 | import { getCurrentStateFromEvent } from 'rel-events'; 206 | 207 | class ChooseDateRangeEventManager { 208 | //... 209 | shouldDispatch = (appState, event) => { 210 | const currentState = getCurrentStateFromEvent({ 211 | event: ChooseDateRangeEvent, 212 | appState: appState, 213 | }); 214 | 215 | return ( 216 | currentState.startDate !== event.startDate 217 | && currentState.endDate !== event.endDate 218 | ); 219 | } 220 | // ... 221 | } 222 | ``` 223 | 224 | #### EventManager.afterDispatch, EventManager.afterSuccess, EventManager.afterFailure 225 | 226 | These optional methods acre called after other EventManager methods, and depend on the type of Event you're binding the EventManager to. All of them receive the previousState and the newState as arguments. 227 | 228 | - `afterDispatch`: called after a regular Event finished calling the `onDispatch` method; 229 | - `afterSuccess` and `afterFailure`: called after a HTTPEvent finishes calling `onSuccess` and `onFailure` respectively. 230 | 231 | ```js 232 | // on eventManagers.js 233 | import * as Sentry from '@sentry.browser'; 234 | 235 | class ChooseDateRangeEventManager { 236 | 237 | //... 238 | 239 | afterDispatch = (previousState, newState) => { 240 | if (previoustState.isValid && newState.isInvalid) { 241 | Sentry.captureMessage('Something went wrong'); 242 | } 243 | } 244 | 245 | // ... 246 | 247 | } 248 | ``` 249 | 250 | --------------------------------------- 251 | 252 | ## `getCurrentStateFromEvent` API docs 253 | 254 | `getCurrentStateFromEvent` is only supposed to be used inside `shouldDispatch` methods from EventManagers. They receive the state of all the events (the `appState`) and returns data concerning a specific Event. 255 | 256 | ```js 257 | // on eventManagers.js 258 | import { getCurrentStateFromEvent } from 'rel-events'; 259 | import { ChooseDateRangeEvent } from './events'; 260 | 261 | class ChooseDateRangeEventManager { 262 | 263 | //... 264 | 265 | shouldDispatch = (appState, event) => { 266 | const currentState = getCurrentStateFromEvent({ 267 | event: ChooseDateRangeEvent, 268 | appState: appState, 269 | }); 270 | // returns { startDate: Date, endDate: Date }, for example 271 | 272 | return ( 273 | currentState.startDate !== event.startDate 274 | && currentState.endDate !== event.endDate 275 | ); 276 | } 277 | 278 | // ... 279 | 280 | } 281 | ``` 282 | -------------------------------------------------------------------------------- /dist/events.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.HTTPEvent = exports.Event = void 0; 9 | 10 | var _objectWithoutProperties2 = _interopRequireDefault(require("@babel/runtime/helpers/objectWithoutProperties")); 11 | 12 | var _inherits2 = _interopRequireDefault(require("@babel/runtime/helpers/inherits")); 13 | 14 | var _possibleConstructorReturn2 = _interopRequireDefault(require("@babel/runtime/helpers/possibleConstructorReturn")); 15 | 16 | var _getPrototypeOf2 = _interopRequireDefault(require("@babel/runtime/helpers/getPrototypeOf")); 17 | 18 | var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); 19 | 20 | var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck")); 21 | 22 | var _reactRedux = require("react-redux"); 23 | 24 | function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function () { var Super = (0, _getPrototypeOf2.default)(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = (0, _getPrototypeOf2.default)(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return (0, _possibleConstructorReturn2.default)(this, result); }; } 25 | 26 | function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } } 27 | 28 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 29 | 30 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 31 | 32 | var _debounce = require('lodash.debounce'); 33 | 34 | var isNumber = function isNumber(n) { 35 | return !window.isNaN(parseFloat(n)) && !window.isNaN(n - 0); 36 | }; 37 | 38 | var Event = function Event() { 39 | var _this = this; 40 | 41 | var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, 42 | _name = _ref.name, 43 | manager = _ref.manager, 44 | useDataFrom = _ref.useDataFrom, 45 | _ref$debounce = _ref.debounce, 46 | debounce = _ref$debounce === void 0 ? false : _ref$debounce, 47 | _ref$debounceDelay = _ref.debounceDelay, 48 | debounceDelay = _ref$debounceDelay === void 0 ? 300 : _ref$debounceDelay, 49 | _ref$listenTo = _ref.listenTo, 50 | _listenTo = _ref$listenTo === void 0 ? [] : _ref$listenTo; 51 | 52 | (0, _classCallCheck2.default)(this, Event); 53 | 54 | this.createReducers = function () { 55 | var reducers = {}; 56 | 57 | if (_this.useDataFrom) { 58 | reducers[_this.useDataFrom] = _this._createReducersTo(); 59 | } else { 60 | reducers[_this.name] = _this._createReducersTo(); 61 | } 62 | 63 | return reducers; 64 | }; 65 | 66 | this.register = function (_ref2) { 67 | var Component = _ref2.Component, 68 | _ref2$props = _ref2.props, 69 | props = _ref2$props === void 0 ? [] : _ref2$props; 70 | 71 | if (!Component) { 72 | throw new Error('You must pass a Component inside the Component key when registering it to an Event.'); 73 | } 74 | 75 | return (0, _reactRedux.connect)(_this._bindDataToProps(props), _this._bindDispatchToProps)(Component); 76 | }; 77 | 78 | this._createReducersTo = function () { 79 | return function () { 80 | var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : _this.manager.initialState; 81 | var action = arguments.length > 1 ? arguments[1] : undefined; 82 | 83 | if (action.type === _this.reducerName) { 84 | var newState = _this.manager.onDispatch(state, action); 85 | 86 | if (_this.manager.afterDispatch) { 87 | setTimeout(function () { 88 | return _this.manager.afterDispatch(state, newState); 89 | }, 0); 90 | } 91 | 92 | _this.__UNSAFE_state = newState; 93 | 94 | _this._chainEvents(action); 95 | 96 | return newState; 97 | } 98 | 99 | _this._chainEvents(action); 100 | 101 | return state; 102 | }; 103 | }; 104 | 105 | this._chainEvents = function (action) { 106 | var listenTo = _this.listenTo, 107 | _formatToRedux = _this._formatToRedux, 108 | cachedArgs = _this.__UNSAFE_cachedArgs; 109 | 110 | if (listenTo.length) { 111 | listenTo.map(function (_ref3) { 112 | var event = _ref3.event, 113 | triggerOn = _ref3.triggerOn, 114 | autocompleteCallArgs = _ref3.autocompleteCallArgs; 115 | event = event(); 116 | var reducer = event.reducerName ? event.reducerName : event.reducers[triggerOn]; 117 | 118 | if (action.type === reducer) { 119 | setTimeout(function () { 120 | var dispatchData = autocompleteCallArgs ? _objectSpread(_objectSpread({}, cachedArgs), event.__UNSAFE_state) : event.__UNSAFE_state; 121 | 122 | action.__UNSAFE_dispatch(_formatToRedux(dispatchData)); 123 | }); 124 | } 125 | }); 126 | } 127 | }; 128 | 129 | this._callRedux = function (dispatchData) { 130 | return _this.__UNSAFE_reduxDispatch(_this._formatToRedux(dispatchData)); 131 | }; 132 | 133 | this._formatToRedux = function (dispatchData) { 134 | _this.__UNSAFE_cachedArgs = dispatchData; 135 | return _objectSpread({ 136 | type: _this.reducerName, 137 | shouldDispatch: _this.manager.shouldDispatch || function () { 138 | return true; 139 | }, 140 | extraData: dispatchData 141 | }, dispatchData); 142 | }; 143 | 144 | this._bindDataToProps = function (props) { 145 | if (_this.useDataFrom && props.length) { 146 | throw new Error("When configuring 'useDataFrom', you will end up with an empty state. Listen to the event with the name described in the 'useDataFrom' key instead."); 147 | } 148 | 149 | var name = _this.name; 150 | return function (state) { 151 | var data = {}; 152 | data["_event_".concat(name)] = _this; 153 | props.map(function (key) { 154 | data[key] = state[name][key]; 155 | return null; 156 | }); 157 | return data; 158 | }; 159 | }; 160 | 161 | this._bindDispatchToProps = function (reduxDispatch) { 162 | _this.__UNSAFE_reduxDispatch = reduxDispatch; 163 | var actions = {}; 164 | actions[_this.name] = _this._callRedux; 165 | return actions; 166 | }; 167 | 168 | this._formatReducerName = function (name) { 169 | return name.replace(/\.?([A-Z])/g, function (_x, y) { 170 | return "_".concat(y.toLowerCase()); 171 | }).replace(/^_/, '').toUpperCase(); 172 | }; 173 | 174 | if (arguments.length === 0) { 175 | throw new Error('An Event should not be initialized without parameters.'); 176 | } else { 177 | if (!_name) { 178 | throw new Error('An Event should be initialized with an event name.'); 179 | } 180 | 181 | if (!manager) { 182 | throw new Error('An Event should be initialized with an EventManager.'); 183 | } 184 | 185 | if (!Array.isArray(_listenTo) || !_listenTo.every(function (obj) { 186 | return obj.hasOwnProperty('event') && obj.hasOwnProperty('triggerOn') && typeof obj.event === 'function'; 187 | })) { 188 | throw new Error('ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.'); 189 | } 190 | } 191 | 192 | if (debounce && !isNumber(debounceDelay)) { 193 | throw new Error('When debounce is true, debounceDelay needs to be a Number.'); 194 | } 195 | 196 | this.name = _name; 197 | this.manager = manager; 198 | this.debounce = debounce; 199 | this.debounceDelay = debounceDelay; 200 | this.listenTo = _listenTo; 201 | this.useDataFrom = useDataFrom; 202 | this.__UNSAFE_state = manager.initialState; 203 | this.reducerName = this._formatReducerName(this.name); 204 | 205 | if (this.debounce) { 206 | this._callRedux = _debounce(this._callRedux, this.debounceDelay); 207 | } 208 | }; 209 | 210 | exports.Event = Event; 211 | 212 | var HTTPEvent = /*#__PURE__*/function (_Event) { 213 | (0, _inherits2.default)(HTTPEvent, _Event); 214 | 215 | var _super = _createSuper(HTTPEvent); 216 | 217 | function HTTPEvent() { 218 | var _this2; 219 | 220 | var _ref4 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, 221 | name = _ref4.name, 222 | rest = (0, _objectWithoutProperties2.default)(_ref4, ["name"]); 223 | 224 | (0, _classCallCheck2.default)(this, HTTPEvent); 225 | _this2 = _super.call(this, _objectSpread({ 226 | name: name 227 | }, rest)); 228 | 229 | _this2._formatToRedux = function (dispatchData) { 230 | _this2.__UNSAFE_cachedArgs = dispatchData; 231 | var shouldDispatch = _this2.manager.shouldDispatch; 232 | return { 233 | types: _this2.reducers, 234 | extraData: dispatchData, 235 | apiCallFunction: _this2.manager.call(dispatchData), 236 | shouldDispatch: shouldDispatch || function () { 237 | return true; 238 | } 239 | }; 240 | }; 241 | 242 | _this2._createReducersTo = function () { 243 | return function () { 244 | var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : _this2.manager.initialState; 245 | var action = arguments.length > 1 ? arguments[1] : undefined; 246 | var newState = state; 247 | 248 | if (action.type === _this2.reducers.request) { 249 | newState = _this2.manager.onDispatch(state, action); 250 | } 251 | 252 | if (action.type === _this2.reducers.success) { 253 | newState = _this2.manager.onSuccess(state, action); 254 | 255 | if (_this2.manager.afterSuccess) { 256 | setTimeout(function () { 257 | return _this2.manager.afterSuccess(state, newState); 258 | }, 0); 259 | } 260 | } 261 | 262 | if (action.type === _this2.reducers.failure) { 263 | newState = _this2.manager.onFailure(state, action); 264 | 265 | if (_this2.manager.afterFailure) { 266 | setTimeout(function () { 267 | return _this2.manager.afterFailure(state, newState); 268 | }, 0); 269 | } 270 | } 271 | 272 | _this2.__UNSAFE_state = newState; 273 | 274 | _this2._chainEvents(action); 275 | 276 | return newState; 277 | }; 278 | }; 279 | 280 | delete _this2.reducerName; 281 | _this2.reducers = { 282 | request: "".concat(_this2._formatReducerName(_this2.name), "_REQUEST"), 283 | success: "".concat(_this2._formatReducerName(_this2.name), "_SUCCESS"), 284 | failure: "".concat(_this2._formatReducerName(_this2.name), "_FAILURE") 285 | }; 286 | return _this2; 287 | } 288 | 289 | return HTTPEvent; 290 | }(Event); 291 | 292 | exports.HTTPEvent = HTTPEvent; -------------------------------------------------------------------------------- /__tests__/events.test.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/named 2 | import { Event, HTTPEvent, __RewireAPI__ } from '../lib/events'; 3 | 4 | describe('Event', () => { 5 | it('should initialize correctly', async () => { 6 | let TestEvent = new Event({ 7 | name: 'testEvent', 8 | manager: { initialState: { initial: 'state' } }, 9 | }); 10 | expect(TestEvent.name).toEqual('testEvent'); 11 | expect(TestEvent.manager).toEqual({ initialState: { initial: 'state' } }); 12 | expect(TestEvent.__UNSAFE_state).toEqual({ initial: 'state' }); 13 | expect(TestEvent.reducerName).toEqual('TEST_EVENT'); 14 | expect(TestEvent.listenTo).toEqual([]); 15 | expect(TestEvent.debounce).toEqual(false); 16 | expect(TestEvent.debounceDelay).toEqual(300); 17 | 18 | const mockEvent = jest.fn(() => 'event'); 19 | const listenTo = [{ event: mockEvent, triggerOn: 'dispatch' }]; 20 | TestEvent = new Event({ 21 | name: 'testEvent', 22 | manager: { initialState: { initial: 'state' } }, 23 | listenTo, 24 | debounce: true, 25 | debounceDelay: 200, 26 | }); 27 | expect(TestEvent.name).toEqual('testEvent'); 28 | expect(TestEvent.manager).toEqual({ initialState: { initial: 'state' } }); 29 | expect(TestEvent.__UNSAFE_state).toEqual({ initial: 'state' }); 30 | expect(TestEvent.reducerName).toEqual('TEST_EVENT'); 31 | expect(TestEvent.listenTo).toEqual(listenTo); 32 | expect(TestEvent.debounce).toEqual(true); 33 | expect(TestEvent.debounceDelay).toEqual(200); 34 | }); 35 | 36 | it('should throw an error when initializing with Empty', async () => { 37 | expect(() => new Event()).toThrow('An Event should not be initialized without parameters.'); 38 | }); 39 | 40 | it('should throw error when initializing without name', async () => { 41 | expect(() => new Event({})).toThrow('An Event should be initialized with an event name.'); 42 | }); 43 | 44 | it('should throw error when initializing without manager', async () => { 45 | expect(() => new Event({ name: 'testEvent' })).toThrow( 46 | 'An Event should be initialized with an EventManager.', 47 | ); 48 | }); 49 | 50 | it('should throw error when initializing with debounce true and non number duration', async () => { 51 | expect( 52 | () => new Event({ name: 'testEvent', manager: {}, debounce: true, debounceDelay: 'wrong' }), 53 | ).toThrow('When debounce is true, debounceDelay needs to be a Number.'); 54 | }); 55 | 56 | it('should throw error when initializing with invalid listenTo param', async () => { 57 | expect(() => new Event({ name: 'testEvent', manager: {}, listenTo: {} })).toThrow( 58 | 'ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.', 59 | ); 60 | expect(() => new Event({ name: 'testEvent', manager: {}, listenTo: [{}] })).toThrow( 61 | 'ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.', 62 | ); 63 | expect(() => new Event({ name: 'testEvent', manager: {}, listenTo: [{ event: '' }] })).toThrow( 64 | 'ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.', 65 | ); 66 | expect( 67 | () => new Event({ name: 'testEvent', manager: {}, listenTo: [{ event: () => ({}) }] }), 68 | ).toThrow( 69 | 'ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.', 70 | ); 71 | expect( 72 | () => new Event({ name: 'testEvent', manager: {}, listenTo: [{ triggerOn: '' }] }), 73 | ).toThrow( 74 | 'ListenTo must be an array of { event, triggerOn } objects, and the event key should be a function that returns an Event or HTTPEvent.', 75 | ); 76 | expect( 77 | () => 78 | new Event({ 79 | name: 'testEvent', 80 | manager: {}, 81 | listenTo: [{ event: () => ({}), triggerOn: 'dispatch' }], 82 | }), 83 | ).not.toThrow(); 84 | }); 85 | 86 | it('_callRedux should call _formatToRedux passing data using redux dispatch', async () => { 87 | const TestEvent = new Event({ name: 'testEvent', manager: {} }); 88 | const reduxDispatch = jest.fn(); 89 | 90 | TestEvent._formatToRedux = jest.fn(); 91 | TestEvent.__UNSAFE_reduxDispatch = reduxDispatch; 92 | 93 | const data = { test: 'data' }; 94 | 95 | TestEvent._callRedux(data); 96 | 97 | expect(reduxDispatch).toBeCalled(); 98 | expect(TestEvent._formatToRedux).toBeCalledWith({ ...data, shouldDispatch: expect.Function }); 99 | }); 100 | 101 | it('_callRedux should be debounced if Event is initialized with debounce as true', async () => { 102 | const mockedDebounce = jest.fn(() => data => data); 103 | __RewireAPI__.__Rewire__('_debounce', mockedDebounce); 104 | 105 | const TestEvent = new Event({ name: 'testEvent', manager: {}, debounce: true }); 106 | 107 | expect(TestEvent._callRedux('test')).toEqual('test'); 108 | expect(mockedDebounce).toHaveBeenCalledWith(expect.any(Function), TestEvent.debounceDelay); 109 | }); 110 | 111 | it('debounced _callRedux should use passed debounceDelay if debouce is true', async () => { 112 | const mockedDebounce = jest.fn(() => data => data); 113 | __RewireAPI__.__Rewire__('_debounce', mockedDebounce); 114 | 115 | const TestEvent = new Event({ 116 | name: 'testEvent', 117 | manager: {}, 118 | debounce: true, 119 | debounceDelay: 200, 120 | }); 121 | 122 | expect(TestEvent._callRedux('test')).toEqual('test'); 123 | expect(mockedDebounce).toHaveBeenCalledWith(expect.any(Function), 200); 124 | }); 125 | 126 | it('_bindDataToProps should map state and set event on it', async () => { 127 | const TestEvent = new Event({ name: 'testEvent', manager: {} }); 128 | const keysList = ['test', 'yep']; 129 | const state = { testEvent: {} }; 130 | 131 | const _bindDataToProps = TestEvent._bindDataToProps(keysList); 132 | const mappedState = _bindDataToProps(state); 133 | 134 | expect(typeof _bindDataToProps).toBe('function'); 135 | expect(mappedState._event_testEvent).toBe(TestEvent); 136 | expect(mappedState).toHaveProperty('test'); 137 | expect(mappedState).toHaveProperty('yep'); 138 | }); 139 | 140 | it('_bindDispatchToProps should map actions and set __UNSAFE_reduxDispatch', async () => { 141 | const reduxDispatch = jest.fn(); 142 | const TestEvent = new Event({ name: 'testEvent', manager: {} }); 143 | TestEvent._callRedux = jest.fn(() => 'dispatched'); 144 | 145 | expect(TestEvent.__UNSAFE_reduxDispatch).toBeUndefined(); 146 | 147 | const _bindDispatchToProps = TestEvent._bindDispatchToProps(reduxDispatch); 148 | 149 | expect(TestEvent.__UNSAFE_reduxDispatch).toEqual(reduxDispatch); 150 | expect(_bindDispatchToProps.testEvent()).toEqual('dispatched'); 151 | }); 152 | 153 | it('_formatToRedux should return correct data', async () => { 154 | let TestEvent = new Event({ name: 'testEvent', manager: {} }); 155 | let expectedReturn = { 156 | type: 'TEST_EVENT', 157 | extraData: { 158 | test: 'yes', 159 | }, 160 | test: 'yes', 161 | shouldDispatch: expect.any(Function), 162 | }; 163 | 164 | expect(TestEvent.__UNSAFE_cachedArgs).toBeUndefined(); 165 | let returnedValue = TestEvent._formatToRedux({ test: 'yes' }); 166 | expect(TestEvent.__UNSAFE_cachedArgs).toEqual({ test: 'yes' }); 167 | 168 | expect(returnedValue).toEqual(expectedReturn); 169 | expect(returnedValue.shouldDispatch()).toEqual(true); 170 | 171 | const shouldDispatch = () => 'lala'; 172 | TestEvent = new Event({ name: 'testEvent', manager: { shouldDispatch } }); 173 | expectedReturn = { 174 | type: 'TEST_EVENT', 175 | extraData: { 176 | test: 'yes', 177 | }, 178 | test: 'yes', 179 | shouldDispatch, 180 | }; 181 | 182 | returnedValue = TestEvent._formatToRedux({ test: 'yes' }); 183 | expect(returnedValue).toEqual(expectedReturn); 184 | expect(returnedValue.shouldDispatch()).toEqual('lala'); 185 | }); 186 | 187 | it('createReducers should return object containing reducers', async () => { 188 | const EventManager = { 189 | onDispatch: jest.fn(() => ({ test: 'it works!' })), 190 | initialState: { initial: 'state' }, 191 | }; 192 | const TestEvent = new Event({ name: 'testEvent', manager: EventManager }); 193 | TestEvent._chainEvents = jest.fn(); 194 | const reducers = TestEvent.createReducers(); 195 | 196 | expect(reducers).toHaveProperty('testEvent'); 197 | expect(typeof reducers.testEvent).toBe('function'); 198 | 199 | expect(TestEvent._chainEvents).not.toBeCalled(); 200 | expect(reducers.testEvent(undefined, { type: 'notThisOne' })).toEqual( 201 | EventManager.initialState, 202 | ); 203 | expect(EventManager.onDispatch).not.toBeCalled(); 204 | 205 | expect(reducers.testEvent({}, { type: 'TEST_EVENT' })).toEqual({ test: 'it works!' }); 206 | expect(EventManager.onDispatch).toBeCalled(); 207 | expect(TestEvent._chainEvents).toBeCalledWith({ type: 'TEST_EVENT' }); 208 | expect(TestEvent.__UNSAFE_state).toEqual({ test: 'it works!' }); 209 | }); 210 | 211 | it('createReducers should return object containing reducers from other event for event with useDataFrom', async () => { 212 | const EventManager = { 213 | onDispatch: jest.fn(() => ({ test: 'it works!' })), 214 | initialState: { initial: 'state' }, 215 | }; 216 | const TestEvent = new Event({ 217 | name: 'testEvent', 218 | useDataFrom: 'otherEvent', 219 | manager: EventManager, 220 | }); 221 | TestEvent._chainEvents = jest.fn(); 222 | const reducers = TestEvent.createReducers(); 223 | 224 | expect(reducers).toHaveProperty('otherEvent'); 225 | expect(reducers.testEvent).toBeUndefined(); 226 | expect(typeof reducers.otherEvent).toBe('function'); 227 | 228 | expect(TestEvent._chainEvents).not.toBeCalled(); 229 | expect(reducers.otherEvent(undefined, { type: 'notThisOne' })).toEqual( 230 | EventManager.initialState, 231 | ); 232 | expect(EventManager.onDispatch).not.toBeCalled(); 233 | 234 | expect(reducers.otherEvent({}, { type: 'TEST_EVENT' })).toEqual({ test: 'it works!' }); 235 | expect(EventManager.onDispatch).toBeCalled(); 236 | expect(TestEvent._chainEvents).toBeCalledWith({ type: 'TEST_EVENT' }); 237 | expect(TestEvent.__UNSAFE_state).toEqual({ test: 'it works!' }); 238 | }); 239 | 240 | it('createReducers should return object containing reducers with after dispatch', async () => { 241 | jest.useFakeTimers(); 242 | const EventManager = { 243 | onDispatch: jest.fn(() => ({ test: 'it works!' })), 244 | afterDispatch: jest.fn(() => 'after dispatch triggered!'), 245 | }; 246 | const TestEvent = new Event({ name: 'testEvent', manager: EventManager }); 247 | const reducers = TestEvent.createReducers(); 248 | 249 | expect(reducers).toHaveProperty('testEvent'); 250 | expect(typeof reducers.testEvent).toBe('function'); 251 | 252 | expect(reducers.testEvent({}, { type: 'notThisOne' })).toEqual({}); 253 | expect(EventManager.onDispatch).not.toBeCalled(); 254 | 255 | expect(reducers.testEvent({}, { type: 'TEST_EVENT' })).toEqual({ test: 'it works!' }); 256 | expect(EventManager.onDispatch).toBeCalled(); 257 | 258 | expect(EventManager.afterDispatch).not.toBeCalled(); 259 | jest.runAllTimers(); 260 | expect(EventManager.afterDispatch).toBeCalledWith({}, { test: 'it works!' }); 261 | }); 262 | 263 | it('register should throw if no Component is passed', async () => { 264 | const TestEvent = new Event({ name: 'testEvent', manager: {} }); 265 | 266 | expect(() => TestEvent.register({ props: ['test'] })).toThrow( 267 | 'You must pass a Component inside the Component key when registering it to an Event.', 268 | ); 269 | }); 270 | 271 | it('register should call redux connect correctly', async () => { 272 | const TestEvent = new Event({ name: 'testEvent', manager: {} }); 273 | const Component = {}; 274 | 275 | const returnedConnect = jest.fn(() => 'final return'); 276 | const mockedReduxConnect = jest.fn(() => returnedConnect); 277 | __RewireAPI__.__Rewire__('connect', mockedReduxConnect); 278 | TestEvent._bindDataToProps = jest.fn(() => 'bound data'); 279 | 280 | let returnedValue = TestEvent.register({ Component, props: ['test'] }); 281 | expect(returnedValue).toBe('final return'); 282 | 283 | expect(mockedReduxConnect).toHaveBeenCalledWith('bound data', TestEvent._bindDispatchToProps); 284 | expect(TestEvent._bindDataToProps).toHaveBeenCalledWith(['test']); 285 | expect(returnedConnect).toHaveBeenCalledWith(Component); 286 | 287 | returnedValue = TestEvent.register({ Component }); 288 | expect(returnedValue).toBe('final return'); 289 | 290 | expect(mockedReduxConnect).toHaveBeenCalledWith('bound data', TestEvent._bindDispatchToProps); 291 | expect(TestEvent._bindDataToProps).toHaveBeenCalledWith([]); 292 | expect(returnedConnect).toHaveBeenCalledWith(Component); 293 | }); 294 | 295 | it('register should raise error when props key is passed to event with useDataFrom', async () => { 296 | const TestEvent = new Event({ name: 'testEvent', useDataFrom: 'otherEvent', manager: {} }); 297 | const Component = {}; 298 | 299 | expect(() => TestEvent.register({ Component, props: ['test'] })).toThrow( 300 | `When configuring 'useDataFrom', you will end up with an empty state. Listen to the event with the name described in the 'useDataFrom' key instead.`, 301 | ); 302 | }); 303 | 304 | it('_chainEvents iterates listenTo array and calls correct functions for normal events', async () => { 305 | jest.useFakeTimers(); 306 | const ListenedEventReturnFunction = jest.fn(() => ({ 307 | reducerName: 'LISTENED_EVENT', 308 | __UNSAFE_state: { data: 'here' }, 309 | })); 310 | const TestEvent = new Event({ 311 | name: 'testEvent', 312 | manager: {}, 313 | listenTo: [{ event: ListenedEventReturnFunction, triggerOn: 'onDispatch' }], 314 | __UNSAFE_callArgs: { test: 'data' }, 315 | }); 316 | const action = { type: 'LISTENED_EVENT', __UNSAFE_dispatch: jest.fn() }; 317 | TestEvent._formatToRedux = jest.fn(() => '_formatToReduxCalled'); 318 | expect(ListenedEventReturnFunction).not.toBeCalled(); 319 | 320 | TestEvent._chainEvents(action); 321 | 322 | expect(ListenedEventReturnFunction).toBeCalled(); 323 | expect(TestEvent._formatToRedux).not.toBeCalled(); 324 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 325 | 326 | jest.runAllTimers(); 327 | expect(action.__UNSAFE_dispatch).toBeCalledWith('_formatToReduxCalled'); 328 | expect(TestEvent._formatToRedux).toBeCalledWith({ data: 'here' }); 329 | }); 330 | 331 | it('_chainEvents iterates listenTo array and calls with cached args', async () => { 332 | jest.useFakeTimers(); 333 | const ListenedEventReturnFunction = jest.fn(() => ({ 334 | reducerName: 'LISTENED_EVENT', 335 | __UNSAFE_state: { data: 'here' }, 336 | })); 337 | const TestEvent = new Event({ 338 | name: 'testEvent', 339 | manager: {}, 340 | listenTo: [ 341 | { 342 | event: ListenedEventReturnFunction, 343 | triggerOn: 'onDispatch', 344 | autocompleteCallArgs: true, 345 | }, 346 | ], 347 | }); 348 | TestEvent.__UNSAFE_cachedArgs = { test: 'data', data: 'there' }; 349 | const action = { type: 'LISTENED_EVENT', __UNSAFE_dispatch: jest.fn() }; 350 | TestEvent._formatToRedux = jest.fn(() => '_formatToReduxCalled'); 351 | expect(ListenedEventReturnFunction).not.toBeCalled(); 352 | 353 | TestEvent._chainEvents(action); 354 | 355 | expect(ListenedEventReturnFunction).toBeCalled(); 356 | expect(TestEvent._formatToRedux).not.toBeCalled(); 357 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 358 | 359 | jest.runAllTimers(); 360 | expect(action.__UNSAFE_dispatch).toBeCalledWith('_formatToReduxCalled'); 361 | expect(TestEvent._formatToRedux).toBeCalledWith({ data: 'here', test: 'data' }); 362 | }); 363 | }); 364 | 365 | describe('HTTPEvent', () => { 366 | it('should initialize correctly', async () => { 367 | let TestEvent = new HTTPEvent({ name: 'testEvent', manager: {} }); 368 | expect(TestEvent).not.toHaveProperty('reducerName'); 369 | expect(TestEvent.name).toEqual('testEvent'); 370 | expect(TestEvent.manager).toEqual({}); 371 | expect(TestEvent.listenTo).toEqual([]); 372 | expect(TestEvent.reducers).toEqual({ 373 | request: 'TEST_EVENT_REQUEST', 374 | success: 'TEST_EVENT_SUCCESS', 375 | failure: 'TEST_EVENT_FAILURE', 376 | }); 377 | 378 | const mockEvent = jest.fn(() => 'event'); 379 | const listenTo = [{ event: mockEvent, triggerOn: 'onDispatch' }]; 380 | TestEvent = new HTTPEvent({ name: 'testEvent', manager: {}, listenTo }); 381 | expect(TestEvent).not.toHaveProperty('reducerName'); 382 | expect(TestEvent.name).toEqual('testEvent'); 383 | expect(TestEvent.manager).toEqual({}); 384 | expect(TestEvent.listenTo).toEqual(listenTo); 385 | expect(TestEvent.reducers).toEqual({ 386 | request: 'TEST_EVENT_REQUEST', 387 | success: 'TEST_EVENT_SUCCESS', 388 | failure: 'TEST_EVENT_FAILURE', 389 | }); 390 | }); 391 | 392 | it('should throw an error when initializing with Empty', async () => { 393 | expect(() => new HTTPEvent()).toThrow('An Event should be initialized with an event name.'); 394 | }); 395 | 396 | it('_formatToRedux should return correct data', async () => { 397 | let EventManager = { 398 | call: jest.fn(() => 'api called'), 399 | }; 400 | let TestEvent = new HTTPEvent({ name: 'testEvent', manager: EventManager }); 401 | let expectedReturn = { 402 | types: TestEvent.reducers, 403 | extraData: { test: 'data' }, 404 | apiCallFunction: 'api called', 405 | shouldDispatch: expect.any(Function), 406 | }; 407 | 408 | expect(TestEvent.__UNSAFE_cachedArgs).toBeUndefined(); 409 | let _formatToReduxReturn = TestEvent._formatToRedux({ test: 'data' }); 410 | expect(TestEvent.__UNSAFE_cachedArgs).toEqual({ test: 'data' }); 411 | 412 | expect(_formatToReduxReturn).toEqual(expectedReturn); 413 | expect(_formatToReduxReturn.shouldDispatch()).toBeTruthy(); 414 | 415 | EventManager = { 416 | call: jest.fn(() => 'api called'), 417 | shouldDispatch: () => false, 418 | }; 419 | TestEvent = new HTTPEvent({ name: 'testEvent', manager: EventManager }); 420 | expectedReturn = { 421 | types: TestEvent.reducers, 422 | extraData: { test: 'data' }, 423 | apiCallFunction: 'api called', 424 | shouldDispatch: EventManager.shouldDispatch, 425 | }; 426 | 427 | _formatToReduxReturn = TestEvent._formatToRedux({ test: 'data' }); 428 | expect(_formatToReduxReturn).toEqual(expectedReturn); 429 | expect(_formatToReduxReturn.shouldDispatch()).toBeFalsy(); 430 | }); 431 | 432 | it('createReducers should return object containing reducers', async () => { 433 | const EventManager = { 434 | onDispatch: jest.fn(() => 'request dispatched'), 435 | onSuccess: jest.fn(() => 'success dispatched'), 436 | onFailure: jest.fn(() => 'failure dispatched'), 437 | call: jest.fn(), 438 | initialState: { initial: 'state' }, 439 | }; 440 | const TestEvent = new HTTPEvent({ name: 'testEvent', manager: EventManager }); 441 | const reducers = TestEvent.createReducers(); 442 | 443 | expect(reducers).toHaveProperty('testEvent'); 444 | expect(typeof reducers.testEvent).toBe('function'); 445 | 446 | expect(reducers.testEvent(undefined, { type: 'notThisOne' })).toEqual( 447 | EventManager.initialState, 448 | ); 449 | expect(EventManager.onDispatch).not.toBeCalled(); 450 | expect(EventManager.onSuccess).not.toBeCalled(); 451 | expect(EventManager.onFailure).not.toBeCalled(); 452 | 453 | expect(reducers.testEvent({}, { type: 'TEST_EVENT_REQUEST' })).toEqual('request dispatched'); 454 | expect(EventManager.onDispatch).toBeCalledWith({}, { type: 'TEST_EVENT_REQUEST' }); 455 | 456 | expect(reducers.testEvent({}, { type: 'TEST_EVENT_SUCCESS' })).toEqual('success dispatched'); 457 | expect(EventManager.onSuccess).toBeCalledWith({}, { type: 'TEST_EVENT_SUCCESS' }); 458 | 459 | expect(reducers.testEvent({}, { type: 'TEST_EVENT_FAILURE' })).toEqual('failure dispatched'); 460 | expect(EventManager.onFailure).toBeCalledWith({}, { type: 'TEST_EVENT_FAILURE' }); 461 | }); 462 | 463 | it('createReducers should return object containing reducers with after success and failure', async () => { 464 | jest.useFakeTimers(); 465 | 466 | const EventManager = { 467 | onDispatch: jest.fn(() => 'request dispatched'), 468 | onSuccess: jest.fn(() => 'success dispatched'), 469 | onFailure: jest.fn(() => 'failure dispatched'), 470 | afterSuccess: jest.fn(() => 'success dispatched'), 471 | afterFailure: jest.fn(() => 'failure dispatched'), 472 | call: jest.fn(), 473 | }; 474 | const TestEvent = new HTTPEvent({ name: 'testEvent', manager: EventManager }); 475 | const reducers = TestEvent.createReducers(); 476 | 477 | expect(reducers).toHaveProperty('testEvent'); 478 | expect(typeof reducers.testEvent).toBe('function'); 479 | 480 | expect(reducers.testEvent({}, { type: 'notThisOne' })).toEqual({}); 481 | expect(EventManager.onDispatch).not.toBeCalled(); 482 | expect(EventManager.onSuccess).not.toBeCalled(); 483 | expect(EventManager.onFailure).not.toBeCalled(); 484 | 485 | expect(reducers.testEvent({}, { type: 'TEST_EVENT_REQUEST' })).toEqual('request dispatched'); 486 | expect(EventManager.onDispatch).toBeCalledWith({}, { type: 'TEST_EVENT_REQUEST' }); 487 | 488 | expect(reducers.testEvent({}, { type: 'TEST_EVENT_SUCCESS' })).toEqual('success dispatched'); 489 | expect(EventManager.onSuccess).toBeCalledWith({}, { type: 'TEST_EVENT_SUCCESS' }); 490 | 491 | expect(reducers.testEvent({}, { type: 'TEST_EVENT_FAILURE' })).toEqual('failure dispatched'); 492 | expect(EventManager.onFailure).toBeCalledWith({}, { type: 'TEST_EVENT_FAILURE' }); 493 | 494 | expect(EventManager.afterSuccess).not.toBeCalled(); 495 | expect(EventManager.afterFailure).not.toBeCalled(); 496 | jest.runAllTimers(); 497 | expect(EventManager.afterSuccess).toBeCalledWith({}, 'success dispatched'); 498 | expect(EventManager.afterFailure).toBeCalledWith({}, 'failure dispatched'); 499 | }); 500 | 501 | it('_chainEvents iterates listenTo array and calls correct functions for request events', async () => { 502 | jest.useFakeTimers(); 503 | const ListenedEventReturnFunction = jest.fn(() => ({ 504 | reducers: { onSuccess: 'LISTENED_EVENT' }, 505 | __UNSAFE_state: '__UNSAFE_state', 506 | })); 507 | const TestEvent = new Event({ 508 | name: 'testEvent', 509 | manager: {}, 510 | listenTo: [{ event: ListenedEventReturnFunction, triggerOn: 'onSuccess' }], 511 | }); 512 | const action = { type: 'LISTENED_EVENT', __UNSAFE_dispatch: jest.fn() }; 513 | TestEvent._formatToRedux = jest.fn(() => '_formatToReduxCalled'); 514 | expect(ListenedEventReturnFunction).not.toBeCalled(); 515 | 516 | TestEvent._chainEvents(action); 517 | 518 | expect(ListenedEventReturnFunction).toBeCalled(); 519 | expect(TestEvent._formatToRedux).not.toBeCalled(); 520 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 521 | 522 | jest.runAllTimers(); 523 | expect(action.__UNSAFE_dispatch).toBeCalledWith('_formatToReduxCalled'); 524 | expect(TestEvent._formatToRedux).toBeCalledWith('__UNSAFE_state'); 525 | }); 526 | 527 | it('_chainEvents iterates listenTo array and does not call for unmatched triggerOn', async () => { 528 | jest.useFakeTimers(); 529 | const ListenedEventReturnFunction = jest.fn(() => ({ 530 | reducers: { onSuccess: 'LISTENED_EVENT' }, 531 | __UNSAFE_state: '__UNSAFE_state', 532 | })); 533 | const TestEvent = new Event({ 534 | name: 'testEvent', 535 | manager: {}, 536 | listenTo: [{ event: ListenedEventReturnFunction, triggerOn: 'onDispatch' }], 537 | }); 538 | const action = { type: 'LISTENED_EVENT', __UNSAFE_dispatch: jest.fn() }; 539 | TestEvent._formatToRedux = jest.fn(() => '_formatToReduxCalled'); 540 | expect(ListenedEventReturnFunction).not.toBeCalled(); 541 | 542 | TestEvent._chainEvents(action); 543 | 544 | expect(ListenedEventReturnFunction).toBeCalled(); 545 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 546 | expect(TestEvent._formatToRedux).not.toBeCalled(); 547 | 548 | jest.runAllTimers(); 549 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 550 | expect(TestEvent._formatToRedux).not.toBeCalled(); 551 | }); 552 | 553 | it('_chainEvents iterates listenTo array and does not call strange events', async () => { 554 | jest.useFakeTimers(); 555 | const ListenedEventReturnFunction = jest.fn(() => ({ 556 | reducers: { onSuccess: 'LISTENED_EVENT' }, 557 | __UNSAFE_state: '__UNSAFE_state', 558 | })); 559 | const TestEvent = new Event({ 560 | name: 'testEvent', 561 | manager: {}, 562 | listenTo: [{ event: ListenedEventReturnFunction, triggerOn: 'onSuccess' }], 563 | }); 564 | const action = { type: 'NOT_LISTENED_EVENT', __UNSAFE_dispatch: jest.fn() }; 565 | TestEvent._formatToRedux = jest.fn(() => '_formatToReduxCalled'); 566 | expect(ListenedEventReturnFunction).not.toBeCalled(); 567 | 568 | TestEvent._chainEvents(action); 569 | 570 | expect(ListenedEventReturnFunction).toBeCalled(); 571 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 572 | expect(TestEvent._formatToRedux).not.toBeCalled(); 573 | 574 | jest.runAllTimers(); 575 | expect(action.__UNSAFE_dispatch).not.toBeCalled(); 576 | expect(TestEvent._formatToRedux).not.toBeCalled(); 577 | }); 578 | }); 579 | --------------------------------------------------------------------------------