├── .babelrc
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .npmignore
├── README.md
├── package.json
├── readme-src
├── dev-tools-1v.jpg
├── dev-tools.png
└── readme-1v.md
├── src
├── entity-api.js
├── index.js
├── reducers-builder.js
└── utils.js
└── tests
├── config
└── global-mocks.js
├── integration
├── api-actions.test.js
└── demo.test.js
└── unit
├── api-reducers.test.js
├── entitity-api-instance.test.js
└── utils.test.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0"],
3 | "plugins": [
4 | "transform-object-assign"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # top-most EditorConfig file
2 | root = true
3 |
4 | # Unix-style newlines with a newline ending every file
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | # Developer files
12 | [*.{js, css, html, rb, package.json, .travis.yml}]
13 | indent_style = space
14 | indent_size = 2
15 |
16 | # Markdown
17 | [*.md]
18 | indent_style = space
19 | indent_size = 4
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "rambler",
4 | "rules": {
5 | "react/jsx-uses-vars": 1,
6 | "react/react-in-jsx-scope": 1
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /deploy
2 | /nbproject/
3 | vendor/
4 | shared/
5 | node_modules/
6 | lib/
7 | static/
8 | web/static/
9 | web/index.html
10 | .idea/*
11 | *.DS_Store
12 | *.js.map
13 | *.iml
14 | /sftp-config.json
15 | .old_hooks/
16 | npm-debug.log
17 | _tests/_output/*
18 | app/runtime
19 | runtime/
20 | _tests/_output/*
21 | .user.ini
22 | ### JetBrains template
23 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
24 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
25 |
26 | # User-specific stuff:
27 | .idea/workspace.xml
28 | .idea/tasks.xml
29 | .idea/dictionaries
30 | .idea/vcs.xml
31 | .idea/jsLibraryMappings.xml
32 |
33 | # Sensitive or high-churn files:
34 | .idea/dataSources.ids
35 | .idea/dataSources.xml
36 | .idea/dataSources.local.xml
37 | .idea/sqlDataSources.xml
38 | .idea/dynamic.xml
39 | .idea/uiDesigner.xml
40 |
41 | # Gradle:
42 | .idea/gradle.xml
43 | .idea/libraries
44 |
45 | # Mongo Explorer plugin:
46 | .idea/mongoSettings.xml
47 |
48 | ## File-based project format:
49 | *.iws
50 |
51 | ## Plugin-specific files:
52 |
53 | # IntelliJ
54 | /out/
55 |
56 | # mpeltonen/sbt-idea plugin
57 | .idea_modules/
58 |
59 | # JIRA plugin
60 | atlassian-ide-plugin.xml
61 |
62 | # Crashlytics plugin (for Android Studio and IntelliJ)
63 | com_crashlytics_export_strings.xml
64 | crashlytics.properties
65 | crashlytics-build.properties
66 | fabric.properties
67 | ### Node template
68 | # Logs
69 | logs
70 | *.log
71 | npm-debug.log*
72 |
73 | # Runtime data
74 | pids
75 | *.pid
76 | *.seed
77 |
78 | # Directory for instrumented libs generated by jscoverage/JSCover
79 | lib-cov
80 |
81 | # Coverage directory used by tools like istanbul
82 | coverage
83 |
84 | # nyc test coverage
85 | .nyc_output
86 |
87 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
88 | .grunt
89 |
90 | # node-waf configuration
91 | .lock-wscript
92 |
93 | # Compiled binary addons (http://nodejs.org/api/addons.html)
94 | build/Release
95 |
96 | # Dependency directories
97 | jspm_packages
98 |
99 | # Optional npm cache directory
100 | .npm
101 |
102 | # Optional REPL history
103 | .node_repl_history
104 | /.idea/
105 | /coverage/
106 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | /*
2 | !/lib
3 | !/src
4 | !/redux-api-middleware
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redux-rest-adapter
2 |
3 | [](https://npmjs.org/package/redux-rest-adapter)
4 | [](https://codeclimate.com/github/maksim-chekrishov/redux-rest-adapter)
5 | [](https://codeclimate.com/github/maksim-chekrishov/redux-rest-adapter/coverage)
6 |
7 | redux-rest-adapter is a tool for easy connection your REST api with redux store.
8 |
9 | Compatible with [json.api specification](http://jsonapi.org/)
10 |
11 | ## Main points
12 | - Write **code** instead of reducers and actions for trivial data operations.
13 |
14 | ## Changelog
15 |
16 | Starts from v2.0.0 redux-rest-adapter based on
17 | [axios](https://www.npmjs.com/package/axios) and
18 | [promise-middleware](https://www.npmjs.com/package/promise-middleware)
19 | for easy access to promises and better experience with isomorphic app.
20 |
21 | [Versions 1.x.x](https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/master/readme-src/readme-1v.md)
22 |
23 | ### short-example.js
24 |
25 | ```js
26 | import EntityApi, {promiseMiddleware} from 'redux-rest-adapter';
27 | import {createStore, applyMiddleware, combineReducers} from 'redux';
28 |
29 | const tagsApi = new EntityApi({
30 | entityName: 'TAG',
31 | endpointUrl: 'api/v2/tags'
32 | });
33 |
34 | const apiReducer = combineReducers({
35 | TAG: tagsApi.configureReducer()
36 | });
37 |
38 | const store = createStore(
39 | combineReducers({
40 | api: apiReducer
41 | }),
42 | {},
43 | applyMiddleware(promiseMiddleware())
44 | );
45 |
46 | store.dispatch(tagsApi.actions.load()).then(()=> {
47 | console.log(store.getState().api.TAG.data); // [{id:1, name:'tag1'}, {id:2, name:'tag2'}];
48 | })
49 | ```
50 |
51 | ## Setup
52 |
53 | ### your/known-entities-api.js
54 |
55 | ```js
56 | import EntityApi from 'redux-rest-adapter';
57 |
58 | export const KnownEntitiesUrls = {
59 | NEWS_TAGS: 'news-tags',
60 | NEWS_TAG_FOR_EDIT: 'news-tags',
61 | //..
62 | };
63 | export default _.mapValues(KnownEntitiesUrls, (url, name) => new EntityApi({
64 | entityName: name,
65 | endpointUrl: 'api/v2/' + url
66 | }));
67 | ```
68 |
69 | ### your/api-reducer.js
70 |
71 | ```js
72 | import knownEntitiesApi from 'your/known-entities-api';
73 |
74 | // Ability to extend default api reducers
75 | const apiReducersExtensions = {
76 | NEWS_TAGS: tagsReducer
77 | }
78 |
79 | const apiReducers = _.mapValues(knownEntitiesApi, (api, key) => api.configureReducer(apiReducersExtensions[key]));
80 |
81 | export default combineReducers(apiReducers);
82 | ```
83 |
84 | ### your/index-reducer.js
85 |
86 | ```js
87 | import apiReducer from 'your/api-reducer';
88 |
89 | export default combineReducers({
90 | api: apiReducer
91 | //..
92 | });
93 | ```
94 |
95 | ### your/configure-store.js
96 |
97 | ```js
98 | import indexReducer from 'your/index-reducer';
99 | import {promiseMiddleware} from 'redux-rest-adapter';
100 | //..
101 | export default function configureStore(initialState) {
102 | return createStore(
103 | indexReducer,
104 | initialState,
105 | applyMiddleware(promiseMiddleware())
106 | );
107 | }
108 | ```
109 |
110 | ### your/entities-actions.js
111 |
112 | ```js
113 | import knownEntitiesApi from 'your/known-entities-api';
114 |
115 | export default _.mapValues(knownEntitiesApi, entityApi => entityApi.actions);
116 | ```
117 |
118 |
119 | ## Adapter is ready
120 |
121 | 
122 |
123 | ## Usage
124 |
125 | ### Actions
126 |
127 | ```js
128 | import entitesActions from 'your/entities-actions';
129 |
130 | dispatch(entitesActions.NEWS_TAG.load()); // GET: api/v2/news-tags
131 | dispatch(entitesActions.NEWS_TAG.load(1)); // GET: api/v2/news-tags/1
132 |
133 | // --- NOTE: HTTP methods for create and update operations can be configured
134 | dispatch(entitesActions.NEWS_TAG.update(1, {name: 'new tag'})); // PATCH: api/v2/news-tags/1
135 | dispatch(entitesActions.NEWS_TAG.create({name: 'new tag'})); // POST: api/v2/news-tags
136 | dispatch(entitesActions.NEWS_TAG.remove(1)); // DELETE: api/v2/news-tags/1
137 |
138 | // --- Silent methods for changing store without sync with backend
139 | dispatch(entitesActions.NEWS_TAG.set({name: 'new tag'})); // set new data
140 | dispatch(entitesActions.NEWS_TAG.reset()); // reset to initial state
141 |
142 | ```
143 |
144 | ### React component example
145 | ```js
146 | import entitesActions from 'your/entities-actions';
147 |
148 | class TagsComponent extends Component {
149 | componentWillMount() {
150 | this.props.loadList();
151 | }
152 |
153 | //..
154 |
155 | componentWillUnmount() {
156 | this.props.resetEntryForEdit();
157 | }
158 |
159 | onTagFormSubmit = ()=> {
160 | const data = this.props.tagForEdit;
161 | if (data.id) {
162 | this.props.updateTag(data.id, data);
163 | } else {
164 | this.props.createTag(data);
165 | }
166 | }
167 |
168 | render() {
169 | return (
170 | this.props.pending ?
171 | :
172 |
173 | {/*...*/}
174 |
175 | );
176 | }
177 | }
178 |
179 | const mapStateToProps = (state) => ({
180 | list: state.api.NEWS_TAGS.data || [],
181 | pending: state.api.NEWS_TAGS._pending,
182 | tagForEdit: state.api.NEWS_TAG_FOR_EDIT.data || {}
183 | });
184 |
185 | const mapDispatchToProps = {
186 | createTag: entitiesActions.NEWS_TAG_FOR_EDIT.create,
187 | updateTag: entitiesActions.NEWS_TAG_FOR_EDIT.update,
188 | resetEntryForEdit: entitiesActions.NEWS_TAG_FOR_EDIT.reset,
189 | loadList: entitiesActions.NEWS_TAGS.load
190 | };
191 |
192 | const TagsContainer = connect(mapStateToProps, mapDispatchToProps)(TagsComponent);
193 |
194 | export {TagsComponent, TagsContainer};
195 | ```
196 |
197 | ## Configuration
198 |
199 | ### EntityApi constructor options
200 |
201 | Name | Type | Default | Description
202 | --- | --- | --- | ---
203 | `entityName` | `String` | *Required.* will be used for naming state and actionTypes.
204 | `endpointUrl` | `String | *Required.* endpointUrl
205 | `reducersBuilderCustom` | `Object`| `reducersBuilderDefault` | Customer can redefine interface of reducers-builder.js
206 | `axiosConfig` | `Object`| `{}` | [axios config](https://github.com/mzabriskie/axios#request-config)
207 | `resourceKey` | `String`| `'data'` | Name of data property key at response object
208 | `idKey` | `String`| `'id'` | Name of id property key at response data object. Required for CRUD reducer extensions
209 | `restHttpMethods` | `Object`| `{create:'post', update:'patch'}` | Customer can change HTTP methods used for create and update actions
210 |
211 |
212 | ## TODO
213 |
214 | Example of generated list reducer (basic CRUD operations)
215 |
216 | ### See also
217 |
218 | [redux-localstorage-adapter](https://www.npmjs.com/package/redux-localstorage-adapter)
219 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-rest-adapter",
3 | "version": "2.0.7",
4 | "description": "REST adapter for redux",
5 | "main": "lib/index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/maksim-chekrishov/redux-rest-adapter"
9 | },
10 | "bugs": {
11 | "url": "https://github.com/maksim-chekrishov/redux-rest-adapter/issues"
12 | },
13 | "keywords": [
14 | "redux",
15 | "rest-api",
16 | "rest",
17 | "rest-client",
18 | "rest-adapter",
19 | "redux-rest"
20 | ],
21 | "scripts": {
22 | "build": "export NODE_ENV=development; yarn && $(npm bin)/babel src -d lib",
23 | "test": "npm run build && jest",
24 | "send-cover": "CODECLIMATE_REPO_TOKEN=b2b59ab7c1c4c064063ea46e2462af844a9c6b000529811379d7b2a666e23285 codeclimate-test-reporter < ./coverage/lcov.info",
25 | "prepub": "rm -rf node_modules && npm t && npm run send-cover"
26 | },
27 | "author": "Maksim Chekrishov (https://github.com/maksim-chekrishov)",
28 | "license": "ISC",
29 | "dependencies": {
30 | "axios": "0.15.2",
31 | "redux-promise-middleware": "4.2.0"
32 | },
33 | "devDependencies": {
34 | "axios-mock-adapter": "^1.7.1",
35 | "babel-cli": "^6.0.0",
36 | "babel-core": "latest",
37 | "babel-eslint": "latest",
38 | "babel-jest": "^17.0.2",
39 | "babel-plugin-transform-object-assign": "^6.22.0",
40 | "babel-polyfill": "^6.20.0",
41 | "babel-preset-es2015": "~6.0.15",
42 | "babel-preset-stage-0": "~6.0.15",
43 | "eslint": "~1.10.3",
44 | "eslint-config-rambler": "0.0.3",
45 | "jest-cli": "17.0.2",
46 | "lodash": "latest",
47 | "redux": "^3.6.0",
48 | "redux-mock-store": "^1.2.1"
49 | },
50 | "jest": {
51 | "collectCoverage": true,
52 | "collectCoverageFrom": [
53 | "lib/*.js"
54 | ],
55 | "setupFiles": [
56 | "tests/config/global-mocks.js"
57 | ]
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/readme-src/dev-tools-1v.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/4396f55c7164519345885018112caf63b73bf3b6/readme-src/dev-tools-1v.jpg
--------------------------------------------------------------------------------
/readme-src/dev-tools.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/4396f55c7164519345885018112caf63b73bf3b6/readme-src/dev-tools.png
--------------------------------------------------------------------------------
/readme-src/readme-1v.md:
--------------------------------------------------------------------------------
1 | # redux-rest-adapter
2 |
3 | redux-rest-adapter is REST adapter for redux based on [redux-api-middleware](https://www.npmjs.com/package/redux-api-middleware)
4 |
5 | [](https://badge.fury.io/js/redux-rest-adapter)
6 |
7 | ##Setup
8 |
9 | ###known-entities-api.js
10 |
11 | ```js
12 | import EntityApi from 'redux-rest-adapter';
13 |
14 | export const KnownEntitiesUrls = {
15 | NEWS_TAGS: 'news-tags',
16 | NEWS_TAG_FOR_EDIT: 'news-tags',
17 | //..
18 | };
19 | export default _.mapValues(KnownEntitiesUrls, (url, name)=> new EntityApi({
20 | entityName: name,
21 | endpointUrl: config.endpointRoot + url,
22 | resourceKey: 'result' // data key at payload, default - 'data'
23 | }));
24 | ```
25 |
26 | ###entities-reducer.js
27 |
28 | ```js
29 | // Ability to extend default api reducers
30 | const entityReducersExtensions = {
31 | NEWS_TAGS: tagsReducer
32 | }
33 |
34 | const entitiesReducers = _.mapValues(knownEntitiesApi, (api, key) => api.configureReducer(entityReducersExtensions[key]));
35 |
36 | export default combineReducers(entitiesReducers);
37 | ```
38 |
39 | ###your-index-reducer.js
40 |
41 | ```js
42 | export default combineReducers({
43 | entities: entitiesReducers
44 | //..
45 | });
46 | ```
47 |
48 | ###configure-store.js
49 |
50 | ```js
51 | import {apiMiddleware} from 'redux-rest-adapter/redux-api-middleware';
52 | //..
53 |
54 |
55 | export default function configureStore(initialState) {
56 | return createStore(
57 | yourIndexReducer,
58 | initialState,
59 | applyMiddleware(apiMiddleware)
60 | );
61 | }
62 | ```
63 |
64 | ###entities-actions.js
65 |
66 | ```js
67 | export default _.mapValues(knownEntitiesApi, entityApi => entityApi.actions);
68 | ```
69 |
70 |
71 | ##Adapter is ready
72 |
73 | 
74 |
75 | ##Usage
76 |
77 | ###tags-container.js
78 | ```js
79 | class TagsComponent extends Component {
80 | componentWillMount() {
81 | this.props.loadList();
82 | }
83 |
84 | //..
85 |
86 | componentWillUnmount() {
87 | this.props.resetEntryForEdit();
88 | }
89 |
90 | onTagFormSubmit = ()=> {
91 | const data = this.props.tagForEdit;
92 | if (data.id) {
93 | this.props.updateTag(data.id, data);
94 | } else {
95 | this.props.createTag(data);
96 | }
97 | }
98 |
99 | render() {
100 | return (
101 | this.props.pending ?
102 | :
103 |
104 | {/*...*/}
105 |
106 | );
107 | }
108 | }
109 |
110 | const mapStateToProps = (state) => ({
111 | list: state.entities.NEWS_TAGS.data || [],
112 | pending: state.entities.NEWS_TAGS.pending,
113 | tagForEdit: state.entities.NEWS_TAG_FOR_EDIT.data || {}
114 | });
115 |
116 | const mapDispatchToProps = {
117 | createTag: entitiesActions.NEWS_TAG_FOR_EDIT.create,
118 | updateTag: entitiesActions.NEWS_TAG_FOR_EDIT.update,
119 | removeTag: entitiesActions.NEWS_TAG_FOR_EDIT.remove,
120 | setTagForEdit: entitiesActions.NEWS_TAG_FOR_EDIT.set,
121 | resetEntryForEdit: entitiesActions.NEWS_TAG_FOR_EDIT.reset,
122 | loadList: entitiesActions.NEWS_TAGS.load
123 | };
124 |
125 | const TagsContainer = connect(mapStateToProps, mapDispatchToProps)(TagsComponent);
126 |
127 | export {TagsComponent, TagsContainer};
128 | ```
129 |
130 | ##TODO
131 |
132 | Example of generated list reducer (basic CRUD operations)
133 |
134 | ##Changelog
135 |
136 | - v1.1.6 - Add source code to package. Workaround for issue with [babel and redux-api-middleware@1.1.2](https://github.com/agraboso/redux-api-middleware/issues/83)
137 |
138 |
139 |
140 | ###See also
141 |
142 | [redux-localstorage-adapter](https://www.npmjs.com/package/redux-localstorage-adapter)
143 |
--------------------------------------------------------------------------------
/src/entity-api.js:
--------------------------------------------------------------------------------
1 | import reducersBuilderDefault from './reducers-builder';
2 | import axios from 'axios';
3 |
4 | export const RestMethods = {
5 | LOAD: 'load',
6 | CREATE: 'create',
7 | UPDATE: 'update',
8 | REMOVE: 'remove'
9 | };
10 |
11 | export const RestHttpMethodsDefault = {
12 | 'create': 'post',
13 | 'update': 'patch'
14 | };
15 |
16 | export const SilentMethods = {
17 | SET: 'set',
18 | RESET: 'reset'
19 | };
20 |
21 | export const RequestStatusesDefault = {
22 | REQUEST: 'REQUEST',
23 | SUCCESS: 'SUCCESS',
24 | FAIL: 'FAIL'
25 | };
26 |
27 |
28 | export default class EntityApi {
29 |
30 | _resourceKey = 'data'
31 |
32 | _requestStatuses = [RequestStatusesDefault.REQUEST, RequestStatusesDefault.SUCCESS, RequestStatusesDefault.FAIL];
33 |
34 | _reducersBuilder = reducersBuilderDefault;
35 |
36 | _restHttpMethods = RestHttpMethodsDefault;
37 |
38 | _axiosConfig = {};
39 |
40 | _idKey = 'id'
41 |
42 | /**
43 | * Constructor
44 | *
45 | * @param {Object} options
46 | * @param {string} options.entityName - will be used for naming actionTypes
47 | * @param {string} options.endpointUrl
48 | * @param {Object} [options.reducersBuilderCustom = reducersBuilderDefault]
49 | * @param {Object} [options.axiosConfig = _axiosConfig] - options for redux-api-middleware
50 | * @param {string} [options.resourceKey = _resourceKey] - payload resource key (entity data key)
51 | * @param {Object} [options.restHttpMethods = RestHttpMethodsDefault] - Customer can change http methods for create and update actions
52 | * @param {string} [options.idKey = _idKey] - payload id key (entity id key)
53 | */
54 | constructor(options) {
55 | if (!options || !options.entityName || !options.endpointUrl) {
56 | throw new Error('entityName and endpointUrl are required');
57 | }
58 |
59 | this._entityName = options.entityName;
60 | this._endpointUrl = options.endpointUrl;
61 |
62 | // Options with default values
63 | this._reducersBuilder = options.reducersBuilderCustom || reducersBuilderDefault;
64 | this._resourceKey = options.resourceKey || this._resourceKey;
65 | this._restHttpMethods = options.restHttpMethods || this._restHttpMethods;
66 | this._idKey = options.idKey || this._idKey;
67 | this._axiosConfig = options.axiosConfig || this._axiosConfig;
68 | }
69 |
70 | /**
71 | * Provide actions for api instance
72 | *
73 | * @returns {Object}
74 | */
75 | get actions() {
76 | const _this = this;
77 | const actions = {};
78 |
79 | const allMethods = {...RestMethods, ...SilentMethods};
80 |
81 | for (let key in allMethods) {
82 | if (allMethods.hasOwnProperty(key)) {
83 | const methodName = allMethods[key];
84 | actions[methodName] = _this[methodName].bind(_this);
85 | }
86 | }
87 |
88 | return actions;
89 | }
90 |
91 | get actionsTypes() {
92 | const _this = this;
93 |
94 | // Private property for lazy getter
95 | if (!this._actionsTypes) {
96 | this._actionsTypes = {};
97 |
98 | for (let key in RestMethods) {
99 | if (RestMethods.hasOwnProperty(key)) {
100 | const methodName = RestMethods[key];
101 | const requestStatusActionsOptions = _this.generateRequestActionsOptions(methodName);
102 | const res = {};
103 |
104 | this._requestStatuses.map((status, i)=> {
105 | res[status] = requestStatusActionsOptions[i].type;
106 | });
107 |
108 | _this._actionsTypes[key] = res;
109 | }
110 | }
111 |
112 | for (let key in SilentMethods) {
113 | if (SilentMethods.hasOwnProperty(key)) {
114 | _this.actionsTypes[key] = _this._getActionTypeForMethod(SilentMethods[key]);
115 | }
116 | }
117 | }
118 |
119 | return this._actionsTypes;
120 | }
121 |
122 | _getActionTypeForMethod(apiMethodName) {
123 | return `${this._entityName}_${apiMethodName.toUpperCase()}`;
124 | }
125 |
126 | generateRequestActionsOptions(methodName) {
127 | return this._requestStatuses.map(eventName => ({
128 | type: `${this._entityName}_${methodName.toUpperCase()}_${eventName}`
129 | }));
130 | }
131 |
132 | /**
133 | * Generate reducer for current api instance
134 | * @param {Function} [reducerExtension]
135 | * @param {Object} [initialState = {}]
136 | */
137 | configureReducer(reducerExtension, initialState = {}) {
138 | return this._reducersBuilder.build(this.actionsTypes, reducerExtension, this._resourceKey, initialState);
139 | }
140 |
141 | /**
142 | * Parse options for load method
143 | *
144 | * @param options
145 | * @return {{queryString: string, params: {}}}
146 | * @private
147 | */
148 | _parseLoadOptions(options) {
149 | let queryString = '';
150 | let params = {};
151 |
152 | const paramsType = typeof options;
153 |
154 | switch (paramsType) {
155 | case 'string':
156 | case 'number':
157 | if (options + '') {
158 | queryString = `/${options}`;
159 | }
160 | break;
161 |
162 | case 'object':
163 | const hasPath = options.hasOwnProperty('path');
164 | const hasParams = options.hasOwnProperty('params');
165 |
166 | if (!hasPath && !hasParams) {
167 | params = options;
168 | break;
169 | }
170 | if (hasPath) {
171 | queryString = `/${options.path}`;
172 | }
173 |
174 | if (hasParams) {
175 | params = options.params;
176 | }
177 | break;
178 |
179 | default:
180 | break;
181 | }
182 |
183 | return {queryString, params};
184 | }
185 |
186 |
187 | /**
188 | * Load entity
189 | *
190 | * @param {Object | Number | string} options
191 | * @returns {Object}
192 | *
193 | * @example
194 | *
195 | * entityName.load(1); // get: /entity-name/1
196 | * entityName.load('sub/path'); // get: /entity-name/sub/path
197 | * entityName.load({mode:'short', 'page[limit]':10}}); // get: /entity-name?mode=short&page[limit]=10
198 | * entityName.load({path:'11', params:{mode:'short'}}); // get: /entity-name/11?mode=short
199 | */
200 | [RestMethods.LOAD](options) {
201 | const {queryString, params} = this._parseLoadOptions(options);
202 |
203 | const config = Object.assign({}, this._axiosConfig, {params: params});
204 |
205 | return {
206 | type: this._getActionTypeForMethod(RestMethods.LOAD),
207 | payload: axios.get(`${this._endpointUrl}${queryString}`, config)
208 | .then(res => res.data)
209 | };
210 | }
211 |
212 | [RestMethods.CREATE](entity) {
213 | const createMethodName = this._restHttpMethods.create;
214 | const data = {[this._resourceKey]: entity};
215 |
216 | return {
217 | type: this._getActionTypeForMethod(RestMethods.CREATE),
218 | payload: axios[createMethodName](this._endpointUrl, data, this._axiosConfig)
219 | .then(res => res.data)
220 | };
221 | }
222 |
223 | [RestMethods.UPDATE](id, entity) {
224 | const updateMethodName = this._restHttpMethods.update;
225 | const data = {[this._resourceKey]: entity};
226 |
227 | return {
228 | type: this._getActionTypeForMethod(RestMethods.UPDATE),
229 | payload: axios[updateMethodName](`${this._endpointUrl}/${id}`, data, this._axiosConfig)
230 | .then(res => res.data)
231 | };
232 | }
233 |
234 | /**
235 | * Remove entity by id or entity object
236 | *
237 | * @param {string} [id='']
238 | * @returns {Object}
239 | */
240 | [RestMethods.REMOVE](id = '') {
241 | return {
242 | type: this._getActionTypeForMethod(RestMethods.REMOVE),
243 | payload: axios.delete(`${this._endpointUrl}/${id}`, this._axiosConfig).then(res => res.data),
244 | meta: {[this._idKey]: id}
245 | };
246 | }
247 |
248 | /**
249 | * Provide ability to set data into entity storage
250 | * without server synchronization
251 | *
252 | * @param data
253 | * @returns {{type: string, payload: {result: *}}}
254 | * @constructor
255 | */
256 | [SilentMethods.SET](resource) {
257 | return {
258 | type: this._getActionTypeForMethod(SilentMethods.SET),
259 | payload: {
260 | [this._resourceKey]: resource
261 | }
262 | };
263 | }
264 |
265 | /**
266 | * Provide ability to reset resource in entity storage
267 | * without server synchronization
268 | *
269 | * @returns {{type: string, payload: {result: *}}}
270 | * @constructor
271 | */
272 | [SilentMethods.RESET]() {
273 | return {
274 | type: this._getActionTypeForMethod(SilentMethods.RESET)
275 | };
276 | }
277 | }
278 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 12.12.16.
3 | */
4 | import EntityApi, {RequestStatusesDefault} from './entity-api';
5 | import ReducersBuilder from './reducers-builder';
6 | import reduxPromiseMiddleware from 'redux-promise-middleware';
7 |
8 | export {promiseMiddleware, ReducersBuilder, EntityApi};
9 |
10 | export default EntityApi;
11 |
12 | /**
13 | *
14 | * @param {Object} options - reduxPromiseMiddleware options
15 | */
16 | function promiseMiddleware(options) {
17 | const optionsDefault = {
18 | promiseTypeSuffixes: [RequestStatusesDefault.REQUEST, RequestStatusesDefault.SUCCESS, RequestStatusesDefault.FAIL]
19 | };
20 |
21 | return reduxPromiseMiddleware({...optionsDefault, ...options})
22 | }
23 |
--------------------------------------------------------------------------------
/src/reducers-builder.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 18.12.16.
3 | */
4 |
5 | import {containsString, hasCyclicReferences} from './utils';
6 |
7 | class ReducersBuilder {
8 | /**
9 | * Configure default list reducer extension with basic reactions on CRUD actions
10 | *
11 | * @param {Object} actionsTypes - at least one of actions types is required
12 | * @param {string || Array.} [actionsTypes.createSuccess]
13 | * @param {string || Array.} [actionsTypes.updateSuccess]
14 | * @param {string || Array.} [actionsTypes.deleteSuccess]
15 | * @param {string} [resourceKey= 'data'] - response resource prop name
16 | * @param {string} [idKey='id'] - resource id prop name
17 | * @returns {Function} reducerExtension
18 | */
19 | static buildCRUDExtensionsForList({createSuccess, updateSuccess, deleteSuccess}, resourceKey, idKey) {
20 | resourceKey = resourceKey || 'data';
21 | idKey = idKey || 'id';
22 |
23 | const opt = arguments[0];
24 |
25 | for (let key in opt) {
26 | if (opt.hasOwnProperty(key)) {
27 | // convert to array
28 | opt[key] = [].concat(opt[key]);
29 | }
30 | }
31 |
32 | return function(state = {}, action = {}) {
33 | let id;
34 | let clone;
35 | let result;
36 | const actionType = action.type;
37 |
38 | if (deleteSuccess && deleteSuccess.indexOf(actionType) !== -1) {
39 | /**
40 | * after success we need remove item from list
41 | */
42 | id = action.meta[idKey];
43 | clone = Object.assign({}, state);
44 | clone[resourceKey] = clone[resourceKey] ? clone[resourceKey].filter(item=> item[idKey] !== id) : [];
45 | return clone;
46 | } else if (updateSuccess && updateSuccess.indexOf(actionType) !== -1) {
47 | /**
48 | * after successfully update we need update item at the list
49 | */
50 | id = action.payload[resourceKey][idKey];
51 | result = action.payload[resourceKey];
52 | clone = Object.assign({}, state);
53 | clone[resourceKey] = clone[resourceKey] ? clone[resourceKey].map((item)=>(item[idKey] !== id ? item : result)) : [];
54 | return clone;
55 | } else if (createSuccess && createSuccess.indexOf(actionType) !== -1) {
56 | /**
57 | * after creation new item we need add it to the list
58 | */
59 | result = action.payload[resourceKey];
60 | clone = Object.assign({}, state);
61 | clone[resourceKey] = clone[resourceKey] ? clone[resourceKey].concat([result]) : [];
62 | return clone;
63 | }
64 | return state;
65 | };
66 | }
67 |
68 | /**
69 | * Generate reducers for supplied entity api
70 | *
71 | * @param {Object} actionsTypesTree
72 | * @param {Function | Array.} [reducerExtensions]
73 | * @param {string} [resourceKey= 'data'] - response resource prop name
74 | * @param {Object} [initialState= {}]
75 | * @param {String} [operationsFlags="CRUD"]
76 | * @returns {Function} reducer
77 | */
78 | static build(actionsTypesTree, reducerExtensions, resourceKey = 'data', initialState = {}, operationsFlags = 'CRUDS') {
79 | const normalizedFlags = operationsFlags.toLowerCase();
80 |
81 | const reducerParts = [];
82 |
83 |
84 | // Extension has top level priority to provide ability override default behaviour
85 | if (reducerExtensions) {
86 | Array.isArray(reducerExtensions)
87 | ? reducerParts.push(...reducerExtensions)
88 | : reducerParts.push(reducerExtensions);
89 | }
90 |
91 | containsString(normalizedFlags, 'c') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.CREATE));
92 | containsString(normalizedFlags, 'r') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.LOAD));
93 | containsString(normalizedFlags, 'u') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.UPDATE));
94 | containsString(normalizedFlags, 'd') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.REMOVE));
95 |
96 | // Silent actions
97 | containsString(normalizedFlags, 's') && reducerParts.push(this._buildSilentActionsReducer(actionsTypesTree, resourceKey, initialState));
98 |
99 | return function(state = initialState, action = {}) {
100 | let _state = state;
101 |
102 | reducerParts.forEach((reduce)=> {
103 | _state = reduce(_state, action);
104 | });
105 |
106 | return _state;
107 | };
108 | }
109 |
110 | static _buildSilentActionsReducer(actionsTypesTree, resourceKey, initialState) {
111 | return (state, action) => {
112 | switch (action.type) {
113 |
114 | case actionsTypesTree.RESET:
115 | return initialState;
116 |
117 |
118 | case actionsTypesTree.SET:
119 | return Object.assign({}, state, {
120 | [resourceKey]: Array.isArray(state[resourceKey])
121 | ? [].concat(action.payload[resourceKey])
122 | : Object.assign({}, state[resourceKey], action.payload[resourceKey])
123 | });
124 |
125 | default:
126 | return state;
127 | }
128 | }
129 | }
130 |
131 | static _buildReducerForOperation(operationActionsTypes) {
132 | return (state, action) => {
133 | const actionType = action.type;
134 |
135 | if (operationActionsTypes.REQUEST === actionType) {
136 | return this._reduceRequest(state, action);
137 | } else if (operationActionsTypes.SUCCESS === actionType) {
138 | return this._reduceSuccess(state, action);
139 | } else if (operationActionsTypes.FAIL === actionType) {
140 | return this._reduceFail(state, action);
141 | }
142 | return state;
143 | };
144 | }
145 |
146 |
147 | //
148 | // Common reducer functions
149 | //
150 |
151 | static _reduceRequest(state, action) {
152 | return Object.assign({}, state, {
153 | _pending: true,
154 | _actionMeta: action.meta,
155 | _error: !!action.error,
156 | ...action.payload
157 | });
158 | }
159 |
160 | static _reduceSuccess(state, action) {
161 | return Object.assign({}, state, {
162 | _pending: false,
163 | _actionMeta: action.meta,
164 | _error: false,
165 | ...action.payload
166 | });
167 | }
168 |
169 | static _reduceFail(state, action) {
170 | // console.log('cicle', action.payload.response.request)
171 | let payload = action.payload;
172 |
173 | if (hasCyclicReferences(payload)) {
174 | if (payload.response) {
175 | // The request was made, but the server responded with a status code
176 | // that falls out of the range of 2xx
177 | const {data, status, headers} = payload.response;
178 | payload = {data, status, headers};
179 | } else {
180 | // Something happened in setting up the request that triggered an Error
181 | payload = {message: payload.message};
182 | }
183 | }
184 |
185 | return Object.assign({}, state, {
186 | _pending: false,
187 | _actionMeta: action.meta,
188 | _error: true,
189 | ...payload
190 | });
191 | }
192 | }
193 |
194 | export default ReducersBuilder;
195 |
196 |
197 |
198 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 20.12.16.
3 | */
4 |
5 | export function containsString(str, value) {
6 | return str.indexOf(value) !== -1;
7 | }
8 |
9 | export function hasCyclicReferences(obj) {
10 | var seenObjects = [];
11 |
12 | function detect(obj) {
13 | if (obj && typeof obj === 'object') {
14 | if (seenObjects.indexOf(obj) !== -1) {
15 | return true;
16 | }
17 | seenObjects.push(obj);
18 | for (var key in obj) {
19 | if (obj.hasOwnProperty && obj.hasOwnProperty(key) && detect(obj[key])) {
20 | return true;
21 | }
22 | }
23 | }
24 | return false;
25 | }
26 |
27 | return detect(obj);
28 | }
29 |
--------------------------------------------------------------------------------
/tests/config/global-mocks.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 21.12.16.
3 | */
4 | import axios from 'axios';
5 | import AxiosMockAdapter from 'axios-mock-adapter';
6 | import configureMockStore from 'redux-mock-store';
7 | import {promiseMiddleware} from '../../lib'
8 |
9 | global.mockAxios = new AxiosMockAdapter(axios);
10 |
11 | global.mockStore = configureMockStore([promiseMiddleware()]);
12 |
--------------------------------------------------------------------------------
/tests/integration/api-actions.test.js:
--------------------------------------------------------------------------------
1 | import EntityApi from '../../lib';
2 |
3 | describe('EntityApi', () => {
4 | const resourceKey = 'testResKey';
5 | let store,
6 | apiInstance,
7 | ActionsTypes,
8 | expectedResource;
9 |
10 | beforeEach(() => {
11 | store = mockStore({entities: {TEST: {}}});
12 | //store.dispatch = jest.fn(store.dispatch);
13 |
14 | apiInstance = new EntityApi({
15 | entityName: 'TEST',
16 | endpointUrl: '/test'
17 | });
18 |
19 | expectedResource = {id: 1, title: 'TITLE'};
20 | ActionsTypes = apiInstance.actionsTypes;
21 | })
22 |
23 | afterEach(()=> {
24 | mockAxios.reset();
25 | })
26 |
27 | // --------------------- LOAD --------------------------
28 |
29 | it('should be able to load resource', () => {
30 | mockAxios.onGet('/test').reply(200, {[resourceKey]: expectedResource});
31 |
32 | const expectedActions = [
33 | {type: ActionsTypes.LOAD.REQUEST},
34 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}}
35 | ];
36 |
37 | return store.dispatch(apiInstance.actions.load()).then(res => {
38 | return expect(store.getActions()).toEqual(expectedActions);
39 | });
40 | })
41 |
42 | it('should be able to load resource by id', () => {
43 | const expectedActions = [
44 | {type: ActionsTypes.LOAD.REQUEST},
45 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}}
46 | ];
47 |
48 | mockAxios.onGet('/test/1').reply(200, {[resourceKey]: expectedResource});
49 |
50 | return store.dispatch(apiInstance.actions.load(1)).then(res => {
51 | return expect(store.getActions()).toEqual(expectedActions);
52 | });
53 | })
54 |
55 | // todo: need research axions mock doesn't work with params
56 | xit('should be able to load resource by id with params', () => {
57 | const expectedActions = [
58 | {type: ActionsTypes.LOAD.REQUEST},
59 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}}
60 | ];
61 |
62 | mockAxios.onGet('/test/1?param1=1').reply(200, {[resourceKey]: expectedResource});
63 | // mockAxios.onGet().reply(config=> {
64 | // console.log(config);
65 | // });
66 |
67 | return store.dispatch(apiInstance.actions.load({id: 1, param1: 1})).then(() => {
68 | return expect(store.getActions()).toEqual(expectedActions);
69 | });
70 | })
71 |
72 | // todo: need research axions mock doesn't work with params
73 | xit('should be able to load resource with complex param', () => {
74 | const expectedActions = [
75 | {type: ActionsTypes.LOAD.REQUEST},
76 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}}
77 | ];
78 |
79 | mockAxios.onGet('/test?filter[order]=1').reply(200, {[resourceKey]: expectedResource});
80 |
81 | return store.dispatch(apiInstance.actions.load({'filter[order]': '1'})).then(() => {
82 | return expect(store.getActions()).toEqual(expectedActions);
83 | });
84 | })
85 |
86 | it('should be able to process load fail', () => {
87 | mockAxios.onGet('/test/2').reply(500, {});
88 |
89 | return store.dispatch(apiInstance.actions.load(2)).catch(res => {
90 | let lastAction = store.getActions();
91 | lastAction = lastAction[lastAction.length - 1];
92 |
93 | const hasError = !!lastAction.error;
94 | const isFailed = lastAction.type === ActionsTypes.LOAD.FAIL;
95 |
96 | return expect(isFailed && hasError && res.response.status == 500).toEqual(true);
97 | });
98 | })
99 |
100 |
101 | // --------------------- CREATE --------------------------
102 |
103 | it('should be able to create resource', () => {
104 | const expectedActions = [
105 | {type: ActionsTypes.CREATE.REQUEST},
106 | {type: ActionsTypes.CREATE.SUCCESS, payload: {[resourceKey]: expectedResource}}
107 | ];
108 |
109 | mockAxios.onPost('/test').reply(200, {[resourceKey]: expectedResource});
110 |
111 | return store.dispatch(apiInstance.actions.create(expectedResource)).catch(res => {
112 | const actions = store.getActions();
113 |
114 | return expect(actions).toEqual(expectedActions);
115 | });
116 | })
117 |
118 | it('should be able to process create fail', () => {
119 | mockAxios.onPost('/test').reply(500, {});
120 |
121 | return store.dispatch(apiInstance.actions.create()).catch(res => {
122 | let lastAction = store.getActions();
123 | lastAction = lastAction[lastAction.length - 1];
124 |
125 | return expect(!!lastAction.error &&
126 | lastAction.type === ActionsTypes.CREATE.FAIL &&
127 | res.response.status == 500
128 | ).toEqual(true);
129 | });
130 | })
131 |
132 |
133 | // --------------------- UPDATE --------------------------
134 |
135 | it('should be able to update resource', () => {
136 | mockAxios.onPatch('/test/1').reply(200, {[resourceKey]: expectedResource});
137 |
138 | return store.dispatch(apiInstance.actions.update(1)).then(res => {
139 | const actions = store.getActions();
140 | const lastAction = actions[actions.length - 1];
141 |
142 | return expect(lastAction.type === ActionsTypes.UPDATE.SUCCESS).toEqual(true);
143 | });
144 | })
145 |
146 | it('should be able to process create fail', () => {
147 | mockAxios.onPatch('/test/44').reply(500, {});
148 |
149 | return store.dispatch(apiInstance.actions.update(44)).catch(res => {
150 | let lastAction = store.getActions();
151 | lastAction = lastAction[lastAction.length - 1];
152 |
153 | return expect(!!lastAction.error &&
154 | lastAction.type === ActionsTypes.UPDATE.FAIL &&
155 | res.response.status == 500
156 | ).toEqual(true);
157 | });
158 | })
159 |
160 | // --------------------- DELETE --------------------------
161 |
162 | it('should be able remove source', () => {
163 | mockAxios.onAny('/test/1').reply(200, {});
164 |
165 | return store.dispatch(apiInstance.actions.remove(1)).then(res => {
166 | const actions = store.getActions();
167 | const lastAction = actions[actions.length - 1];
168 |
169 | return expect(lastAction.type).toEqual(ActionsTypes.REMOVE.SUCCESS);
170 | });
171 | })
172 |
173 | it('should be able to process remove fail', () => {
174 | mockAxios.onAny('/test/44').reply(500, {});
175 |
176 | return store.dispatch(apiInstance.actions.remove(44)).catch(res => {
177 | let lastAction = store.getActions();
178 | lastAction = lastAction[lastAction.length - 1];
179 |
180 | return expect(!!lastAction.error &&
181 | lastAction.type === ActionsTypes.REMOVE.FAIL &&
182 | res.response.status == 500
183 | ).toEqual(true);
184 | });
185 | })
186 |
187 |
188 | describe('should provide ability to override http method for ', ()=> {
189 |
190 | beforeEach(()=> {
191 | apiInstance = new EntityApi({
192 | entityName: 'TEST',
193 | endpointUrl: '/test',
194 | restHttpMethods: {create: 'put', update: 'post'}
195 | });
196 | })
197 |
198 | it('update', () => {
199 | mockAxios.onPost('/test/1').reply(200, {[resourceKey]: expectedResource});
200 |
201 | return store.dispatch(apiInstance.actions.update(1)).then(res => {
202 | const actions = store.getActions();
203 | const lastAction = actions[actions.length - 1];
204 |
205 | return expect(lastAction.type).toEqual(ActionsTypes.UPDATE.SUCCESS);
206 | });
207 | })
208 |
209 | it('create', () => {
210 | const expectedActions = [
211 | {type: ActionsTypes.CREATE.REQUEST},
212 | {type: ActionsTypes.CREATE.SUCCESS, payload: {[resourceKey]: expectedResource}}
213 | ];
214 |
215 | mockAxios.onPut('/test').reply(200, {[resourceKey]: expectedResource});
216 |
217 | return store.dispatch(apiInstance.actions.create(expectedResource)).catch(res => {
218 | const actions = store.getActions();
219 |
220 | return expect(actions).toEqual(expectedActions);
221 | });
222 | })
223 |
224 | });
225 |
226 | describe('should provide ability to use axios config for', ()=> {
227 | const axiosConfig = {timeout: 99};
228 |
229 | beforeEach(()=> {
230 | apiInstance = new EntityApi({
231 | entityName: 'TEST',
232 | endpointUrl: '/test',
233 | axiosConfig
234 | });
235 |
236 | mockAxios.onAny().reply(config=>[200, config]);
237 | })
238 |
239 | it('load', () => {
240 | return store.dispatch(apiInstance.actions.load(1)).then(config => {
241 | return expect(config.value.timeout).toEqual(axiosConfig.timeout);
242 | });
243 | });
244 |
245 | it('update', () => {
246 | return store.dispatch(apiInstance.actions.update(1)).then(config => {
247 | return expect(config.value.timeout).toEqual(axiosConfig.timeout);
248 | });
249 | });
250 |
251 | it('remove', () => {
252 | return store.dispatch(apiInstance.actions.remove(1)).then(config => {
253 | return expect(config.value.timeout).toEqual(axiosConfig.timeout);
254 | });
255 | });
256 |
257 | it('create', () => {
258 | return store.dispatch(apiInstance.actions.create(1)).then(config => {
259 | return expect(config.value.timeout).toEqual(axiosConfig.timeout);
260 | });
261 | });
262 | });
263 | })
264 |
--------------------------------------------------------------------------------
/tests/integration/demo.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 10.02.17.
3 | */
4 | import EntityApi, {promiseMiddleware} from '../../lib';
5 | import {createStore, applyMiddleware, combineReducers} from 'redux';
6 |
7 | describe('Demo', () => {
8 |
9 | afterEach(()=> {
10 | mockAxios.reset();
11 | })
12 |
13 | it('should work', () => {
14 | const expectedResource = {id: 1, title: 'TITLE'};
15 |
16 | mockAxios.onGet('api/v2/tags/1').reply(200, {data: expectedResource});
17 |
18 | const tagsApi = new EntityApi({
19 | entityName: 'TAGS',
20 | endpointUrl: 'api/v2/tags'
21 | });
22 |
23 | const apiReducers = combineReducers({
24 | TAGS: tagsApi.configureReducer()
25 | });
26 |
27 | const store = createStore(
28 | combineReducers({
29 | api: apiReducers
30 | }),
31 | {},
32 | applyMiddleware(promiseMiddleware())
33 | );
34 |
35 | return store.dispatch(tagsApi.actions.load(1)).then(()=> {
36 | return expect(store.getState().api.TAGS.data).toEqual(expectedResource);
37 | });
38 | })
39 | });
40 |
--------------------------------------------------------------------------------
/tests/unit/api-reducers.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 03.10.16.
3 | */
4 | import {EntityApi, ReducersBuilder} from '../../lib';
5 | import _ from 'lodash';
6 |
7 | describe('EntityApi', ()=> {
8 | let entityApiInstance = new EntityApi({
9 | entityName: 'TEST',
10 | endpointUrl: 'test'
11 | });
12 |
13 | const resourceKey = 'resourceKey';
14 | const idKey = 'idKey'
15 |
16 | it('should be able to reset item', ()=> {
17 | const initialState = {1: 1};
18 | const reducer = entityApiInstance.configureReducer(null, initialState);
19 | const stateBeforeAction = {2: 2};
20 | const stateAfterAction = reducer(stateBeforeAction, entityApiInstance.reset());
21 |
22 | expect(stateAfterAction).toEqual(initialState);
23 | });
24 |
25 | it('should be able to set item', ()=> {
26 | const resourceToSet = {1: 1};
27 | const reducer = entityApiInstance.configureReducer(null, resourceToSet);
28 | const stateBeforeAction = {data: 1};
29 | const stateAfterAction = reducer(stateBeforeAction, entityApiInstance.set(resourceToSet));
30 |
31 | expect(stateAfterAction.data).toEqual(resourceToSet);
32 | });
33 |
34 | describe('CRUD list extended reducer', ()=> {
35 | const {CreatedActionType, UpdatedActionType, DeletedActionType} = {
36 | CreatedActionType: 'CREATE_SUCCESS',
37 | UpdatedActionType: 'UPDATE_SUCCESS',
38 | DeletedActionType: ['DELETE_SUCCESS', 'ANOTHER_DELETE_SUCCESS']
39 | };
40 |
41 | const crudReducerExtension = ReducersBuilder.buildCRUDExtensionsForList({
42 | createSuccess: CreatedActionType,
43 | updateSuccess: UpdatedActionType,
44 | deleteSuccess: DeletedActionType
45 | }, resourceKey, idKey);
46 |
47 | const crudReducer = entityApiInstance.configureReducer(crudReducerExtension);
48 |
49 | let itemToRemove;
50 | let existedItem;
51 | let itemToCreate;
52 | let stateBeforeAction;
53 |
54 | beforeEach(()=> {
55 | itemToRemove = {[idKey]: 1};
56 | existedItem = {[idKey]: 2};
57 | itemToCreate = {[idKey]: 3};
58 |
59 | stateBeforeAction = {[resourceKey]: [itemToRemove, existedItem]};
60 | });
61 |
62 | it('should be able to remove item from list', ()=> {
63 | const action = {
64 | type: DeletedActionType[0],
65 | meta: itemToRemove
66 | };
67 |
68 | const stateAfterAction = crudReducer(stateBeforeAction, action);
69 |
70 | expect(stateAfterAction[resourceKey].indexOf(itemToRemove)).toEqual(-1);
71 | expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length - 1);
72 | });
73 |
74 | it('should be able reduce several action types to one state', ()=> {
75 | const action = {
76 | type: DeletedActionType[1],
77 | meta: itemToRemove
78 | };
79 |
80 | const stateAfterAction = crudReducer(stateBeforeAction, action);
81 |
82 | expect(stateAfterAction[resourceKey].indexOf(itemToRemove)).toEqual(-1);
83 | //expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length - 1);
84 | });
85 |
86 | it('should be able to update item at the list', ()=> {
87 | const updatedTag = {[idKey]: itemToRemove[idKey], name: 'updatedName'};
88 |
89 | const action = {
90 | type: UpdatedActionType,
91 | payload: {[resourceKey]: updatedTag}
92 | };
93 |
94 | const stateAfterAction = crudReducer(stateBeforeAction, action);
95 | const tagAfterAction = _.find(stateAfterAction[resourceKey], item=>item[idKey] === updatedTag[idKey]);
96 |
97 | expect(tagAfterAction.name).toEqual(updatedTag.name);
98 | expect(itemToRemove).not.toEqual(updatedTag.name);
99 | expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length);
100 | });
101 |
102 | it('should be able to add new item to the list', ()=> {
103 | const action = {
104 | type: CreatedActionType,
105 | payload: {[resourceKey]: itemToCreate}
106 | };
107 |
108 | expect(stateBeforeAction[resourceKey].some(item=> item[idKey] === itemToCreate[idKey])).toBeFalsy();
109 |
110 | const stateAfterAction = crudReducer(stateBeforeAction, action);
111 | const tagAfterAction = stateAfterAction[resourceKey].filter(item=>item[idKey] === itemToCreate[idKey]);
112 |
113 | expect(tagAfterAction).toBeDefined();
114 | expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length + 1);
115 | });
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/tests/unit/entitity-api-instance.test.js:
--------------------------------------------------------------------------------
1 | import EntityApi from '../../lib';
2 |
3 | describe('EntityApi', ()=> {
4 | let apiInstance;
5 |
6 | beforeEach(()=> {
7 | apiInstance = new EntityApi({
8 | entityName: 'TEST',
9 | endpointUrl: 'test'
10 | });
11 | });
12 |
13 | it('should be able to create instance', ()=> {
14 | expect(apiInstance).toBeDefined();
15 | });
16 |
17 | it('constructor should trow error without required params', ()=> {
18 | expect(()=> {
19 | new EntityApi();
20 | }).toThrow('entityName and endpointUrl are required');
21 | });
22 |
23 | it('should be able to configure reducer', ()=> {
24 | expect(typeof apiInstance.configureReducer() === 'function').toBeTruthy();
25 | });
26 |
27 | describe('should be able to parse load options when consumer use', ()=> {
28 |
29 | it('empty string', ()=> {
30 | const parsed = apiInstance._parseLoadOptions('');
31 | expect(parsed.queryString).toEqual('');
32 | expect(parsed.params).toEqual({});
33 | });
34 |
35 | it('not empty string', ()=> {
36 | const parsed = apiInstance._parseLoadOptions('1');
37 | expect(parsed.queryString).toEqual('/1');
38 | expect(parsed.params).toEqual({});
39 | });
40 |
41 | it('number', ()=> {
42 | const parsed = apiInstance._parseLoadOptions(1);
43 | expect(parsed.queryString).toEqual('/1');
44 | expect(parsed.params).toEqual({});
45 | });
46 |
47 | it('object without params and path', ()=> {
48 | const parsed = apiInstance._parseLoadOptions({id: 1});
49 | expect(parsed.queryString).toEqual('');
50 | expect(parsed.params).toEqual({id: 1});
51 | });
52 |
53 | it('object with params only', ()=> {
54 | const parsed = apiInstance._parseLoadOptions({params: {id: 1}});
55 | expect(parsed.queryString).toEqual('');
56 | expect(parsed.params).toEqual({id: 1});
57 | });
58 |
59 | it('object with path only', ()=> {
60 | const parsed = apiInstance._parseLoadOptions({path: '1'});
61 | expect(parsed.queryString).toEqual('/1');
62 | expect(parsed.params).toEqual({});
63 | });
64 |
65 | it('object with path and params', ()=> {
66 | const options = {path: 'path/1', params: {id: 1, mode: 'short'}};
67 | const parsed = apiInstance._parseLoadOptions(options);
68 | expect(parsed.queryString).toEqual(`/${options.path}`);
69 | expect(parsed.params).toEqual(options.params);
70 | });
71 |
72 |
73 | });
74 |
75 |
76 | });
77 |
--------------------------------------------------------------------------------
/tests/unit/utils.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by m.chekryshov on 03.03.17.
3 | */
4 | import {hasCyclicReferences, containsString} from '../../lib/utils';
5 |
6 | describe('hasCyclicReferences method', ()=> {
7 | it('should be able find cycle', ()=> {
8 | const c = {a: 1};
9 | c.c = c;
10 |
11 | expect(hasCyclicReferences(c)).toEqual(true);
12 | });
13 |
14 | it('should find return tru for cycles only', ()=> {
15 | const c = {a: 1};
16 |
17 | expect(hasCyclicReferences(c)).toEqual(false);
18 | });
19 | });
20 |
21 | describe('containsString method', ()=> {
22 | it('should work', ()=> {
23 | const sub = '11';
24 | const c = 'asasassasas'
25 |
26 | expect(containsString(c + sub, sub)).toEqual(true);
27 | expect(containsString(c, sub)).toEqual(false);
28 | });
29 | });
30 |
--------------------------------------------------------------------------------