├── .editorconfig ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── docs ├── actions.md ├── async_action_creators.js ├── reducers.md ├── redux.md └── tips.md ├── example ├── .gitignore ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── readme.md ├── src │ ├── App.js │ ├── index.js │ ├── mocks.js │ └── todos │ │ ├── Form.js │ │ ├── Index.js │ │ ├── List.js │ │ ├── New.js │ │ ├── actions.js │ │ ├── fixture.js │ │ └── reducer.js └── yarn.lock ├── package.json ├── readme.md ├── src ├── actionCreatorsFor.test.ts ├── actionCreatorsFor.ts ├── actionTypesFor.test.ts ├── actionTypesFor.ts ├── constants.ts ├── getDefaultConfig.ts ├── index.test.ts ├── index.ts ├── reducers │ ├── common │ │ ├── create │ │ │ └── start.ts │ │ ├── delete │ │ │ └── start.ts │ │ ├── reducersFor.ts │ │ └── update │ │ │ ├── error.ts │ │ │ └── start.ts │ ├── invariants.ts │ ├── invariants │ │ └── assertHasKey.ts │ ├── list.ts │ ├── list │ │ ├── create │ │ │ ├── error.test.ts │ │ │ ├── error.ts │ │ │ ├── start.test.ts │ │ │ ├── start.ts │ │ │ ├── success.test.ts │ │ │ └── success.ts │ │ ├── delete │ │ │ ├── error.test.ts │ │ │ ├── error.ts │ │ │ ├── start.test.ts │ │ │ ├── start.ts │ │ │ ├── success.test.ts │ │ │ └── success.ts │ │ ├── fetch │ │ │ ├── success.test.ts │ │ │ └── success.ts │ │ ├── invariants.ts │ │ ├── reducersFor.test.ts │ │ ├── reducersFor.ts │ │ ├── store.ts │ │ ├── store │ │ │ ├── assert.ts │ │ │ ├── merge.ts │ │ │ └── remove.ts │ │ └── update │ │ │ ├── error.test.ts │ │ │ ├── error.ts │ │ │ ├── start.test.ts │ │ │ ├── start.ts │ │ │ ├── success.test.ts │ │ │ └── success.ts │ ├── map.ts │ └── map │ │ ├── create │ │ ├── error.test.ts │ │ ├── error.ts │ │ ├── start.test.ts │ │ ├── start.ts │ │ ├── success.test.ts │ │ └── success.ts │ │ ├── delete │ │ ├── error.test.ts │ │ ├── error.ts │ │ ├── start.test.ts │ │ ├── start.ts │ │ ├── success.test.ts │ │ └── success.ts │ │ ├── fetch │ │ ├── success.test.ts │ │ └── success.ts │ │ ├── invariants.ts │ │ ├── reducersFor.test.ts │ │ ├── reducersFor.ts │ │ ├── store.ts │ │ ├── store │ │ ├── assert.ts │ │ ├── merge.ts │ │ └── remove.ts │ │ └── update │ │ ├── error.test.ts │ │ ├── error.ts │ │ ├── start.test.ts │ │ ├── start.ts │ │ ├── success.test.ts │ │ └── success.ts ├── types.ts └── utils │ ├── assertAllHaveKeys.ts │ ├── assertNotArray.ts │ ├── findByKey.ts │ ├── makeScope.ts │ └── wrapArray.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.ts] 12 | indent_style = tab 13 | indent_size = 2 14 | 15 | [*.js] 16 | indent_style = tab 17 | indent_size = 4 18 | 19 | [*.json] 20 | indent_style = tab 21 | indent_size = 4 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | .idea 5 | dist 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.2.0 4 | 5 | - Removed `_cid` after successful creation 6 | 7 | ## 3.1 8 | 9 | - Added `replace` option to `fetchSucess` action. 10 | 11 | ## 3.0.3 12 | 13 | - Fixed the List reducer adding a null client generated key when not needed. 14 | 15 | ## 3.0.2 16 | 17 | - Fix broken `Map.fetch.success reducer` 18 | 19 | ## 3.0 20 | 21 | - Added Map Store 22 | - Switch from lodash to ramda https://github.com/Versent/redux-crud/issues/39 23 | 24 | ## 2.0 25 | 26 | - Remove Seamless-Immutable and Immutable.js stores. These stores are not really necessary, as operations can be done with plain lodash without mutating the original collections. These libs were also adding a huge amount of weight to the library. 27 | 28 | You can wrap the collections with Seamless or Immutable.js after getting them from the store. 29 | 30 | - Remove dependency on the whole Lodash lib. This library now uses individual lodash functions as needed e.g. `lodash.assign`. 31 | 32 | ## **1.0** 33 | 34 | Added Immutable.js store 35 | 36 | **0.10.1** upgrade `action-names` dep, remove left over ES6 37 | 38 | **0.10.0** `.reducersFor` does not mutate the config object 39 | 40 | **0.9.0** Added mutable store (config.store: reduxCrud.STORE_MUTABLE) 41 | 42 | **0.8.0** Add `data` attribute to actions payload. 43 | 44 | **0.7.0** Replaced `unsaved` in createStart and updateStart with `pendingCreate` and `pendingUpdate`. 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Versent 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. -------------------------------------------------------------------------------- /docs/actions.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | ## `.actionTypesFor` 4 | 5 | Creates an object with standard CRUD action types: 6 | ```js 7 | var reduxCrud = require('redux-crud'); 8 | var actionTypes = reduxCrud.actionTypesFor('users'); 9 | 10 | // actionTypes => 11 | 12 | { 13 | USERS_FETCH_START: 'USERS_FETCH_START', 14 | USERS_FETCH_SUCCESS: 'USERS_FETCH_SUCCESS', 15 | USERS_FETCH_ERROR: 'USERS_FETCH_ERROR', 16 | 17 | USERS_UPDATE_START: 'USERS_UPDATE_START', 18 | USERS_UPDATE_SUCCESS: 'USERS_UPDATE_SUCCESS', 19 | USERS_UPDATE_ERROR: 'USERS_UPDATE_ERROR', 20 | 21 | USERS_CREATE_START: 'USERS_CREATE_START', 22 | USERS_CREATE_SUCCESS: 'USERS_CREATE_SUCCESS', 23 | USERS_CREATE_ERROR: 'USERS_CREATE_ERROR', 24 | 25 | USERS_DELETE_START: 'USERS_DELETE_START', 26 | USERS_DELETE_SUCCESS: 'USERS_DELETE_SUCCESS', 27 | USERS_DELETE_ERROR: 'USERS_DELETE_ERROR', 28 | 29 | // Object also contains shortcuts 30 | 31 | fetchStart: 'USERS_FETCH_START', 32 | fetchSuccess: 'USERS_FETCH_SUCCESS', 33 | fetchError: 'USERS_FETCH_ERROR', 34 | 35 | updateStart: 'USERS_UPDATE_START', 36 | updateSuccess: 'USERS_UPDATE_SUCCESS', 37 | updateError: 'USERS_UPDATE_ERROR', 38 | 39 | createStart: 'USERS_CREATE_START', 40 | createSuccess: 'USERS_CREATE_SUCCESS', 41 | createError: 'USERS_CREATE_ERROR', 42 | 43 | deleteStart: 'USERS_DELETE_START', 44 | deleteSuccess: 'USERS_DELETE_SUCCESS', 45 | deleteError: 'USERS_DELETE_ERROR', 46 | } 47 | ``` 48 | 49 | ## `.actionCreatorsFor` 50 | 51 | Generates the following action creators: 52 | - `fetchStart` 53 | - `fetchSuccess` 54 | - `fetchError` 55 | - `createStart` 56 | - `createSuccess` 57 | - `createError` 58 | - `updateStart` 59 | - `updateSuccess` 60 | - `updateError` 61 | - `deleteStart` 62 | - `deleteSuccess` 63 | - `deleteError` 64 | 65 | ```js 66 | var reduxCrud = require('redux-crud'); 67 | var actionCreators = reduxCrud.actionCreatorsFor('users'); 68 | 69 | // actionCreators => 70 | 71 | { 72 | fetchStart: function(data) { 73 | return { 74 | data: data, 75 | type: 'USERS_FETCH_START', 76 | }; 77 | }, 78 | 79 | /* 80 | If data.replace is true, existing records in the store will 81 | be replaced instead of merged. 82 | */ 83 | fetchSuccess: function(users, data) { 84 | return { 85 | data: data, 86 | records: users, 87 | type: 'USERS_FETCH_SUCCESS', 88 | }; 89 | }, 90 | 91 | fetchError: function(error, data) { 92 | return { 93 | data: data, 94 | error: error, 95 | type: 'USERS_FETCH_ERROR', 96 | }; 97 | }, 98 | 99 | /* 100 | The user record must have a client generated key 101 | so it can be inserted in the collection optimistically. 102 | */ 103 | createStart: function(user, data) { 104 | return { 105 | data: data, 106 | record: user, 107 | type: 'USERS_CREATE_START', 108 | }; 109 | }, 110 | 111 | createSuccess: function(user, data) { 112 | return { 113 | data: data, 114 | record: user, 115 | type: 'USERS_CREATE_SUCCESS', 116 | }; 117 | }, 118 | 119 | /* 120 | The user record must have the client generated key 121 | so it can be matched with the record inserted optimistically. 122 | */ 123 | createError: function(error, user, data) { 124 | return { 125 | data: data, 126 | error: error, 127 | record: user, 128 | type: 'USERS_CREATE_ERROR', 129 | }; 130 | }, 131 | 132 | updateStart: function(user, data) { 133 | return { 134 | data: data, 135 | record: user, 136 | type: 'USERS_UPDATE_START', 137 | }; 138 | }, 139 | 140 | updateSuccess: function(user, data) { 141 | return { 142 | data: data, 143 | record: user, 144 | type: 'USERS_UPDATE_SUCCESS', 145 | }; 146 | }, 147 | 148 | updateError: function(error, user, data) { 149 | return { 150 | data: data, 151 | error: error, 152 | record: user, 153 | type: 'USERS_UPDATE_ERROR', 154 | }; 155 | }, 156 | 157 | deleteStart: function(user, data) { 158 | return { 159 | data: data, 160 | record: user, 161 | type: 'USERS_DELETE_START', 162 | }; 163 | }, 164 | 165 | deleteSuccess: function(user, data) { 166 | return { 167 | data: data, 168 | record: user, 169 | type: 'USERS_DELETE_SUCCESS', 170 | }; 171 | }, 172 | 173 | deleteError: function(error, user, data) { 174 | return { 175 | data: data, 176 | error: error, 177 | record: user, 178 | type: 'USERS_DELETE_ERROR', 179 | }; 180 | } 181 | } 182 | ``` 183 | 184 | `actionCreatorsFor` takes an optional config object as second argument: 185 | 186 | ```js 187 | reduxCrud.actionCreatorsFor('users', {key: '_id'}); 188 | ``` 189 | 190 | Don't forget to do the same thing in reducers, with `reducersFor`: 191 | 192 | ```js 193 | reduxCrud.Map.reducersFor('users', {key: '_id'}); 194 | ``` 195 | 196 | ### The `data` attribute 197 | 198 | The `data` attribute in the actions payload is optional. The reducer doesn't do anything with this. This is only provided in case you want to pass extra information in the actions. 199 | -------------------------------------------------------------------------------- /docs/async_action_creators.js: -------------------------------------------------------------------------------- 1 | import reduxCrud from 'redux-crud'; 2 | import _ from 'lodash'; 3 | import cuid from 'cuid'; 4 | 5 | const baseActionCreators = reduxCrud.actionCreatorsFor('users'); 6 | 7 | let actionCreators = { 8 | 9 | fetchOne(id) { 10 | return function(dispatch) { 11 | const action = baseActionCreators.fetchStart(); 12 | dispatch(action); 13 | 14 | // send the request 15 | const url = `/users/${id}`; 16 | const promise = someAjaxLibrary({ 17 | url: url, 18 | method: 'GET' 19 | }); 20 | 21 | promise.then(function(response) { 22 | const user = response.data.data; 23 | const action = baseActionCreators.fetchSuccess(user); 24 | dispatch(action); 25 | }, function(response) { 26 | // dispatch the error action 27 | // first param is the error 28 | const action = baseActionCreators.fetchError(response); 29 | dispatch(action); 30 | }).catch(function(err) { 31 | console.error(err.toString()); 32 | }); 33 | 34 | return promise; 35 | } 36 | }, 37 | 38 | fetch(page, limit, replaceExisting) { 39 | return function(dispatch) { 40 | const action = baseActionCreators.fetchStart(); 41 | dispatch(action); 42 | 43 | // send the request 44 | // e.g. /users?page=1&limit=20 45 | const url = `/users`; 46 | const promise = someAjaxLibrary({ 47 | url: url, 48 | method: 'GET', 49 | data: { 50 | page: page, 51 | limit : limit 52 | } 53 | }); 54 | 55 | promise.then(function(response) { 56 | const users = response.data.data; 57 | const action = baseActionCreators.fetchSuccess(users, {replace: replaceExisting}); 58 | dispatch(action); 59 | }, function(response) { 60 | // dispatch the error action 61 | // first param is the error 62 | const action = baseActionCreators.fetchError(response); 63 | dispatch(action); 64 | }).catch(function(err) { 65 | console.error(err.toString()); 66 | }); 67 | 68 | return promise; 69 | } 70 | }, 71 | 72 | create(user) { 73 | return function(dispatch) { 74 | // Generate a cid so we can match the records 75 | var cid = cuid(); 76 | user = user.merge({id: cid}); 77 | 78 | // optimistic creation 79 | const action = baseActionCreators.createStart(user); 80 | dispatch(action); 81 | 82 | // send the request 83 | const url = `/users/`; 84 | const promise = someAjaxLibrary({ 85 | url: url, 86 | method: 'POST', 87 | data: { 88 | user 89 | } 90 | }); 91 | 92 | promise.then(function(response) { 93 | const returnedUser = response.data.data; 94 | const action = baseActionCreators.createSuccess(returnedUser, cid); 95 | dispatch(action); 96 | }, function(response) { 97 | const action = baseActionCreators.createError(response, user); 98 | dispatch(action); 99 | }).catch(function(err) { 100 | console.error(err.toString()); 101 | }); 102 | 103 | return promise; 104 | } 105 | }, 106 | 107 | update(user) { 108 | return function(dispatch) { 109 | // optimistic update 110 | const action = baseActionCreators.updateStart(user); 111 | dispatch(action); 112 | 113 | // send the request 114 | const url = `/users/${user.id}`; 115 | const promise = someAjaxLibrary({ 116 | url: url, 117 | method: 'PATCH', 118 | data: { 119 | user 120 | } 121 | }); 122 | 123 | promise.then(function(response) { 124 | const returnedUser = response.data.data; 125 | const action = baseActionCreators.updateSuccess(returnedUser); 126 | dispatch(action); 127 | }, function(response) { 128 | const action = baseActionCreators.updateError(response, user); 129 | dispatch(action); 130 | }).catch(function(err) { 131 | console.error(err.toString()); 132 | }); 133 | 134 | return promise; 135 | } 136 | }, 137 | 138 | delete(user) { 139 | return function(dispatch) { 140 | // optimistic delete 141 | const action = baseActionCreators.deleteStart(user); 142 | dispatch(action); 143 | 144 | // send the request 145 | const url = `/users/${user.id}`; 146 | const promise = someAjaxLibrary({ 147 | url: url, 148 | method: 'DELETE' 149 | }); 150 | 151 | promise.then(function(response) { 152 | const returnedUser = response.data.data; 153 | const action = baseActionCreators.deleteSuccess(returnedUser); 154 | dispatch(action); 155 | }, function(response) { 156 | const action = baseActionCreators.deleteError(response, user); 157 | dispatch(action); 158 | }).catch(function(err) { 159 | console.error(err.toString()); 160 | }); 161 | 162 | return promise; 163 | } 164 | }, 165 | 166 | } 167 | 168 | actionCreators = _.extend(baseActionCreators, actionCreators); 169 | 170 | export default actionCreators; 171 | 172 | -------------------------------------------------------------------------------- /docs/reducers.md: -------------------------------------------------------------------------------- 1 | # Reducers 2 | 3 | ## `.List.reducersFor` and `.Map.reducersFor` 4 | 5 | There are `reducersFor` for each type of store: 6 | 7 | ```js 8 | var reduxCrud = require('redux-crud'); 9 | var reducers = reduxCrud.List.reducersFor('users'); 10 | 11 | // or 12 | 13 | var reducers = reduxCrud.Map.reducersFor('users'); 14 | ``` 15 | 16 | `reducersFor` creates a reducer function for the given resource. Redux CRUD assumes that all records will have a unique key, e.g. `id`. 17 | 18 | It generates the following reducers: 19 | 20 | - `fetchSuccess` 21 | - `createStart` 22 | - `createSuccess` 23 | - `createError` 24 | - `updateStart` 25 | - `updateSuccess` 26 | - `updateError` 27 | - `deleteStart` 28 | - `deleteSuccess` 29 | - `deleteError` 30 | 31 | *Note: There are no `fetchStart` and `fetchError` reducers.* 32 | 33 | ```js 34 | var reduxCrud = require('redux-crud'); 35 | var reducers = reduxCrud.List.reducersFor('users'); 36 | 37 | // reducers => 38 | 39 | function (state, action) { 40 | switch (action.type) { 41 | case 'USERS_FETCH_SUCCESS': 42 | ... 43 | case 'USERS_CREATE_START': 44 | ... 45 | case 'USERS_CREATE_SUCCESS': 46 | ... 47 | } 48 | } 49 | ``` 50 | 51 | `reducersFor` takes an optional config object as second argument: 52 | 53 | ```js 54 | reduxCrud.List.reducersFor('users', {key: '_id'}); 55 | ``` 56 | 57 | Don't forget to do the same thing in actions, with `actionCreatorsFor`: 58 | 59 | ```js 60 | reduxCrud.actionCreatorsFor('users', {key: '_id'}); 61 | ``` 62 | 63 | __config.key__ 64 | 65 | Key to be used for merging records. Default: 'id'. 66 | 67 | ## What each reducer does 68 | 69 | ### `fetchSuccess` 70 | 71 | Listens for an action like this (generated by `actionCreatorsFor`): 72 | 73 | ```js 74 | { 75 | records: users, 76 | type: 'USERS_FETCH_SUCCESS', 77 | } 78 | ``` 79 | 80 | Takes one record or an array of records and adds them to the current state. Uses the given `key` or `id` by default to merge. 81 | If `data.replace` is set to true and passed to the action, existing records will be replaced by the ones provided and not merged (default behaviour). 82 | 83 | ### `createStart` 84 | 85 | Listens for an action like: 86 | 87 | ```js 88 | { 89 | type: 'USERS_CREATE_START', 90 | record: user, 91 | } 92 | ``` 93 | 94 | Adds the record optimistically to the collection. The record must have a client generated key e.g. `id`, otherwise the reducer will throw an error. This key is necessary for matching records on `createSuccess` and `createError`. This client generated key is just temporary, is not expected that you will use this key when saving your data in the backend, it is just there so records can be matched. 95 | 96 | __This action is optional, dispatch this only if you want optimistic creation.__ [Read more about this](#about-optimistic-changes). 97 | 98 | For generating keys see [cuid](https://github.com/ericelliott/cuid). 99 | 100 | Also adds `busy` and `pendingCreate` to the record so you can display proper indicators in your UI. 101 | 102 | ### `createSuccess` 103 | 104 | Listens for an action like this (generated by `actionCreatorsFor`): 105 | 106 | ```js 107 | { 108 | type: 'USERS_CREATE_SUCCESS', 109 | record: user, 110 | cid: clientGeneratedId 111 | } 112 | ``` 113 | 114 | Takes one record and adds it to the current state. Uses the given `key` (`id` by default) to merge. 115 | 116 | The `cid` attribute is optional but it should be used when dispatching `createStart`. This `cid` will be used for matching the record and replacing it with the saved one. 117 | 118 | ### `createError` 119 | 120 | Listens for an action like: 121 | 122 | ```js 123 | { 124 | type: 'USERS_CREATE_ERROR', 125 | record: user, 126 | } 127 | ``` 128 | 129 | This reducer removes the record from the collection. The record key is used for matching the records. So if a record was added optimistically using `createStart` then the keys must match. 130 | 131 | ### `updateStart` 132 | 133 | Listens for an action like this (generated by `actionCreatorsFor`): 134 | 135 | ```js 136 | { 137 | type: 'USERS_UPDATE_START', 138 | record: user 139 | } 140 | ``` 141 | 142 | Takes one record and merges it to the current state. Uses the given `key` or `id` by default to merge. 143 | 144 | It also add these two properties to the record: 145 | - `busy` 146 | - `pendingUpdate` 147 | 148 | You can use this to display relevant information in the UI e.g. a spinner. 149 | 150 | ### `updateSuccess` 151 | 152 | Listens for an action like this (generated by `actionCreatorsFor`): 153 | 154 | ```js 155 | { 156 | type: 'USERS_UPDATE_SUCCESS', 157 | record: user 158 | } 159 | ``` 160 | 161 | Takes one record and merges it to the current state. Uses the given `key` or `id` by default to merge. 162 | 163 | ### `updateError` 164 | 165 | Listens for an action like this (generated by `actionCreatorsFor`): 166 | 167 | ```js 168 | { 169 | type: 'USERS_UPDATE_ERROR', 170 | record: user, 171 | error: error 172 | } 173 | ``` 174 | 175 | This reducer will remove `busy` from the given record. It will not rollback the record to their previous state as we don't want users to lose their changes. The record will keep the `pendingUpdate` attribute set to true. 176 | 177 | ## `deleteStart` 178 | 179 | Listens for an action like this (generated by `actionCreatorsFor`): 180 | 181 | ```js 182 | { 183 | type: 'USERS_DELETE_START', 184 | record: user 185 | } 186 | ``` 187 | 188 | Marks the given record as `deleted` and `busy`. This reducer doesn't actually remove it. In your UI you can filter out records with `deleted` to hide them. 189 | 190 | ## `deleteSuccess` 191 | 192 | Listens for an action like this (generated by `actionCreatorsFor`): 193 | 194 | ```js 195 | { 196 | type: 'USERS_DELETE_SUCCESS', 197 | record: user 198 | } 199 | ``` 200 | 201 | This reducer removes the given record from the store. 202 | 203 | ## `deleteError` 204 | 205 | Listens for an action like this (generated by `actionCreatorsFor`): 206 | 207 | ```js 208 | { 209 | type: 'USERS_DELETE_ERROR', 210 | record: user, 211 | error: error 212 | } 213 | ``` 214 | 215 | Removes `deleted` and `busy` from the given record. 216 | -------------------------------------------------------------------------------- /docs/redux.md: -------------------------------------------------------------------------------- 1 | # Using with Redux 2 | 3 | ### Action creators 4 | 5 | Create your action creators by extending the standard actions: 6 | ```js 7 | import _ from 'lodash'; 8 | import reduxCrud from 'redux-crud'; 9 | 10 | const standardActionCreators = reduxCrud.actionCreatorsFor('users'); 11 | 12 | let actionCreators = { 13 | update(user) { 14 | ... 15 | } 16 | } 17 | 18 | actionCreators = _.assign(standardActionCreators, actionCreators); 19 | 20 | export default actionCreators; 21 | ``` 22 | 23 | ### Async action creators 24 | 25 | Redux CRUD only generates sync action creators. Async action creators still need to be added: 26 | ```js 27 | 28 | const standardActionCreators = reduxCrud.actionCreatorsFor('users'); 29 | 30 | let actionCreators = { 31 | update(user) { 32 | return function(dispatch) { 33 | // dispatch a `updateStart` for optimistic updates 34 | const action = standardActionCreators.updateStart(user); 35 | dispatch(action); 36 | 37 | // send the request 38 | const url = `/users/${user.id}`; 39 | const promise = someAjaxLibrary({ 40 | url: url, 41 | method: 'PUT', 42 | data: { 43 | user 44 | } 45 | }); 46 | 47 | promise.then(function(response) { 48 | // dispatch the success action 49 | const returnedUser = response.data.data; 50 | const action = standardActionCreators.updateSuccess(returnedUser); 51 | dispatch(action); 52 | }, function(response) { 53 | // rejection 54 | // dispatch the error action 55 | // first param is the error 56 | const action = standardActionCreators.updateError(response, user); 57 | dispatch(action); 58 | }).catch(function(err) { 59 | console.error(err.toString()); 60 | }); 61 | 62 | return promise; 63 | } 64 | }, 65 | ... 66 | } 67 | ``` 68 | 69 | [See a list of examples here](./async_action_creators.js) 70 | 71 | ### Reducers 72 | 73 | Redux CRUD generates standard reducers for __`fetch`__, __`create`__, __`update`__ and __`delete`__. 74 | 75 | Create your Redux application: 76 | ```js 77 | import thunkMiddleware from 'redux-thunk'; 78 | import loggerMiddleware from 'redux-logger'; 79 | import { combineReducers } from 'redux'; 80 | import { createStore, applyMiddleware } from 'redux'; 81 | import reduxCrud from 'redux-crud'; 82 | 83 | const createStoreWithMiddleware = applyMiddleware( 84 | thunkMiddleware, // lets us dispatch() functions 85 | loggerMiddleware // neat middleware that logs actions 86 | )(createStore); 87 | 88 | const allReducers = combineReducers({ 89 | users: reduxCrud.Map.reducersFor('users'), 90 | posts: reduxCrud.Map.reducersFor('posts'), 91 | }); 92 | 93 | const store = createStoreWithMiddleware(allReducers); 94 | ``` 95 | 96 | ### Extending reducers 97 | 98 | There are many cases when the generated reducers are not enough. For example you might want to delete relevant `comments` when a `post` is deleted. You can extend a reducer function like this: 99 | 100 | ```js 101 | // comments/reducers.js 102 | 103 | import reduxCrud from 'redux-crud'; 104 | 105 | const standardReducers = reduxCrud.Map.reducersFor('comments'); 106 | 107 | function reducers(state=[], action) { 108 | switch(action.type) { 109 | case 'POSTS_DELETE_SUCCESS': 110 | // ...delete comments for the given post and return a new state for comments 111 | return state; 112 | default: 113 | // pass to the generated reducers 114 | return standardReducers(state, action); 115 | } 116 | } 117 | 118 | export default reducers; 119 | ``` 120 | 121 | Then you can use this reducer: 122 | 123 | ```js 124 | import commentsReducers from './comments/reducers'; 125 | 126 | const allReducers = combineReducers({ 127 | comments: commentsReducers, 128 | posts: reduxCrud.Map.reducersFor('posts'), 129 | }); 130 | ``` 131 | -------------------------------------------------------------------------------- /docs/tips.md: -------------------------------------------------------------------------------- 1 | # Tips 2 | 3 | ### Getting data to your components 4 | 5 | With React use [React-Redux](https://github.com/rackt/react-redux). 6 | 7 | ## Avoid nesting 8 | 9 | Don't atttempt to store nested resources. e.g. `{id: 1, posts: [{...}]}`. This makes harder to keep the information in sync with the UI. Instead always normalize the resources when they arrive from the server and store them in collections of their own. 10 | 11 | ### Normalizing records 12 | 13 | Your API might return something like: 14 | 15 | ```js 16 | { 17 | id: 1, 18 | label: 'Some post', 19 | comments: [ 20 | {id: 1, body: '...'}, 21 | {id: 2, body: '...'}, 22 | ] 23 | } 24 | ``` 25 | 26 | Instead of trying to work with nested records in your views, you should normalize them in your async action creator: 27 | 28 | ```js 29 | 30 | const baseActionCreators = reduxCrud.actionCreatorsFor('posts'); 31 | const baseCommentsActionCreators = reduxCrud.actionCreatorsFor('comments'); 32 | 33 | fetch() { 34 | return function(dispatch) { 35 | const action = baseActionCreators.fetchStart(); 36 | dispatch(action); 37 | 38 | const url = `/posts/`; 39 | const promise = someAjaxLibrary({ 40 | url: url, 41 | method: 'GET' 42 | }); 43 | 44 | promise.then(function(response) { 45 | const posts = response.data.data; 46 | const action = baseActionCreators.fetchSuccess(posts); 47 | dispatch(action); 48 | 49 | /***********************************************/ 50 | /* Get the comments and send them to the store */ 51 | const comments = _(posts).map(function(post) { 52 | return post.comments; 53 | }).flatten().value(); 54 | 55 | const commentsAction = baseCommentsActionCreators.fetchSuccess(comments); 56 | dispatch(commentsAction); 57 | /**********************************************/ 58 | }, function(response) { 59 | const action = baseActionCreators.fetchError(response); 60 | dispatch(action); 61 | }).catch(function(err) { 62 | console.error(err.toString()); 63 | }); 64 | 65 | return promise; 66 | } 67 | }, 68 | ``` 69 | 70 | ### Use plural resources 71 | 72 | Use a collection of resources and name them using the plural form e.g. `users` instead of `user`. 73 | 74 | ### About optimistic changes 75 | 76 | Dispatching `createStart`, `updateStart` and `deleteStart` will result in optimistic changes to your store. See the description of what each reducer does above. `updateStart` and `deleteStart` will just work out of the box. `createStart` needs additional code from you. 77 | 78 | This is an example async action creator with optimistic creation: 79 | 80 | ```js 81 | create(user) { 82 | return function(dispatch) { 83 | // Generate a cid so we can match the records 84 | var cid = cuid(); 85 | 86 | // Add the cid as the primary key 87 | user = user.merge({id: cid}); 88 | 89 | // Optimistic creation 90 | // This action creator will throw if user doesn't have a primary key 91 | const action = baseActionCreators.createStart(user); 92 | dispatch(action); 93 | 94 | // send the request 95 | const url = `/users/`; 96 | const promise = someAjaxLibrary({ 97 | url: url, 98 | method: 'POST', 99 | data: { 100 | user 101 | } 102 | }); 103 | 104 | promise.then(function(response) { 105 | const returnedUser = response.data.data; 106 | // We need to pass the cid as the second argument 107 | const action = baseActionCreators.createSuccess(returnedUser, cid); 108 | dispatch(action); 109 | }, function(response) { 110 | const action = baseActionCreators.createError(response, user); 111 | dispatch(action); 112 | }).catch(function(err) { 113 | console.error(err.toString()); 114 | }); 115 | 116 | return promise; 117 | } 118 | }, 119 | ``` 120 | 121 | Note how we need to pass the `cid` as the second argument to `createSuccess`. __If we don't the reducer will not be able to match the records and you will end up with duplicates__. 122 | 123 | Adding a client generated `id` to a record doesn't mean that you need to use that `id` for saving it in the backend. You can still generate ids as usual in your DB. 124 | 125 | When the record comes back saved from the server the reducer will try to match `id` on the optimistically inserted record with `cid` on the `createSuccess` action. If it finds a match it will replace the optimistically inserted record with the given one. That record will now have the normal `id` given by the backend (The client generated id is thrown away at this point). 126 | 127 | ### Pending attributes 128 | 129 | `createStart` and `updateStart` will add the following attributes: 130 | 131 | - **createStart**: Adds `busy` and `pendingCreate` 132 | - **updateStart**: Adds `busy` and `pendingUpdate` 133 | - **updateError**: Removes `busy` but leaves `pendingUpdate` 134 | 135 | You can use these special attributes for showing indicators and preventing navigation: 136 | 137 | - Show a busy indicator when `busy` is true. 138 | - Do not allow navigation to a resource when `pendingCreate` is true. 139 | - Show a _retry_ button when an update fails: `busy` is false but `pendingUpdate` is true. 140 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "ace-css": "^1.1.0", 7 | "axios": "^0.15.3", 8 | "axios-mock-adapter": "^1.7.1", 9 | "bows": "^1.6.0", 10 | "cuid": "^1.3.8", 11 | "invariant": "^2.2.2", 12 | "ramda": "^0.23.0", 13 | "react-fa": "^4.1.2", 14 | "react-redux": "^5.0.2", 15 | "react-scripts": "0.8.5", 16 | "redux": "^3.6.0", 17 | "redux-logger": "^2.7.4", 18 | "redux-thunk": "^2.2.0" 19 | }, 20 | "dependencies": { 21 | "react": "^15.4.2", 22 | "react-dom": "^15.4.2" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test --env=jsdom", 28 | "eject": "react-scripts eject" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Versent/redux-crud/5011cc55a7518823c77bf519b82b881f54ca4039/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React App 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | // import loggerMiddleware from 'redux-logger' 2 | import { Provider } from 'react-redux' 3 | import * as redux from "redux" 4 | import bows from "bows" 5 | import initMocks from "./mocks" 6 | import React from "react" 7 | import thunkMiddleware from 'redux-thunk' 8 | 9 | import todosReducer from './todos/reducer' 10 | import TodosIndex from './todos/Index' 11 | 12 | var log = bows("App") 13 | 14 | log("App") 15 | 16 | initMocks() 17 | 18 | const allReducers = redux.combineReducers({ 19 | todos: todosReducer, 20 | }) 21 | 22 | const store = redux.createStore( 23 | allReducers, 24 | redux.compose( 25 | redux.applyMiddleware(thunkMiddleware) 26 | ) 27 | ) 28 | 29 | class App extends React.Component { 30 | render() { 31 | return ( 32 | 33 |
34 | 35 |
36 |
37 | ) 38 | } 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import '../node_modules/ace-css/css/ace.css' 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render( 7 | , 8 | document.getElementById("root") 9 | ); 10 | -------------------------------------------------------------------------------- /example/src/mocks.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import AxiosMock from "axios-mock-adapter" 3 | import r from "ramda" 4 | import fixture from "./todos/fixture" 5 | 6 | var mock = new AxiosMock(axios, { delayResponse: 500 }) 7 | var nextMockId = 100 8 | 9 | export default function init() { 10 | mock.onGet("/todos").reply(200, fixture) 11 | 12 | mock.onPost("/todos").reply(function(config) { 13 | nextMockId++ 14 | var record = JSON.parse(config.data) 15 | record = r.merge(record, { 16 | id: nextMockId 17 | }) 18 | return [200, record] 19 | }) 20 | 21 | mock.onPatch(/\/todos\/\d+/).reply(function(config) { 22 | return [200, config.data] 23 | }) 24 | 25 | mock.onDelete(/\/todos\/\d+/).reply(200) 26 | } 27 | -------------------------------------------------------------------------------- /example/src/todos/Form.js: -------------------------------------------------------------------------------- 1 | // import bows from "bows" 2 | import Icon from "react-fa" 3 | import r from "ramda" 4 | import React from "react" 5 | 6 | const PT = React.PropTypes 7 | // const log = bows("todos--Form") 8 | 9 | class Form extends React.Component { 10 | 11 | constructor(props, ctx) { 12 | super(props, ctx) 13 | this.state = this.getCleanState(props) 14 | } 15 | 16 | componentWillReceiveProps(nextProps) { 17 | this.setState(this.getCleanState(nextProps)) 18 | } 19 | 20 | getCleanState(props) { 21 | const todo = props.todo 22 | return { 23 | title: todo.title, 24 | } 25 | } 26 | 27 | onChange(event) { 28 | const { value } = event.target 29 | this.setState({ 30 | title: value, 31 | }) 32 | } 33 | 34 | onSave(event) { 35 | event.preventDefault() 36 | var todo = r.merge(this.props.todo, this.state) 37 | this.props.onCommit(todo) 38 | } 39 | 40 | render() { 41 | return ( 42 |
43 |
44 | 50 |
51 | 54 |
55 |
56 |
57 | ) 58 | } 59 | 60 | } 61 | 62 | Form.propTypes = { 63 | dispatch: PT.func.isRequired, 64 | onCommit: PT.func.isRequired, 65 | todo: PT.object.isRequired, 66 | } 67 | 68 | export default Form 69 | -------------------------------------------------------------------------------- /example/src/todos/Index.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import actions from "./actions" 3 | import invariant from "invariant" 4 | import List from "./List" 5 | import New from "./New" 6 | import React from "react" 7 | 8 | const PT = React.PropTypes 9 | 10 | class Index extends React.Component { 11 | 12 | componentDidMount() { 13 | this.fetchTodos() 14 | } 15 | 16 | get dispatch() { 17 | return this.props.dispatch 18 | } 19 | 20 | fetchTodos() { 21 | const action = actions.fetch() 22 | this.dispatch(action) 23 | } 24 | 25 | render() { 26 | var props = this.props 27 | 28 | invariant(props.dispatch, "Required dispatch") 29 | invariant(props.todos, "Required todos") 30 | 31 | return ( 32 |
33 |

Todos

34 | 35 | 39 |
40 | ) 41 | } 42 | } 43 | 44 | Index.propTypes = { 45 | dispatch: PT.func.isRequired, 46 | } 47 | 48 | export default connect(state => state)(Index) 49 | -------------------------------------------------------------------------------- /example/src/todos/List.js: -------------------------------------------------------------------------------- 1 | // import bows from "bows" 2 | import actions from "./actions" 3 | import Icon from "react-fa" 4 | import invariant from "invariant" 5 | import r from "ramda" 6 | import React from "react" 7 | 8 | var PT = React.PropTypes 9 | var baseClass = "todos-List" 10 | // var log = bows(baseClass) 11 | 12 | class List extends React.Component { 13 | 14 | onToggle(todo, done, event) { 15 | event.preventDefault() 16 | todo = r.merge(todo, {done}) 17 | const action = actions.update(todo) 18 | this.props.dispatch(action) 19 | } 20 | 21 | onDelete(todo, event) { 22 | event.preventDefault() 23 | const action = actions.delete(todo) 24 | this.props.dispatch(action) 25 | } 26 | 27 | onRename() { 28 | const action = actions.renameAll() 29 | this.props.dispatch(action) 30 | } 31 | 32 | onShuffleName() { 33 | const action = actions.shuffleName() 34 | this.props.dispatch(action) 35 | } 36 | 37 | renderCheck(todo) { 38 | if (todo.done) { 39 | return ( 40 | 44 | 45 | 46 | ) 47 | } else { 48 | return ( 49 | 53 | 54 | 55 | ) 56 | } 57 | } 58 | 59 | renderTodos() { 60 | return this.props.todos.map(todo => { 61 | return ( 62 | 63 | 64 | {todo.title} 65 | 66 | 67 | {this.renderCheck(todo)} 68 | 71 | 72 | 73 | ) 74 | }) 75 | } 76 | 77 | render() { 78 | var props = this.props 79 | 80 | invariant(props.todos, "Required todos") 81 | invariant(props.dispatch, "Required dispatch") 82 | 83 | return ( 84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | {this.renderTodos()} 94 | 95 |
Title
96 |
97 | ) 98 | } 99 | } 100 | 101 | List.propTypes = { 102 | todos: PT.array.isRequired, 103 | dispatch: PT.func.isRequired, 104 | } 105 | 106 | export default List 107 | -------------------------------------------------------------------------------- /example/src/todos/New.js: -------------------------------------------------------------------------------- 1 | import actions from "./actions" 2 | // import bows from "bows" 3 | import Form from "./Form" 4 | import invariant from "invariant" 5 | import React from "react" 6 | 7 | var PT = React.PropTypes 8 | // var log = bows("todos-New") 9 | 10 | class New extends React.Component { 11 | 12 | constructor(props, ctx) { 13 | super(props, ctx) 14 | this.state = this.getCleanState() 15 | } 16 | 17 | getCleanState() { 18 | return { 19 | todo: { 20 | title: "", 21 | }, 22 | } 23 | } 24 | 25 | onCommit(todo) { 26 | const action = actions.create(todo) 27 | const dispatch = this.props.dispatch 28 | dispatch(action) 29 | this.setState(this.getCleanState()) 30 | } 31 | 32 | render() { 33 | var { props, state } = this 34 | 35 | invariant(props.dispatch, "Required dispatch") 36 | 37 | return ( 38 |
39 |
44 |
45 | ) 46 | } 47 | 48 | } 49 | 50 | New.propTypes = { 51 | dispatch: PT.func.isRequired, 52 | } 53 | 54 | export default New 55 | -------------------------------------------------------------------------------- /example/src/todos/actions.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import bows from "bows" 3 | import cuid from "cuid" 4 | import r from "ramda" 5 | import reduxCrud from "../../../dist/index" 6 | 7 | var baseActionCreators = reduxCrud.actionCreatorsFor("todos") 8 | var log = bows("todos-actions") 9 | 10 | let actionCreators = { 11 | 12 | fetch() { 13 | log("fetch todos") 14 | return function(dispatch, getState) { 15 | 16 | const action = baseActionCreators.fetchStart() 17 | log("action", action) 18 | dispatch(action) 19 | 20 | // send the request 21 | const url = "/todos" 22 | const promise = axios({ 23 | url: url, 24 | }) 25 | 26 | promise.then(function(response) { 27 | log("success", response) 28 | // dispatch the success action 29 | const returned = response.data 30 | const successAction = baseActionCreators.fetchSuccess(returned) 31 | log("successAction", successAction) 32 | dispatch(successAction) 33 | }, function(response) { 34 | log("rejection", response) 35 | // On rejection dispatch the error action 36 | const errorAction = baseActionCreators.fetchError(response) 37 | dispatch(errorAction) 38 | }).catch(function(err) { 39 | console.error(err.toString()) 40 | }) 41 | 42 | return promise 43 | } 44 | }, 45 | 46 | create(todo) { 47 | return function(dispatch) { 48 | const cid = cuid() 49 | todo = r.merge(todo, {id: cid}) 50 | 51 | const optimisticAction = baseActionCreators.createStart(todo) 52 | dispatch(optimisticAction) 53 | 54 | const url = "/todos" 55 | const promise = axios({ 56 | url: url, 57 | method: "POST", 58 | data: todo, 59 | }) 60 | 61 | promise.then(function(response) { 62 | // dispatch the success action 63 | const returned = response.data 64 | const successAction = baseActionCreators.createSuccess(returned, cid) 65 | dispatch(successAction) 66 | }, function(response) { 67 | // rejection 68 | // dispatch the error action 69 | const errorAction = baseActionCreators.createError(response, todo) 70 | dispatch(errorAction) 71 | }).catch(function(err) { 72 | console.error(err.toString()) 73 | }) 74 | 75 | return promise 76 | 77 | } 78 | }, 79 | 80 | update(todo) { 81 | return function(dispatch) { 82 | const optimisticAction = baseActionCreators.updateStart(todo) 83 | dispatch(optimisticAction) 84 | 85 | const url = `/todos/${todo.id}` 86 | const promise = axios({ 87 | url: url, 88 | method: "PATCH", 89 | data: todo, 90 | }) 91 | 92 | promise.then(function(response) { 93 | // dispatch the success action 94 | const returned = response.data 95 | const successAction = baseActionCreators.updateSuccess(returned) 96 | dispatch(successAction) 97 | }, function(response) { 98 | // rejection 99 | // dispatch the error action 100 | const errorAction = baseActionCreators.updateError(response, todo) 101 | dispatch(errorAction) 102 | }).catch(function(err) { 103 | console.error(err.toString()) 104 | }) 105 | 106 | return promise 107 | 108 | } 109 | }, 110 | 111 | delete(todo) { 112 | return function(dispatch) { 113 | const optimisticAction = baseActionCreators.deleteStart(todo) 114 | dispatch(optimisticAction) 115 | 116 | const url = `/todos/${todo.id}` 117 | const promise = axios({ 118 | url: url, 119 | method: "DELETE", 120 | }) 121 | 122 | promise.then(function(response) { 123 | // dispatch the success action 124 | const successAction = baseActionCreators.deleteSuccess(todo) 125 | dispatch(successAction) 126 | }, function(response) { 127 | // rejection 128 | // dispatch the error action 129 | const errorAction = baseActionCreators.deleteError(response, todo) 130 | dispatch(errorAction) 131 | }).catch(function(err) { 132 | console.error(err.toString()) 133 | }) 134 | 135 | return promise 136 | } 137 | }, 138 | 139 | } 140 | 141 | actionCreators = r.merge(baseActionCreators, actionCreators) 142 | 143 | export default actionCreators 144 | -------------------------------------------------------------------------------- /example/src/todos/fixture.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | "id": 1, 4 | "title": "Learn Elm", 5 | "done": true 6 | }, 7 | { 8 | "id": 2, 9 | "title": "Learn Haskell" 10 | }, 11 | { 12 | "id": 3, 13 | "title": "Learn Elixir", 14 | "done": true 15 | }, 16 | { 17 | "id": 4, 18 | "title": "Learn Rust" 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /example/src/todos/reducer.js: -------------------------------------------------------------------------------- 1 | import reduxCrud from "../../../dist/index" 2 | import bows from "bows" 3 | 4 | var baseReducers = reduxCrud.List.reducersFor("todos") 5 | var log = bows("todos-reducer") 6 | 7 | export default function reducer(state=[], action) { 8 | log(action) 9 | 10 | switch (action.type) { 11 | default: 12 | return baseReducers(state, action) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-crud", 3 | "version": "3.3.0", 4 | "description": "Redux CRUD", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "tsc && ava dist/**/*.test.js", 10 | "lint": "tslint src/**/*.ts", 11 | "format": "prettier --bracket-spacing=false --write 'src/**/*.ts'", 12 | "precommit": "lint-staged", 13 | "prepublish": "npm run build" 14 | }, 15 | "lint-staged": { 16 | "src/**/*.ts": [ 17 | "prettier --write --bracket-spacing=false", 18 | "git add" 19 | ] 20 | }, 21 | "files": [ 22 | "dist", 23 | "src" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/Versent/redux-crud" 28 | }, 29 | "keywords": [ 30 | "redux", 31 | "crud" 32 | ], 33 | "author": "Versent", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/Versent/redux-crud/issues" 37 | }, 38 | "homepage": "https://github.com/Versent/redux-crud", 39 | "devDependencies": { 40 | "ava": "^0.22.0", 41 | "husky": "^0.14.3", 42 | "lint-staged": "^4.2.1", 43 | "prettier": "^1.3.1", 44 | "testdouble": "^3.2.5", 45 | "tslint": "^5.7.0", 46 | "typescript": "^2.1.5" 47 | }, 48 | "dependencies": { 49 | "action-names": "^0.4.0", 50 | "invariant": "^2.1.0", 51 | "ramda": "^0.24.1" 52 | }, 53 | "ava": { 54 | "failFast": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Redux CRUD 2 | 3 | **Looking for a new mantainer**: See 4 | 5 | [ ![Codeship Status for Versent/redux-crud](https://codeship.com/projects/41be3440-293a-0133-d1a0-76c73dc375da/status?branch=master)](https://codeship.com/projects/97928) 6 | 7 | Redux CRUD is a convention driven way of building CRUD applications using Redux. After building several Flux applications we found that we always end up creating the same action types, actions and reducers for all our resources. 8 | 9 | Redux CRUD gives you a standard set of: 10 | 11 | - action types: e.g. `USER_UPDATE_SUCCESS` 12 | - actions: e.g. `updateSuccess`, `updateError` 13 | - reducers: for the action types above e.g. `updateSuccess` 14 | 15 | # Working with resources in Redux 16 | 17 | When building an app you might have resources like __`users`__, __`posts`__ and __`comments`__. 18 | 19 | You'll probably end up with action types for them like: 20 | 21 | - `USERS_FETCH_SUCCESS` 22 | - `POSTS_FETCH_SUCCESS` 23 | - `COMMENTS_FETCH_SUCCESS` 24 | 25 | And action creators like: 26 | 27 | - `users.fetchSuccess` 28 | - `posts.fetchSuccess` 29 | - `comments.fetchSuccess` 30 | 31 | There's obvious repetition there. Redux CRUD aims to remove this boilerplate by providing strong conventions on naming and processing data. 32 | 33 | ## Stores 34 | 35 | Redux-crud provides two stores: 36 | 37 | - __List__. A plain JS array. This preserves the order of records. 38 | - __Map__. A JS object where records are indexed by key. This provides faster writes and lookups. 39 | 40 | ## Docs 41 | 42 | ### [Actions](./docs/actions.md) 43 | ### [Reducers](./docs/reducers.md) 44 | ### [Using with Redux](./docs/redux.md) 45 | ### [Tips](./docs/tips.md) 46 | 47 | ## Testing 48 | 49 | ``` 50 | npm test 51 | ``` 52 | 53 | ## Example 54 | 55 | You can see [a basic example here](./example) 56 | 57 | -------------------------------------------------------------------------------- /src/actionCreatorsFor.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import actionCreatorsFor from "./actionCreatorsFor"; 3 | 4 | const error = {}; 5 | const actionCreators = actionCreatorsFor("users"); 6 | const subject = " actionCreatorsFor: "; 7 | const arrayRegEx = /Expected record not to be an array/; 8 | 9 | function makeUser() { 10 | return { 11 | id: 11 12 | }; 13 | } 14 | 15 | function makeUsers() { 16 | return [makeUser()]; 17 | } 18 | 19 | test(subject + "returns the actionCreators", function(t) { 20 | t.truthy(actionCreators.fetchStart); 21 | t.truthy(actionCreators.fetchSuccess); 22 | t.truthy(actionCreators.fetchError); 23 | 24 | t.truthy(actionCreators.createStart); 25 | t.truthy(actionCreators.createSuccess); 26 | t.truthy(actionCreators.createError); 27 | 28 | t.truthy(actionCreators.updateStart); 29 | t.truthy(actionCreators.updateSuccess); 30 | t.truthy(actionCreators.updateError); 31 | 32 | t.truthy(actionCreators.deleteStart); 33 | t.truthy(actionCreators.deleteSuccess); 34 | t.truthy(actionCreators.deleteError); 35 | }); 36 | 37 | test(subject + "fetchStart", function(t) { 38 | var data = {foo: 1}; 39 | 40 | var action = actionCreators.fetchStart(data); 41 | 42 | t.deepEqual(action.type, "USERS_FETCH_START"); 43 | t.deepEqual(action.data, data, "has the data"); 44 | }); 45 | 46 | test(subject + "fetchSuccess", function(t) { 47 | var data = {foo: 1}; 48 | var users = makeUsers(); 49 | 50 | var action = actionCreators.fetchSuccess(users, data); 51 | 52 | t.deepEqual(action.type, "USERS_FETCH_SUCCESS"); 53 | t.deepEqual(action.records, users, "has the user"); 54 | t.deepEqual(action.data, data, "has the data"); 55 | 56 | function withoutPayload() { 57 | actionCreators.fetchSuccess(); 58 | } 59 | t.throws(withoutPayload, /Expected records/); 60 | }); 61 | 62 | test(subject + "fetchError", function(t) { 63 | var data = {foo: 1}; 64 | 65 | var action = actionCreators.fetchError(error, data); 66 | 67 | t.deepEqual(action.type, "USERS_FETCH_ERROR"); 68 | t.deepEqual(action.error, error, "has the error"); 69 | t.deepEqual(action.data, data, "has the data"); 70 | }); 71 | 72 | test(subject + "createStart", function(t) { 73 | var user = makeUser(); 74 | var data = {foo: 1}; 75 | 76 | var action = actionCreators.createStart(user, data); 77 | 78 | t.deepEqual(action.type, "USERS_CREATE_START"); 79 | t.deepEqual(action.record, user, "has the user"); 80 | t.deepEqual(action.data, data, "has the data"); 81 | 82 | function withoutPayload() { 83 | actionCreators.createStart(); 84 | } 85 | t.throws(withoutPayload, /Expected record/); 86 | 87 | // it expects single record 88 | function withArray() { 89 | actionCreators.createStart([]); 90 | } 91 | t.throws(withArray, arrayRegEx); 92 | 93 | // it expects a key on the record 94 | function withoutKey() { 95 | var user = {}; 96 | actionCreators.createStart(user); 97 | } 98 | t.throws(withoutKey, /Expected record\.id in createStart/); 99 | }); 100 | 101 | test(subject + "createSuccess", function(t) { 102 | var user = makeUser(); 103 | var data = {foo: 1}; 104 | 105 | var action = actionCreators.createSuccess(user, "abc", data); 106 | 107 | t.deepEqual(action.type, "USERS_CREATE_SUCCESS"); 108 | t.deepEqual(action.record, user, "has the user"); 109 | t.deepEqual(action.cid, "abc", "has the cid"); 110 | t.deepEqual(action.data, data, "has the data"); 111 | 112 | function withoutPayload() { 113 | actionCreators.createSuccess(); 114 | } 115 | t.throws(withoutPayload, /Expected record/); 116 | 117 | // it expects one 118 | function withArray() { 119 | actionCreators.createSuccess([]); 120 | } 121 | t.throws(withArray, arrayRegEx); 122 | }); 123 | 124 | test(subject + "createError", function(t) { 125 | var user = makeUser(); 126 | var data = {foo: 1}; 127 | 128 | var action = actionCreators.createError(error, user, data); 129 | 130 | t.deepEqual(action.type, "USERS_CREATE_ERROR"); 131 | t.deepEqual(action.error, error); 132 | t.deepEqual(action.record, user, "has the user"); 133 | t.deepEqual(action.data, data, "has the data"); 134 | 135 | function withoutPayload() { 136 | actionCreators.createError(error); 137 | } 138 | t.throws(withoutPayload, /Expected record/); 139 | 140 | // it expects single record 141 | function withArray() { 142 | actionCreators.createError(error, []); 143 | } 144 | t.throws(withArray, arrayRegEx); 145 | 146 | function withoutKey() { 147 | var user = {}; 148 | actionCreators.createError(error, user); 149 | } 150 | t.throws(withoutKey, /Expected record\.id in createError/); 151 | }); 152 | 153 | test(subject + "updateStart", function(t) { 154 | var user = makeUser(); 155 | var data = {foo: 1}; 156 | 157 | var action = actionCreators.updateStart(user, data); 158 | 159 | t.deepEqual(action.type, "USERS_UPDATE_START"); 160 | t.deepEqual(action.record, user, "has the user"); 161 | t.deepEqual(action.data, data, "has the data"); 162 | 163 | function withoutPayload() { 164 | actionCreators.updateStart(); 165 | } 166 | t.throws(withoutPayload, /Expected record/); 167 | 168 | // it expects one 169 | function withArray() { 170 | actionCreators.updateStart([]); 171 | } 172 | t.throws(withArray, arrayRegEx); 173 | }); 174 | 175 | test(subject + "updateSuccess", function(t) { 176 | var user = makeUser(); 177 | var data = {foo: 1}; 178 | 179 | var action = actionCreators.updateSuccess(user, data); 180 | 181 | t.deepEqual(action.type, "USERS_UPDATE_SUCCESS"); 182 | t.deepEqual(action.record, user, "has the user"); 183 | t.deepEqual(action.data, data, "has the data"); 184 | 185 | function withoutPayload() { 186 | actionCreators.updateSuccess(); 187 | } 188 | t.throws(withoutPayload, /Expected record/); 189 | 190 | // it expects one 191 | function withArray() { 192 | actionCreators.updateSuccess([]); 193 | } 194 | t.throws(withArray, arrayRegEx); 195 | }); 196 | 197 | test(subject + "updateError", function(t) { 198 | var user = makeUser(); 199 | var data = {foo: 1}; 200 | 201 | var action = actionCreators.updateError(error, user, data); 202 | 203 | t.deepEqual(action.type, "USERS_UPDATE_ERROR"); 204 | t.deepEqual(action.error, error); 205 | t.deepEqual(action.record, user, "has the user"); 206 | t.deepEqual(action.data, data, "has the data"); 207 | 208 | function withoutPayload() { 209 | actionCreators.updateError(error); 210 | } 211 | t.throws(withoutPayload, /Expected record/); 212 | 213 | // it expects one 214 | function withArray() { 215 | actionCreators.updateError(error, []); 216 | } 217 | t.throws(withArray, arrayRegEx); 218 | }); 219 | 220 | test(subject + "deleteStart", function(t) { 221 | var user = makeUser(); 222 | var data = {foo: 1}; 223 | 224 | var action = actionCreators.deleteStart(user, data); 225 | 226 | t.deepEqual(action.type, "USERS_DELETE_START"); 227 | t.deepEqual(action.record, user, "has the user"); 228 | t.deepEqual(action.data, data, "has the data"); 229 | 230 | function withoutPayload() { 231 | actionCreators.deleteStart(); 232 | } 233 | t.throws(withoutPayload, /Expected record/); 234 | 235 | // it expects one 236 | function withArray() { 237 | actionCreators.deleteStart([]); 238 | } 239 | t.throws(withArray, arrayRegEx); 240 | }); 241 | 242 | test(subject + "deleteSuccess", function(t) { 243 | var user = makeUser(); 244 | var data = {foo: 1}; 245 | 246 | var action = actionCreators.deleteSuccess(user, data); 247 | 248 | t.deepEqual(action.type, "USERS_DELETE_SUCCESS"); 249 | t.deepEqual(action.record, user, "has the user"); 250 | t.deepEqual(action.data, data, "has the data"); 251 | 252 | function withoutPayload() { 253 | actionCreators.deleteSuccess(); 254 | } 255 | t.throws(withoutPayload, /Expected record/); 256 | 257 | // it expects one 258 | function withArray() { 259 | actionCreators.deleteSuccess([]); 260 | } 261 | t.throws(withArray, arrayRegEx); 262 | }); 263 | 264 | test(subject + "deleteError", function(t) { 265 | var user = makeUser(); 266 | var data = {foo: 1}; 267 | 268 | var action = actionCreators.deleteError(error, user, data); 269 | 270 | t.deepEqual(action.type, "USERS_DELETE_ERROR"); 271 | t.deepEqual(action.error, error); 272 | t.deepEqual(action.record, user, "has the user"); 273 | t.deepEqual(action.data, data, "has the data"); 274 | 275 | function withoutPayload() { 276 | actionCreators.deleteError(error); 277 | } 278 | t.throws(withoutPayload, /Expected record/); 279 | 280 | // it expects one 281 | function withArray() { 282 | actionCreators.deleteError(error, []); 283 | } 284 | t.throws(withArray, arrayRegEx); 285 | }); 286 | -------------------------------------------------------------------------------- /src/actionCreatorsFor.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge"; 2 | import * as invariant from "invariant"; 3 | 4 | import actionTypesFor from "./actionTypesFor"; 5 | import assertNotArray from "./utils/assertNotArray"; 6 | import constants from "./constants"; 7 | import getDefaultConfig from "./getDefaultConfig"; 8 | 9 | import {Config, ReducerName} from "./types"; 10 | 11 | // const invariant = require("invariant") 12 | 13 | function actionCreatorsFor(resourceName: string, config?: Config) { 14 | if (resourceName == null) 15 | throw new Error("actionCreatorsFor: Expected resourceName"); 16 | 17 | config = config || getDefaultConfig(resourceName); 18 | config = merge(config, {resourceName}); 19 | 20 | const actionTypes = actionTypesFor(resourceName); 21 | const key = config.key || constants.DEFAULT_KEY; 22 | 23 | function assertError(actionCreatorName: ReducerName, error) { 24 | invariant(error != null, "Expected error in " + actionCreatorName); 25 | } 26 | 27 | function assertOneRecord(actionCreatorName: ReducerName, record?: any) { 28 | invariant(record != null, "Expected record in " + actionCreatorName); 29 | assertNotArray(config, "createStart", record); 30 | invariant( 31 | record[key] != null, 32 | "Expected record." + key + " in " + actionCreatorName 33 | ); 34 | } 35 | 36 | function assertManyRecords(actionCreatorName, records) { 37 | invariant(records != null, "Expected records " + actionCreatorName); 38 | } 39 | 40 | return { 41 | fetchStart(data?) { 42 | return { 43 | data: data, 44 | type: actionTypes.fetchStart 45 | }; 46 | }, 47 | 48 | fetchSuccess(records?: T[], data?) { 49 | var name: ReducerName = "fetchSuccess"; 50 | assertManyRecords(name, records); 51 | 52 | return { 53 | data: data, 54 | records: records, 55 | type: actionTypes.fetchSuccess 56 | }; 57 | }, 58 | 59 | fetchError(error?, data?) { 60 | var name: ReducerName = "fetchError"; 61 | assertError(name, error); 62 | 63 | return { 64 | data: data, 65 | error: error, 66 | type: actionTypes.fetchError 67 | }; 68 | }, 69 | 70 | createStart(record?: T, data?) { 71 | var name: ReducerName = "createStart"; 72 | assertOneRecord(name, record); 73 | 74 | return { 75 | data: data, 76 | record: record, 77 | type: actionTypes.createStart 78 | }; 79 | }, 80 | 81 | createSuccess(record?: T, clientGeneratedKey?, data?) { 82 | var name: ReducerName = "createSuccess"; 83 | assertOneRecord(name, record); 84 | 85 | return { 86 | cid: clientGeneratedKey, 87 | data: data, 88 | record: record, 89 | type: actionTypes.createSuccess 90 | }; 91 | }, 92 | 93 | createError(error?, record?: T, data?) { 94 | var name: ReducerName = "createError"; 95 | assertError(name, error); 96 | assertOneRecord(name, record); 97 | 98 | return { 99 | data: data, 100 | error: error, 101 | record: record, 102 | type: actionTypes.createError 103 | }; 104 | }, 105 | 106 | updateStart(record?: T, data?) { 107 | var name: ReducerName = "updateStart"; 108 | assertOneRecord(name, record); 109 | 110 | return { 111 | data: data, 112 | record: record, 113 | type: actionTypes.updateStart 114 | }; 115 | }, 116 | 117 | updateSuccess(record?: T, data?) { 118 | var name: ReducerName = "updateSuccess"; 119 | assertOneRecord(name, record); 120 | 121 | return { 122 | data: data, 123 | record: record, 124 | type: actionTypes.updateSuccess 125 | }; 126 | }, 127 | 128 | updateError(error?, record?: T, data?) { 129 | var name: ReducerName = "updateError"; 130 | assertError(name, error); 131 | assertOneRecord(name, record); 132 | 133 | return { 134 | data: data, 135 | error: error, 136 | record: record, 137 | type: actionTypes.updateError 138 | }; 139 | }, 140 | 141 | deleteStart(record?: T, data?) { 142 | var name: ReducerName = "deleteStart"; 143 | assertOneRecord(name, record); 144 | 145 | return { 146 | data: data, 147 | record: record, 148 | type: actionTypes.deleteStart 149 | }; 150 | }, 151 | 152 | deleteSuccess(record?: T, data?) { 153 | var name: ReducerName = "deleteSuccess"; 154 | assertOneRecord(name, record); 155 | 156 | return { 157 | data: data, 158 | record: record, 159 | type: actionTypes.deleteSuccess 160 | }; 161 | }, 162 | 163 | deleteError(error?, record?: T, data?) { 164 | var name: ReducerName = "deleteError"; 165 | assertError(name, error); 166 | assertOneRecord(name, record); 167 | 168 | return { 169 | data: data, 170 | error: error, 171 | record: record, 172 | type: actionTypes.deleteError 173 | }; 174 | } 175 | }; 176 | } 177 | 178 | export default actionCreatorsFor; 179 | -------------------------------------------------------------------------------- /src/actionTypesFor.test.ts: -------------------------------------------------------------------------------- 1 | import actionTypesFor from "./actionTypesFor"; 2 | import test from "ava"; 3 | 4 | const actionTypes = actionTypesFor("users"); 5 | 6 | test("returns the action actionTypes", function(t) { 7 | t.deepEqual(actionTypes.USERS_FETCH_START, "USERS_FETCH_START"); 8 | t.deepEqual(actionTypes.USERS_FETCH_SUCCESS, "USERS_FETCH_SUCCESS"); 9 | t.deepEqual(actionTypes.USERS_FETCH_ERROR, "USERS_FETCH_ERROR"); 10 | 11 | t.deepEqual(actionTypes.USERS_UPDATE_START, "USERS_UPDATE_START"); 12 | t.deepEqual(actionTypes.USERS_UPDATE_SUCCESS, "USERS_UPDATE_SUCCESS"); 13 | t.deepEqual(actionTypes.USERS_UPDATE_ERROR, "USERS_UPDATE_ERROR"); 14 | 15 | t.deepEqual(actionTypes.USERS_CREATE_START, "USERS_CREATE_START"); 16 | t.deepEqual(actionTypes.USERS_CREATE_SUCCESS, "USERS_CREATE_SUCCESS"); 17 | t.deepEqual(actionTypes.USERS_CREATE_ERROR, "USERS_CREATE_ERROR"); 18 | 19 | t.deepEqual(actionTypes.USERS_DELETE_START, "USERS_DELETE_START"); 20 | t.deepEqual(actionTypes.USERS_DELETE_SUCCESS, "USERS_DELETE_SUCCESS"); 21 | t.deepEqual(actionTypes.USERS_DELETE_ERROR, "USERS_DELETE_ERROR"); 22 | }); 23 | 24 | test("returns aliases", function(t) { 25 | t.deepEqual(actionTypes.fetchStart, "USERS_FETCH_START"); 26 | t.deepEqual(actionTypes.fetchSuccess, "USERS_FETCH_SUCCESS"); 27 | t.deepEqual(actionTypes.fetchError, "USERS_FETCH_ERROR"); 28 | 29 | t.deepEqual(actionTypes.updateStart, "USERS_UPDATE_START"); 30 | t.deepEqual(actionTypes.updateSuccess, "USERS_UPDATE_SUCCESS"); 31 | t.deepEqual(actionTypes.updateError, "USERS_UPDATE_ERROR"); 32 | 33 | t.deepEqual(actionTypes.createStart, "USERS_CREATE_START"); 34 | t.deepEqual(actionTypes.createSuccess, "USERS_CREATE_SUCCESS"); 35 | t.deepEqual(actionTypes.createError, "USERS_CREATE_ERROR"); 36 | 37 | t.deepEqual(actionTypes.deleteStart, "USERS_DELETE_START"); 38 | t.deepEqual(actionTypes.deleteSuccess, "USERS_DELETE_SUCCESS"); 39 | t.deepEqual(actionTypes.deleteError, "USERS_DELETE_ERROR"); 40 | }); 41 | -------------------------------------------------------------------------------- /src/actionTypesFor.ts: -------------------------------------------------------------------------------- 1 | import * as actionTypesFor from "action-names"; 2 | 3 | export default actionTypesFor; 4 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import {ReducerName} from "./types"; 2 | 3 | const CREATE_ERROR: ReducerName = "createError"; 4 | const CREATE_START: ReducerName = "createStart"; 5 | const CREATE_SUCCESS: ReducerName = "createSuccess"; 6 | const DELETE_ERROR: ReducerName = "deleteError"; 7 | const DELETE_START: ReducerName = "deleteStart"; 8 | const DELETE_SUCCESS: ReducerName = "deleteSuccess"; 9 | const FETCH_SUCCESS: ReducerName = "fetchSuccess"; 10 | const UPDATE_ERROR: ReducerName = "updateError"; 11 | const UPDATE_START: ReducerName = "updateStart"; 12 | const UPDATE_SUCCESS: ReducerName = "updateSuccess"; 13 | 14 | export default { 15 | DEFAULT_KEY: "id", 16 | STORE_LIST: "STORE_LIST", 17 | STORE_MAP: "STORE_MAP", 18 | REDUCER_NAMES: { 19 | CREATE_ERROR, 20 | CREATE_START, 21 | CREATE_SUCCESS, 22 | DELETE_ERROR, 23 | DELETE_START, 24 | DELETE_SUCCESS, 25 | FETCH_SUCCESS, 26 | UPDATE_ERROR, 27 | UPDATE_START, 28 | UPDATE_SUCCESS 29 | }, 30 | SPECIAL_KEYS: { 31 | BUSY: "busy", 32 | CLIENT_GENERATED_ID: "_cid", 33 | DELETED: "deleted", 34 | PENDING_CREATE: "pendingCreate", 35 | PENDING_UPDATE: "pendingUpdate" 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/getDefaultConfig.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "./types"; 2 | 3 | export default function getDefaultConfig(resourceName: string): Config { 4 | return { 5 | key: "id", 6 | resourceName 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as is from "ramda/src/is"; 3 | import index from "./index"; 4 | 5 | test("it has the expected functions", function(t) { 6 | t.truthy(is(Function, index.actionCreatorsFor)); 7 | t.truthy(is(Function, index.actionTypesFor)); 8 | t.truthy(is(Object, index.List)); 9 | t.truthy(is(Function, index.List.reducersFor)); 10 | t.truthy(is(Object, index.Map)); 11 | t.truthy(is(Function, index.Map.reducersFor)); 12 | }); 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "./types"; 2 | import actionCreatorsFor from "./actionCreatorsFor"; 3 | import actionTypesFor from "./actionTypesFor"; 4 | import constants from "./constants"; 5 | import List from "./reducers/list"; 6 | import Map from "./reducers/map"; 7 | 8 | export * from "./types"; 9 | 10 | export default { 11 | actionCreatorsFor, 12 | actionTypesFor, 13 | constants, 14 | List, 15 | Map 16 | }; 17 | -------------------------------------------------------------------------------- /src/reducers/common/create/start.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | import constants from "../../../constants"; 3 | 4 | export function prepareRecord(record: Object) { 5 | var recordStatus = { 6 | [constants.SPECIAL_KEYS.BUSY]: true, 7 | [constants.SPECIAL_KEYS.PENDING_CREATE]: true 8 | }; 9 | 10 | return merge(record, recordStatus); 11 | } 12 | -------------------------------------------------------------------------------- /src/reducers/common/delete/start.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | import constants from "../../../constants"; 3 | 4 | export function prepareRecord(record: Object) { 5 | var recordStatus = { 6 | [constants.SPECIAL_KEYS.DELETED]: true, 7 | [constants.SPECIAL_KEYS.BUSY]: true 8 | }; 9 | 10 | return merge(record, recordStatus); 11 | } 12 | -------------------------------------------------------------------------------- /src/reducers/common/reducersFor.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | 3 | import actionTypesFor from "../../actionTypesFor"; 4 | import constants from "../../constants"; 5 | 6 | import {Config, ReducerName} from "../../types"; 7 | 8 | function reducersFor(resourceName: string, args = {}, emptyState, reducers) { 9 | if (resourceName == null) 10 | throw new Error("reducersFor: Expected resourceName"); 11 | 12 | var defaults = { 13 | key: constants.DEFAULT_KEY, 14 | resourceName: resourceName 15 | }; 16 | 17 | var config = merge(defaults, args); 18 | 19 | return function getReducer(state, action) { 20 | state = state || emptyState; 21 | 22 | if (action == null) 23 | throw new Error(resourceName + " reducers: Expected action"); 24 | 25 | var actionTypes = actionTypesFor(resourceName); 26 | var record = action.record; 27 | 28 | switch (action.type) { 29 | case actionTypes.fetchSuccess: 30 | return reducers.fetchSuccess( 31 | config, 32 | state, 33 | action.records, 34 | emptyState, 35 | action.data && action.data.replace 36 | ); 37 | 38 | case actionTypes.createStart: 39 | return reducers.createStart(config, state, record); 40 | 41 | case actionTypes.createSuccess: 42 | return reducers.createSuccess(config, state, record, action.cid); 43 | 44 | case actionTypes.createError: 45 | return reducers.createError(config, state, record); 46 | 47 | case actionTypes.updateStart: 48 | return reducers.updateStart(config, state, record); 49 | 50 | case actionTypes.updateSuccess: 51 | return reducers.updateSuccess(config, state, record); 52 | 53 | case actionTypes.updateError: 54 | return reducers.updateError(config, state, record); 55 | 56 | case actionTypes.deleteStart: 57 | return reducers.deleteStart(config, state, record); 58 | 59 | case actionTypes.deleteSuccess: 60 | return reducers.deleteSuccess(config, state, record); 61 | 62 | case actionTypes.deleteError: 63 | return reducers.deleteError(config, state, record); 64 | 65 | default: 66 | return state; 67 | } 68 | }; 69 | } 70 | 71 | export default reducersFor; 72 | -------------------------------------------------------------------------------- /src/reducers/common/update/error.ts: -------------------------------------------------------------------------------- 1 | import * as dissoc from "ramda/src/dissoc" 2 | import constants from "../../../constants"; 3 | 4 | export function prepareRecord(record: Object) { 5 | return dissoc(constants.SPECIAL_KEYS.BUSY, record); 6 | } 7 | -------------------------------------------------------------------------------- /src/reducers/common/update/start.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | import constants from "../../../constants"; 3 | 4 | export function prepareRecord(record: Object) { 5 | var recordStatus = { 6 | [constants.SPECIAL_KEYS.BUSY]: true, 7 | [constants.SPECIAL_KEYS.PENDING_UPDATE]: true 8 | }; 9 | 10 | return merge(record, recordStatus); 11 | } 12 | -------------------------------------------------------------------------------- /src/reducers/invariants.ts: -------------------------------------------------------------------------------- 1 | import assertHasKey from "./invariants/assertHasKey"; 2 | import assertNotArray from "../utils/assertNotArray"; 3 | import constants from "../constants"; 4 | import makeScope from "../utils/makeScope"; 5 | import wrapArray from "../utils/wrapArray"; 6 | 7 | import { 8 | Config, 9 | InvariantsBaseArgs, 10 | InvariantsExtraArgs, 11 | ReducerName 12 | } from "../types"; 13 | 14 | export default function invariants( 15 | baseArgs: InvariantsBaseArgs, 16 | extraArgs: InvariantsExtraArgs 17 | ) { 18 | var config = extraArgs.config; 19 | 20 | if (!config.resourceName) throw new Error("Expected config.resourceName"); 21 | 22 | const scope = makeScope(config, baseArgs.reducerName); 23 | 24 | if (!config.key) throw new Error(scope + ": Expected config.key"); 25 | if (!extraArgs.record) throw new Error(scope + ": Expected record/s"); 26 | 27 | extraArgs.assertValidStore(scope, extraArgs.current); 28 | 29 | if (!baseArgs.canBeArray) { 30 | assertNotArray(extraArgs.config, baseArgs.reducerName, extraArgs.record); 31 | } 32 | 33 | assertHasKey(extraArgs.config, scope, extraArgs.record); 34 | } 35 | -------------------------------------------------------------------------------- /src/reducers/invariants/assertHasKey.ts: -------------------------------------------------------------------------------- 1 | import * as forEach from "ramda/src/forEach" 2 | 3 | import constants from "../../constants"; 4 | import wrapArray from "../../utils/wrapArray"; 5 | 6 | import {Config, ReducerName} from "../../types"; 7 | 8 | export default function assertHasKey( 9 | config: Config, 10 | scope: string, 11 | recordOrRecords: any 12 | ): void { 13 | var key = config.key; 14 | var records = wrapArray(recordOrRecords); 15 | 16 | forEach(function(record) { 17 | if (record[key] == null) { 18 | throw new Error(scope + ": Expected record to have ." + key); 19 | } 20 | })(records); 21 | } 22 | -------------------------------------------------------------------------------- /src/reducers/list.ts: -------------------------------------------------------------------------------- 1 | import reducersFor from "./list/reducersFor"; 2 | 3 | export default { 4 | reducersFor 5 | }; 6 | -------------------------------------------------------------------------------- /src/reducers/list/create/error.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | 3 | import constants from "../../../constants"; 4 | import reducer from "./error"; 5 | 6 | var subject = constants.REDUCER_NAMES.CREATE_ERROR; 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | 12 | function getCurrent() { 13 | return [ 14 | { 15 | id: 1, 16 | name: "Blue" 17 | }, 18 | { 19 | id: "abc", 20 | name: "Green" 21 | } 22 | ]; 23 | } 24 | 25 | test(subject + "throws if given an array", function(t) { 26 | var curr = getCurrent(); 27 | var created = []; 28 | 29 | function fn() { 30 | reducer(config, curr, created); 31 | } 32 | 33 | t.throws(fn, TypeError); 34 | }); 35 | 36 | test(subject + "removes the record", function(t) { 37 | var curr = getCurrent(); 38 | var created = { 39 | id: "abc", 40 | name: "Green" 41 | }; 42 | var updated = reducer(config, curr, created); 43 | 44 | t.deepEqual(updated.length, 1); 45 | }); 46 | -------------------------------------------------------------------------------- /src/reducers/list/create/error.ts: -------------------------------------------------------------------------------- 1 | import assertNotArray from "../../../utils/assertNotArray"; 2 | import constants from "../../../constants"; 3 | import invariants from "../invariants"; 4 | import remove from "../store/remove"; 5 | 6 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.CREATE_ERROR; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function error( 15 | config: Config, 16 | current: Array, 17 | record: any 18 | ): Array { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | return remove(config, current, record); 22 | } 23 | -------------------------------------------------------------------------------- /src/reducers/list/create/start.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./start"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = "createStart: "; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | { 18 | id: 2, 19 | name: "Red" 20 | } 21 | ]; 22 | } 23 | 24 | function getValid() { 25 | return { 26 | id: 3, 27 | name: "Green" 28 | }; 29 | } 30 | 31 | test(subject + "throws if given an array", function(t) { 32 | var curr = getCurrent(); 33 | var created = []; 34 | function fn() { 35 | reducer(config, curr, created); 36 | } 37 | 38 | t.throws(fn, TypeError); 39 | }); 40 | 41 | test(subject + "adds the new record", function(t) { 42 | var curr = getCurrent(); 43 | var other = { 44 | id: 3, 45 | name: "Green" 46 | }; 47 | var updated = reducer(config, curr, other); 48 | 49 | t.deepEqual(updated.length, 3, "adds the record"); 50 | }); 51 | 52 | test(subject + "it throws when record doesnt have an id", function(t) { 53 | var curr = getCurrent(); 54 | var record = { 55 | name: "Green" 56 | }; 57 | 58 | var f = function() { 59 | reducer(config, curr, record); 60 | }; 61 | t.throws(f, /users.createStart: Expected record to have .id/); 62 | }); 63 | 64 | test(subject + "adds busy and pendingCreate", function(t) { 65 | var curr = getCurrent(); 66 | var record = getValid(); 67 | var updated = reducer(config, curr, record); 68 | 69 | t.deepEqual(updated[2].name, "Green"); 70 | t.truthy(updated[2].busy, "adds busy"); 71 | t.truthy(updated[2].pendingCreate, "adds pendingCreate"); 72 | }); 73 | -------------------------------------------------------------------------------- /src/reducers/list/create/start.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/create/start"; 2 | import constants from "../../../constants"; 3 | import invariants from "../invariants"; 4 | import store from "../store"; 5 | 6 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.CREATE_START; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function start( 15 | config: Config, 16 | current: Array, 17 | record: any 18 | ): Array { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | // mark record as unsaved and busy 22 | var newRecord = prepareRecord(record); 23 | 24 | return store.merge(current, newRecord, config.key); 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/list/create/success.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./success"; 3 | import test from "ava"; 4 | 5 | var subject = "createSuccess: "; 6 | var config = { 7 | key: constants.DEFAULT_KEY, 8 | resourceName: "users" 9 | }; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | { 18 | id: 2, 19 | name: "Red" 20 | } 21 | ]; 22 | } 23 | 24 | test(subject + "it throws if it cannot find config.key", function(t) { 25 | var curr = getCurrent(); 26 | var record = {}; 27 | var config = { 28 | resourceName: "users" 29 | }; 30 | var f = function() { 31 | reducer(config, curr, record); 32 | }; 33 | t.throws(f, /users.createSuccess: Expected config.key/); 34 | }); 35 | 36 | test(subject + "doesnt mutate the original collection", function(t) { 37 | var curr = getCurrent(); 38 | var record = { 39 | id: 3, 40 | name: "Green" 41 | }; 42 | var updated = reducer(config, curr, record); 43 | 44 | t.is(curr.length, 2); 45 | }); 46 | 47 | test(subject + "throws if given an array", function(t) { 48 | var curr = getCurrent(); 49 | var record = []; 50 | function fn() { 51 | reducer(config, curr, record); 52 | } 53 | 54 | t.throws(fn, TypeError); 55 | }); 56 | 57 | test(subject + "adds the record", function(t) { 58 | var curr = getCurrent(); 59 | var record = { 60 | id: 3, 61 | name: "Green" 62 | }; 63 | var updated = reducer(config, curr, record); 64 | 65 | t.is(updated.length, 3); 66 | }); 67 | 68 | test(subject + "merges if exists", function(t) { 69 | var curr = getCurrent(); 70 | var record = { 71 | id: 2, 72 | name: "Green" 73 | }; 74 | var updated = reducer(config, curr, record); 75 | 76 | t.is(updated.length, 2); 77 | t.is(updated[1].id, 2); 78 | t.is(updated[1].name, "Green"); 79 | }); 80 | 81 | test(subject + "uses the given key", function(t) { 82 | var config = { 83 | key: "_id", 84 | resourceName: "users" 85 | }; 86 | var curr = [ 87 | { 88 | _id: 2, 89 | name: "Blue" 90 | } 91 | ]; 92 | var record = { 93 | _id: 2, 94 | name: "Green" 95 | }; 96 | var updated = reducer(config, curr, record); 97 | 98 | t.is(updated.length, 1); 99 | }); 100 | 101 | test(subject + "it throws when record doesnt have an id", function(t) { 102 | var curr = getCurrent(); 103 | var record = { 104 | name: "Green" 105 | }; 106 | 107 | var f = function() { 108 | reducer(config, curr, record); 109 | }; 110 | t.throws(f, /users.createSuccess: Expected record to have .id/); 111 | }); 112 | 113 | test(subject + "it uses the cid", function(t) { 114 | var cid = "abc"; 115 | var curr = [ 116 | { 117 | id: cid, 118 | name: "Blue" 119 | } 120 | ]; 121 | var record = { 122 | id: 3, 123 | name: "Green" 124 | }; 125 | var updated = reducer(config, curr, record, cid); 126 | t.is(updated.length, 1); 127 | }); 128 | 129 | test(subject + " cleans the cid", function(t) { 130 | var cid = "abc"; 131 | var curr = [ 132 | { 133 | id: cid, 134 | name: "Blue" 135 | } 136 | ]; 137 | 138 | var record = { 139 | id: 3, 140 | name: "Green" 141 | }; 142 | 143 | var updated = reducer(config, curr, record, cid); 144 | var updatedRecord = updated[0]; 145 | 146 | t.is(updatedRecord._cid, undefined); 147 | }); 148 | 149 | test(subject + "removes busy and pendingCreate", function(t) { 150 | var curr = [ 151 | { 152 | busy: true, 153 | id: 2, 154 | name: "Green", 155 | pendingCreate: true 156 | } 157 | ]; 158 | var record = { 159 | id: 2, 160 | name: "Yellow" 161 | }; 162 | var updated = reducer(config, curr, record); 163 | 164 | t.is(updated.length, 1); 165 | t.truthy(updated[0].busy == null, "removes busy"); 166 | t.truthy(updated[0].pendingCreate == null, "removes pendingCreate"); 167 | }); 168 | -------------------------------------------------------------------------------- /src/reducers/list/create/success.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import invariants from "../invariants"; 3 | 4 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 5 | 6 | var reducerName: ReducerName = constants.REDUCER_NAMES.CREATE_SUCCESS; 7 | var invariantArgs: InvariantsBaseArgs = { 8 | reducerName, 9 | canBeArray: false 10 | }; 11 | 12 | export default function success( 13 | config: Config, 14 | current: Array, 15 | addedRecord: any, 16 | clientGeneratedKey?: string 17 | ): Array { 18 | invariants(invariantArgs, config, current, addedRecord); 19 | 20 | var key = config.key; 21 | var done = false; 22 | 23 | // Update existing records 24 | var updatedCollection = current.map(function(record) { 25 | var recordKey = record[key]; 26 | if (recordKey == null) throw new Error("Expected record to have " + key); 27 | var isSameKey = recordKey === addedRecord[key]; 28 | var isSameClientGetKey = 29 | clientGeneratedKey != null && clientGeneratedKey === recordKey; 30 | if (isSameKey || isSameClientGetKey) { 31 | done = true; 32 | return addedRecord; 33 | } else { 34 | return record; 35 | } 36 | }); 37 | 38 | // Add if not updated 39 | if (!done) { 40 | updatedCollection = updatedCollection.concat([addedRecord]); 41 | } 42 | 43 | return updatedCollection; 44 | } 45 | -------------------------------------------------------------------------------- /src/reducers/list/delete/error.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./error"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = constants.REDUCER_NAMES.DELETE_ERROR; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue", 16 | deleted: true, 17 | busy: true 18 | }, 19 | { 20 | id: 2, 21 | name: "Red", 22 | deleted: true, 23 | busy: true 24 | } 25 | ]; 26 | } 27 | 28 | test(subject + "throws if given an array", function(t) { 29 | var curr = getCurrent(); 30 | var record = []; 31 | function fn() { 32 | reducer(config, curr, record); 33 | } 34 | 35 | t.throws(fn, TypeError); 36 | }); 37 | 38 | test(subject + "doesnt mutate", function(t) { 39 | var curr = getCurrent(); 40 | var record = { 41 | id: 1 42 | }; 43 | var updated = reducer(config, curr, record); 44 | 45 | t.is(curr[0].deleted, true); 46 | t.is(curr[0].busy, true); 47 | t.is(updated[0].deleted, undefined); 48 | t.is(updated[0].busy, undefined); 49 | }); 50 | 51 | test(subject + "removes deleted and busy", function(t) { 52 | var curr = getCurrent(); 53 | var record = { 54 | id: 1 55 | }; 56 | var updated = reducer(config, curr, record); 57 | 58 | t.deepEqual(updated.length, 2, "doesnt remove record"); 59 | t.truthy(updated[0].deleted == null, "removes deleted"); 60 | t.truthy(updated[0].busy == null, "removes busy"); 61 | 62 | t.truthy(updated[1].deleted, "doesnt removes deleted from others"); 63 | t.truthy(updated[1].busy, "doesnt removes busy from others"); 64 | }); 65 | 66 | test(subject + "uses the given key", function(t) { 67 | var config = { 68 | key: "_id", 69 | resourceName: "users" 70 | }; 71 | var curr = [ 72 | { 73 | _id: 1, 74 | deleted: true, 75 | busy: true 76 | } 77 | ]; 78 | var record = { 79 | _id: 1 80 | }; 81 | var updated = reducer(config, curr, record); 82 | 83 | t.truthy(updated[0].deleted == null, "removes deleted"); 84 | t.truthy(updated[0].busy == null, "removes busy"); 85 | }); 86 | 87 | test(subject + "it throws when record doesnt have an id", function(t) { 88 | var curr = getCurrent(); 89 | var record = { 90 | name: "Green" 91 | }; 92 | 93 | var f = function() { 94 | reducer(config, curr, record); 95 | }; 96 | t.throws(f); 97 | }); 98 | -------------------------------------------------------------------------------- /src/reducers/list/delete/error.ts: -------------------------------------------------------------------------------- 1 | import * as omit from "ramda/src/omit" 2 | 3 | import constants from "../../../constants"; 4 | import findByKey from "../../../utils/findByKey"; 5 | import invariants from "../invariants"; 6 | import store from "../store"; 7 | 8 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 9 | 10 | var reducerName: ReducerName = constants.REDUCER_NAMES.DELETE_ERROR; 11 | var invariantArgs: InvariantsBaseArgs = { 12 | reducerName, 13 | canBeArray: false 14 | }; 15 | 16 | export default function error( 17 | config: Config, 18 | current: Array, 19 | record: any 20 | ): Array { 21 | invariants(invariantArgs, config, current, record); 22 | 23 | var key = config.key; 24 | var deleteId = record[key]; 25 | var deleteRecord = findByKey(current, key, deleteId); 26 | deleteRecord = omit(["deleted", "busy"], deleteRecord); 27 | 28 | return store.merge(current, deleteRecord, key); 29 | } 30 | -------------------------------------------------------------------------------- /src/reducers/list/delete/start.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./start"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = "deleteStart: "; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | { 18 | id: 2, 19 | name: "Red" 20 | } 21 | ]; 22 | } 23 | 24 | function getValid() { 25 | return { 26 | id: 1, 27 | name: "Green" 28 | }; 29 | } 30 | 31 | test(subject + "throws if given an array", function(t) { 32 | var curr = getCurrent(); 33 | var record = []; 34 | function fn() { 35 | reducer(config, curr, record); 36 | } 37 | 38 | t.throws(fn, TypeError); 39 | }); 40 | 41 | test(subject + "marks record as deleted and busy", function(t) { 42 | var curr = getCurrent(); 43 | var record = getValid(); 44 | var updated = reducer(config, curr, record); 45 | 46 | t.is(updated[0].deleted, true); 47 | t.is(updated[0].busy, true); 48 | 49 | t.truthy(updated[1].deleted == null, "doesnt add deleted to others"); 50 | t.truthy(updated[1].busy == null, "doesnt add busy to others"); 51 | }); 52 | 53 | test(subject + "doesnt mutate", function(t) { 54 | var curr = getCurrent(); 55 | var record = getValid(); 56 | var updated = reducer(config, curr, record); 57 | 58 | t.is(updated[0].deleted, true); 59 | t.is(curr[0]["deleted"], undefined); 60 | }); 61 | 62 | test(subject + "uses the given key", function(t) { 63 | var config = { 64 | key: "_id", 65 | resourceName: "users" 66 | }; 67 | var curr = [ 68 | { 69 | _id: 1 70 | } 71 | ]; 72 | var record = { 73 | _id: 1 74 | }; 75 | var updated = reducer(config, curr, record); 76 | 77 | t.truthy(updated[0].deleted, "adds deleted"); 78 | }); 79 | 80 | test(subject + "it throws when record dont have an id", function(t) { 81 | var curr = getCurrent(); 82 | var record = { 83 | name: "Green" 84 | }; 85 | 86 | var f = function() { 87 | reducer(config, curr, record); 88 | }; 89 | t.throws(f); 90 | }); 91 | -------------------------------------------------------------------------------- /src/reducers/list/delete/start.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/delete/start"; 2 | import constants from "../../../constants"; 3 | import findByKey from "../../../utils/findByKey"; 4 | import invariants from "../invariants"; 5 | import store from "../store"; 6 | 7 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 8 | 9 | var reducerName: ReducerName = constants.REDUCER_NAMES.DELETE_START; 10 | var invariantArgs: InvariantsBaseArgs = { 11 | reducerName, 12 | canBeArray: false 13 | }; 14 | 15 | export default function start( 16 | config: Config, 17 | current: Array, 18 | record: any 19 | ): Array { 20 | invariants(invariantArgs, config, current, record); 21 | 22 | var key = config.key; 23 | var deleteId = record[key]; 24 | 25 | var deleteRecord = findByKey(current, key, deleteId); 26 | deleteRecord = prepareRecord(deleteRecord); 27 | 28 | return store.merge(current, deleteRecord, key); 29 | } 30 | -------------------------------------------------------------------------------- /src/reducers/list/delete/success.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./success"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = "deleteSuccess: "; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | { 18 | id: 2, 19 | name: "Red" 20 | } 21 | ]; 22 | } 23 | 24 | function getValid() { 25 | return { 26 | id: 1, 27 | name: "Green" 28 | }; 29 | } 30 | 31 | test(subject + "throws if given an array", function(t) { 32 | var curr = getCurrent(); 33 | var record = []; 34 | function fn() { 35 | reducer(config, curr, record); 36 | } 37 | 38 | t.throws(fn, TypeError); 39 | }); 40 | 41 | test(subject + "removes the record", function(t) { 42 | var curr = getCurrent(); 43 | var record = getValid(); 44 | var updated = reducer(config, curr, record); 45 | 46 | t.is(updated.length, 1, "removes the record"); 47 | t.is(updated[0].id, 2); 48 | }); 49 | 50 | test(subject + "doesnt mutate the original collection", function(t) { 51 | var curr = getCurrent(); 52 | var record = getValid(); 53 | var updated = reducer(config, curr, record); 54 | 55 | t.is(curr.length, 2); 56 | t.is(updated.length, 1); 57 | }); 58 | 59 | test(subject + "uses the given key", function(t) { 60 | var config = { 61 | key: "_id", 62 | resourceName: "users" 63 | }; 64 | var curr = [ 65 | { 66 | _id: 1 67 | } 68 | ]; 69 | var record = { 70 | _id: 1 71 | }; 72 | var updated = reducer(config, curr, record); 73 | 74 | t.deepEqual(updated.length, 0, "removes the record"); 75 | }); 76 | 77 | test(subject + "it throws when record dont have an id", function(t) { 78 | var curr = getCurrent(); 79 | var record = { 80 | name: "Green" 81 | }; 82 | 83 | var f = function() { 84 | reducer(config, curr, record); 85 | }; 86 | t.throws(f); 87 | }); 88 | -------------------------------------------------------------------------------- /src/reducers/list/delete/success.ts: -------------------------------------------------------------------------------- 1 | import * as reject from "ramda/src/reject" 2 | 3 | import invariants from "../invariants"; 4 | import constants from "../../../constants"; 5 | 6 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.DELETE_SUCCESS; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function success( 15 | config: Config, 16 | current: Array, 17 | record: any 18 | ): Array { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | var key = config.key; 22 | var deleteId = record[key]; 23 | 24 | function predicate(existingRecord) { 25 | return deleteId == existingRecord[key]; 26 | } 27 | 28 | return reject(predicate, current); 29 | } 30 | -------------------------------------------------------------------------------- /src/reducers/list/fetch/success.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./success"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = "fetchSuccess: "; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | { 18 | id: 2, 19 | name: "Red" 20 | } 21 | ]; 22 | } 23 | 24 | test(subject + " adds the records", function(t) { 25 | var curr = getCurrent(); 26 | var more = [ 27 | { 28 | id: 3, 29 | name: "Green" 30 | } 31 | ]; 32 | var updated = reducer(config, curr, more, []); 33 | 34 | t.is(updated.length, 3); 35 | }); 36 | 37 | test(subject + " doesnt mutate the original collection", function(t) { 38 | var curr = getCurrent(); 39 | var more = [ 40 | { 41 | id: 3, 42 | name: "Green" 43 | } 44 | ]; 45 | var updated = reducer(config, curr, more, []); 46 | 47 | t.is(curr.length, 2); 48 | t.is(updated.length, 3); 49 | }); 50 | 51 | test(subject + " merges", function(t) { 52 | var curr = getCurrent(); 53 | var more = [ 54 | { 55 | id: 2, 56 | name: "Green" 57 | } 58 | ]; 59 | var updated = reducer(config, curr, more, []); 60 | 61 | t.is(updated.length, 2); 62 | t.is(updated[1].id, 2); 63 | t.is(updated[1].name, "Green"); 64 | }); 65 | 66 | test(subject + " replaces", function(t) { 67 | const curr = getCurrent(); 68 | const more = [ 69 | { 70 | id: 2, 71 | name: "Green" 72 | } 73 | ]; 74 | const updated = reducer(config, curr, more, [], true); 75 | 76 | t.is(updated.length, 1); 77 | t.is(updated[0].id, 2); 78 | t.is(updated[0].name, "Green"); 79 | }); 80 | 81 | test(subject + "preserves the order", function(t) { 82 | var curr = []; 83 | var more = [ 84 | { 85 | id: 11, 86 | label: "Eleven" 87 | }, 88 | { 89 | id: 7, 90 | label: "Sevent" 91 | } 92 | ]; 93 | var updated = reducer(config, curr, more, []); 94 | 95 | t.is(updated.length, 2, "it has two"); 96 | t.is(updated[0].id, 11, "it is in the right position"); 97 | }); 98 | 99 | test(subject + "uses the given key", function(t) { 100 | var config = { 101 | key: "_id", 102 | resourceName: "users" 103 | }; 104 | var curr = [ 105 | { 106 | _id: 2, 107 | name: "Blue" 108 | } 109 | ]; 110 | var more = [ 111 | { 112 | _id: 2, 113 | name: "Green" 114 | } 115 | ]; 116 | var updated = reducer(config, curr, more, []); 117 | 118 | t.is(updated.length, 1); 119 | }); 120 | 121 | test(subject + "it throws when records dont have an id", function(t) { 122 | var curr = getCurrent(); 123 | var more = [ 124 | { 125 | name: "Green" 126 | } 127 | ]; 128 | 129 | var f = function() { 130 | reducer(config, curr, more, []); 131 | }; 132 | t.throws(f); 133 | }); 134 | 135 | test(subject + "can take one record", function(t) { 136 | var curr = getCurrent(); 137 | var one = { 138 | id: 3, 139 | name: "Green" 140 | }; 141 | var updated = reducer(config, curr, one, []); 142 | 143 | t.is(updated.length, 3); 144 | }); 145 | -------------------------------------------------------------------------------- /src/reducers/list/fetch/success.ts: -------------------------------------------------------------------------------- 1 | import assertAllHaveKeys from "../../../utils/assertAllHaveKeys"; 2 | import constants from "../../../constants"; 3 | import store from "../store"; 4 | import wrapArray from "../../../utils/wrapArray"; 5 | import invariants from "../invariants"; 6 | 7 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 8 | 9 | const reducerName: ReducerName = constants.REDUCER_NAMES.FETCH_SUCCESS; 10 | const invariantArgs: InvariantsBaseArgs = { 11 | reducerName, 12 | canBeArray: true 13 | }; 14 | 15 | export default function success( 16 | config: Config, 17 | current: any[], 18 | records: any, 19 | emptyState: any, 20 | replace: boolean = false 21 | ): any[] { 22 | invariants(invariantArgs, config, current, records); 23 | 24 | // wrap array 25 | records = wrapArray(records); 26 | 27 | // All given records must have a key 28 | assertAllHaveKeys(config, reducerName, records); 29 | 30 | return store.merge(replace ? emptyState : current, records, config.key); 31 | } 32 | -------------------------------------------------------------------------------- /src/reducers/list/invariants.ts: -------------------------------------------------------------------------------- 1 | import invariants from "../invariants"; 2 | import store from "./store"; 3 | 4 | import {Config, InvariantsBaseArgs, ReducerName} from "../../types"; 5 | 6 | export default function invariantsList( 7 | invariantArgs: InvariantsBaseArgs, 8 | config: Config, 9 | current: Array, 10 | record: any 11 | ) { 12 | var extra = { 13 | assertValidStore: store.assert, 14 | config, 15 | current, 16 | record 17 | }; 18 | invariants(invariantArgs, extra); 19 | } 20 | -------------------------------------------------------------------------------- /src/reducers/list/reducersFor.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as td from "testdouble"; 3 | 4 | import constants from "../../constants"; 5 | import reducersFor from "./reducersFor"; 6 | 7 | const current = [{}]; 8 | const user = {}; 9 | const error = ""; 10 | const config = { 11 | key: constants.DEFAULT_KEY, 12 | resourceName: "users" 13 | }; 14 | const subject = "reducersFor: "; 15 | 16 | test(subject + "calls fetchSuccess", function(t) { 17 | const fetchSuccess = td.function(); 18 | const reducers = reducersFor("users", {}, {fetchSuccess}); 19 | 20 | var users = [user]; 21 | 22 | reducers(current, { 23 | records: users, 24 | type: "USERS_FETCH_SUCCESS" 25 | }); 26 | 27 | td.verify(fetchSuccess(config, current, users, [], undefined)); 28 | t.pass(); 29 | }); 30 | 31 | test(subject + "calls fetchSuccess with replace", function(t) { 32 | const fetchSuccess = td.function(); 33 | const reducers = reducersFor("users", {}, {fetchSuccess}); 34 | 35 | var users = [user]; 36 | 37 | reducers(current, { 38 | data: {replace: true}, 39 | records: users, 40 | type: "USERS_FETCH_SUCCESS" 41 | }); 42 | 43 | td.verify(fetchSuccess(config, current, users, [], true)); 44 | t.pass(); 45 | }); 46 | 47 | test(subject + "calls createStart", function(t) { 48 | const createStart = td.function(); 49 | const reducers = reducersFor("users", {}, {createStart}); 50 | 51 | reducers(current, { 52 | record: user, 53 | type: "USERS_CREATE_START" 54 | }); 55 | 56 | td.verify(createStart(config, current, user)); 57 | t.pass(); 58 | }); 59 | 60 | test(subject + "calls createSuccess", function(t) { 61 | const createSuccess = td.function(); 62 | const reducers = reducersFor("users", {}, {createSuccess}); 63 | 64 | var cid = "abc"; 65 | 66 | reducers(current, { 67 | record: user, 68 | type: "USERS_CREATE_SUCCESS", 69 | cid: cid 70 | }); 71 | 72 | td.verify(createSuccess(config, current, user, cid)); 73 | t.pass(); 74 | }); 75 | 76 | test(subject + "calls createError", function(t) { 77 | const createError = td.function(); 78 | const reducers = reducersFor("users", {}, {createError}); 79 | 80 | reducers(current, { 81 | error: error, 82 | record: user, 83 | type: "USERS_CREATE_ERROR" 84 | }); 85 | 86 | td.verify(createError(config, current, user)); 87 | t.pass(); 88 | }); 89 | 90 | test(subject + "calls updateStart", function(t) { 91 | const updateStart = td.function(); 92 | const reducers = reducersFor("users", {}, {updateStart}); 93 | 94 | reducers(current, { 95 | record: user, 96 | type: "USERS_UPDATE_START" 97 | }); 98 | 99 | td.verify(updateStart(config, current, user)); 100 | t.pass(); 101 | }); 102 | 103 | test(subject + "calls updateSuccess", function(t) { 104 | const updateSuccess = td.function(); 105 | const reducers = reducersFor("users", {}, {updateSuccess}); 106 | 107 | reducers(current, { 108 | record: user, 109 | type: "USERS_UPDATE_SUCCESS" 110 | }); 111 | 112 | td.verify(updateSuccess(config, current, user)); 113 | t.pass(); 114 | }); 115 | 116 | test(subject + "calls updateError", function(t) { 117 | const updateError = td.function(); 118 | const reducers = reducersFor("users", {}, {updateError}); 119 | 120 | reducers(current, { 121 | error: error, 122 | record: user, 123 | type: "USERS_UPDATE_ERROR" 124 | }); 125 | 126 | td.verify(updateError(config, current, user)); 127 | t.pass(); 128 | }); 129 | 130 | test(subject + "calls deleteStart", function(t) { 131 | const deleteStart = td.function(); 132 | const reducers = reducersFor("users", {}, {deleteStart}); 133 | 134 | reducers(current, { 135 | record: user, 136 | type: "USERS_DELETE_START" 137 | }); 138 | 139 | td.verify(deleteStart(config, current, user)); 140 | t.pass(); 141 | }); 142 | 143 | test(subject + "calls deleteSuccess", function(t) { 144 | const deleteSuccess = td.function(); 145 | const reducers = reducersFor("users", {}, {deleteSuccess}); 146 | 147 | reducers(current, { 148 | record: user, 149 | type: "USERS_DELETE_SUCCESS" 150 | }); 151 | 152 | td.verify(deleteSuccess(config, current, user)); 153 | t.pass(); 154 | }); 155 | 156 | test(subject + "calls deleteError", function(t) { 157 | const deleteError = td.function(); 158 | const reducers = reducersFor("users", {}, {deleteError}); 159 | 160 | reducers(current, { 161 | error: error, 162 | record: user, 163 | type: "USERS_DELETE_ERROR" 164 | }); 165 | 166 | td.verify(deleteError(config, current, user)); 167 | t.pass(); 168 | }); 169 | 170 | test(subject + "it passes the given key", function(t) { 171 | const createStart = td.function(); 172 | const reducers = reducersFor("users", {key: "_id"}, {createStart}); 173 | 174 | reducers(current, { 175 | record: user, 176 | type: "USERS_CREATE_START" 177 | }); 178 | 179 | var expectedConfig = { 180 | key: "_id", 181 | resourceName: "users" 182 | }; 183 | 184 | td.verify(createStart(expectedConfig, current, user)); 185 | t.pass(); 186 | }); 187 | 188 | test(subject + "it doesnt mutate the config", function(t) { 189 | const config = {}; 190 | reducersFor("users", config); 191 | reducersFor("monkeys", config); 192 | 193 | t.deepEqual(config, {}); 194 | }); 195 | -------------------------------------------------------------------------------- /src/reducers/list/reducersFor.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | 3 | import actionTypesFor from "../../actionTypesFor"; 4 | import constants from "../../constants"; 5 | import commonReducersFor from "../common/reducersFor"; 6 | import createError from "./create/error"; 7 | import createStart from "./create/start"; 8 | import createSuccess from "./create/success"; 9 | import deleteError from "./delete/error"; 10 | import deleteStart from "./delete/start"; 11 | import deleteSuccess from "./delete/success"; 12 | import fetchSuccess from "./fetch/success"; 13 | import updateError from "./update/error"; 14 | import updateStart from "./update/start"; 15 | import updateSuccess from "./update/success"; 16 | import {Config, ReducerName} from "../../types"; 17 | 18 | const baseReducers = { 19 | createError, 20 | createStart, 21 | createSuccess, 22 | deleteError, 23 | deleteStart, 24 | deleteSuccess, 25 | fetchSuccess, 26 | updateError, 27 | updateStart, 28 | updateSuccess 29 | }; 30 | 31 | export default function reducersFor(resourceName: string, args = {}, deps?) { 32 | const reducers = merge(baseReducers, deps); 33 | return commonReducersFor(resourceName, args, [], reducers); 34 | } 35 | -------------------------------------------------------------------------------- /src/reducers/list/store.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "../../types"; 2 | import assert from "./store/assert"; 3 | import remove from "./store/remove"; 4 | import merge from "./store/merge"; 5 | 6 | export default { 7 | assert, 8 | remove, 9 | merge 10 | }; 11 | -------------------------------------------------------------------------------- /src/reducers/list/store/assert.ts: -------------------------------------------------------------------------------- 1 | import * as is from "ramda/src/is" 2 | 3 | export default function assert(scope: string, current: Array): void { 4 | var isArray = is(Array, current); 5 | if (!isArray) throw new Error(scope + ": Expected current to be an array"); 6 | } 7 | -------------------------------------------------------------------------------- /src/reducers/list/store/merge.ts: -------------------------------------------------------------------------------- 1 | import wrapArray from "../../../utils/wrapArray"; 2 | 3 | /* 4 | Replaces an existing record in a list 5 | Or adds if not there 6 | */ 7 | export default function merge(current, records, key) { 8 | records = wrapArray(records); 9 | var recordMap = {}; 10 | var indexMap = {}; 11 | var newRecords = current.slice(0); 12 | 13 | current.forEach(function(record, index) { 14 | var recordKey = record[key]; 15 | if (recordKey == null) throw new Error("Expected record to have " + key); 16 | recordMap[recordKey] = record; 17 | indexMap[recordKey] = index; 18 | }); 19 | 20 | records.forEach(function(record) { 21 | var recordId = record[key]; 22 | if (recordMap[recordId]) { 23 | newRecords[indexMap[recordId]] = record; 24 | } else { 25 | indexMap[recordId] = newRecords.length; 26 | newRecords.push(record); 27 | } 28 | recordMap[recordId] = record; 29 | }); 30 | 31 | return newRecords; 32 | } 33 | -------------------------------------------------------------------------------- /src/reducers/list/store/remove.ts: -------------------------------------------------------------------------------- 1 | import * as reject from "ramda/src/reject" 2 | 3 | import {Config} from "../../../types"; 4 | 5 | export default function remove( 6 | config: Config, 7 | current: Array, 8 | addedRecord: any 9 | ): Array { 10 | var key = config.key; 11 | 12 | function predicate(record: any) { 13 | var recordKey = record[key]; 14 | var isSameKey = addedRecord[key] === recordKey; 15 | return isSameKey; 16 | } 17 | 18 | return reject(predicate, current); 19 | } 20 | -------------------------------------------------------------------------------- /src/reducers/list/update/error.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./error"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = constants.REDUCER_NAMES.UPDATE_ERROR; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue", 16 | busy: true, 17 | pendingUpdate: true 18 | }, 19 | { 20 | id: 2, 21 | name: "Red", 22 | busy: true, 23 | pendingUpdate: true 24 | } 25 | ]; 26 | } 27 | 28 | function getValid() { 29 | return { 30 | id: 2, 31 | name: "Green" 32 | }; 33 | } 34 | 35 | test(subject + "throws if given an array", function(t) { 36 | var curr = getCurrent(); 37 | var record = []; 38 | function fn() { 39 | reducer(config, curr, record); 40 | } 41 | 42 | t.throws(fn, TypeError); 43 | }); 44 | 45 | test(subject + "doesnt add record if not there", function(t) { 46 | var curr = getCurrent(); 47 | var record = { 48 | id: 3, 49 | name: "Green" 50 | }; 51 | var updated = reducer(config, curr, record); 52 | 53 | t.is(updated.length, 2); 54 | }); 55 | 56 | test(subject + "removes busy", function(t) { 57 | var curr = getCurrent(); 58 | var record = getValid(); 59 | var updated = reducer(config, curr, record); 60 | 61 | t.truthy(updated[0].busy, "doesnt remove on others"); 62 | t.truthy(updated[1].busy == null, "removes busy"); 63 | }); 64 | 65 | test(subject + "doesnt mutate the original collection", function(t) { 66 | var curr = getCurrent(); 67 | var record = getValid(); 68 | var updated = reducer(config, curr, record); 69 | 70 | t.is(curr[1].busy, true); 71 | t.is(updated[1].busy, undefined); 72 | }); 73 | 74 | test(subject + "doesnt remove pendingUpdate", function(t) { 75 | var curr = getCurrent(); 76 | var record = getValid(); 77 | var updated = reducer(config, curr, record); 78 | 79 | t.truthy(updated[1].pendingUpdate); 80 | }); 81 | 82 | test(subject + "uses the given key", function(t) { 83 | var config = { 84 | key: "_id", 85 | resourceName: "users" 86 | }; 87 | var curr = [ 88 | { 89 | _id: 2, 90 | name: "Blue", 91 | busy: true, 92 | unsaved: true 93 | } 94 | ]; 95 | var record = { 96 | _id: 2 97 | }; 98 | var updated = reducer(config, curr, record); 99 | 100 | t.truthy(updated[0].busy == null, "removes busy"); 101 | }); 102 | 103 | test(subject + "it throws when record dont have an id", function(t) { 104 | var curr = getCurrent(); 105 | var record = { 106 | name: "Green" 107 | }; 108 | 109 | var f = function() { 110 | reducer(config, curr, record); 111 | }; 112 | 113 | t.throws(f); 114 | }); 115 | -------------------------------------------------------------------------------- /src/reducers/list/update/error.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/update/error"; 2 | import constants from "../../../constants"; 3 | import findByKey from "../../../utils/findByKey"; 4 | import invariants from "../invariants"; 5 | import store from "../store"; 6 | 7 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 8 | 9 | var reducerName: ReducerName = constants.REDUCER_NAMES.UPDATE_ERROR; 10 | var invariantArgs: InvariantsBaseArgs = { 11 | reducerName, 12 | canBeArray: false 13 | }; 14 | 15 | export default function error( 16 | config: Config, 17 | current: Array, 18 | record: any 19 | ): Array { 20 | invariants(invariantArgs, config, current, record); 21 | 22 | // We don"t want to rollback 23 | var key = config.key; 24 | var updatedId = record[key]; 25 | var updatedRecord = findByKey(current, key, updatedId); 26 | 27 | if (updatedRecord == null) return current; 28 | 29 | updatedRecord = prepareRecord(updatedRecord); 30 | 31 | return store.merge(current, updatedRecord, key); 32 | } 33 | -------------------------------------------------------------------------------- /src/reducers/list/update/start.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./start"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = constants.REDUCER_NAMES.UPDATE_START; 10 | 11 | function getCurrent() { 12 | return [ 13 | { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | { 18 | id: 2, 19 | name: "Red" 20 | } 21 | ]; 22 | } 23 | 24 | function getValid() { 25 | return { 26 | id: 2, 27 | name: "Green" 28 | }; 29 | } 30 | 31 | test(subject + "throws if given an array", function(t) { 32 | var curr = getCurrent(); 33 | var record = []; 34 | function fn() { 35 | reducer(config, curr, record); 36 | } 37 | 38 | t.throws(fn, TypeError); 39 | }); 40 | 41 | test(subject + "adds the record if not there", function(t) { 42 | var curr = getCurrent(); 43 | var record = { 44 | id: 3, 45 | name: "Green" 46 | }; 47 | var updated = reducer(config, curr, record); 48 | 49 | t.is(updated.length, 3); 50 | }); 51 | 52 | test(subject + "doesnt mutate the original", function(t) { 53 | var curr = getCurrent(); 54 | var record = { 55 | id: 3, 56 | name: "Green" 57 | }; 58 | var updated = reducer(config, curr, record); 59 | 60 | t.is(curr.length, 2); 61 | t.is(updated.length, 3); 62 | }); 63 | 64 | test(subject + "updates existing", function(t) { 65 | var curr = getCurrent(); 66 | var record = getValid(); 67 | var updated = reducer(config, curr, record); 68 | 69 | t.is(updated.length, 2); 70 | t.is(updated[1].id, 2); 71 | t.is(updated[1].name, "Green"); 72 | }); 73 | 74 | test(subject + "uses the given key", function(t) { 75 | var config = { 76 | key: "_id", 77 | resourceName: "users" 78 | }; 79 | var curr = [ 80 | { 81 | _id: 2, 82 | name: "Blue" 83 | } 84 | ]; 85 | var record = { 86 | _id: 2, 87 | name: "Green" 88 | }; 89 | var updated = reducer(config, curr, record); 90 | 91 | t.is(updated.length, 1); 92 | }); 93 | 94 | test(subject + "it throws when record dont have an id", function(t) { 95 | var curr = getCurrent(); 96 | var record = { 97 | name: "Green" 98 | }; 99 | 100 | var f = function() { 101 | reducer(config, curr, record); 102 | }; 103 | t.throws(f); 104 | }); 105 | 106 | test(subject + "adds busy and pendingUpdate", function(t) { 107 | var curr = getCurrent(); 108 | var record = getValid(); 109 | var updated = reducer(config, curr, record); 110 | 111 | t.deepEqual(updated[1].name, "Green"); 112 | t.truthy(updated[1].busy, "adds busy"); 113 | t.truthy(updated[1].pendingUpdate, "adds pendingUpdate"); 114 | }); 115 | -------------------------------------------------------------------------------- /src/reducers/list/update/start.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/update/start"; 2 | import constants from "../../../constants"; 3 | import invariants from "../invariants"; 4 | import store from "../store"; 5 | 6 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.UPDATE_START; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function start( 15 | config: Config, 16 | current: Array, 17 | record: any 18 | ): Array { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | // mark record as unsaved and busy 22 | var newRecord = prepareRecord(record); 23 | 24 | // replace record 25 | return store.merge(current, newRecord, config.key); 26 | } 27 | -------------------------------------------------------------------------------- /src/reducers/list/update/success.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./success"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | 10 | var subject = constants.REDUCER_NAMES.UPDATE_SUCCESS; 11 | 12 | function getCurrent() { 13 | return [ 14 | { 15 | id: 1, 16 | name: "Blue", 17 | unsaved: true, 18 | busy: true 19 | }, 20 | { 21 | id: 2, 22 | name: "Red", 23 | unsaved: true, 24 | busy: true 25 | } 26 | ]; 27 | } 28 | 29 | function getValid() { 30 | return { 31 | id: 2, 32 | name: "Green" 33 | }; 34 | } 35 | 36 | test(subject + "throws if given an array", function(t) { 37 | var curr = getCurrent(); 38 | var record = []; 39 | function fn() { 40 | reducer(config, curr, record); 41 | } 42 | 43 | t.throws(fn, TypeError); 44 | }); 45 | 46 | test(subject + "adds the record if not there", function(t) { 47 | var curr = getCurrent(); 48 | var record = { 49 | id: 3, 50 | name: "Green" 51 | }; 52 | var updated = reducer(config, curr, record); 53 | 54 | t.is(updated.length, 3); 55 | }); 56 | 57 | test(subject + "doesnt mutate the original collection", function(t) { 58 | var curr = getCurrent(); 59 | var record = { 60 | id: 3, 61 | name: "Green" 62 | }; 63 | var updated = reducer(config, curr, record); 64 | 65 | t.is(curr.length, 2); 66 | t.is(updated.length, 3); 67 | }); 68 | 69 | test(subject + "updates existing", function(t) { 70 | var curr = getCurrent(); 71 | var record = getValid(); 72 | var updated = reducer(config, curr, record); 73 | 74 | t.is(updated.length, 2); 75 | t.is(updated[1].id, 2); 76 | t.is(updated[1].name, "Green"); 77 | }); 78 | 79 | test(subject + "uses the given key", function(t) { 80 | var config = { 81 | key: "_id", 82 | resourceName: "users" 83 | }; 84 | var curr = [ 85 | { 86 | _id: 2, 87 | name: "Blue" 88 | } 89 | ]; 90 | var record = { 91 | _id: 2, 92 | name: "Green" 93 | }; 94 | var updated = reducer(config, curr, record); 95 | 96 | t.is(updated.length, 1); 97 | }); 98 | 99 | test(subject + "it throws when record dont have an id", function(t) { 100 | var curr = getCurrent(); 101 | var record = { 102 | name: "Green" 103 | }; 104 | 105 | var f = function() { 106 | reducer(config, curr, record); 107 | }; 108 | t.throws(f); 109 | }); 110 | 111 | test(subject + "removes busy and pendingUpdate", function(t) { 112 | var curr = [ 113 | { 114 | id: 2, 115 | name: "Green", 116 | pendingUpdate: true, 117 | busy: true 118 | } 119 | ]; 120 | var record = getValid(); 121 | var updated = reducer(config, curr, record); 122 | 123 | t.deepEqual(updated.length, 1); 124 | t.truthy(updated[0].busy == null, "removes busy"); 125 | t.truthy(updated[0].pendingUpdate == null, "removes pendingUpdate"); 126 | }); 127 | -------------------------------------------------------------------------------- /src/reducers/list/update/success.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import invariants from "../invariants"; 3 | import store from "../store"; 4 | 5 | import {Config, InvariantsBaseArgs, ReducerName} from "../../../types"; 6 | 7 | var reducerName: ReducerName = constants.REDUCER_NAMES.UPDATE_SUCCESS; 8 | var invariantArgs: InvariantsBaseArgs = { 9 | reducerName, 10 | canBeArray: false 11 | }; 12 | 13 | export default function success( 14 | config: Config, 15 | current: Array, 16 | record: any 17 | ): Array { 18 | invariants(invariantArgs, config, current, record); 19 | 20 | return store.merge(current, record, config.key); 21 | } 22 | -------------------------------------------------------------------------------- /src/reducers/map.ts: -------------------------------------------------------------------------------- 1 | import reducersFor from "./map/reducersFor"; 2 | 3 | export default { 4 | reducersFor 5 | }; 6 | -------------------------------------------------------------------------------- /src/reducers/map/create/error.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import test from "ava"; 3 | 4 | import constants from "../../../constants"; 5 | import reducer from "./error"; 6 | 7 | var subject = constants.REDUCER_NAMES.CREATE_ERROR; 8 | var config = { 9 | key: constants.DEFAULT_KEY, 10 | resourceName: "users" 11 | }; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue" 18 | }, 19 | 2: { 20 | id: "abc", 21 | name: "Green" 22 | } 23 | }; 24 | } 25 | 26 | test(subject + "throws if given an array", function(t) { 27 | var curr = getCurrent(); 28 | var created = []; 29 | 30 | function fn() { 31 | reducer(config, curr, created); 32 | } 33 | 34 | t.throws(fn, TypeError); 35 | }); 36 | 37 | test(subject + "removes the record", function(t) { 38 | var curr = getCurrent(); 39 | t.deepEqual(values(curr).length, 2); 40 | 41 | var created = { 42 | id: "abc", 43 | name: "Green" 44 | }; 45 | var updated = reducer(config, curr, created); 46 | 47 | t.deepEqual(values(updated).length, 2); 48 | }); 49 | -------------------------------------------------------------------------------- /src/reducers/map/create/error.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import invariants from "../invariants"; 3 | import store from "../store"; 4 | 5 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 6 | 7 | var reducerName: ReducerName = constants.REDUCER_NAMES.CREATE_ERROR; 8 | var invariantArgs: InvariantsBaseArgs = { 9 | reducerName, 10 | canBeArray: false 11 | }; 12 | 13 | export default function error( 14 | config: Config, 15 | current: Map, 16 | record: any 17 | ): Map { 18 | invariants(invariantArgs, config, current, record); 19 | 20 | return store.remove(config, current, record); 21 | } 22 | -------------------------------------------------------------------------------- /src/reducers/map/create/start.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import test from "ava"; 3 | 4 | import constants from "../../../constants"; 5 | import reducer from "./start"; 6 | 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | var subject = constants.REDUCER_NAMES.CREATE_START; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue" 18 | }, 19 | 2: { 20 | id: "abc", 21 | name: "Green" 22 | } 23 | }; 24 | } 25 | 26 | function getValid() { 27 | return { 28 | id: 3, 29 | name: "Green" 30 | }; 31 | } 32 | 33 | test(subject + " throws if given an array", function(t) { 34 | var curr = getCurrent(); 35 | var created = []; 36 | function fn() { 37 | reducer(config, curr, created); 38 | } 39 | 40 | t.throws(fn, TypeError); 41 | }); 42 | 43 | test(subject + " adds the new record", function(t) { 44 | var curr = getCurrent(); 45 | 46 | var other = { 47 | id: 3, 48 | name: "Green" 49 | }; 50 | var updated = reducer(config, curr, other); 51 | 52 | t.is(values(updated).length, 3, "adds the record"); 53 | }); 54 | 55 | test(subject + "it throws when record doesnt have an id", function(t) { 56 | var curr = getCurrent(); 57 | var record = { 58 | name: "Green" 59 | }; 60 | 61 | var f = function() { 62 | reducer(config, curr, record); 63 | }; 64 | t.throws(f, /users.createStart: Expected record to have .id/); 65 | }); 66 | 67 | test(subject + "adds busy and pendingCreate", function(t) { 68 | var curr = getCurrent(); 69 | var record = getValid(); 70 | var updated = reducer(config, curr, record); 71 | 72 | t.is(updated["3"].name, "Green"); 73 | t.truthy(updated["3"].busy, "adds busy"); 74 | t.truthy(updated["3"].pendingCreate, "adds pendingCreate"); 75 | }); 76 | -------------------------------------------------------------------------------- /src/reducers/map/create/start.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/create/start"; 2 | import constants from "../../../constants"; 3 | import invariants from "../invariants"; 4 | import store from "../store"; 5 | 6 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.CREATE_START; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function start( 15 | config: Config, 16 | current: Map, 17 | record: any 18 | ): Map { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | // mark record as unsaved and busy 22 | var newRecord = prepareRecord(record); 23 | 24 | return store.merge(config, current, newRecord); 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/map/create/success.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import * as keys from "ramda/src/keys" 3 | import test from "ava"; 4 | 5 | import constants from "../../../constants"; 6 | import reducer from "./success"; 7 | 8 | var subject = constants.REDUCER_NAMES.CREATE_SUCCESS; 9 | var config = { 10 | key: constants.DEFAULT_KEY, 11 | resourceName: "users" 12 | }; 13 | 14 | function getCurrent() { 15 | return { 16 | 1: { 17 | id: 1, 18 | name: "Blue" 19 | }, 20 | 2: { 21 | id: "abc", 22 | name: "Green" 23 | } 24 | }; 25 | } 26 | 27 | test(subject + " it throws if it cannot find config.key", function(t) { 28 | var curr = getCurrent(); 29 | var record = {}; 30 | var config = { 31 | resourceName: "users" 32 | }; 33 | var f = function() { 34 | reducer(config, curr, record); 35 | }; 36 | t.throws(f, /users.createSuccess: Expected config.key/); 37 | }); 38 | 39 | test(subject + " doesn't mutate the original collection", function(t) { 40 | var curr = getCurrent(); 41 | var record = { 42 | id: 3, 43 | name: "Green" 44 | }; 45 | var updated = reducer(config, curr, record); 46 | 47 | t.is(values(updated).length, 3); 48 | t.is(values(curr).length, 2); 49 | }); 50 | 51 | test(subject + " throws if given an array", function(t) { 52 | var curr = getCurrent(); 53 | var record = []; 54 | function fn() { 55 | reducer(config, curr, record); 56 | } 57 | 58 | t.throws(fn, TypeError); 59 | }); 60 | 61 | test(subject + " adds the record", function(t) { 62 | var curr = getCurrent(); 63 | var record = { 64 | id: 3, 65 | name: "Green" 66 | }; 67 | var updated = reducer(config, curr, record); 68 | var actual = keys(updated); 69 | var expected = ["1", "2", "3"]; 70 | 71 | t.deepEqual(actual, expected); 72 | }); 73 | 74 | test(subject + " doesn't mutate the given record", function(t) { 75 | var curr = getCurrent(); 76 | 77 | function getRecord() { 78 | return { 79 | busy: true, 80 | id: 3, 81 | name: "Green" 82 | }; 83 | } 84 | var original = getRecord(); 85 | var expected = getRecord(); 86 | 87 | var updated = reducer(config, curr, original); 88 | 89 | t.deepEqual(original, expected); 90 | }); 91 | 92 | test(subject + " merges if exists", function(t) { 93 | var curr = getCurrent(); 94 | var record = { 95 | id: 2, 96 | name: "Green" 97 | }; 98 | var updated = reducer(config, curr, record); 99 | 100 | t.is(values(updated).length, 2); 101 | t.is(updated["2"].id, 2); 102 | t.is(updated["2"].name, "Green"); 103 | }); 104 | 105 | test(subject + " uses the given key", function(t) { 106 | var config = { 107 | key: "_id", 108 | resourceName: "users" 109 | }; 110 | var curr = { 111 | 2: { 112 | _id: 2, 113 | name: "Blue" 114 | } 115 | }; 116 | var record = { 117 | _id: 2, 118 | name: "Green" 119 | }; 120 | 121 | var updated = reducer(config, curr, record); 122 | 123 | t.is(values(updated).length, 1); 124 | }); 125 | 126 | test(subject + " it throws when record doesn't have an id", function(t) { 127 | var curr = getCurrent(); 128 | var record = { 129 | name: "Green" 130 | }; 131 | 132 | var f = function() { 133 | reducer(config, curr, record); 134 | }; 135 | t.throws(f, /users.createSuccess: Expected record to have .id/); 136 | }); 137 | 138 | test(subject + " uses the cid to merge the record", function(t) { 139 | var cid = "abc"; 140 | var curr = { 141 | [cid]: { 142 | id: cid, 143 | name: "Green" 144 | } 145 | }; 146 | var record = { 147 | id: 3, 148 | name: "Green" 149 | }; 150 | 151 | var updated = reducer(config, curr, record, cid); 152 | 153 | // It has the expected key 154 | var expectedKeys = ["3"]; 155 | var actualKeys = keys(updated); 156 | t.deepEqual(actualKeys, expectedKeys); 157 | 158 | // It merged the record 159 | t.deepEqual(updated["3"], record); 160 | }); 161 | 162 | test(subject + " cleans the cid", function(t) { 163 | var cid = "abc"; 164 | var curr = { 165 | [cid]: { 166 | id: cid, 167 | name: "Green" 168 | } 169 | }; 170 | 171 | var record = { 172 | id: 3, 173 | name: "Green" 174 | }; 175 | 176 | var updated = reducer(config, curr, record, cid); 177 | var updatedRecord = updated["3"]; 178 | 179 | t.is(updatedRecord._cid, undefined); 180 | }); 181 | 182 | test(subject + " removes busy and pendingCreate", function(t) { 183 | var curr = { 184 | 2: { 185 | busy: true, 186 | id: 2, 187 | name: "Green", 188 | pendingCreate: true 189 | } 190 | }; 191 | var record = { 192 | id: 2, 193 | name: "Yellow" 194 | }; 195 | var updated = reducer(config, curr, record); 196 | 197 | t.is(values(updated).length, 1); 198 | t.truthy(updated["2"].busy == null, "removes busy"); 199 | t.truthy(updated["2"].pendingCreate == null, "removes pendingCreate"); 200 | }); 201 | -------------------------------------------------------------------------------- /src/reducers/map/create/success.ts: -------------------------------------------------------------------------------- 1 | import * as dissoc from "ramda/src/dissoc" 2 | import * as lensProp from "ramda/src/lensProp" 3 | import * as set from "ramda/src/set" 4 | 5 | import constants from "../../../constants"; 6 | import invariants from "../invariants"; 7 | 8 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 9 | 10 | var reducerName: ReducerName = constants.REDUCER_NAMES.CREATE_SUCCESS; 11 | var invariantArgs: InvariantsBaseArgs = { 12 | reducerName, 13 | canBeArray: false 14 | }; 15 | 16 | export default function success( 17 | config: Config, 18 | current: Map, 19 | addedRecord: any, 20 | clientGeneratedKey?: string 21 | ): Map { 22 | invariants(invariantArgs, config, current, addedRecord); 23 | 24 | var key = config.key; 25 | var addedRecordKey: string = addedRecord[key]; 26 | var addedRecordKeyLens = lensProp(addedRecordKey); 27 | var currentWithoutClientGeneratedKey = dissoc(clientGeneratedKey, current); 28 | 29 | return set( 30 | addedRecordKeyLens, 31 | addedRecord, 32 | currentWithoutClientGeneratedKey 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/reducers/map/delete/error.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import test from "ava"; 3 | 4 | import constants from "../../../constants"; 5 | import reducer from "./error"; 6 | 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | var subject = constants.REDUCER_NAMES.DELETE_ERROR; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue", 18 | deleted: true, 19 | busy: true 20 | }, 21 | 2: { 22 | id: 2, 23 | name: "Red", 24 | deleted: true, 25 | busy: true 26 | } 27 | }; 28 | } 29 | 30 | test(subject + "throws if given an array", function(t) { 31 | var curr = getCurrent(); 32 | var record = []; 33 | function fn() { 34 | reducer(config, curr, record); 35 | } 36 | 37 | t.throws(fn, TypeError); 38 | }); 39 | 40 | test(subject + "doesnt mutate", function(t) { 41 | var curr = getCurrent(); 42 | var record = { 43 | id: 1 44 | }; 45 | 46 | var updated = reducer(config, curr, record); 47 | 48 | t.is(curr["1"].deleted, true); 49 | t.is(curr["1"].busy, true); 50 | t.is(updated["1"].deleted, undefined); 51 | t.is(updated["1"].busy, undefined); 52 | }); 53 | 54 | test(subject + "removes deleted and busy", function(t) { 55 | var curr = getCurrent(); 56 | var record = { 57 | id: 1 58 | }; 59 | var updated = reducer(config, curr, record); 60 | 61 | t.is(values(updated).length, 2, "doesnt remove record"); 62 | t.truthy(updated["1"].deleted == null, "removes deleted"); 63 | t.truthy(updated["1"].busy == null, "removes busy"); 64 | 65 | t.truthy(updated["2"].deleted, "doesnt removes deleted from others"); 66 | t.truthy(updated["2"].busy, "doesnt removes busy from others"); 67 | }); 68 | 69 | test(subject + "uses the given key", function(t) { 70 | var config = { 71 | key: "_id", 72 | resourceName: "users" 73 | }; 74 | var curr = { 75 | 1: { 76 | _id: 1, 77 | deleted: true, 78 | busy: true 79 | } 80 | }; 81 | var record = { 82 | _id: 1 83 | }; 84 | var updated = reducer(config, curr, record); 85 | 86 | t.truthy(updated["1"].deleted == null, "removes deleted"); 87 | t.truthy(updated["1"].busy == null, "removes busy"); 88 | }); 89 | 90 | test(subject + "it throws when record doesnt have an id", function(t) { 91 | var curr = getCurrent(); 92 | var record = { 93 | name: "Green" 94 | }; 95 | 96 | var f = function() { 97 | reducer(config, curr, record); 98 | }; 99 | t.throws(f); 100 | }); 101 | -------------------------------------------------------------------------------- /src/reducers/map/delete/error.ts: -------------------------------------------------------------------------------- 1 | import * as omit from "ramda/src/omit" 2 | import * as merge from "ramda/src/merge" 3 | 4 | import constants from "../../../constants"; 5 | import findByKey from "../../../utils/findByKey"; 6 | import invariants from "../invariants"; 7 | import store from "../store"; 8 | 9 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 10 | 11 | var reducerName: ReducerName = constants.REDUCER_NAMES.DELETE_ERROR; 12 | var invariantArgs: InvariantsBaseArgs = { 13 | reducerName, 14 | canBeArray: false 15 | }; 16 | 17 | export default function error( 18 | config: Config, 19 | current: Map, 20 | record: any 21 | ): Map { 22 | invariants(invariantArgs, config, current, record); 23 | 24 | var key = config.key; 25 | var deleteId = record[key]; 26 | 27 | // Find the record 28 | var deleteRecord = current[deleteId]; 29 | 30 | if (deleteRecord == null) { 31 | return current; 32 | } else { 33 | // Remove deleted and busy 34 | deleteRecord = omit(["deleted", "busy"], deleteRecord); 35 | 36 | return merge(current, {[deleteId]: deleteRecord}); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/reducers/map/delete/start.test.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import reducer from "./start"; 3 | import test from "ava"; 4 | 5 | var config = { 6 | key: constants.DEFAULT_KEY, 7 | resourceName: "users" 8 | }; 9 | var subject = constants.REDUCER_NAMES.DELETE_START; 10 | 11 | function getCurrent() { 12 | return { 13 | 1: { 14 | id: 1, 15 | name: "Blue" 16 | }, 17 | 2: { 18 | id: 2, 19 | name: "Red" 20 | } 21 | }; 22 | } 23 | 24 | function getValid() { 25 | return { 26 | id: 1, 27 | name: "Green" 28 | }; 29 | } 30 | 31 | test(subject + "throws if given an array", function(t) { 32 | var curr = getCurrent(); 33 | var record = []; 34 | function fn() { 35 | reducer(config, curr, record); 36 | } 37 | 38 | t.throws(fn, TypeError); 39 | }); 40 | 41 | test(subject + "marks record as deleted and busy", function(t) { 42 | var curr = getCurrent(); 43 | var record = getValid(); 44 | var updated = reducer(config, curr, record); 45 | 46 | t.is(updated["1"].deleted, true); 47 | t.is(updated["1"].busy, true); 48 | 49 | t.truthy(updated["2"].deleted == null, "doesnt add deleted to others"); 50 | t.truthy(updated["2"].busy == null, "doesnt add busy to others"); 51 | }); 52 | 53 | test(subject + "doesnt mutate", function(t) { 54 | var curr = getCurrent(); 55 | var record = getValid(); 56 | var updated = reducer(config, curr, record); 57 | 58 | t.is(updated["1"].deleted, true); 59 | t.is(curr["1"]["deleted"], undefined); 60 | }); 61 | 62 | test(subject + "uses the given key", function(t) { 63 | var config = { 64 | key: "_id", 65 | resourceName: "users" 66 | }; 67 | var curr = { 68 | 1: { 69 | _id: 1 70 | } 71 | }; 72 | var record = { 73 | _id: 1 74 | }; 75 | var updated = reducer(config, curr, record); 76 | 77 | t.truthy(updated["1"].deleted, "adds deleted"); 78 | }); 79 | 80 | test(subject + "it throws when record dont have an id", function(t) { 81 | var curr = getCurrent(); 82 | var record = { 83 | name: "Green" 84 | }; 85 | 86 | var f = function() { 87 | reducer(config, curr, record); 88 | }; 89 | t.throws(f); 90 | }); 91 | -------------------------------------------------------------------------------- /src/reducers/map/delete/start.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/delete/start"; 2 | import invariants from "../invariants"; 3 | import constants from "../../../constants"; 4 | import store from "../store"; 5 | 6 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.DELETE_START; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function start( 15 | config: Config, 16 | current: Map, 17 | record: any 18 | ): Map { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | var key = config.key; 22 | var deleteId = record[key]; 23 | var deleteRecord = current[deleteId]; 24 | 25 | if (deleteRecord == null) { 26 | return current; 27 | } else { 28 | deleteRecord = prepareRecord(deleteRecord); 29 | 30 | return store.merge(config, current, deleteRecord); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/reducers/map/delete/success.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import test from "ava"; 3 | 4 | import constants from "../../../constants"; 5 | import reducer from "./success"; 6 | 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | var subject = constants.REDUCER_NAMES.DELETE_SUCCESS; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue" 18 | }, 19 | 2: { 20 | id: 2, 21 | name: "Red" 22 | } 23 | }; 24 | } 25 | 26 | function getValid() { 27 | return { 28 | id: 1, 29 | name: "Green" 30 | }; 31 | } 32 | 33 | test(subject + "throws if given an array", function(t) { 34 | var curr = getCurrent(); 35 | var record = []; 36 | function fn() { 37 | reducer(config, curr, record); 38 | } 39 | 40 | t.throws(fn, TypeError); 41 | }); 42 | 43 | test(subject + "removes the record", function(t) { 44 | var curr = getCurrent(); 45 | var record = getValid(); 46 | var updated = reducer(config, curr, record); 47 | 48 | t.is(values(updated).length, 1, "removes the record"); 49 | t.is(updated["1"], undefined); 50 | }); 51 | 52 | test(subject + "doesnt mutate the original collection", function(t) { 53 | var curr = getCurrent(); 54 | var record = getValid(); 55 | var updated = reducer(config, curr, record); 56 | 57 | t.is(values(curr).length, 2); 58 | t.is(values(updated).length, 1); 59 | }); 60 | 61 | test(subject + "uses the given key", function(t) { 62 | var config = { 63 | key: "_id", 64 | resourceName: "users" 65 | }; 66 | var curr = [ 67 | { 68 | _id: 1 69 | } 70 | ]; 71 | var record = { 72 | _id: 1 73 | }; 74 | var updated = reducer(config, curr, record); 75 | 76 | t.deepEqual(values(updated).length, 0, "removes the record"); 77 | }); 78 | 79 | test(subject + "it throws when record dont have an id", function(t) { 80 | var curr = getCurrent(); 81 | var record = { 82 | name: "Green" 83 | }; 84 | 85 | var f = function() { 86 | reducer(config, curr, record); 87 | }; 88 | t.throws(f); 89 | }); 90 | -------------------------------------------------------------------------------- /src/reducers/map/delete/success.ts: -------------------------------------------------------------------------------- 1 | import * as reject from "ramda/src/reject" 2 | 3 | import invariants from "../invariants"; 4 | import constants from "../../../constants"; 5 | 6 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.DELETE_SUCCESS; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function success( 15 | config: Config, 16 | current: Map, 17 | record: any 18 | ): Map { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | var key = config.key; 22 | var deleteId = record[key]; 23 | 24 | function predicate(existingRecord) { 25 | return deleteId == existingRecord[key]; 26 | } 27 | 28 | return reject(predicate, current); 29 | } 30 | -------------------------------------------------------------------------------- /src/reducers/map/fetch/success.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import * as merge from "ramda/src/merge" 3 | import test from "ava"; 4 | 5 | import constants from "../../../constants"; 6 | import reducer from "./success"; 7 | 8 | var config = { 9 | key: constants.DEFAULT_KEY, 10 | resourceName: "users" 11 | }; 12 | 13 | var subject = constants.REDUCER_NAMES.FETCH_SUCCESS; 14 | 15 | function getCurrent() { 16 | return { 17 | 1: { 18 | id: 1, 19 | name: "Blue" 20 | }, 21 | 2: { 22 | id: 2, 23 | name: "Red" 24 | } 25 | }; 26 | } 27 | 28 | test(subject + " adds the records", function(t) { 29 | var curr = getCurrent(); 30 | var more = [ 31 | { 32 | id: 42, 33 | name: "Green", 34 | }, 35 | ]; 36 | 37 | var updated = reducer(config, curr, more, {}); 38 | 39 | var expected = { 40 | 1: { 41 | id: 1, 42 | name: "Blue", 43 | }, 44 | 2: { 45 | id: 2, 46 | name: "Red", 47 | }, 48 | 42: { 49 | id: 42, 50 | name: "Green", 51 | }, 52 | } 53 | 54 | t.deepEqual(updated, expected); 55 | }); 56 | 57 | test(subject + " throws when config.key is wrong", function(t) { 58 | var curr = getCurrent(); 59 | var more = [ 60 | { 61 | id: 42, 62 | name: "Green", 63 | }, 64 | ]; 65 | 66 | var config2 = merge(config, { 67 | key: "_id", 68 | }) 69 | 70 | var f = function() { 71 | reducer(config2, curr, more, {}); 72 | }; 73 | t.throws(f); 74 | }) 75 | 76 | test(subject + " doesnt mutate the original collection", function(t) { 77 | var curr = getCurrent(); 78 | var more = [ 79 | { 80 | id: 3, 81 | name: "Green" 82 | } 83 | ]; 84 | var updated = reducer(config, curr, more, {}); 85 | 86 | t.is(values(curr).length, 2); 87 | t.is(values(updated).length, 3); 88 | }); 89 | 90 | test(subject + " merges", function(t) { 91 | var curr = getCurrent(); 92 | var more = [ 93 | { 94 | id: 2, 95 | name: "Green" 96 | } 97 | ]; 98 | var updated = reducer(config, curr, more, {}); 99 | 100 | t.is(values(updated).length, 2); 101 | t.is(updated["2"].id, 2); 102 | t.is(updated["2"].name, "Green"); 103 | }); 104 | 105 | test(subject + " replaces", function(t) { 106 | const curr = getCurrent(); 107 | const more = [ 108 | { 109 | id: 2, 110 | name: "Green" 111 | } 112 | ]; 113 | const updated = reducer(config, curr, more, {}, true); 114 | 115 | t.is(values(updated).length, 1); 116 | t.is(updated["2"].id, 2); 117 | t.is(updated["2"].name, "Green"); 118 | }); 119 | 120 | test(subject + " uses the given key", function(t) { 121 | var config = { 122 | key: "_id", 123 | resourceName: "users" 124 | }; 125 | var curr = { 126 | 2: { 127 | _id: 2, 128 | name: "Blue" 129 | } 130 | }; 131 | var more = [ 132 | { 133 | _id: 2, 134 | name: "Green" 135 | } 136 | ]; 137 | 138 | var updated = reducer(config, curr, more, {}); 139 | 140 | t.is(values(updated).length, 1); 141 | }); 142 | 143 | test(subject + " it throws when records dont have an id", function(t) { 144 | var curr = getCurrent(); 145 | var more = [ 146 | { 147 | name: "Green" 148 | } 149 | ]; 150 | 151 | var f = function() { 152 | reducer(config, curr, more, {}); 153 | }; 154 | t.throws(f); 155 | }); 156 | 157 | test(subject + " can take one record", function(t) { 158 | var curr = getCurrent(); 159 | var one = { 160 | id: 3, 161 | name: "Green" 162 | }; 163 | var updated = reducer(config, curr, one, {}); 164 | 165 | t.is(values(updated).length, 3); 166 | }); 167 | -------------------------------------------------------------------------------- /src/reducers/map/fetch/success.ts: -------------------------------------------------------------------------------- 1 | import * as indexBy from "ramda/src/indexBy" 2 | import * as prop from "ramda/src/prop" 3 | import * as merge from "ramda/src/merge" 4 | 5 | import assertAllHaveKeys from "../../../utils/assertAllHaveKeys"; 6 | import constants from "../../../constants"; 7 | import invariants from "../invariants"; 8 | import wrapArray from "../../../utils/wrapArray"; 9 | 10 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 11 | 12 | const reducerName: ReducerName = constants.REDUCER_NAMES.FETCH_SUCCESS; 13 | const invariantArgs: InvariantsBaseArgs = { 14 | reducerName, 15 | canBeArray: true 16 | }; 17 | 18 | export default function success( 19 | config: Config, 20 | current: Map, 21 | records: any, 22 | emptyState: any, 23 | replace: boolean = false 24 | ): Map { 25 | invariants(invariantArgs, config, current, records); 26 | 27 | // wrap array 28 | records = wrapArray(records); 29 | 30 | // All given records must have a key 31 | assertAllHaveKeys(config, reducerName, records); 32 | 33 | const mergeValues = indexBy(prop(config.key), records); 34 | 35 | return merge(replace ? emptyState : current, mergeValues); 36 | } 37 | -------------------------------------------------------------------------------- /src/reducers/map/invariants.ts: -------------------------------------------------------------------------------- 1 | import invariants from "../invariants"; 2 | import store from "./store"; 3 | 4 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../types"; 5 | 6 | export default function invariantsMap( 7 | invariantArgs: InvariantsBaseArgs, 8 | config: Config, 9 | current: Map, 10 | record: any 11 | ) { 12 | var extra = { 13 | assertValidStore: store.assert, 14 | config, 15 | current, 16 | record 17 | }; 18 | 19 | invariants(invariantArgs, extra); 20 | } 21 | -------------------------------------------------------------------------------- /src/reducers/map/reducersFor.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as td from "testdouble"; 3 | 4 | import constants from "../../constants"; 5 | import reducersFor from "./reducersFor"; 6 | 7 | const current = [{}]; 8 | const user = {}; 9 | const error = ""; 10 | const config = { 11 | key: constants.DEFAULT_KEY, 12 | resourceName: "users" 13 | }; 14 | const subject = "reducersFor: "; 15 | 16 | test(subject + "calls fetchSuccess", function(t) { 17 | const fetchSuccess = td.function(); 18 | const reducers = reducersFor("users", {}, {fetchSuccess}); 19 | 20 | var users = [user]; 21 | 22 | reducers(current, { 23 | records: users, 24 | type: "USERS_FETCH_SUCCESS" 25 | }); 26 | 27 | td.verify(fetchSuccess(config, current, users, {}, undefined)); 28 | t.pass(); 29 | }); 30 | 31 | test(subject + "calls fetchSuccess with replace", function(t) { 32 | const fetchSuccess = td.function(); 33 | const reducers = reducersFor("users", {}, {fetchSuccess}); 34 | 35 | var users = [user]; 36 | 37 | reducers(current, { 38 | data: {replace: true}, 39 | records: users, 40 | type: "USERS_FETCH_SUCCESS" 41 | }); 42 | 43 | td.verify(fetchSuccess(config, current, users, {}, true)); 44 | t.pass(); 45 | }); 46 | 47 | test(subject + "calls createStart", function(t) { 48 | const createStart = td.function(); 49 | const reducers = reducersFor("users", {}, {createStart}); 50 | 51 | reducers(current, { 52 | record: user, 53 | type: "USERS_CREATE_START" 54 | }); 55 | 56 | td.verify(createStart(config, current, user)); 57 | t.pass(); 58 | }); 59 | 60 | test(subject + "calls createSuccess", function(t) { 61 | const createSuccess = td.function(); 62 | const reducers = reducersFor("users", {}, {createSuccess}); 63 | 64 | var cid = "abc"; 65 | 66 | reducers(current, { 67 | record: user, 68 | type: "USERS_CREATE_SUCCESS", 69 | cid: cid 70 | }); 71 | 72 | td.verify(createSuccess(config, current, user, cid)); 73 | t.pass(); 74 | }); 75 | 76 | test(subject + "calls createError", function(t) { 77 | const createError = td.function(); 78 | const reducers = reducersFor("users", {}, {createError}); 79 | 80 | reducers(current, { 81 | error: error, 82 | record: user, 83 | type: "USERS_CREATE_ERROR" 84 | }); 85 | 86 | td.verify(createError(config, current, user)); 87 | t.pass(); 88 | }); 89 | 90 | test(subject + "calls updateStart", function(t) { 91 | const updateStart = td.function(); 92 | const reducers = reducersFor("users", {}, {updateStart}); 93 | 94 | reducers(current, { 95 | record: user, 96 | type: "USERS_UPDATE_START" 97 | }); 98 | 99 | td.verify(updateStart(config, current, user)); 100 | t.pass(); 101 | }); 102 | 103 | test(subject + "calls updateSuccess", function(t) { 104 | const updateSuccess = td.function(); 105 | const reducers = reducersFor("users", {}, {updateSuccess}); 106 | 107 | reducers(current, { 108 | record: user, 109 | type: "USERS_UPDATE_SUCCESS" 110 | }); 111 | 112 | td.verify(updateSuccess(config, current, user)); 113 | t.pass(); 114 | }); 115 | 116 | test(subject + "calls updateError", function(t) { 117 | const updateError = td.function(); 118 | const reducers = reducersFor("users", {}, {updateError}); 119 | 120 | reducers(current, { 121 | error: error, 122 | record: user, 123 | type: "USERS_UPDATE_ERROR" 124 | }); 125 | 126 | td.verify(updateError(config, current, user)); 127 | t.pass(); 128 | }); 129 | 130 | test(subject + "calls deleteStart", function(t) { 131 | const deleteStart = td.function(); 132 | const reducers = reducersFor("users", {}, {deleteStart}); 133 | 134 | reducers(current, { 135 | record: user, 136 | type: "USERS_DELETE_START" 137 | }); 138 | 139 | td.verify(deleteStart(config, current, user)); 140 | t.pass(); 141 | }); 142 | 143 | test(subject + "calls deleteSuccess", function(t) { 144 | const deleteSuccess = td.function(); 145 | const reducers = reducersFor("users", {}, {deleteSuccess}); 146 | 147 | reducers(current, { 148 | record: user, 149 | type: "USERS_DELETE_SUCCESS" 150 | }); 151 | 152 | td.verify(deleteSuccess(config, current, user)); 153 | t.pass(); 154 | }); 155 | 156 | test(subject + "calls deleteError", function(t) { 157 | const deleteError = td.function(); 158 | const reducers = reducersFor("users", {}, {deleteError}); 159 | 160 | reducers(current, { 161 | error: error, 162 | record: user, 163 | type: "USERS_DELETE_ERROR" 164 | }); 165 | 166 | td.verify(deleteError(config, current, user)); 167 | t.pass(); 168 | }); 169 | 170 | test(subject + "it passes the given key", function(t) { 171 | const createStart = td.function(); 172 | const reducers = reducersFor("users", {key: "_id"}, {createStart}); 173 | 174 | reducers(current, { 175 | record: user, 176 | type: "USERS_CREATE_START" 177 | }); 178 | 179 | var expectedConfig = { 180 | key: "_id", 181 | resourceName: "users" 182 | }; 183 | 184 | td.verify(createStart(expectedConfig, current, user)); 185 | t.pass(); 186 | }); 187 | 188 | test(subject + "it doesnt mutate the config", function(t) { 189 | const config = {}; 190 | reducersFor("users", config); 191 | reducersFor("monkeys", config); 192 | 193 | t.deepEqual(config, {}); 194 | }); 195 | -------------------------------------------------------------------------------- /src/reducers/map/reducersFor.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | 3 | import actionTypesFor from "../../actionTypesFor"; 4 | import constants from "../../constants"; 5 | import commonReducersFor from "../common/reducersFor"; 6 | import createError from "./create/error"; 7 | import createStart from "./create/start"; 8 | import createSuccess from "./create/success"; 9 | import deleteError from "./delete/error"; 10 | import deleteStart from "./delete/start"; 11 | import deleteSuccess from "./delete/success"; 12 | import fetchSuccess from "./fetch/success"; 13 | import updateError from "./update/error"; 14 | import updateStart from "./update/start"; 15 | import updateSuccess from "./update/success"; 16 | 17 | import {Config, ReducerName} from "../../types"; 18 | 19 | const baseReducers = { 20 | createError, 21 | createStart, 22 | createSuccess, 23 | deleteError, 24 | deleteStart, 25 | deleteSuccess, 26 | fetchSuccess, 27 | updateError, 28 | updateStart, 29 | updateSuccess 30 | }; 31 | 32 | export default function reducersFor(resourceName: string, args = {}, deps?) { 33 | const reducers = merge(baseReducers, deps); 34 | return commonReducersFor(resourceName, args, {}, reducers); 35 | } 36 | -------------------------------------------------------------------------------- /src/reducers/map/store.ts: -------------------------------------------------------------------------------- 1 | import {Config, Map} from "../../types"; 2 | import assert from "./store/assert"; 3 | import merge from "./store/merge"; 4 | import remove from "./store/remove"; 5 | 6 | export default { 7 | assert, 8 | merge, 9 | remove 10 | }; 11 | -------------------------------------------------------------------------------- /src/reducers/map/store/assert.ts: -------------------------------------------------------------------------------- 1 | import * as is from "ramda/src/is" 2 | 3 | import {Map} from "../../../types"; 4 | 5 | export default function assertValidStore( 6 | scope: string, 7 | current: Map 8 | ): void { 9 | if (!is(Object, current)) 10 | throw new Error(scope + ": Expected current to be an object"); 11 | } 12 | -------------------------------------------------------------------------------- /src/reducers/map/store/merge.ts: -------------------------------------------------------------------------------- 1 | import * as merge from "ramda/src/merge" 2 | 3 | import {Config, Map} from "../../../types"; 4 | 5 | /* 6 | Adds or replace one record 7 | */ 8 | export default function replace( 9 | config: Config, 10 | current: Map, 11 | record: any 12 | ): Map { 13 | var key = config.key; 14 | var recordKey = record[key]; 15 | 16 | return merge(current, {[recordKey]: record}); 17 | } 18 | -------------------------------------------------------------------------------- /src/reducers/map/store/remove.ts: -------------------------------------------------------------------------------- 1 | import * as omit from "ramda/src/omit" 2 | 3 | import {Config, Map} from "../../../types"; 4 | 5 | export default function remove( 6 | config: Config, 7 | current: Map, 8 | record: any 9 | ): Map { 10 | var key = config.key; 11 | var recordKey = record[key]; 12 | 13 | return omit([recordKey], current); 14 | } 15 | -------------------------------------------------------------------------------- /src/reducers/map/update/error.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import test from "ava"; 3 | 4 | import constants from "../../../constants"; 5 | import reducer from "./error"; 6 | 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | var subject = constants.REDUCER_NAMES.UPDATE_ERROR; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue", 18 | busy: true, 19 | pendingUpdate: true 20 | }, 21 | 2: { 22 | id: 2, 23 | name: "Red", 24 | busy: true, 25 | pendingUpdate: true 26 | } 27 | }; 28 | } 29 | 30 | function getValid() { 31 | return { 32 | id: 2, 33 | name: "Green" 34 | }; 35 | } 36 | 37 | test(subject + "throws if given an array", function(t) { 38 | var curr = getCurrent(); 39 | var record = []; 40 | function fn() { 41 | reducer(config, curr, record); 42 | } 43 | 44 | t.throws(fn, TypeError); 45 | }); 46 | 47 | test(subject + "doesnt add record if not there", function(t) { 48 | var curr = getCurrent(); 49 | var record = { 50 | id: 3, 51 | name: "Green" 52 | }; 53 | var updated = reducer(config, curr, record); 54 | 55 | t.is(values(updated).length, 2); 56 | }); 57 | 58 | test(subject + "removes busy", function(t) { 59 | var curr = getCurrent(); 60 | var record = getValid(); 61 | var updated = reducer(config, curr, record); 62 | 63 | t.truthy(updated["1"].busy, "doesnt remove on others"); 64 | t.truthy(updated["2"].busy == null, "removes busy"); 65 | }); 66 | 67 | test(subject + "doesnt mutate the original collection", function(t) { 68 | var curr = getCurrent(); 69 | var record = getValid(); 70 | var updated = reducer(config, curr, record); 71 | 72 | t.is(curr["2"].busy, true); 73 | t.is(updated["2"].busy, undefined); 74 | }); 75 | 76 | test(subject + "doesnt remove pendingUpdate", function(t) { 77 | var curr = getCurrent(); 78 | var record = getValid(); 79 | var updated = reducer(config, curr, record); 80 | 81 | t.truthy(updated["2"].pendingUpdate); 82 | }); 83 | 84 | test(subject + "uses the given key", function(t) { 85 | var config = { 86 | key: "_id", 87 | resourceName: "users" 88 | }; 89 | var curr = { 90 | 2: { 91 | _id: 2, 92 | name: "Blue", 93 | busy: true, 94 | unsaved: true 95 | } 96 | }; 97 | var record = { 98 | _id: 2 99 | }; 100 | var updated = reducer(config, curr, record); 101 | 102 | t.truthy(updated["2"].busy == null, "removes busy"); 103 | }); 104 | 105 | test(subject + "it throws when record dont have an id", function(t) { 106 | var curr = getCurrent(); 107 | var record = { 108 | name: "Green" 109 | }; 110 | 111 | var f = function() { 112 | reducer(config, curr, record); 113 | }; 114 | 115 | t.throws(f); 116 | }); 117 | -------------------------------------------------------------------------------- /src/reducers/map/update/error.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/update/error"; 2 | import constants from "../../../constants"; 3 | import invariants from "../invariants"; 4 | import store from "../store"; 5 | 6 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.UPDATE_ERROR; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function error( 15 | config: Config, 16 | current: Map, 17 | record: any 18 | ): Map { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | // We don"t want to rollback 22 | var key = config.key; 23 | var updatedId = record[key]; 24 | var updatedRecord = current[updatedId]; 25 | 26 | if (updatedRecord == null) return current; 27 | 28 | updatedRecord = prepareRecord(updatedRecord); 29 | 30 | return store.merge(config, current, updatedRecord); 31 | } 32 | -------------------------------------------------------------------------------- /src/reducers/map/update/start.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | import test from "ava"; 3 | 4 | import constants from "../../../constants"; 5 | import reducer from "./start"; 6 | 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | var subject = constants.REDUCER_NAMES.UPDATE_START; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue" 18 | }, 19 | 2: { 20 | id: 2, 21 | name: "Red" 22 | } 23 | }; 24 | } 25 | 26 | function getValid() { 27 | return { 28 | id: 2, 29 | name: "Green" 30 | }; 31 | } 32 | 33 | test(subject + "throws if given an array", function(t) { 34 | var curr = getCurrent(); 35 | var record = []; 36 | function fn() { 37 | reducer(config, curr, record); 38 | } 39 | 40 | t.throws(fn, TypeError); 41 | }); 42 | 43 | test(subject + "adds the record if not there", function(t) { 44 | var curr = getCurrent(); 45 | var record = { 46 | id: 3, 47 | name: "Green" 48 | }; 49 | var updated = reducer(config, curr, record); 50 | 51 | t.is(values(updated).length, 3); 52 | }); 53 | 54 | test(subject + "doesnt mutate the original", function(t) { 55 | var curr = getCurrent(); 56 | var record = { 57 | id: 3, 58 | name: "Green" 59 | }; 60 | var updated = reducer(config, curr, record); 61 | 62 | t.is(values(curr).length, 2); 63 | t.is(values(updated).length, 3); 64 | }); 65 | 66 | test(subject + "updates existing", function(t) { 67 | var curr = getCurrent(); 68 | var record = getValid(); 69 | var updated = reducer(config, curr, record); 70 | 71 | t.is(values(updated).length, 2); 72 | t.is(updated["2"].id, 2); 73 | t.is(updated["2"].name, "Green"); 74 | }); 75 | 76 | test(subject + "uses the given key", function(t) { 77 | var config = { 78 | key: "_id", 79 | resourceName: "users" 80 | }; 81 | var curr = { 82 | 2: { 83 | _id: 2, 84 | name: "Blue" 85 | } 86 | }; 87 | var record = { 88 | _id: 2, 89 | name: "Green" 90 | }; 91 | var updated = reducer(config, curr, record); 92 | 93 | t.is(values(updated).length, 1); 94 | }); 95 | 96 | test(subject + "it throws when record dont have an id", function(t) { 97 | var curr = getCurrent(); 98 | var record = { 99 | name: "Green" 100 | }; 101 | 102 | var f = function() { 103 | reducer(config, curr, record); 104 | }; 105 | t.throws(f); 106 | }); 107 | 108 | test(subject + "adds busy and pendingUpdate", function(t) { 109 | var curr = getCurrent(); 110 | var record = getValid(); 111 | var updated = reducer(config, curr, record); 112 | 113 | t.deepEqual(updated["2"].name, "Green"); 114 | t.truthy(updated["2"].busy, "adds busy"); 115 | t.truthy(updated["2"].pendingUpdate, "adds pendingUpdate"); 116 | }); 117 | -------------------------------------------------------------------------------- /src/reducers/map/update/start.ts: -------------------------------------------------------------------------------- 1 | import {prepareRecord} from "../../common/update/start"; 2 | import constants from "../../../constants"; 3 | import invariants from "../invariants"; 4 | import store from "../store"; 5 | 6 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 7 | 8 | var reducerName: ReducerName = constants.REDUCER_NAMES.UPDATE_START; 9 | var invariantArgs: InvariantsBaseArgs = { 10 | reducerName, 11 | canBeArray: false 12 | }; 13 | 14 | export default function start( 15 | config: Config, 16 | current: Map, 17 | record: any 18 | ): Map { 19 | invariants(invariantArgs, config, current, record); 20 | 21 | // mark record as unsaved and busy 22 | var newRecord = prepareRecord(record); 23 | 24 | // replace record 25 | return store.merge(config, current, newRecord); 26 | } 27 | -------------------------------------------------------------------------------- /src/reducers/map/update/success.test.ts: -------------------------------------------------------------------------------- 1 | import * as values from "ramda/src/values" 2 | 3 | import constants from "../../../constants"; 4 | import reducer from "./success"; 5 | import test from "ava"; 6 | 7 | var config = { 8 | key: constants.DEFAULT_KEY, 9 | resourceName: "users" 10 | }; 11 | var subject = constants.REDUCER_NAMES.UPDATE_SUCCESS; 12 | 13 | function getCurrent() { 14 | return { 15 | 1: { 16 | id: 1, 17 | name: "Blue", 18 | unsaved: true, 19 | busy: true 20 | }, 21 | 2: { 22 | id: 2, 23 | name: "Red", 24 | unsaved: true, 25 | busy: true 26 | } 27 | }; 28 | } 29 | 30 | function getValid() { 31 | return { 32 | id: 2, 33 | name: "Green" 34 | }; 35 | } 36 | 37 | test(subject + "throws if given an array", function(t) { 38 | var curr = getCurrent(); 39 | var record = []; 40 | function fn() { 41 | reducer(config, curr, record); 42 | } 43 | 44 | t.throws(fn, TypeError); 45 | }); 46 | 47 | test(subject + "adds the record if not there", function(t) { 48 | var curr = getCurrent(); 49 | var record = { 50 | id: 3, 51 | name: "Green" 52 | }; 53 | var updated = reducer(config, curr, record); 54 | 55 | t.is(values(updated).length, 3); 56 | }); 57 | 58 | test(subject + "doesnt mutate the original collection", function(t) { 59 | var curr = getCurrent(); 60 | var record = { 61 | id: 3, 62 | name: "Green" 63 | }; 64 | var updated = reducer(config, curr, record); 65 | 66 | t.is(values(curr).length, 2); 67 | t.is(values(updated).length, 3); 68 | }); 69 | 70 | test(subject + "updates existing", function(t) { 71 | var curr = getCurrent(); 72 | var record = getValid(); 73 | var updated = reducer(config, curr, record); 74 | 75 | t.is(values(updated).length, 2); 76 | t.is(updated["2"].id, 2); 77 | t.is(updated["2"].name, "Green"); 78 | }); 79 | 80 | test(subject + "uses the given key", function(t) { 81 | var config = { 82 | key: "_id", 83 | resourceName: "users" 84 | }; 85 | var curr = { 86 | 2: { 87 | _id: 2, 88 | name: "Blue" 89 | } 90 | }; 91 | var record = { 92 | _id: 2, 93 | name: "Green" 94 | }; 95 | var updated = reducer(config, curr, record); 96 | 97 | t.is(values(updated).length, 1); 98 | }); 99 | 100 | test(subject + "it throws when record dont have an id", function(t) { 101 | var curr = getCurrent(); 102 | var record = { 103 | name: "Green" 104 | }; 105 | 106 | var f = function() { 107 | reducer(config, curr, record); 108 | }; 109 | t.throws(f); 110 | }); 111 | 112 | test(subject + "removes busy and pendingUpdate", function(t) { 113 | var curr = { 114 | 2: { 115 | id: 2, 116 | name: "Green", 117 | pendingUpdate: true, 118 | busy: true 119 | } 120 | }; 121 | var record = getValid(); 122 | var updated = reducer(config, curr, record); 123 | 124 | t.deepEqual(values(updated).length, 1); 125 | t.truthy(updated["2"].busy == null, "removes busy"); 126 | t.truthy(updated["2"].pendingUpdate == null, "removes pendingUpdate"); 127 | }); 128 | -------------------------------------------------------------------------------- /src/reducers/map/update/success.ts: -------------------------------------------------------------------------------- 1 | import constants from "../../../constants"; 2 | import invariants from "../invariants"; 3 | import store from "../store"; 4 | 5 | import {Config, InvariantsBaseArgs, Map, ReducerName} from "../../../types"; 6 | 7 | var reducerName: ReducerName = constants.REDUCER_NAMES.UPDATE_SUCCESS; 8 | var invariantArgs: InvariantsBaseArgs = { 9 | reducerName, 10 | canBeArray: false 11 | }; 12 | 13 | export default function success( 14 | config: Config, 15 | current: Map, 16 | record: any 17 | ): Map { 18 | invariants(invariantArgs, config, current, record); 19 | 20 | return store.merge(config, current, record); 21 | } 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | key?: string, 3 | resourceName: string 4 | } 5 | 6 | export interface InvariantsBaseArgs { 7 | reducerName: ReducerName, 8 | canBeArray: boolean 9 | } 10 | 11 | export interface InvariantsExtraArgs { 12 | assertValidStore: (scope: string, current: any) => void, 13 | config: Config, 14 | current: any, 15 | record: any 16 | } 17 | 18 | export interface Map { 19 | [key: string]: T 20 | } 21 | 22 | export type ReducerName = 23 | | "createError" 24 | | "createSuccess" 25 | | "createStart" 26 | | "deleteError" 27 | | "deleteSuccess" 28 | | "deleteStart" 29 | | "fetchSuccess" 30 | | "fetchError" 31 | | "updateError" 32 | | "updateSuccess" 33 | | "updateStart"; 34 | 35 | export interface StoreList { 36 | remove: (config: Config, current: Array, record: any) => Array 37 | } 38 | 39 | export interface StoreMap { 40 | remove: (config: Config, current: Map, record: any) => Map 41 | } 42 | 43 | export interface Record { 44 | id: string | number, 45 | _cid?: string | number, 46 | busy?: boolean, 47 | deleted?: boolean, 48 | pendingCreate?: boolean, 49 | pendingUpdate?: boolean 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/assertAllHaveKeys.ts: -------------------------------------------------------------------------------- 1 | import * as has from "ramda/src/has" 2 | import * as all from "ramda/src/all" 3 | 4 | export default function(config, reducerName, records) { 5 | // All given records must have a key 6 | var haskey = has(config.key); 7 | var allKeys = all(haskey, records); 8 | 9 | if (!allKeys) { 10 | throw new Error( 11 | reducerName + 12 | ": Expected all records to have a value for the store's key `" + 13 | config.key + 14 | "`" 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/assertNotArray.ts: -------------------------------------------------------------------------------- 1 | import * as is from "ramda/src/is" 2 | 3 | import makeScope from "../utils/makeScope"; 4 | 5 | import {Config, ReducerName} from "../types"; 6 | 7 | export default function(config: Config, reducerName: ReducerName, record: any) { 8 | var scope = makeScope(config, reducerName); 9 | var isArray = is(Array, record); 10 | 11 | if (isArray) 12 | throw new TypeError(scope + ": Expected record not to be an array"); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/findByKey.ts: -------------------------------------------------------------------------------- 1 | import * as find from "ramda/src/find" 2 | 3 | export default function findByKey(collection, key, id) { 4 | function predicate(record) { 5 | return record[key] === id; 6 | } 7 | 8 | return find(predicate, collection); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/makeScope.ts: -------------------------------------------------------------------------------- 1 | import {Config, ReducerName} from "../types"; 2 | 3 | export default function makeScope( 4 | config: Config, 5 | reducerName: ReducerName 6 | ): string { 7 | return config.resourceName + "." + reducerName; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/wrapArray.ts: -------------------------------------------------------------------------------- 1 | import * as is from "ramda/src/is" 2 | 3 | export default function wrapArray(recordOrRecords) { 4 | var isArray = is(Array, recordOrRecords); 5 | return isArray ? recordOrRecords : [recordOrRecords]; 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015"], 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "target": "es5", 9 | "allowSyntheticDefaultImports": true 10 | }, 11 | "exclude": [ 12 | "dist", 13 | "node_modules", 14 | "example" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | /* 3 | * Possible values: 4 | * - the name of a built-in config 5 | * - the name of an NPM module which has a "main" file that exports a config object 6 | * - a relative path to a JSON file 7 | */ 8 | "extends": "tslint:recommended", 9 | "rules": { 10 | /* 11 | * Any rules specified here will override those from the base config we are extending. 12 | */ 13 | "curly": false, 14 | "no-var-keyword": false, 15 | "only-arrow-functions": false, 16 | "ordered-imports": false, 17 | "trailing-comma": false 18 | }, 19 | "jsRules": { 20 | /* 21 | * Any rules specified here will override those from the base config we are extending. 22 | */ 23 | "curly": true 24 | }, 25 | "rulesDirectory": [ 26 | /* 27 | * A list of relative or absolute paths to directories that contain custom rules. 28 | * See the Custom Rules documentation below for more details. 29 | */ 30 | ] 31 | } 32 | --------------------------------------------------------------------------------