├── static └── .gitkeep ├── .eslintignore ├── config ├── prod.env.js ├── test.env.js ├── dev.env.js └── index.js ├── .travis.yml ├── .gitignore ├── src ├── plugin │ ├── actions.js │ ├── index.js │ ├── helpers │ │ ├── appendStateWithResponse.js │ │ ├── __snapshots__ │ │ │ └── appendStateWithResponse.spec.js.snap │ │ └── appendStateWithResponse.spec.js │ ├── api-handler-helper.js │ ├── mixin.js │ ├── __snapshots__ │ │ ├── api-handler-component.spec.js.snap │ │ └── module.spec.js.snap │ ├── utils.js │ ├── api-handler-component.spec.js │ ├── api-handler-component.js │ ├── vuex-api-hoc.js │ ├── module.spec.js │ └── module.js ├── store.js ├── child.vue ├── main.js └── App.vue ├── .npmignore ├── .editorconfig ├── .babelrc ├── index.html ├── .eslintrc.js ├── package.json └── README.md /static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production2"' 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | notifications: 5 | email: false 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | test/unit/coverage 6 | test/e2e/reports 7 | selenium-debug.log 8 | .idea 9 | lib/ 10 | docs/ 11 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var devEnv = require('./dev.env') 3 | 4 | module.exports = merge(devEnv, { 5 | NODE_ENV: '"testing"' 6 | }) 7 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"' 6 | }) 7 | -------------------------------------------------------------------------------- /src/plugin/actions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | request: 'Request_Api_Call', 3 | success: 'Success_Api_Call', 4 | clear: 'Clear_Api_Call', 5 | error: 'Error_Api_Call' 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | test/unit/coverage 6 | test/e2e/reports 7 | selenium-debug.log 8 | .idea 9 | config 10 | build 11 | index.html 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import vuexApi from './plugin' 4 | Vue.use(Vuex) 5 | 6 | const debug = process.env.NODE_ENV !== 'production' 7 | 8 | export default new Vuex.Store({ 9 | modules: { 10 | vuexApi 11 | }, 12 | strict: debug 13 | }) 14 | -------------------------------------------------------------------------------- /src/child.vue: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by vouill on 7/1/18. 3 | */ 4 | 5 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", { "modules": false }], 4 | "stage-2" 5 | ], 6 | "comments": false, 7 | "env": { 8 | "test": { 9 | "presets": ["es2015", "stage-2"], 10 | "plugins": [ 11 | ["module-resolver", { 12 | "root": ["./src"], 13 | "alias": { 14 | "src": "./src" 15 | } 16 | }]] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/plugin/index.js: -------------------------------------------------------------------------------- 1 | import { default as module } from './module' 2 | export { default as actions } from './actions' 3 | export { ApiHandlerComponent } from './api-handler-component' 4 | export { getApiResp, getApiState, getApiStatus, getApiData } from './api-handler-helper' 5 | export { default as hoc } from './vuex-api-hoc' 6 | export { vuexApiCallMixin, vuexApiGetStateMixin } from './mixin' 7 | export default module 8 | -------------------------------------------------------------------------------- /src/plugin/helpers/appendStateWithResponse.js: -------------------------------------------------------------------------------- 1 | import set from 'lodash/fp/set' 2 | 3 | export default (state, keyPath, responseStateObject) => { 4 | if(!keyPath){ 5 | throw new Error('keyPath need to be defined') 6 | } 7 | if(typeof keyPath === 'string'){ 8 | return { stateObject: responseStateObject , head: keyPath } 9 | 10 | } 11 | const [head, ...tail] = keyPath; 12 | return { stateObject: tail.length > 0 ? set(tail, responseStateObject, state[head]): responseStateObject , head } 13 | } 14 | -------------------------------------------------------------------------------- /src/plugin/api-handler-helper.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get' 2 | 3 | export const getApiState = (keyPath, path = [], defaultValue) => state => get(state, ['vuexApi', keyPath, ...path], defaultValue) 4 | export const getApiResp = (keyPath, path = [], defaultValue) => state => get(state, ['vuexApi', keyPath, 'resp', ...path], defaultValue) 5 | export const getApiData = (keyPath, path = [], defaultValue) => state => get(state, ['vuexApi', keyPath, 'resp', 'data', ...path], defaultValue) 6 | export const getApiStatus = (keyPath, path = [], defaultValue) => state => get(state, ['vuexApi', keyPath, 'status', ...path], defaultValue) 7 | -------------------------------------------------------------------------------- /src/plugin/mixin.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get' 2 | import { mapState } from 'vuex' 3 | import pluginActions from './actions' 4 | 5 | export const vuexApiCallMixin = { 6 | methods: { 7 | vuexApiCall: function (obj) { 8 | return this.$store.dispatch(pluginActions.request, obj) 9 | }, 10 | vuexApiClear: function (keyPath) { 11 | return this.$store.dispatch(pluginActions.request, keyPath) 12 | } 13 | } 14 | } 15 | 16 | export const vuexApiGetStateMixin = (keyPath, attrName) => ({ 17 | computed: mapState({ 18 | [attrName || 'apiData']: state => get(state.vuexApi,keyPath), 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | import App from './App' 5 | import store from './store' 6 | import TreeView from 'vue-json-tree-view' 7 | import { ApiHandlerComponent } from './plugin' 8 | Vue.use(TreeView) 9 | Vue.component('github-api', ApiHandlerComponent({ requestConfig: { baseURL: 'https://api.github.com' } })) 10 | Vue.component('json-api', ApiHandlerComponent({ requestConfig: { baseURL: 'https://jsonplaceholder.typicode.com' } })) 11 | /* eslint-disable no-new */ 12 | new Vue({ 13 | el: '#app', 14 | store, 15 | components: { App }, 16 | template: '', 17 | }) 18 | -------------------------------------------------------------------------------- /src/plugin/__snapshots__/api-handler-component.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Api handler call the correct hooks when mounting 1`] = ` 4 | Array [ 5 | Array [ 6 | "Request_Api_Call", 7 | Object { 8 | "keyPath": "totally-a-keypath", 9 | "params": undefined, 10 | "url": "some/url", 11 | }, 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Api handler should be called after destroy 1`] = ` 17 | Array [ 18 | Array [ 19 | "Request_Api_Call", 20 | Object { 21 | "keyPath": "totally-a-keypath", 22 | "params": undefined, 23 | "requestConfig": Object { 24 | "baseURL": "https://api.github.com", 25 | }, 26 | "url": "some/url", 27 | }, 28 | ], 29 | Array [ 30 | "Clear_Api_Call", 31 | "totally-a-keypath", 32 | ], 33 | ] 34 | `; 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vuex-api 6 | 7 | 8 | 9 | 10 | Fork me on GitHub 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/plugin/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-self-compare */ 2 | 'use strict' 3 | 4 | var hasOwnProperty = Object.prototype.hasOwnProperty 5 | 6 | function is (x, y) { 7 | if (x === y) { 8 | return x !== 0 || 1 / x === 1 / y 9 | } else { 10 | return x !== x && y !== y 11 | } 12 | } 13 | 14 | export default function shallowEqual (objA, objB) { 15 | if (is(objA, objB)) { 16 | return true 17 | } 18 | if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { 19 | return false 20 | } 21 | var keysA = Object.keys(objA) 22 | var keysB = Object.keys(objB) 23 | if (keysA.length !== keysB.length) { 24 | return false 25 | } 26 | for (var i = 0; i < keysA.length; i++) { 27 | if (!hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { 28 | return false 29 | } 30 | } 31 | return true 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parserOptions: { 6 | parser: "babel-eslint", 7 | ecmaVersion: 2017, 8 | sourceType: "module" 9 | }, 10 | env: { 11 | browser: true, 12 | jest: true, 13 | }, 14 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 15 | "extends": ["standard", "eslint:recommended", "plugin:vue/recommended"], 16 | // required to lint *.vue files 17 | plugins: [ 18 | "vue" 19 | ], 20 | // add your custom rules here 21 | 'rules': { 22 | "vue/max-attributes-per-line": 0, 23 | "object-curly-spacing": [2,"always"], 24 | // allow paren-less arrow functions 25 | 'arrow-parens': 0, 26 | // allow async-await 27 | 'generator-star-spacing': 0, 28 | // allow debugger during development 29 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/plugin/helpers/__snapshots__/appendStateWithResponse.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Api handler should mutate correctly already existing key on keyPath with more than one item 1`] = ` 4 | Object { 5 | "foo": Object { 6 | "bar": Object { 7 | "baz": Object { 8 | "data": Object { 9 | "toto": "tutu", 10 | }, 11 | "status": "loading", 12 | }, 13 | }, 14 | "data": "bax", 15 | "status": "success", 16 | }, 17 | } 18 | `; 19 | 20 | exports[`Api handler should mutate correctly already existing key on keyPath with one item 1`] = ` 21 | Object { 22 | "foo": Object { 23 | "data": Object { 24 | "toto": "tutu", 25 | }, 26 | "status": "loading", 27 | }, 28 | } 29 | `; 30 | 31 | exports[`Api handler should mutate correctly on keyPath with more than one item 1`] = ` 32 | Object { 33 | "foo": Object { 34 | "bar": Object { 35 | "baz": Object { 36 | "data": Object { 37 | "toto": "tutu", 38 | }, 39 | "status": "loading", 40 | }, 41 | }, 42 | }, 43 | } 44 | `; 45 | 46 | exports[`Api handler should mutate correctly on keyPath with one item 1`] = ` 47 | Object { 48 | "foo": Object { 49 | "data": Object { 50 | "toto": "tutu", 51 | }, 52 | "status": "loading", 53 | }, 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /src/plugin/api-handler-component.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'vue-test-utils' 2 | import ApiHandlerComponent from './api-handler-component' 3 | 4 | describe('Api handler', () => { 5 | it('call the correct hooks when mounting', () => { 6 | const dispatch = jest.fn() 7 | mount(ApiHandlerComponent(), { mocks: { $store: { dispatch } }, propsData: { url: 'some/url', keyPath: 'totally-a-keypath' } }) 8 | expect(dispatch.mock.calls).toMatchSnapshot() 9 | }) 10 | 11 | it('should be called after destroy', () => { 12 | const dispatch = jest.fn() 13 | const wrapper = mount(ApiHandlerComponent({ requestConfig: { baseURL: 'https://api.github.com' } }), { mocks: { $store: { dispatch } }, propsData: { persistent: false, url: 'some/url', keyPath: 'totally-a-keypath' } }) 14 | wrapper.vm.$destroy() 15 | expect(dispatch.mock.calls).toMatchSnapshot() 16 | }) 17 | 18 | // it('should call the api after arg change', () => { 19 | // const dispatch = jest.fn() 20 | // const wrapper = mount(ApiHandlerComponent({ requestConfig: { baseURL: 'https://api.github.com' } }), { mocks: { $store: { dispatch } }, propsData: { persistent: false, url: 'some/url', keyPath: 'totally-a-keypath', args: { post: 1 } } }) 21 | // wrapper.setProps({ args: { post: 2 } }) 22 | // wrapper.setProps({ args: { post: 3 } }) 23 | // expect(dispatch.mock.calls).toMatchSnapshot() 24 | // }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/plugin/api-handler-component.js: -------------------------------------------------------------------------------- 1 | import pluginActions from './actions' 2 | import isShallowEqual from './utils'; 3 | 4 | export const ApiHandlerComponent = (initArgs) => ({ 5 | name: 'api-handled', 6 | props: { 7 | keyPath: { type: String, required: true }, 8 | url: { type: String, required: true }, 9 | period: Number, 10 | params: Object, 11 | persistent: { type: Boolean, default: true } 12 | }, 13 | methods: { 14 | apiRequest: function () { 15 | const { keyPath, url, params } = this 16 | this.$store.dispatch(pluginActions.request, { keyPath, url, params, ...initArgs }) 17 | } 18 | }, 19 | watch: { 20 | params: { 21 | handler: function (newParams, oldParams) { 22 | if(!isShallowEqual(newParams, oldParams)){ 23 | this.apiRequest() 24 | } 25 | }, 26 | deep: true 27 | }, 28 | url: function () { 29 | this.apiRequest() 30 | } 31 | }, 32 | created: function () { 33 | if (this.period) { 34 | this.intervalId = setInterval(this.apiRequest, this.period) 35 | } 36 | this.apiRequest() 37 | 38 | }, 39 | destroyed: function () { 40 | if (!this.persistent) { 41 | this.$store.dispatch(pluginActions.clear, this.keyPath) 42 | } 43 | if (this.intervalId) { 44 | clearInterval(this.intervalId) 45 | } 46 | }, 47 | render () { 48 | return null 49 | } 50 | }) 51 | 52 | export default ApiHandlerComponent 53 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | env: require('./prod.env'), 7 | index: path.resolve(__dirname, '../docs/index.html'), 8 | assetsRoot: path.resolve(__dirname, '../docs'), 9 | assetsSubDirectory: 'static', 10 | assetsPublicPath: './', 11 | productionSourceMap: true, 12 | // Gzip off by default as many popular static hosts such as 13 | // Surge or Netlify already gzip all static assets for you. 14 | // Before setting to `true`, make sure to: 15 | // npm install --save-dev compression-webpack-plugin 16 | productionGzip: false, 17 | productionGzipExtensions: ['js', 'css'], 18 | // Run the build command with an extra argument to 19 | // View the bundle analyzer report after build finishes: 20 | // `npm run build --report` 21 | // Set to `true` or `false` to always turn it on or off 22 | bundleAnalyzerReport: process.env.npm_config_report 23 | }, 24 | dev: { 25 | env: require('./dev.env'), 26 | port: 8080, 27 | autoOpenBrowser: true, 28 | assetsSubDirectory: 'static', 29 | assetsPublicPath: '/', 30 | proxyTable: {}, 31 | // CSS Sourcemaps off by default because relative paths are "buggy" 32 | // with this option, according to the CSS-Loader README 33 | // (https://github.com/webpack/css-loader#sourcemaps) 34 | // In our experience, they generally work as expected, 35 | // just be aware of this issue when enabling this option. 36 | cssSourceMap: false 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/plugin/vuex-api-hoc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import get from 'lodash/get' 3 | 4 | export const ApiHandlerComponent = { 5 | name: 'vuex-helper', 6 | props: { 7 | keyPath: { type: [String, Array], required: true }, 8 | loadingComponent: Object, 9 | }, 10 | computed: { 11 | storeObject () { 12 | return get(this.$store.state.vuexApi, this.keyPath) 13 | }, 14 | }, 15 | render (h) { 16 | if(!this.storeObject){ 17 | return null 18 | } 19 | if(this.storeObject.status === 'loading' && this.$slots.loading){ 20 | if(this.$slots.loading[0].componentOptions){ 21 | this.$slots.loading[0].componentOptions.propsData.vuexApiStoreObject = this.storeObject 22 | } 23 | return h('div', this.$slots.loading) 24 | } 25 | if(!this.storeObject.firstCallDone && this.$slots.firstCallDone){ 26 | if(this.$slots.firstCallDone[0].componentOptions){ 27 | this.$slots.firstCallDone[0].componentOptions.propsData.vuexApiStoreObject = this.storeObject 28 | } 29 | return h('div', this.$slots.firstCallDone) 30 | } 31 | if(this.storeObject.status === 'success' && this.$slots.success){ 32 | if(this.$slots.success[0].componentOptions){ 33 | this.$slots.success[0].componentOptions.propsData.vuexApiStoreObject = this.storeObject 34 | this.$slots.success[0].componentOptions.propsData.vuexApiData = this.storeObject.resp.data 35 | } 36 | return h('div', this.$slots.success) 37 | } 38 | if(this.storeObject.status === 'error' && this.$slots.error){ 39 | if(this.$slots.error[0].componentOptions){ 40 | this.$slots.error[0].componentOptions.propsData.vuexApiStoreObject = this.storeObject 41 | this.$slots.error[0].componentOptions.propsData.vuexApiError = this.storeObject.resp.err 42 | } 43 | return h('div', this.$slots.error) 44 | } 45 | return null 46 | } 47 | } 48 | 49 | export default ApiHandlerComponent 50 | -------------------------------------------------------------------------------- /src/plugin/helpers/appendStateWithResponse.spec.js: -------------------------------------------------------------------------------- 1 | import mutateStateWithResponse from './appendStateWithResponse' 2 | 3 | const set = (state, key, obj) => { 4 | state[key] = obj; 5 | return state 6 | } 7 | 8 | 9 | 10 | describe('Api handler', () => { 11 | it('should throw error when no keyPath', () => { 12 | const state = {}; 13 | const resp = { status: 'loading', data: { toto: 'tutu' } } 14 | expect(() => mutateStateWithResponse(state, undefined, resp)).toThrow() 15 | }) 16 | 17 | it('should mutate correctly on keyPath with one item', () => { 18 | const state = {}; 19 | const resp = { status: 'loading', data: { toto: 'tutu' } } 20 | const keyPath = ['foo'] 21 | const { stateObject, head } = mutateStateWithResponse(state, keyPath, resp) 22 | expect(set(state, head, stateObject )).toMatchSnapshot() 23 | }) 24 | 25 | it('should mutate correctly on keyPath with more than one item', () => { 26 | const state = {}; 27 | const resp = { status: 'loading', data: { toto: 'tutu' } } 28 | const keyPath = ['foo', 'bar', 'baz']; 29 | const { stateObject, head } = mutateStateWithResponse(state, keyPath, resp) 30 | expect(set(state, head, stateObject )).toMatchSnapshot() 31 | }) 32 | 33 | it('should mutate correctly already existing key on keyPath with one item', () => { 34 | const state = { foo: { status: 'success', data: 'bax' } }; 35 | const resp = { status: 'loading', data: { toto: 'tutu' } } 36 | const keyPath = ['foo'] 37 | const { stateObject, head } = mutateStateWithResponse(state, keyPath, resp) 38 | expect(set(state, head, stateObject )).toMatchSnapshot() 39 | }) 40 | 41 | it('should mutate correctly already existing key on keyPath with more than one item', () => { 42 | const state = { foo: { status: 'success', data: 'bax' } }; 43 | const resp = { status: 'loading', data: { toto: 'tutu' } } 44 | const keyPath = ['foo', 'bar', 'baz']; 45 | const { stateObject, head } = mutateStateWithResponse(state, keyPath, resp) 46 | expect(set(state, head, stateObject )).toMatchSnapshot() 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/plugin/module.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by vouill on 10/24/17. 3 | */ 4 | import module from './module' 5 | import pluginActions from './actions' 6 | import moxios from 'moxios' 7 | 8 | const testRequest = { url: 'testUrl', method: 'testMethod', baseURL: 'baseUrl' } 9 | 10 | describe('Module Actions', () => { 11 | beforeEach(function () { 12 | moxios.install() 13 | }) 14 | 15 | afterEach(function () { 16 | moxios.uninstall() 17 | }) 18 | it('should process request', () => { 19 | const commit = jest.fn() 20 | module.actions[pluginActions.request]({ commit }, testRequest) 21 | expect(commit.mock.calls).toMatchSnapshot() 22 | }) 23 | 24 | it('should process clear', () => { 25 | const commit = jest.fn() 26 | module.actions[pluginActions.clear]({ commit }, { keyPath: 'someKeypath' }) 27 | expect(commit.mock.calls).toMatchSnapshot() 28 | }) 29 | 30 | it('should process onSuccess', () => { 31 | moxios.wait(() => { 32 | const request = moxios.requests.mostRecent() 33 | request.respondWith({ 34 | status: 200, 35 | response: 'test' 36 | }) 37 | }) 38 | 39 | const commit = jest.fn() 40 | const dispatch = jest.fn() 41 | return module.actions[pluginActions.request]({ commit, dispatch }, { keyPath: 'someKeypath', url: 'testUrl', onSuccess: { dispatchAction: { type: 'test-type', payload: 'test-payload' } } }).then(() => { 42 | expect(commit.mock.calls).toMatchSnapshot() 43 | expect(dispatch.mock.calls).toMatchSnapshot() 44 | }) 45 | }) 46 | }) 47 | 48 | describe('Module mutations', () => { 49 | let state = {} 50 | const keyPath = 'someKeyPath' 51 | beforeEach(() => { 52 | state = {} 53 | }) 54 | it('should process request', () => { 55 | module.mutations[pluginActions.request](state, { keyPath }) 56 | expect(state).toMatchSnapshot() 57 | }) 58 | 59 | it('should process success', () => { 60 | module.mutations[pluginActions.success](state, { keyPath, resp: { date: 'fakeData' } }) 61 | expect(state).toMatchSnapshot() 62 | }) 63 | 64 | it('should process error', () => { 65 | module.mutations[pluginActions.error](state, { keyPath, err: { data: 'fakeError' } }) 66 | expect(state).toMatchSnapshot() 67 | }) 68 | 69 | it('should process clear', () => { 70 | module.mutations[pluginActions.clear](state, keyPath) 71 | expect(state).toMatchSnapshot() 72 | }) 73 | 74 | it('should process request -> success', () => { 75 | module.mutations[pluginActions.clear](state, keyPath) 76 | module.mutations[pluginActions.success](state, { keyPath, resp: { date: 'fakeData' } }) 77 | expect(state).toMatchSnapshot() 78 | }) 79 | 80 | it('should process request -> error', () => { 81 | module.mutations[pluginActions.clear](state, keyPath) 82 | expect(state).toMatchSnapshot() 83 | module.mutations[pluginActions.error](state, { keyPath, err: { data: 'fakeError' } }) 84 | expect(state).toMatchSnapshot() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 30 | 35 | 96 | 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-api", 3 | "version": "0.2.9", 4 | "description": "Effortlessly handle api calls with vuex without repeating yourself.", 5 | "author": "Vouill ", 6 | "main": "./lib/vuex-api.js", 7 | "bugs": "https://github.com/vouill/vuex-api/issues", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/vouill/vuex-api" 11 | }, 12 | "scripts": { 13 | "dev": "node build/dev-server.js", 14 | "build:docs": "node build/build.js", 15 | "build:lib": "webpack --bail --progress --hide-modules --config build/webpack.lib.conf.js", 16 | "test": "npm run lint; jest src", 17 | "test-watch": "jest --watch", 18 | "lint": "eslint --ext .js,.vue src" 19 | }, 20 | "dependencies": { 21 | "axios": "^0.16.2", 22 | "vue": "^2.4.2" 23 | }, 24 | "devDependencies": { 25 | "autoprefixer": "^6.7.2", 26 | "babel-core": "^6.22.1", 27 | "babel-eslint": "^7.1.1", 28 | "babel-jest": "^21.0.0", 29 | "babel-loader": "^6.2.10", 30 | "babel-plugin-istanbul": "^3.1.2", 31 | "babel-plugin-module-resolver": "^2.7.1", 32 | "babel-plugin-transform-runtime": "^6.22.0", 33 | "babel-preset-es2015": "^6.22.0", 34 | "babel-preset-stage-2": "^6.22.0", 35 | "babel-register": "^6.22.0", 36 | "connect-history-api-fallback": "^1.3.0", 37 | "cross-env": "^3.1.4", 38 | "cross-spawn": "^5.0.1", 39 | "css-loader": "^0.26.1", 40 | "eslint": "^3.14.1", 41 | "eslint-config-standard": "^6.2.1", 42 | "eslint-friendly-formatter": "^2.0.7", 43 | "eslint-loader": "^1.6.1", 44 | "eslint-plugin-html": "^2.0.0", 45 | "eslint-plugin-promise": "^3.4.0", 46 | "eslint-plugin-standard": "^2.0.1", 47 | "eslint-plugin-vue": "^4.0.0-beta.2", 48 | "eventsource-polyfill": "^0.9.6", 49 | "express": "^4.14.1", 50 | "extract-text-webpack-plugin": "^2.0.0-rc.2", 51 | "file-loader": "^0.10.0", 52 | "friendly-errors-webpack-plugin": "^1.1.3", 53 | "function-bind": "^1.1.0", 54 | "html-webpack-plugin": "^2.28.0", 55 | "http-proxy-middleware": "^0.17.3", 56 | "inject-loader": "^2.0.1", 57 | "jest": "^21.0.1", 58 | "jest-serializer-vue": "^0.2.0", 59 | "jest-vue": "^0.5.2", 60 | "lodash": "^4.17.4", 61 | "lodash-es": "^4.17.4", 62 | "lolex": "^1.5.2", 63 | "moxios": "^0.4.0", 64 | "opn": "^4.0.2", 65 | "ora": "^1.1.0", 66 | "semver": "^5.3.0", 67 | "shelljs": "^0.7.6", 68 | "url-loader": "^0.5.7", 69 | "vue": "^2.4.2", 70 | "vue-json-tree-view": "^2.1.1", 71 | "vue-loader": "^11.3.4", 72 | "vue-router": "^2.7.0", 73 | "vue-style-loader": "^3.0.1", 74 | "vue-template-compiler": "^2.4.2", 75 | "vue-test-utils": "^1.0.0-beta.2", 76 | "vuex": "^3.0.0", 77 | "webpack": "^2.4.1", 78 | "webpack-bundle-analyzer": "^2.2.1", 79 | "webpack-dev-middleware": "^1.10.0", 80 | "webpack-hot-middleware": "^2.16.1", 81 | "webpack-merge": "^2.6.1" 82 | }, 83 | "engines": { 84 | "node": ">= 4.0.0", 85 | "npm": ">= 3.0.0" 86 | }, 87 | "jest": { 88 | "moduleFileExtensions": [ 89 | "js", 90 | "vue" 91 | ], 92 | "transform": { 93 | "^.+\\.js$": "/node_modules/babel-jest", 94 | ".*\\.(vue)$": "/node_modules/jest-vue" 95 | }, 96 | "snapshotSerializers": [ 97 | "/node_modules/jest-serializer-vue" 98 | ] 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/plugin/module.js: -------------------------------------------------------------------------------- 1 | import appendStateWithResponse from './helpers/appendStateWithResponse'; 2 | import pluginActions from './actions' 3 | import vue from 'vue' 4 | import axios from 'axios' 5 | import shallowEqual from './utils' 6 | 7 | const { CancelToken } = axios 8 | 9 | const state = {} 10 | 11 | // used for request cancelation 12 | const previousStateRequest = { cancel: null, url: null, params: {}, state: 'finished' } 13 | 14 | const actions = { 15 | [pluginActions.request]: ({ commit, dispatch }, { requestConfig, ...otherConfig }) => { 16 | const { url, method, onSuccess, onError, keyPath, params } = otherConfig 17 | return new Promise((resolve, reject) => { 18 | commit(pluginActions.request, { keyPath }) 19 | if (url === previousStateRequest.url && previousStateRequest.state === 'started' && shallowEqual(params, previousStateRequest.params)) { 20 | previousStateRequest.cancel() 21 | } 22 | previousStateRequest.url = url 23 | previousStateRequest.state = 'started' 24 | previousStateRequest.params = params 25 | axios({ 26 | ...requestConfig, 27 | ...otherConfig, 28 | method: method || 'GET', 29 | url, 30 | cancelToken: new CancelToken(c => { 31 | previousStateRequest.cancel = c 32 | }) 33 | }).then(resp => { 34 | previousStateRequest.state = 'finished' 35 | commit(pluginActions.success, { resp: resp, keyPath }) 36 | if (onSuccess) { 37 | const { dispatchAction, executeFunction, commitAction } = onSuccess 38 | if (dispatchAction) { 39 | const { type, payload } = dispatchAction 40 | dispatch(type, payload) 41 | } 42 | if (commitAction) { 43 | const { type, payload } = commitAction 44 | commit(type, payload) 45 | } 46 | if (executeFunction) { 47 | executeFunction(resp, { commit, dispatch }) 48 | } 49 | } 50 | resolve(resp) 51 | }).catch(err => { 52 | if (axios.isCancel(err)) { 53 | return 54 | } 55 | if (onError) { 56 | const { dispatchAction, executeFunction, commitAction } = onError 57 | if (dispatchAction) { 58 | const { type, payload } = dispatchAction 59 | dispatch(type, payload) 60 | } 61 | if (commitAction) { 62 | const { type, payload } = commitAction 63 | commit(type, payload) 64 | } 65 | if (executeFunction) { 66 | executeFunction(err, { commit, dispatch }) 67 | } 68 | } 69 | 70 | commit(pluginActions.error, { err: err.response, keyPath }) 71 | reject(err) 72 | }) 73 | }) 74 | }, 75 | [pluginActions.clear]: ({ commit }, keyPath) => { 76 | return new Promise((resolve, reject) => { 77 | commit(pluginActions.clear, keyPath) 78 | resolve() 79 | }) 80 | } 81 | } 82 | 83 | const mutations = { 84 | [pluginActions.request]: (state, { keyPath }) => { 85 | const responseStateObject = { status: 'loading' } 86 | const { stateObject, head } = appendStateWithResponse(state, keyPath, responseStateObject) 87 | vue.set(state, head, stateObject) 88 | }, 89 | [pluginActions.success]: (state, { keyPath, resp }) => { 90 | delete resp.config // make this configurable 91 | const responseStateObject = { status: 'success', firstCallDone: true, resp } 92 | const { stateObject, head } = appendStateWithResponse(state, keyPath, responseStateObject) 93 | vue.set(state, head, stateObject) 94 | }, 95 | [pluginActions.error]: (state, { keyPath, err }) => { 96 | const responseStateObject = { status: 'error', err, firstCallDone: true } 97 | const { stateObject, head } = appendStateWithResponse(state, keyPath, responseStateObject) 98 | vue.set(state, head, stateObject) 99 | }, 100 | [pluginActions.clear]: (state, keyPath) => { 101 | const responseStateObject = {} 102 | const { stateObject, head } = appendStateWithResponse(state, keyPath, responseStateObject) 103 | vue.set(state, head, stateObject) 104 | } 105 | } 106 | 107 | const module = { 108 | state, 109 | actions, 110 | mutations 111 | } 112 | 113 | export default module 114 | -------------------------------------------------------------------------------- /src/plugin/__snapshots__/module.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Module Actions should process clear 1`] = ` 4 | Array [ 5 | Array [ 6 | "Clear_Api_Call", 7 | Object { 8 | "keyPath": "someKeypath", 9 | }, 10 | ], 11 | ] 12 | `; 13 | 14 | exports[`Module Actions should process onSuccess 1`] = ` 15 | Array [ 16 | Array [ 17 | "Request_Api_Call", 18 | Object { 19 | "keyPath": "someKeypath", 20 | }, 21 | ], 22 | Array [ 23 | "Success_Api_Call", 24 | Object { 25 | "keyPath": "someKeypath", 26 | "resp": Response { 27 | "code": undefined, 28 | "config": Object { 29 | "adapter": [Function], 30 | "cancelToken": CancelToken { 31 | "promise": Promise {}, 32 | }, 33 | "data": undefined, 34 | "headers": Object { 35 | "Accept": "application/json, text/plain, */*", 36 | }, 37 | "keyPath": "someKeypath", 38 | "maxContentLength": -1, 39 | "method": "get", 40 | "onSuccess": Object { 41 | "dispatchAction": Object { 42 | "payload": "test-payload", 43 | "type": "test-type", 44 | }, 45 | }, 46 | "timeout": 0, 47 | "transformRequest": Object { 48 | "0": [Function], 49 | }, 50 | "transformResponse": Object { 51 | "0": [Function], 52 | }, 53 | "url": "testUrl", 54 | "validateStatus": [Function], 55 | "xsrfCookieName": "XSRF-TOKEN", 56 | "xsrfHeaderName": "X-XSRF-TOKEN", 57 | }, 58 | "data": "test", 59 | "headers": undefined, 60 | "request": Request { 61 | "config": Object { 62 | "adapter": [Function], 63 | "cancelToken": CancelToken { 64 | "promise": Promise {}, 65 | }, 66 | "data": undefined, 67 | "headers": Object { 68 | "Accept": "application/json, text/plain, */*", 69 | }, 70 | "keyPath": "someKeypath", 71 | "maxContentLength": -1, 72 | "method": "get", 73 | "onSuccess": Object { 74 | "dispatchAction": Object { 75 | "payload": "test-payload", 76 | "type": "test-type", 77 | }, 78 | }, 79 | "timeout": 0, 80 | "transformRequest": Object { 81 | "0": [Function], 82 | }, 83 | "transformResponse": Object { 84 | "0": [Function], 85 | }, 86 | "url": "testUrl", 87 | "validateStatus": [Function], 88 | "xsrfCookieName": "XSRF-TOKEN", 89 | "xsrfHeaderName": "X-XSRF-TOKEN", 90 | }, 91 | "headers": Object { 92 | "Accept": "application/json, text/plain, */*", 93 | }, 94 | "reject": [Function], 95 | "resolve": [Function], 96 | "responseType": undefined, 97 | "timeout": 0, 98 | "url": "testUrl", 99 | "withCredentials": false, 100 | }, 101 | "status": 200, 102 | "statusText": undefined, 103 | }, 104 | }, 105 | ], 106 | ] 107 | `; 108 | 109 | exports[`Module Actions should process onSuccess 2`] = ` 110 | Array [ 111 | Array [ 112 | "test-type", 113 | "test-payload", 114 | ], 115 | ] 116 | `; 117 | 118 | exports[`Module Actions should process request 1`] = ` 119 | Array [ 120 | Array [ 121 | "Request_Api_Call", 122 | Object { 123 | "keyPath": undefined, 124 | }, 125 | ], 126 | ] 127 | `; 128 | 129 | exports[`Module mutations should process clear 1`] = ` 130 | Object { 131 | "someKeyPath": Object {}, 132 | } 133 | `; 134 | 135 | exports[`Module mutations should process error 1`] = ` 136 | Object { 137 | "someKeyPath": Object { 138 | "err": Object { 139 | "data": "fakeError", 140 | }, 141 | "firstCallDone": true, 142 | "status": "error", 143 | }, 144 | } 145 | `; 146 | 147 | exports[`Module mutations should process request -> error 1`] = ` 148 | Object { 149 | "someKeyPath": Object {}, 150 | } 151 | `; 152 | 153 | exports[`Module mutations should process request -> error 2`] = ` 154 | Object { 155 | "someKeyPath": Object { 156 | "err": Object { 157 | "data": "fakeError", 158 | }, 159 | "firstCallDone": true, 160 | "status": "error", 161 | }, 162 | } 163 | `; 164 | 165 | exports[`Module mutations should process request -> success 1`] = ` 166 | Object { 167 | "someKeyPath": Object { 168 | "firstCallDone": true, 169 | "resp": Object { 170 | "date": "fakeData", 171 | }, 172 | "status": "success", 173 | }, 174 | } 175 | `; 176 | 177 | exports[`Module mutations should process request 1`] = ` 178 | Object { 179 | "someKeyPath": Object { 180 | "status": "loading", 181 | }, 182 | } 183 | `; 184 | 185 | exports[`Module mutations should process success 1`] = ` 186 | Object { 187 | "someKeyPath": Object { 188 | "firstCallDone": true, 189 | "resp": Object { 190 | "date": "fakeData", 191 | }, 192 | "status": "success", 193 | }, 194 | } 195 | `; 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/vouill/vuex-api.svg?branch=master)](https://travis-ci.org/vouill/vuex-api) 2 | 3 | # Deprecated 4 | 5 | Not maintenained anymore. 6 | 7 | 8 | # Vuex api 9 | 10 | ## Intro 11 | 12 | `vuex-api` is a library that wants to help you handle API as easily as possible. Yes, that's it ! 13 | 14 | 15 | 16 | ## Install 17 | 18 | 1. Make sure you have `vuex` set up in your app. 19 | 2. In your `store.js`, install the `vuex-api` module: 20 | 21 | ```javascript 22 | import vuexApi from 'vuex-api' 23 | 24 | export default new Vuex.Store({ 25 | modules: { 26 | vuexApi, 27 | }, 28 | }) 29 | ``` 30 | 31 | 32 | 33 | ## Usage 34 | 35 | This library allows you [multiple](#documentation) way to make and consume API calls. Here is an exemple of one of them: 36 | 37 | ```javascript 38 | created: function () { 39 | this.vuexApiCall( 40 | { 41 | baseURL: 'https://jsonplaceholder.typicode.com', 42 | url: 'posts', 43 | keyPath: ['typicode', 'posts'] // is the to reach the data in your vuex state 44 | } 45 | ) 46 | }, 47 | ``` 48 | 49 | 50 | 51 | ```html 52 | // Render components based on the status of the API call. The component will have the api call status injected in their props automatically 53 | 54 | 55 | 56 | 57 | 58 | ``` 59 | 60 | 61 | 62 | 63 | 64 | ## Motivation 65 | 66 | You can do API calls for anything: 67 | 68 | - getting a blog post 69 | - create a recipe 70 | - delete a user 71 | 72 | However, API calls are a pretty repetitive when you think about it. 73 | 74 | 1. Emit the API call 75 | 2. Display loading (or not) 76 | 3. Receive Data 77 | 4. Display Data (or error) 78 | 5. Maybe as consequence execute custom code 79 | 80 | 81 | 82 | This Library goals is to abstract this repetition from you. You only need to give the necessary data to perform the API call and that's it. The library will: 83 | 84 | - Handle the whole api call from loading to success and error 85 | - Give you mixins and HOC to help you make and retrieve API calls. 86 | 87 | 88 | 89 | ## Documentation 90 | 91 | Vuex-api is nothing but a vuex module. It listen to a certain action, with a payload that will be used to perform the API Call. Then it will store the different states of the response in the vuex state. 92 | 93 | Because we have all the logic handled at one place, we can add tooling around that will help us reduce the pain to handle api calls. 94 | 95 | This doc is split in 2 parts. 96 | 97 | -> Sending API calls 98 | 99 | -> Retrieve API call data 100 | 101 | ## Emit API calls 102 | 103 | Emitting an API call will always require you to send the info necessary to perform the api request. 104 | 105 | ``` 106 | baseURL: 'https://jsonplaceholder.typicode.com' 107 | method: 'GET' 108 | url: 'posts' 109 | keyPath: ['article', 'slugId'] // the only attribute not shapped in the axios request object. I will define the path where the api call states will be stored in vuex. 110 | ``` 111 | 112 | The following are 3 independant methods to emit the same API call. 113 | 114 | ### Using store dispatch 115 | 116 | [>> Interactive demo](https://codesandbox.io/s/5yq65jrqop) 117 | 118 | To emit a vuex-API call you need to fire an action with the following data: 119 | 120 | ```javascript 121 | import { actions } from 'vuex-api' 122 | 123 | created: function(){ 124 | this.$store.dispatch(actions.request,{ 125 | baseURL: 'https://jsonplaceholder.typicode.com', 126 | url: 'posts', 127 | keyPath: ['typicode', 'posts'] 128 | } 129 | ) 130 | }, 131 | ``` 132 | 133 | 134 | 135 | ### Using mixins 136 | 137 | [>> Interactive demo](https://codesandbox.io/s/n92rmvk844) 138 | 139 | ``` 140 | mixins:[ 141 | vuexApiCallMixin, 142 | ], 143 | ``` 144 | 145 | 146 | 147 | `vuexApiCallMixin` exposes to the vue component 2 methods: 148 | 149 | - `vuexApiCall(VuexApiRequestObject)` Perform an API call using the passed VuexApiRequestObject 150 | - `vuexApiClear(keyPath)` Clears the state at the given keypath 151 | 152 | 153 | 154 | ```javascript 155 | created: function () { 156 | this.vuexApiCall( 157 | { 158 | baseURL: 'https://jsonplaceholder.typicode.com', 159 | url: 'posts', 160 | keyPath: ['typicode', 'posts'] 161 | } 162 | ) 163 | }, 164 | ``` 165 | 166 | 167 | 168 | ### Using a component 169 | 170 | [>> Interactive demo](https://codesandbox.io/s/2p4q04pxj) 171 | 172 | This one is mostly suited for GET calls. It's a pretty powerfull way to handle GET calls in a breaze. 173 | 174 | ```javascript 175 | Vue.component('json-api', ApiHandlerComponent({ 176 | requestConfig: { baseURL: 'https://jsonplaceholder.typicode.com' } 177 | })) 178 | ``` 179 | 180 | ```html 181 | 182 | ``` 183 | 184 | 185 | 186 | ## Retrieve API call data 187 | 188 | ### Accessing state 189 | 190 | [>> Interactive demo](https://codesandbox.io/s/5yq65jrqop) 191 | 192 | You can get the data related to your API call directly from the vuex state as you would do normally 193 | 194 | ```javascript 195 | this.$store.state.vuexApi.popular.feed 196 | 197 | // or 198 | 199 | computed: mapState({ 200 | data = state => state.vuexApi.popular.feed 201 | }) 202 | ``` 203 | 204 | ### Using Mixin 205 | 206 | [>> Interactive demo](https://codesandbox.io/s/n92rmvk844) 207 | 208 | `vuexApiGetStateMixin(keyPath, attributeName?)` exposes to the vue component a computed value representing the state at the given keyPath under `this[attributeName]`. 209 | 210 | 211 | 212 | ### Use HOC 213 | 214 | [>> Interactive demo](https://codesandbox.io/s/25lq28o0p) 215 | 216 | This hoc will render the given child component depending on the status ate of the API Call. More over it will auto inject the api call response to the children in its props. 217 | 218 | ```html 219 | 220 | 221 | 222 | 223 | 224 | ``` 225 | 226 | 227 | 228 | --------------------------------------------------------------------------------