├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .huskyrc.js ├── .lintstagedrc.json ├── .markdownlint.json ├── .npmignore ├── .nycrc.json ├── .prettierignore ├── .textlintrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── renovate.json ├── src ├── core.js ├── core.test.js ├── errors.js ├── helpers.js ├── helpers.test.js └── index.js └── tea.yaml /.eslintignore: -------------------------------------------------------------------------------- 1 | !.*.js 2 | coverage/ 3 | dist/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 9 | "parserOptions": { 10 | "sourceType": "module", 11 | "ecmaVersion": 2017 12 | }, 13 | "rules": { 14 | "linebreak-style": ["error", "unix"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | coverage/ 3 | dist/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'lint-staged && npm test' 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": ["eslint --fix", "git add"], 3 | "*.md": ["markdownlint", "textlint", "git add"], 4 | "*.{json,yml}": ["prettier --write", "git add"] 5 | } 6 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "line-length": false 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | source/ 2 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": ["html", "text"], 3 | "exclude": ["src/*.test.js"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | .nyc_output/ 3 | coverage/ 4 | dist/ 5 | node_modules/ -------------------------------------------------------------------------------- /.textlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | filters: {}, 3 | rules: { 4 | '@textlint-rule/no-invalid-control-character': true, 5 | 'common-misspellings': { 6 | ignore: [] 7 | }, 8 | terminology: { 9 | defaultTerms: true 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - '10' 5 | install: 6 | - npm install 7 | script: 8 | - npm run test-coverage-ci 9 | after_success: 10 | - npm run show-coverage-ci 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Eric Elliott 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autodux 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/ericelliott/autodux/badge.svg?branch=master)](https://coveralls.io/github/ericelliott/autodux?branch=master) 4 | 5 | Redux on autopilot. 6 | 7 | Brought to you by [EricElliottJS.com](https://EricElliottJS.com) and [DevAnywhere.io](https://devanywhere.io). 8 | 9 | ## Install 10 | 11 | ```shell 12 | npm install --save autodux 13 | ``` 14 | 15 | And then in your file: 16 | 17 | ```js 18 | import autodux from 'autodux'; 19 | ``` 20 | 21 | Or using CommonJS syntax: 22 | 23 | ```js 24 | const autodux = require('autodux'); 25 | ``` 26 | 27 | ## Redux on Autopilot 28 | 29 | Autodux lets you create reducers like this: 30 | 31 | ```js 32 | export const { 33 | actions: { 34 | setUser, setUserName, setAvatar 35 | }, 36 | selectors: { 37 | getUser, getUserName, getAvatar 38 | } 39 | } = autodux({ 40 | slice: 'user', 41 | initial: { 42 | userName: 'Anonymous', 43 | avatar: 'anon.png' 44 | } 45 | }); 46 | ``` 47 | 48 | That creates a full set of action creators, selectors, reducers, and action type constants -- everything you need for fully functional Redux state management. 49 | 50 | Everything's on autopilot -- but you can override everything when you need to. 51 | 52 | ## Why 53 | 54 | Redux is great, but you have to make a lot of boilerplate: 55 | 56 | * Action type constants 57 | * Action creators 58 | * Reducers 59 | * Selectors 60 | 61 | It's **great** that Redux is such a low-level tool. It's allowed a lot of flexibility and enabled the community to experiment with best practices and patterns. 62 | 63 | It's **terrible** that Redux is such a low-level tool. It turns out that: 64 | 65 | * Most reducers spend most of their logic switching over action types. 66 | * Most of the actual state updates could be replaced with generic tools like "concat payload to an array", "remove object from array by some prop", or "increment a number". Better to reuse utilities than to implement these things from scratch every time. 67 | * Lots of action creators don't need arguments or payloads -- just action types. 68 | * Action types can be automatically generated by combining the name of the state slice with the name of the action, e.g., `counter/increment`. 69 | * Lots of selectors just need to grab the state slice. 70 | 71 | Lots of Redux beginners separate all these things into separate files, meaning you have to open and import a whole bunch of files just to get some simple state management in your app. 72 | 73 | What if you could write some simple, declarative code that would automatically create your: 74 | 75 | * Action type constants 76 | * Reducer switching logic 77 | * State slice selectors 78 | * Action object shape - automatically inserting the correct action type so all you have to worry about is the payload 79 | * Action creators if the input is the payload or no payload is required, i.e., `x => ({ type: 'foo', payload: x })` 80 | * Action reducers if the state can be assigned from the action payload (i.e., `{...state, ...payload}`) 81 | 82 | Turns out, when you add this simple logic on top of Redux, you can do a lot more with a lot less code. 83 | 84 | ```js 85 | import autodux, { id } from 'autodux'; 86 | 87 | export const { 88 | reducer, 89 | initial, 90 | slice, 91 | actions: { 92 | increment, 93 | decrement, 94 | multiply 95 | }, 96 | selectors: { 97 | getValue 98 | } 99 | } = autodux({ 100 | // the slice of state your reducer controls 101 | slice: 'counter', 102 | 103 | // The initial value of your reducer state 104 | initial: 0, 105 | 106 | // No need to implement switching logic -- it's 107 | // done for you. 108 | actions: { 109 | increment: state => state + 1, 110 | decrement: state => state - 1, 111 | multiply: (state, payload) => state * payload 112 | }, 113 | 114 | // No need to select the state slice -- it's done for you. 115 | selectors: { 116 | getValue: id 117 | } 118 | }); 119 | ``` 120 | 121 | As you can see, you can destructure and export the return value directly where you call `autodux()` to reduce boilerplate to a minimum. It returns an object that looks like this: 122 | 123 | ```js 124 | { 125 | initial: 0, 126 | actions: { 127 | increment: { [Function] 128 | type: 'counter/increment' 129 | }, 130 | decrement: { [Function] 131 | type: 'counter/decrement' 132 | }, 133 | multiply: { [Function] 134 | type: 'counter/multiply' 135 | } 136 | }, 137 | selectors: { 138 | getValue: [Function: wrapper] 139 | }, 140 | reducer: [Function: reducer], 141 | slice: 'counter' 142 | } 143 | ``` 144 | 145 | Let's explore that object a bit: 146 | 147 | ```js 148 | const actions = [ 149 | increment(), 150 | increment(), 151 | increment(), 152 | decrement() 153 | ]; 154 | 155 | const state = actions.reduce(reducer, initial); 156 | 157 | console.log(getValue({ counter: state })); // 2 158 | console.log(increment.type); // 'counter/increment' 159 | ``` 160 | 161 | ## API Differences 162 | 163 | Action creators, reducers and selectors have simplified APIs. 164 | 165 | ### Automate (Almost) Everything with Defaults 166 | 167 | With autodux, you can omit (almost) everything. 168 | 169 | #### Default Actions 170 | 171 | An action is an action creator/reducer pair. Usually, these line up in neat 1:1 mappings. It turns out, you can do a lot with some simple defaults: 172 | 173 | * A `set${slice}` action lets you set any key in your reducer -- basically: `{...state, ...payload}`. If your `slice` is called `user`, the `set` creator will be called `setUser`. 174 | * Actions for `set{key}` will exist for each key in your `initial` state. If you have a key called `userName`, you'll have an action called `setUserName` created automatically. 175 | 176 | #### Default Selectors 177 | 178 | Like action creators and reducers, selectors are automatically created for each key in your initial state. `get{key}` will exist for each key in your `initial` state., and `get{slice}` will exist for the entire reducer state. 179 | 180 | For simple reducers, all the action creators, reducers, and selectors can be created for you automatically. All you have to do is specify the initial state shape and export the bindings. 181 | 182 | ### Action Creators 183 | 184 | Action creators are optional! If you need to set a username, you might normally create an action creator like this: 185 | 186 | ```js 187 | const setUserName = userName => ({ 188 | type: 'userReducer/setUserName', 189 | payload: userName 190 | }) 191 | ``` 192 | 193 | With autodux, if your action creator maps directly from input to payload, you can omit it. autodux will do it for you. 194 | 195 | By omitting the action creator, you can shorten this: 196 | 197 | ```js 198 | actions: { 199 | multiply: { 200 | create: by => by, 201 | reducer: (state, payload) => state * payload 202 | } 203 | } 204 | ``` 205 | 206 | To this: 207 | 208 | ```js 209 | actions: { 210 | multiply: (state, payload) => state * payload 211 | } 212 | ``` 213 | 214 | #### No need to set the type 215 | 216 | You don't need to worry about setting the type in autodux action creators. That's handled for you automatically. In other words, all an action creator has to do is return the payload. 217 | 218 | With Redux alone you might write: 219 | 220 | ```js 221 | const setUserName = userName => ({ 222 | type: 'userReducer/setUserName', 223 | payload: userName 224 | }) 225 | ``` 226 | 227 | With autodux, that becomes: 228 | 229 | ```js 230 | userName => userName 231 | ``` 232 | 233 | Since that's the default behavior, you can omit that one entirely. 234 | 235 | You don't need to create action creators unless you need to map the inputs to a different payload output. For example, if you need to translate between an auth provider user and your own application user objects, you could use an action creator like this: 236 | 237 | ```js 238 | ({ userId, displayName }) => ({ uid: userId, userName: displayName }) 239 | ``` 240 | 241 | Here's how you'd implement our multiply action if you want to use a named parameter for the multiplication factor: 242 | 243 | ```js 244 | //... 245 | actions: { 246 | multiply: { 247 | // Destructure the named parameter, and return it 248 | // as the action payload: 249 | create: ({ by }) => by, 250 | reducer: (state, payload) => state * payload 251 | } 252 | } 253 | //... 254 | ``` 255 | 256 | ### Reducers 257 | 258 | > Note: Reducers are optional. If your reducer would just assign the payload props to the state (`{...state, ...payload}`), you're already done. 259 | 260 | #### No switch required 261 | 262 | Switching over different action types is automatic, so we don't need an action object that isolates the action type and payload. Instead, we pass the action payload directly, e.g: 263 | 264 | With Redux: 265 | 266 | ```js 267 | const INCREMENT = 'INCREMENT'; 268 | const DECREMENT = 'DECREMENT'; 269 | const MULTIPLY = 'MULTIPLY'; 270 | 271 | const counter = (state = 0, action = {}) { 272 | switch (action.type){ 273 | case INCREMENT: return state + 1; 274 | case DECREMENT: return state - 1; 275 | case MULTIPLY : return state * action.payload 276 | default: return state; 277 | } 278 | }; 279 | ``` 280 | 281 | With Autodux, action type handlers are switched over automatically. No more switching logic or manual juggling with action type constants. 282 | 283 | ```js 284 | const counter = autodux({ 285 | slice: 'counter', 286 | initial: 0, 287 | actions: { 288 | increment: state => state + 1, 289 | decrement: state => state - 1, 290 | multiply: (state, payload) => state * payload 291 | } 292 | }); 293 | ``` 294 | 295 | Autodux infers action types for you automatically using the slice and the action name, and eliminates the need to write switching logic or worry about (or forget) the default case. 296 | 297 | Because the switching is handled automatically, your reducers don't need to worry about the action type. Instead, they're passed the payload directly. 298 | 299 | ### Selectors 300 | 301 | > Note: Selectors are optional. By default, every key in your initial state will have its own selector, prepended with `get` and camelCased. For example, if you have a key called `userName`, a `getUserName` selector will be created automatically. 302 | 303 | Selectors are designed to take the application's complete root state object, but the slice you care about is automatically selected for you, so you can write your selectors as if you're only dealing with the local reducer. 304 | 305 | This has some implications with unit tests. The following selector will just return the local reducer state (*Note: You'll automatically get a default selector that does the same thing, so you don't ever need to do this yourself*): 306 | 307 | ```js 308 | import { autodux, id } from 'autodux'; 309 | 310 | const counter = autodux({ 311 | // stuff here 312 | selectors: { 313 | getValue: id // state => state 314 | }, 315 | // other stuff 316 | ``` 317 | 318 | In your unit tests, you'll need to pass the key for the state slice to mock the global store state: 319 | 320 | ```js 321 | test('counter.getValue', assert => { 322 | const msg = 'should return the current count'; 323 | const { getValue } = counter.selectors; 324 | 325 | const actual = getValue({ counter: 3 }); 326 | const expected = 3; 327 | 328 | assert.same(actual, expected, msg); 329 | assert.end(); 330 | }); 331 | ``` 332 | 333 | Although you should avoid selecting state from outside the slice you care about, the root state object is passed as a convenience second argument to selectors: 334 | 335 | ```js 336 | import autodux from 'autodux'; 337 | 338 | const counter = autodux({ 339 | // stuff here 340 | selectors: { 341 | // other selectors 342 | rootState: (_, root) => root 343 | }, 344 | // other stuff 345 | ``` 346 | 347 | In your unit tests, this allows you to retrieve the entire root state: 348 | 349 | ```js 350 | test('counter.rootState', assert => { 351 | const msg = 'should return the root state'; 352 | const { rootState } = counter.selectors; 353 | 354 | const actual = rootState({ counter: 3, otherSlice: 'data' }); 355 | const expected = { counter: 3, otherSlice: 'data' }; 356 | 357 | assert.same(actual, expected, msg); 358 | assert.end(); 359 | }); 360 | ``` 361 | 362 | ## Extras 363 | 364 | ### `assign = (key: String) => reducer: Function` 365 | 366 | Often, we want our reducers to simply set a key in the state to the payload value. `assign()` makes that easy. e.g.: 367 | 368 | ```js 369 | const { 370 | actions: { 371 | setUserName, 372 | setAvatar 373 | }, 374 | reducer 375 | } = autodux({ 376 | slice: 'user', 377 | initial: { 378 | userName: 'Anonymous', 379 | avatar: 'anonymous.png' 380 | }, 381 | actions: { 382 | setUserName: assign('userName'), 383 | setAvatar: assign('avatar') 384 | } 385 | }); 386 | 387 | const userName = 'Foo'; 388 | const avatar = 'foo.png'; 389 | 390 | const state = [ 391 | setUserName(userName), 392 | setAvatar(avatar) 393 | ].reduce(reducer, undefined); 394 | // => { userName: 'Foo', avatar: 'foo.png' } 395 | ``` 396 | 397 | Since that's the default behavior when actions are omitted, you can also shorten that to: 398 | 399 | ```js 400 | const { 401 | actions: { 402 | setUserName, 403 | setAvatar 404 | }, 405 | reducer 406 | } = autodux({ 407 | slice: 'user', 408 | initial: { 409 | userName: 'Anonymous', 410 | avatar: 'anonymous.png' 411 | } 412 | }); 413 | ``` 414 | 415 | ### `id = x => x` 416 | 417 | Useful for selectors that simply return the slice state: 418 | 419 | ```js 420 | selectors: { 421 | getValue: id 422 | } 423 | ``` 424 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-global-assign 2 | require = require('esm')(module); 3 | 4 | module.exports = require('./src/index.js'); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autodux", 3 | "version": "5.0.3", 4 | "description": "Automate the Redux boilerplate.", 5 | "main": "index.js", 6 | "module": "src/index.js", 7 | "scripts": { 8 | "lint": "npm run -s lint-js && npm run -s lint-md && echo 'Lint finished.'", 9 | "lint-js": "eslint . --fix", 10 | "lint-md": "markdownlint *.md && textlint *.md", 11 | "test": "node -r esm node_modules/.bin/riteway src/*.test.js | tap-nirvana", 12 | "test-coverage": "nyc npm test", 13 | "test-coverage-ci": "nyc --reporter=text-lcov npm test", 14 | "show-coverage-ci": "nyc report --reporter=text-lcov | coveralls", 15 | "show-coverage-text": "nyc report --reporter=text || echo \"Run 'npm run test-coverage' first.\"", 16 | "show-coverage-html": "open coverage/index.html || echo \"Run 'npm run test-coverage' first.\"", 17 | "debug": "echo 'Open debugger in Chrome: \"chrome://inspect\".' && node -r esm --inspect-brk node_modules/.bin/riteway src/*.test.js", 18 | "watch": "watch 'clear && npm -s test' src", 19 | "update": "updtr" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/ericelliott/autodux.git" 24 | }, 25 | "keywords": [ 26 | "Redux", 27 | "fp", 28 | "functional" 29 | ], 30 | "author": "Eric Elliott", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/ericelliott/autodux/issues" 34 | }, 35 | "homepage": "https://github.com/ericelliott/autodux#readme", 36 | "devDependencies": { 37 | "@textlint-rule/textlint-rule-no-invalid-control-character": "1.2.0", 38 | "coveralls": "3.1.0", 39 | "eslint": "6.8.0", 40 | "eslint-config-prettier": "6.10.0", 41 | "eslint-plugin-prettier": "3.1.4", 42 | "husky": "3.1.0", 43 | "lint-staged": "9.5.0", 44 | "markdownlint-cli": "0.23.2", 45 | "nyc": "14.1.1", 46 | "prettier": "1.19.1", 47 | "riteway": "6.2.0", 48 | "tap-nirvana": "1.1.0", 49 | "textlint": "11.7.6", 50 | "textlint-rule-common-misspellings": "1.0.1", 51 | "textlint-rule-terminology": "1.1.30", 52 | "updtr": "3.1.0", 53 | "watch": "1.0.2" 54 | }, 55 | "dependencies": { 56 | "esm": "3.2.25", 57 | "ramda": "0.27.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true 3 | }; 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "automergeType": "branch", 7 | "major": { 8 | "automerge": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import { path, prop } from 'ramda'; 2 | 3 | import { SLICE_VALUE_ERROR } from './errors'; 4 | import { 5 | id, 6 | isSliceValid, 7 | isPrimitive, 8 | isFunction, 9 | selectIfFunction, 10 | getSelectorName, 11 | getActionCreatorName, 12 | getType 13 | } from './helpers'; 14 | 15 | /** 16 | * Create action creator for given slice (with optional payload mapper). 17 | */ 18 | const createActionCreator = (slice, name, mapPayload) => { 19 | const type = getType(slice, name); 20 | return Object.assign( 21 | (...args) => ({ 22 | type, 23 | payload: isFunction(mapPayload) ? mapPayload(...args) : args[0] 24 | }), 25 | { type } 26 | ); 27 | }; 28 | 29 | /** 30 | * Create reducer for given key from initial state. 31 | */ 32 | const createReducer = key => (state, payload) => 33 | !key && isPrimitive(payload) 34 | ? payload 35 | : Object.assign({}, state, key ? { [key]: payload } : payload); 36 | 37 | /** 38 | * Create selector for given slice. 39 | */ 40 | const createSelector = (slice, fn) => state => fn(state[slice], state); 41 | 42 | /** 43 | * Create default action creators, reducers and selectors. 44 | */ 45 | const createDefaults = (slice, initial) => { 46 | const sliceActionCreatorName = getActionCreatorName(slice); 47 | 48 | return Object.keys(initial).reduce( 49 | ([actionCreators, reducers, selectors], key) => { 50 | const actionCreatorName = getActionCreatorName(key); 51 | 52 | return [ 53 | Object.assign(actionCreators, { 54 | [actionCreatorName]: createActionCreator(slice, actionCreatorName) 55 | }), 56 | Object.assign(reducers, { 57 | [actionCreatorName]: createReducer(key) 58 | }), 59 | Object.assign(selectors, { 60 | [getSelectorName(key)]: createSelector( 61 | slice, 62 | state => prop(key, state) 63 | ) 64 | }) 65 | ]; 66 | }, 67 | [ 68 | { 69 | [sliceActionCreatorName]: createActionCreator( 70 | slice, 71 | sliceActionCreatorName 72 | ) 73 | }, 74 | { 75 | [sliceActionCreatorName]: createReducer() 76 | }, 77 | { 78 | [getSelectorName(slice)]: createSelector( 79 | slice, 80 | id 81 | ) 82 | } 83 | ] 84 | ); 85 | }; 86 | 87 | /** 88 | * Produce action creators from user-defined 'actions' object. 89 | */ 90 | const createActionCreators = (slice, actions) => 91 | Object.keys(actions).reduce( 92 | (obj, action) => 93 | Object.assign({}, obj, { 94 | [action]: createActionCreator(slice, action, actions[action].create) 95 | }), 96 | {} 97 | ); 98 | 99 | /** 100 | * Produce selectors from user-defined 'selectors' object. 101 | */ 102 | const createSelectors = (slice, selectors) => 103 | Object.keys(selectors).reduce( 104 | (obj, key) => 105 | Object.assign(obj, { 106 | [key]: createSelector( 107 | slice, 108 | selectors[key] 109 | ) 110 | }), 111 | {} 112 | ); 113 | 114 | /** 115 | * Validate options supplied to 'autodux'. 116 | */ 117 | const checkOptions = ({ slice }) => { 118 | if (!isSliceValid(slice)) { 119 | throw new Error(SLICE_VALUE_ERROR); 120 | } 121 | }; 122 | 123 | export default function autodux(options = {}) { 124 | checkOptions(options); 125 | 126 | const { initial = '', actions = {}, selectors = {}, slice } = options; 127 | 128 | const [ 129 | defaultActionCreators, 130 | defaultReducers, 131 | defaultSelectors 132 | ] = createDefaults(slice, initial); 133 | 134 | const allSelectors = Object.assign( 135 | {}, 136 | defaultSelectors, 137 | createSelectors(slice, selectors) 138 | ); 139 | 140 | const allActions = Object.assign( 141 | {}, 142 | defaultActionCreators, 143 | createActionCreators(slice, actions) 144 | ); 145 | 146 | const rootReducer = ( 147 | state = initial, 148 | { type = getType('unknown', 'unknown'), payload } = {} 149 | ) => { 150 | const [namespace, subType] = type.split('/'); 151 | 152 | // Look for reducer with top-to-bottom precedence. 153 | // Fall back to default actions, then undefined. 154 | // 'actions[subType]' key can be a function or an object, 155 | // so the value is selected only if it's a function: 156 | const reducer = [ 157 | path([subType, 'reducer'], actions), 158 | actions[subType], 159 | defaultReducers[subType] 160 | ].reduceRight((f, v) => selectIfFunction(v) || f); 161 | 162 | return namespace === slice && (actions[subType] || defaultReducers[subType]) 163 | ? reducer 164 | ? reducer(state, payload) 165 | : Object.assign({}, state, payload) 166 | : state; 167 | }; 168 | 169 | return { 170 | initial, 171 | reducer: rootReducer, 172 | slice, 173 | selectors: allSelectors, 174 | actions: allActions 175 | }; 176 | } 177 | 178 | /** 179 | * Create reducer that assigns payload to the given key. 180 | */ 181 | export const assign = key => (state, payload) => 182 | Object.assign({}, state, { 183 | [key]: payload 184 | }); 185 | -------------------------------------------------------------------------------- /src/core.test.js: -------------------------------------------------------------------------------- 1 | import { describe, Try } from 'riteway'; 2 | 3 | import autodux, { assign } from './core'; 4 | import { id } from './helpers'; 5 | import { SLICE_VALUE_ERROR } from './errors'; 6 | 7 | const createCounterDux = (initial = 0) => 8 | autodux({ 9 | // The slice of state your reducer controls. 10 | slice: 'counter', 11 | 12 | // The initial value for the slice of state. 13 | initial, 14 | 15 | // No need to implement switching logic, it's done for you. 16 | actions: { 17 | // Shorthand definition of action and corresponding reducer. 18 | increment: state => state + 1, 19 | decrement: state => state - 1, 20 | 21 | // Another way to define action and reducer. 22 | multiply: { 23 | // Define custom mapping of action creator parameter to action payload. 24 | create: ({ by }) => by, 25 | 26 | reducer: (state, payload) => state * payload 27 | }, 28 | 29 | // Define action and reducer without custom mapping of action creator parameter to action payload. 30 | divide: { 31 | reducer: (state, payload) => Math.floor(state / payload) 32 | } 33 | }, 34 | 35 | selectors: { 36 | // No need to select the state slice, it's done for you. 37 | getValue: id, 38 | 39 | // Get access to the root state in case you need it. 40 | getState: (_, rootState) => rootState 41 | } 42 | }); 43 | 44 | describe('autodux({ … }).slice', async assert => { 45 | assert({ 46 | given: "'autodux' is called with 'slice'", 47 | should: 'have the correct value', 48 | actual: createCounterDux().slice, 49 | expected: 'counter' 50 | }); 51 | }); 52 | 53 | describe('autodux({ … }).initial', async assert => { 54 | assert({ 55 | given: "'autodux' is called with 'slice' and 'initial'", 56 | should: 'return valid initial state', 57 | actual: createCounterDux().initial, 58 | expected: 0 59 | }); 60 | 61 | assert({ 62 | given: "'autodux' is called without 'initial'", 63 | should: 'return initial state as an empty string', 64 | actual: autodux({ slice: 'user' }).initial, 65 | expected: '' 66 | }); 67 | }); 68 | 69 | describe('autodux({ … }).actions', async assert => { 70 | assert({ 71 | given: "'autodux' is called with 'slice' and 'actions'", 72 | should: 'contain action creators', 73 | actual: Object.keys(createCounterDux().actions), 74 | expected: ['setCounter', 'increment', 'decrement', 'multiply', 'divide'] 75 | }); 76 | 77 | { 78 | const { actions } = createCounterDux(); 79 | 80 | assert({ 81 | given: "'autodux' is called with 'slice' and 'actions'", 82 | should: 'contain action creators that return correct action objects', 83 | actual: [ 84 | actions.increment(), 85 | actions.decrement(), 86 | actions.multiply({ by: 2 }), 87 | actions.divide(1) 88 | ], 89 | expected: [ 90 | { type: 'counter/increment', payload: undefined }, 91 | { type: 'counter/decrement', payload: undefined }, 92 | { type: 'counter/multiply', payload: 2 }, 93 | { type: 'counter/divide', payload: 1 } 94 | ] 95 | }); 96 | } 97 | 98 | { 99 | const { 100 | actions: { setCounter, increment, decrement, multiply, divide } 101 | } = createCounterDux(); 102 | 103 | assert({ 104 | given: "'autodux' is called with 'slice' and 'actions'", 105 | should: 'contain action creators with correct action type constants', 106 | actual: [ 107 | setCounter.type, 108 | increment.type, 109 | decrement.type, 110 | multiply.type, 111 | divide.type 112 | ], 113 | expected: [ 114 | 'counter/setCounter', 115 | 'counter/increment', 116 | 'counter/decrement', 117 | 'counter/multiply', 118 | 'counter/divide' 119 | ] 120 | }); 121 | } 122 | 123 | { 124 | const { actions } = autodux({ 125 | slice: 'words' 126 | }); 127 | 128 | assert({ 129 | given: "'autodux' is called with 'slice' and without 'actions'", 130 | should: 131 | "contain a single action creator ('set${slice}') for setting the state of the slice", 132 | actual: Object.keys(actions).length, 133 | expected: 1 134 | }); 135 | } 136 | 137 | { 138 | const { actions } = createCounterDux(); 139 | 140 | const value = 50; 141 | 142 | assert({ 143 | given: "'autodux' is called with 'slice' and 'actions'", 144 | should: 145 | "contain action creator ('set${slice}') for setting the state of the slice", 146 | actual: actions.setCounter(value), 147 | expected: { 148 | type: 'counter/setCounter', 149 | payload: value 150 | } 151 | }); 152 | } 153 | 154 | { 155 | const { 156 | actions: { setUser } 157 | } = autodux({ 158 | slice: 'slice', 159 | actions: { 160 | setUser: { 161 | create: ({ userId, displayName }) => ({ 162 | uid: userId, 163 | userName: displayName 164 | }) 165 | } 166 | } 167 | }); 168 | const userId = '123'; 169 | const displayName = 'Foo'; 170 | 171 | assert({ 172 | given: 'a custom action creator', 173 | should: 'produce custom payload output', 174 | actual: setUser({ userId, displayName }), 175 | expected: { 176 | type: setUser.type, 177 | payload: { 178 | uid: userId, 179 | userName: displayName 180 | } 181 | } 182 | }); 183 | } 184 | 185 | { 186 | const { actions, reducer, initial } = createCounterDux(128); 187 | 188 | assert({ 189 | given: "'autodux' is called with 'slice' and 'actions'", 190 | should: 191 | 'return action creator that maps parameters to action payload by default', 192 | actual: [actions.divide(2)].reduce(reducer, initial), 193 | expected: 64 194 | }); 195 | } 196 | 197 | { 198 | const { 199 | actions: { setUserName, setAge } 200 | } = autodux({ 201 | slice: 'user', 202 | initial: { 203 | userName: 'Anonymous', 204 | age: 0 205 | } 206 | }); 207 | 208 | const userName = 'Freddie'; 209 | const age = 23; 210 | 211 | assert({ 212 | given: "'autodux' is called with 'slice' and without 'actions'", 213 | should: 214 | 'contain correct action creators for each key in the initial state', 215 | actual: [setUserName(userName), setAge(age)], 216 | expected: [ 217 | { 218 | type: 'user/setUserName', 219 | payload: userName 220 | }, 221 | { 222 | type: 'user/setAge', 223 | payload: age 224 | } 225 | ] 226 | }); 227 | } 228 | }); 229 | 230 | describe('autodux({ … }).reducer', async assert => { 231 | { 232 | const { 233 | actions: { increment, decrement }, 234 | reducer, 235 | initial 236 | } = createCounterDux(); 237 | 238 | assert({ 239 | given: "'autodux' is called with 'slice' and 'actions'", 240 | should: 'return reducer that switches correctly', 241 | actual: [increment(), increment(), increment(), decrement()].reduce( 242 | reducer, 243 | initial 244 | ), 245 | expected: 2 246 | }); 247 | } 248 | 249 | { 250 | const { 251 | actions: { increment, multiply }, 252 | reducer, 253 | initial 254 | } = createCounterDux(); 255 | 256 | assert({ 257 | given: "'autodux' is called with 'slice' and 'actions'", 258 | should: 259 | 'return reducer that receives action payload as the second parameter', 260 | actual: [increment(), increment(), multiply({ by: 2 })].reduce( 261 | reducer, 262 | initial 263 | ), 264 | expected: 4 265 | }); 266 | } 267 | 268 | { 269 | const initial = { name: 'Jim' }; 270 | 271 | const { reducer } = autodux({ 272 | slice: 'user', 273 | initial 274 | }); 275 | 276 | assert({ 277 | given: "'autodux' is called with 'slice' and without 'actions'", 278 | should: 'return reducer that returns valid default state', 279 | actual: reducer(), 280 | expected: initial 281 | }); 282 | } 283 | 284 | { 285 | const { 286 | actions: { setInfo }, 287 | reducer, 288 | initial 289 | } = autodux({ slice: 'info', initial: 'Some text goes here…' }); 290 | 291 | assert({ 292 | given: "'autodux' is called with 'slice' and without 'actions'", 293 | should: 294 | 'return reducer that changes the state to the primitive value of action payload', 295 | actual: [ 296 | [setInfo('Hi!')].reduce(reducer, initial), 297 | [setInfo(9)].reduce(reducer, initial), 298 | [setInfo(undefined)].reduce(reducer, initial), 299 | [setInfo(true)].reduce(reducer, initial), 300 | [setInfo(null)].reduce(reducer, initial) 301 | ], 302 | expected: ['Hi!', 9, undefined, true, null] 303 | }); 304 | } 305 | }); 306 | 307 | describe('autodux({ …, actions: { …: assign(key) } }).reducer', async assert => { 308 | const { 309 | actions: { setUserName, setAvatar }, 310 | reducer 311 | } = autodux({ 312 | slice: 'user', 313 | initial: { 314 | userName: 'Anonymous', 315 | avatar: 'anonymous.png' 316 | }, 317 | actions: { 318 | setUserName: assign('userName'), 319 | setAvatar: assign('avatar') 320 | } 321 | }); 322 | 323 | const userName = 'Foo'; 324 | const avatar = 'foo.png'; 325 | 326 | assert({ 327 | given: "'autodux' is called with 'assign' within 'actions'", 328 | should: 329 | "return a reducer that produces state with 'key' that contains action payload value", 330 | actual: [setUserName(userName), setAvatar(avatar)].reduce( 331 | reducer, 332 | undefined 333 | ), 334 | expected: { 335 | userName, 336 | avatar 337 | } 338 | }); 339 | }); 340 | 341 | describe('autodux({ … }).selectors', async assert => { 342 | { 343 | const rootState = { 344 | album: { 345 | name: 'The Works', 346 | year: 1984 347 | } 348 | }; 349 | 350 | const { 351 | selectors: { getName, getYear }, 352 | initial 353 | } = autodux({ 354 | slice: 'album', 355 | initial: rootState.album 356 | }); 357 | 358 | assert({ 359 | given: "'autodux' is called with 'slice' and 'initial'", 360 | should: 'expose a selector for each key in the initial state', 361 | actual: { 362 | name: getName(rootState), 363 | year: getYear(rootState) 364 | }, 365 | expected: initial 366 | }); 367 | } 368 | 369 | { 370 | const rootState = { 371 | user: { 372 | userName: 'Anonymous', 373 | avatar: 'anonymous.png' 374 | } 375 | }; 376 | 377 | const { 378 | selectors: { getUser }, 379 | initial 380 | } = autodux({ 381 | slice: 'user', 382 | initial: rootState.user 383 | }); 384 | 385 | assert({ 386 | given: "'autodux' is called with 'slice' and 'initial'", 387 | should: 'expose a selector for the entire state of the slice', 388 | actual: getUser(rootState), 389 | expected: initial 390 | }); 391 | } 392 | 393 | { 394 | const { getValue } = createCounterDux().selectors; 395 | 396 | assert({ 397 | given: "'autodux' is called with 'slice' and 'selectors'", 398 | should: 'return a selector that knows its state slice', 399 | actual: getValue({ counter: 3 }), 400 | expected: 3 401 | }); 402 | } 403 | 404 | { 405 | const { getState } = createCounterDux().selectors; 406 | 407 | const rootState = { counter: 3, foo: 'bar' }; 408 | 409 | assert({ 410 | given: "'autodux' is called with 'slice' and 'selectors'", 411 | should: 'return a selector that can return the root state object', 412 | actual: getState(rootState), 413 | expected: rootState 414 | }); 415 | } 416 | }); 417 | 418 | describe('autodux()', async assert => { 419 | assert({ 420 | given: "'autodux' is called without an argument", 421 | should: 'throw an error', 422 | actual: Try(autodux).toString(), 423 | expected: new Error(SLICE_VALUE_ERROR).toString() 424 | }); 425 | }); 426 | 427 | describe("autodux({ …, slice: undefined | null | '' })", async assert => { 428 | const error = new Error(SLICE_VALUE_ERROR).toString(); 429 | 430 | assert({ 431 | given: "'autodux' is called with improper 'slice' value", 432 | should: 'throw an error', 433 | actual: [ 434 | Try(autodux, { slice: undefined }).toString(), 435 | Try(autodux, { slice: null }).toString(), 436 | Try(autodux, { slice: '' }).toString() 437 | ], 438 | expected: [error, error, error] 439 | }); 440 | }); 441 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export const SLICE_VALUE_ERROR = "Proper value of 'slice' is required!"; 2 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import { 2 | isNil, 3 | isEmpty, 4 | o, 5 | join, 6 | adjust, 7 | toUpper, 8 | compose, 9 | ifElse, 10 | identity, 11 | toString 12 | } from 'ramda'; 13 | 14 | export const id = x => x; 15 | 16 | const selectIf = predicate => x => predicate(x) && x; 17 | 18 | export const isFunction = f => typeof f === 'function'; 19 | 20 | export const selectIfFunction = selectIf(isFunction); 21 | 22 | const isNumber = n => typeof n === 'number'; 23 | 24 | const isBoolean = b => typeof b === 'boolean'; 25 | 26 | const isString = s => typeof s === 'string'; 27 | 28 | export const isPrimitive = v => 29 | [isString, isNumber, isBoolean, isNil].some(f => f(v)); 30 | 31 | export const isSliceValid = slice => isString(slice) && !isEmpty(slice); 32 | 33 | const capitalizeFirstWord = o(join(''), adjust(0, toUpper)); 34 | 35 | const getName = compose( 36 | capitalizeFirstWord, 37 | ifElse(isString, identity, toString) 38 | ); 39 | 40 | export const getSelectorName = key => `get${getName(key)}`; 41 | 42 | export const getActionCreatorName = key => `set${getName(key)}`; 43 | 44 | export const getType = (slice, actionCreatorName) => 45 | `${slice}/${actionCreatorName}`; 46 | -------------------------------------------------------------------------------- /src/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { describe } from 'riteway'; 2 | 3 | import { 4 | id, 5 | isFunction, 6 | isPrimitive, 7 | selectIfFunction, 8 | isSliceValid, 9 | getSelectorName, 10 | getActionCreatorName, 11 | getType 12 | } from './helpers'; 13 | 14 | describe('id(value)', async assert => { 15 | assert({ 16 | given: 'a value', 17 | should: 'return the same value', 18 | actual: [id(1), id('Sigourney'), id({ key: 0 })], 19 | expected: [1, 'Sigourney', { key: 0 }] 20 | }); 21 | }); 22 | 23 | describe('isFunction(f)', async assert => { 24 | assert({ 25 | given: 'a function', 26 | should: "return 'true'", 27 | actual: isFunction(() => null), 28 | expected: true 29 | }); 30 | 31 | assert({ 32 | given: 33 | "a string, a number, a boolean, 'undefined', 'null', an object or an array", 34 | should: "return 'false'", 35 | actual: [ 36 | isFunction('Gene'), 37 | isFunction(1), 38 | isFunction(true), 39 | isFunction(false), 40 | isFunction(undefined), 41 | isFunction(null), 42 | isFunction({}), 43 | isFunction([]) 44 | ], 45 | expected: Array(8).fill(false) 46 | }); 47 | }); 48 | 49 | describe('selectIfFunction(value)', async assert => { 50 | { 51 | const fn = () => 'Ray'; 52 | 53 | assert({ 54 | given: 'a function', 55 | should: 'return it', 56 | actual: selectIfFunction(fn)(), 57 | expected: 'Ray' 58 | }); 59 | } 60 | 61 | assert({ 62 | given: 'anything other than function', 63 | should: "return 'false'", 64 | actual: [ 65 | selectIfFunction('Jennifer'), 66 | selectIfFunction(2), 67 | selectIfFunction(true), 68 | selectIfFunction(false), 69 | selectIfFunction(undefined), 70 | selectIfFunction(null), 71 | selectIfFunction({}), 72 | selectIfFunction([]) 73 | ], 74 | expected: Array(8).fill(false) 75 | }); 76 | }); 77 | 78 | describe('isPrimitive(value)', async assert => { 79 | assert({ 80 | given: "a string, a number, a boolean, 'undefined' or 'null'", 81 | should: "return 'true'", 82 | actual: [ 83 | isPrimitive('Jason'), 84 | isPrimitive(3), 85 | isPrimitive(true), 86 | isPrimitive(false), 87 | isPrimitive(undefined), 88 | isPrimitive(null) 89 | ], 90 | expected: Array(6).fill(true) 91 | }); 92 | 93 | assert({ 94 | given: 'an object, an array or a function', 95 | should: "return 'false'", 96 | actual: [isPrimitive({}), isPrimitive([]), isPrimitive(() => 0)], 97 | expected: [false, false, false] 98 | }); 99 | }); 100 | 101 | describe('isSliceValid(slice)', async assert => { 102 | assert({ 103 | given: 'a slice as a non-empty string', 104 | should: "return 'true'", 105 | actual: [isSliceValid('cast'), isSliceValid('🚀')], 106 | expected: [true, true] 107 | }); 108 | 109 | assert({ 110 | given: 'a slice as an empty string', 111 | should: "return 'false'", 112 | actual: isSliceValid(''), 113 | expected: false 114 | }); 115 | }); 116 | 117 | describe('getSelectorName(key)', async assert => { 118 | assert({ 119 | given: 'a key as string or number', 120 | should: 121 | 'return a selector name that starts with "get" followed by the capitalized key value', 122 | actual: [ 123 | getSelectorName('bestActressName'), 124 | getSelectorName('🍿'), 125 | getSelectorName('0'), 126 | getSelectorName(1234) 127 | ], 128 | expected: ['getBestActressName', 'get🍿', 'get0', 'get1234'] 129 | }); 130 | }); 131 | 132 | describe('getActionCreatorName(key)', async assert => { 133 | assert({ 134 | given: 'a key as string or number', 135 | should: 136 | 'return an action creator name that starts with "set" followed by the capitalized key value', 137 | actual: [ 138 | getActionCreatorName('bestActressName'), 139 | getActionCreatorName('🍿'), 140 | getActionCreatorName('0'), 141 | getActionCreatorName(1234) 142 | ], 143 | expected: ['setBestActressName', 'set🍿', 'set0', 'set1234'] 144 | }); 145 | }); 146 | 147 | describe('getType(slice, actionCreatorName)', async assert => { 148 | const slice = 'movie'; 149 | const actionCreatorName = 'setProducer'; 150 | 151 | assert({ 152 | given: 'slice and action creator name', 153 | should: 'return correct action type', 154 | actual: getType(slice, actionCreatorName), 155 | expected: `${slice}/${actionCreatorName}` 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default, assign } from './core'; 2 | 3 | export { id } from './helpers'; 4 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x85fb7e6Fc8FC092e0D279A48988840fa0090c3E5' 6 | quorum: 1 7 | --------------------------------------------------------------------------------