├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── config ├── webpack.config.base.js ├── webpack.config.browser.js ├── webpack.config.common.js └── webpack.config.dev.js ├── dist ├── vue-async-operations.browser.js └── vue-async-operations.common.js ├── index.js ├── package-lock.json ├── package.json └── src ├── async-operations.js ├── index.js └── mix.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", { "modules": false }], 4 | "stage-0" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 8 | extends: 'standard', 9 | // required to lint *.vue files 10 | plugins: [ 11 | 'html' 12 | ], 13 | env: { 14 | browser: true, 15 | }, 16 | // add your custom rules here 17 | 'rules': { 18 | // allow paren-less arrow functions 19 | 'arrow-parens': 0, 20 | // allow async-await 21 | 'generator-star-spacing': 0, 22 | // allow debugger during development 23 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 24 | // trailing comma 25 | 'comma-dangle': ['error', 'always-multiline'], 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Devstark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-async-operations 2 | 3 | [![npm](https://img.shields.io/npm/v/vue-async-operations.svg) ![npm](https://img.shields.io/npm/dm/vue-async-operations.svg)](https://www.npmjs.com/package/vue-async-operations) 4 | [![vue2](https://img.shields.io/badge/vue-2.x-brightgreen.svg)](https://vuejs.org/) 5 | 6 | > Managing async operations statuses in your Vue components 7 | 8 | ### Install 9 | 10 | ```bash 11 | npm install vue-async-operations 12 | ``` 13 | 14 | ### Basic usage 15 | ```js 16 | import Vue from 'vue' 17 | import VueAsyncOperations from 'vue-async-operations' 18 | 19 | Vue.use(VueAsyncOperations) 20 | ``` 21 | 22 | Then, in your component options, provide an `asyncOperations` object where each key is a name of an async operation and the value is a function that returns `Promise`: 23 | 24 | ```js 25 | //... 26 | asyncOperations: { 27 | someAsyncStuff () { 28 | // return Promise 29 | // ☝️vm instance is binded to function as `this` context 30 | } 31 | } 32 | //... 33 | ``` 34 | 35 | Or you can link operation to some method in component: 36 | 37 | ```js 38 | //... 39 | asyncOperations: { 40 | someAsyncStuff: 'someMethodName' 41 | } 42 | //... 43 | ``` 44 | 45 | Then, trigger an operation in your component e.g. in `created` hook: 46 | 47 | ```js 48 | //... 49 | created () { 50 | this.$async.someAsyncStuff.$perform() 51 | } 52 | //... 53 | ``` 54 | 55 | And use operation performing state in the template: 56 | 57 | ```html 58 | 59 |
60 | 61 |
62 | 63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 | 71 |
72 | 73 |
74 | 75 | ``` 76 | 77 | ### Several operations in one 78 | 79 | The function that defines an operation may return an array of promises, e.g.: 80 | 81 | ```js 82 | //... 83 | asyncOperations: { 84 | someCompositAsyncStuff () { 85 | return [ 86 | this.someVuexAction(), 87 | this.someAnotherVuexAction() 88 | ] 89 | } 90 | } 91 | //... 92 | ``` 93 | 94 | This way, the operation state handler, that placed under the hood of this plugin, will operates via `Promise.all([])` so reactive states of operation will be changed only when last promise will be resolved or rejected. 95 | 96 | Also, you can define as much separate operations as you need: 97 | 98 | ```js 99 | //... 100 | asyncOperations: { 101 | asyncStuff1 () { //... }, 102 | asyncStuff2 () { //... }, 103 | asyncStuff3 () { //... }, 104 | ... 105 | } 106 | //... 107 | ``` 108 | 109 | ### Passing args 110 | You can pass arguments to `$perform()` method and receive them in operation function: 111 | ```js 112 | //... 113 | asyncOperations: { 114 | // some operation defined as function 115 | stuff1 (a, b, c) { 116 | console.log(a, b, c) // 1, 2, 3 117 | }, 118 | // another operation defined as link to some method of a component 119 | stuff2: 'loadStuff2' 120 | }, 121 | methods: { 122 | loadStuff2 (d, e, f) { 123 | console.log(d, e, f) // 4, 5, 6 124 | } 125 | }, 126 | created () { 127 | this.$async.stuff1.$perform(1, 2, 3) 128 | this.$async.stuff2.$perform(4, 5, 6) 129 | } 130 | //... 131 | ``` 132 | 133 | ### Handle operation result 134 | 135 | The `.$perform()` method of an operation returns `Promise` and passes the result of the original promise into `resolve` and `reject` methods: 136 | 137 | ```js 138 | //... 139 | created () { 140 | this.$async.someAsyncStuff.$perform().then( 141 | result => { // handle a result }, 142 | err => { // handle an error }, 143 | finally => { // handle the finish of an operation } 144 | ) 145 | } 146 | //... 147 | ``` 148 | 149 | If your operation returns an array of promises, the result will contain an array of results in the order they were defined in the original array 150 | 151 | Also, you can handle the result directly in operation function: 152 | 153 | ```js 154 | //... 155 | asyncOperations: { 156 | someOperation: 'someMethod' 157 | }, 158 | methods: { 159 | someMethod () { 160 | this.$api.someApiCall().then( 161 | result => { // handle the result }, 162 | error => { // handle an error } 163 | ) 164 | } 165 | }, 166 | created () { 167 | this.$async.someOperation.$perform() 168 | } 169 | //... 170 | ``` 171 | 172 | ### Operations composing 173 | 174 | If you have several async operations that are leading to some common result and you need to track their reactive statuses separately but also you wanna have an aggregated reactive status for whole batch, you can compose your operations the following way: 175 | 176 | ```js 177 | //... 178 | asyncOperations: { 179 | allAsyncStuff: { 180 | asyncStuff1 () {}, 181 | asyncStuff2 () {} 182 | } 183 | } 184 | //... 185 | ``` 186 | 187 | Then, use separate and aggregated reactive statuses 188 | 189 | ```html 190 | 191 |
Some stuff is still loading...
192 |
All stuff loaded
193 | 194 |
Stuff 1 is loaded
195 | 196 |
Stuff 2 is loaded
197 | 198 | ``` 199 | 200 | ### Performing composed operations with passing args 201 | 202 | ```js 203 | //... 204 | asyncOperations: { 205 | allAsyncStuff: { 206 | asyncStuff1 (a, b, c) { 207 | console.log(a, b, c) // 1, 2, 3 208 | }, 209 | asyncStuff2 ({a, b, c}) { 210 | console.log(a, b, c) // 4, 5, 6 211 | } 212 | } 213 | }, 214 | created () { 215 | this.$async.allAsyncStuff.$perform({ 216 | asyncStuff1: [1, 2, 3], // pass args to `asyncStuff1` 217 | asyncStuff2: {a: 4, b: 5, c: 6} // pass args to `asyncStuff2` 218 | }) 219 | } 220 | //... 221 | ``` 222 | 223 | ### Operation states 224 | 225 | - `$pending` 226 | - `$resolved` 227 | - `$rejected` 228 | - `$err` 229 | 230 | ### Operation methods 231 | 232 | - `$perform` 233 | 234 | ### Plugin options 235 | 236 | You can customize some stuff: 237 | 238 | ```js 239 | Vue.use(VueAsyncOperations, { 240 | mixinPrefix, 241 | dataPropName, 242 | computedPropName, 243 | componentOptionName 244 | }) 245 | ``` 246 | 247 | - `mixinPrefix` - plugin adds to your application a global mixin which injects property with operations states into the components `data` and according to [official vuejs style guide](https://vuejs.org/v2/style-guide/#Private-property-names-essential) this property is prefixed, but you can change this prefix if it's necessary for some reason 248 | 249 | - `dataPropName` - actually the name of the prop mentioned above 250 | 251 | - `computedPropName` - the name of the computed prop you use for getting acces to operations for getting its states and calling `$perform()` method 252 | 253 | - `componentOptionName` - the name of the component option where you define operations 254 | 255 | Plugin defaults are: 256 | ``` 257 | { 258 | mixinPrefix: 'vueAsyncOps_', 259 | dataPropName: 'async', 260 | computedPropName: '$async', 261 | componentOptionName: 'asyncOperations' 262 | } 263 | ``` 264 | -------------------------------------------------------------------------------- /config/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 3 | 4 | var outputFile = 'vue-async-operations' 5 | var globalName = 'VueAsyncOperations' 6 | 7 | var config = require('../package.json') 8 | 9 | module.exports = { 10 | entry: './src/index.js', 11 | module: { 12 | rules: [ 13 | { 14 | enforce: 'pre', 15 | test: /\.(js|vue)$/, 16 | loader: 'eslint-loader', 17 | exclude: /node_modules/, 18 | }, 19 | { 20 | test: /.js$/, 21 | use: 'babel-loader', 22 | }, 23 | { 24 | test: /\.vue$/, 25 | loader: 'vue-loader', 26 | options: { 27 | loaders: { 28 | css: ExtractTextPlugin.extract('css-loader'), 29 | }, 30 | }, 31 | }, 32 | ], 33 | }, 34 | plugins: [ 35 | new webpack.DefinePlugin({ 36 | 'VERSION': JSON.stringify(config.version), 37 | }), 38 | new ExtractTextPlugin(outputFile + '.css'), 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /config/webpack.config.browser.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var merge = require('webpack-merge') 3 | var base = require('./webpack.config.base') 4 | var path = require('path') 5 | 6 | var outputFile = 'vue-async-operations' 7 | var globalName = 'VueAsyncOperations' 8 | 9 | module.exports = merge(base, { 10 | output: { 11 | path: path.resolve(__dirname, '../dist'), 12 | filename: outputFile + '.browser.js', 13 | library: globalName, 14 | libraryTarget: 'umd', 15 | }, 16 | externals: { 17 | // Put external libraries like lodash here 18 | // With their global name 19 | // Example: 'lodash': '_' 20 | }, 21 | plugins: [ 22 | new webpack.optimize.UglifyJsPlugin({ 23 | compress: { 24 | warnings: true, 25 | }, 26 | mangle: false, 27 | }), 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /config/webpack.config.common.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var merge = require('webpack-merge') 3 | var base = require('./webpack.config.base') 4 | var path = require('path') 5 | 6 | var outputFile = 'vue-async-operations' 7 | var globalName = 'VueAsyncOperations' 8 | 9 | module.exports = merge(base, { 10 | output: { 11 | path: path.resolve(__dirname, '../dist'), 12 | filename: outputFile + '.common.js', 13 | libraryTarget: 'commonjs2', 14 | }, 15 | target: 'node', 16 | externals: { 17 | // Put external libraries like lodash here 18 | // With their package name 19 | // Example: 'lodash': 'lodash' 20 | }, 21 | plugins: [ 22 | new webpack.optimize.UglifyJsPlugin({ 23 | compress: { 24 | warnings: true, 25 | }, 26 | mangle: false, 27 | }), 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /config/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var base = require('./webpack.config.base') 3 | var path = require('path') 4 | 5 | var outputFile = 'vue-async-operations' 6 | var globalName = 'VueAsyncOperations' 7 | 8 | module.exports = merge(base, { 9 | output: { 10 | path: path.resolve(__dirname, '../dist'), 11 | filename: outputFile + '.common.js', 12 | library: globalName, 13 | libraryTarget: 'umd', 14 | }, 15 | devtool: 'eval-source-map', 16 | }) 17 | -------------------------------------------------------------------------------- /dist/vue-async-operations.browser.js: -------------------------------------------------------------------------------- 1 | !function(root,factory){"object"==typeof exports&&"object"==typeof module?module.exports=factory():"function"==typeof define&&define.amd?define([],factory):"object"==typeof exports?exports.VueAsyncOperations=factory():root.VueAsyncOperations=factory()}(this,function(){return function(modules){function __webpack_require__(moduleId){if(installedModules[moduleId])return installedModules[moduleId].exports;var module=installedModules[moduleId]={i:moduleId,l:!1,exports:{}};return modules[moduleId].call(module.exports,module,module.exports,__webpack_require__),module.l=!0,module.exports}var installedModules={};return __webpack_require__.m=modules,__webpack_require__.c=installedModules,__webpack_require__.i=function(value){return value},__webpack_require__.d=function(exports,name,getter){__webpack_require__.o(exports,name)||Object.defineProperty(exports,name,{configurable:!1,enumerable:!0,get:getter})},__webpack_require__.n=function(module){var getter=module&&module.__esModule?function(){return module.default}:function(){return module};return __webpack_require__.d(getter,"a",getter),getter},__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)},__webpack_require__.p="",__webpack_require__(__webpack_require__.s=3)}([function(module,__webpack_exports__,__webpack_require__){"use strict";function _toConsumableArray(arr){if(Array.isArray(arr)){for(var i=0,arr2=Array(arr.length);i1&&void 0!==arguments[1]?arguments[1]:{},mergedOptions=_extends({},defaultOptions,options);__WEBPACK_IMPORTED_MODULE_0__async_operations_js__.a.init(mergedOptions),Vue.mixin(__WEBPACK_IMPORTED_MODULE_1__mix_js__.a)}__webpack_exports__.install=install;var __WEBPACK_IMPORTED_MODULE_0__async_operations_js__=__webpack_require__(0),__WEBPACK_IMPORTED_MODULE_1__mix_js__=__webpack_require__(1),_extends=Object.assign||function(target){for(var i=1;i1&&void 0!==arguments[1]?arguments[1]:{},mergedOptions=_extends({},defaultOptions,options);__WEBPACK_IMPORTED_MODULE_0__async_operations_js__.a.init(mergedOptions),Vue.mixin(__WEBPACK_IMPORTED_MODULE_1__mix_js__.a)}Object.defineProperty(__webpack_exports__,"__esModule",{value:!0}),__webpack_exports__.install=install;var __WEBPACK_IMPORTED_MODULE_0__async_operations_js__=__webpack_require__(0),__WEBPACK_IMPORTED_MODULE_1__mix_js__=__webpack_require__(1),_extends=Object.assign||function(target){for(var i=1;i { 2 | return path.split('.').reduce((prev, curr) => { 3 | return prev ? prev[curr] : null 4 | }, target) 5 | } 6 | 7 | export default { 8 | cfg: null, 9 | 10 | init (options) { 11 | this.cfg = { 12 | ...options, 13 | dataPropName: options.mixinPrefix + options.dataPropName, 14 | } 15 | }, 16 | 17 | onCreated (vm) { 18 | if (!vm.$options[this.cfg.componentOptionName]) return 19 | this.buildTree(vm, vm.$options[this.cfg.componentOptionName], this.cfg.dataPropName) 20 | }, 21 | 22 | buildTree (vm, nodes, target) { 23 | const entries = Object.entries(nodes) 24 | entries.forEach(([key, value]) => { 25 | this.addNode(vm, target, key, value) 26 | }) 27 | }, 28 | 29 | addNode (vm, target, key, value) { 30 | if (['string', 'function'].includes(typeof value)) this.addOperation(vm, target, key) 31 | if (typeof value === 'object') this.addBatch(vm, target, key, value) 32 | }, 33 | 34 | addBatch (vm, path, key, obj) { 35 | const batch = this.createNode(vm, 'batch', path, key) 36 | let target = resolvePath(path, vm.$data) 37 | vm.$set(target, key, batch) 38 | const childrenPath = path + '.' + key 39 | this.buildTree(vm, obj, childrenPath) 40 | }, 41 | 42 | addOperation (vm, path, key) { 43 | const single = this.createNode(vm, 'single', path, key) 44 | let target = resolvePath(path, vm.$data) 45 | vm.$set(target, key, single) 46 | }, 47 | 48 | /** 49 | * Create data node for an operation or a batch of operations 50 | * @param {String} type 51 | * @param {String} path 52 | * @param {String} key 53 | */ 54 | createNode (vm, type, path, key) { 55 | return { 56 | $type: type, 57 | $pending: null, 58 | $resolved: null, 59 | $rejected: null, 60 | $err: null, 61 | $perform: (...args) => { 62 | const relPath = path.split('.').filter(el => el !== this.cfg.dataPropName).join('.') 63 | const nodePath = (relPath === '' ? '' : relPath + '.') + key 64 | 65 | switch (type) { 66 | case 'batch': 67 | return this.execBatch(vm, nodePath, args) 68 | case 'single': 69 | return this.execOperation(vm, nodePath, args) 70 | } 71 | }, 72 | } 73 | }, 74 | 75 | /** 76 | * Execute a batch of operations 77 | * @todo pass args to children 78 | * @param {String} batchPath 79 | * @param {Array} args 80 | */ 81 | execBatch (vm, batchPath, args) { 82 | let state = resolvePath(batchPath, vm.$data[this.cfg.dataPropName]) 83 | const batch = resolvePath(batchPath, vm.$options[this.cfg.componentOptionName]) 84 | 85 | this.resetStates(vm, state) 86 | return new Promise((resolve, reject) => { 87 | const arr = Object.entries(batch).map(([key, value]) => { 88 | const childPath = batchPath + '.' + key 89 | const child = resolvePath(childPath, vm.$data[this.cfg.dataPropName]) 90 | const allArgs = [...args][0] 91 | const operationArgs = !allArgs ? undefined : allArgs[key] 92 | return child.$perform(operationArgs) 93 | }) 94 | Promise.all(arr).then( 95 | result => this.handleResolve(vm, state, result, resolve), 96 | err => this.handleReject(vm, state, err, reject) 97 | ) 98 | }) 99 | }, 100 | 101 | /** 102 | * Execute single async operation 103 | * @param {String} funcPath 104 | * @param {Array} args 105 | */ 106 | execOperation (vm, funcPath, args) { 107 | let state = resolvePath(funcPath, vm.$data[this.cfg.dataPropName]) 108 | const func = resolvePath(funcPath, vm.$options[this.cfg.componentOptionName]) 109 | 110 | this.resetStates(vm, state) 111 | return new Promise((resolve, reject) => { 112 | let result 113 | if (typeof func === 'string') result = vm[func](...args) 114 | if (typeof func === 'function') { 115 | result = func.call(vm, ...args) 116 | } 117 | 118 | if (Array.isArray(result)) { 119 | return Promise.all(result).then( 120 | res => this.handleResolve(vm, state, res, resolve), 121 | err => this.handleReject(vm, state, err, reject) 122 | ) 123 | } 124 | 125 | if (!result.then) return this.handleResolve(vm, state, result, resolve) 126 | 127 | result.then( 128 | res => this.handleResolve(vm, state, res, resolve), 129 | err => this.handleReject(vm, state, err, reject) 130 | ) 131 | }) 132 | }, 133 | 134 | resetStates (vm, state) { 135 | vm.$set(state, '$err', null) 136 | vm.$set(state, '$rejected', false) 137 | vm.$set(state, '$resolved', false) 138 | vm.$set(state, '$pending', true) 139 | }, 140 | 141 | handleResolve (vm, state, result, resolve) { 142 | resolve(result) 143 | vm.$set(state, '$pending', false) 144 | vm.$set(state, '$resolved', true) 145 | }, 146 | 147 | handleReject (vm, state, err, reject) { 148 | reject(err) 149 | vm.$set(state, '$pending', false) 150 | vm.$set(state, '$rejected', true) 151 | vm.$set(state, '$err', err) 152 | }, 153 | } 154 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import asyncOperations from './async-operations.js' 2 | import mixin from './mix.js' 3 | 4 | const defaultOptions = { 5 | mixinPrefix: 'vueAsyncOps_', 6 | dataPropName: 'async', 7 | computedPropName: '$async', 8 | componentOptionName: 'asyncOperations', 9 | } 10 | 11 | export function install (Vue, options = {}) { 12 | const mergedOptions = {...defaultOptions, ...options} 13 | asyncOperations.init(mergedOptions) 14 | Vue.mixin(mixin) 15 | } 16 | 17 | /* -- Plugin definition & Auto-install -- */ 18 | /* You shouldn't have to modify the code below */ 19 | 20 | // Plugin 21 | const plugin = { 22 | /* eslint-disable no-undef */ 23 | version: VERSION, 24 | install, 25 | } 26 | 27 | export default plugin 28 | 29 | // Auto-install 30 | let GlobalVue = null 31 | if (typeof window !== 'undefined') { 32 | GlobalVue = window.Vue 33 | } else if (typeof global !== 'undefined') { 34 | GlobalVue = global.Vue 35 | } 36 | if (GlobalVue) { 37 | GlobalVue.use(plugin) 38 | } 39 | -------------------------------------------------------------------------------- /src/mix.js: -------------------------------------------------------------------------------- 1 | import aops from './async-operations.js' 2 | export default { 3 | data () { 4 | return { 5 | [aops.cfg.dataPropName]: {}, 6 | } 7 | }, 8 | computed: { 9 | $async () { 10 | return this[aops.cfg.dataPropName] 11 | }, 12 | }, 13 | created () { 14 | aops.onCreated(this) 15 | }, 16 | } 17 | --------------------------------------------------------------------------------