├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── .yo-rc.json ├── README.md ├── index.js ├── license ├── package.json └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "standard", 3 | "rules": { 4 | "space-before-function-paren": ["error", { 5 | "anonymous": "ignore", 6 | "named": "ignore", 7 | "asyncArrow": "ignore" 8 | }], 9 | } 10 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 14 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm install 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .nyc_output 4 | coverage 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-nm": { 3 | "promptValues": { 4 | "githubUsername": "mokkabonna", 5 | "website": "http://martinhansen.com" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-async-methods 2 | > Vue async methods support 3 | 4 | Gives you utility methods for your promise based methods for use in the view. Also catch errors in the view. 5 | 6 | [Demo](https://jsfiddle.net/nyz4ahys/4/) 7 | 8 | ## Install 9 | 10 | ``` 11 | $ npm install vue-async-methods 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```javascript 17 | import AsyncMethods from 'vue-async-methods' 18 | 19 | Vue.use(AsyncMethods [,options]) 20 | ``` 21 | 22 | ### Options 23 | 24 | #### createComputed 25 | 26 | default `false`, if true: creates computeds that proxies `fetchArticles.resolvedWith` to `articles` 27 | 28 | #### getComputedName(vm, methodName) 29 | 30 | A function that should return the name of the desired computed if createComputed is `true` 31 | 32 | default: 33 | ```js 34 | // turns "fetchArticles", "getArticles" or "loadArticles" into "articles" computed 35 | function (vm, methodName) { 36 | var withoutPrefix = methodName.replace(/^(fetch|get|load)/, '') 37 | return withoutPrefix.slice(0, 1).toLowerCase() + withoutPrefix.slice(1) 38 | } 39 | ``` 40 | 41 | #### onError(err, handledInView, vm, methodName, args) 42 | 43 | default: `null` 44 | 45 | All error raised by the methods will be passed to the onError handler, enabling you to implement 46 | global error handling, logging, etc. 47 | 48 | Now you can define async methods on your vm: 49 | 50 | ```javascript 51 | export default { 52 | asyncMethods: { 53 | fetchArticles() { 54 | return ajax('http://example.com/data.json') 55 | } 56 | }, 57 | } 58 | ``` 59 | 60 | And use the following helper variables in your view: 61 | 62 | ```js 63 | articles // this is a computed that aliases fetchArticles.resolvedWith 64 | fetchArticles //call this function to fetch the articles 65 | fetchArticles.promise // the current or last promise 66 | fetchArticles.isCalled // false until first called 67 | fetchArticles.isPending 68 | fetchArticles.isResolved 69 | fetchArticles.isRejected 70 | fetchArticles.resolvedWith 71 | fetchArticles.resolvedWithEmpty //empty means empty object or empty array 72 | fetchArticles.resolvedWithSomething //opposite of empty 73 | fetchArticles.rejectedWith //Error object 74 | ``` 75 | 76 | It also registers a component called `catch-async-error` that enables you to catch errors in the view instead of in the code. 77 | 78 | 79 | ```html 80 | 81 |
Click button to load data
82 |
Loading data...
83 | 84 | 89 | 90 |
91 | There are no articles. 92 |
93 | 94 | 95 |
96 | Could not load articles due to an error. Details: {{fetchArticles.rejectedWith.message}} 97 |
98 |
99 | ``` 100 | 101 | ## Contributing 102 | 103 | Create tests for new functionality and follow the eslint rules. 104 | 105 | ## License 106 | 107 | MIT © [Martin Hansen](http://martinhansen.com) 108 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var blockRegex = /^(address|blockquote|body|center|dir|div|dl|fieldset|form|h[1-6]|hr|isindex|menu|noframes|noscript|ol|p|pre|table|ul|dd|dt|frameset|li|tbody|td|tfoot|th|thead|tr|html)$/i 4 | 5 | function isBlockLevel(name) { 6 | return blockRegex.test(name) 7 | } 8 | 9 | function isEmpty(val) { 10 | if (Array.isArray(val)) { 11 | return val.length === 0 12 | } else if (typeof val === 'object' && val !== null) { 13 | return Object.keys(val).length === 0 14 | } else if (val === null || val === undefined) { 15 | return true 16 | } else { 17 | return false 18 | } 19 | } 20 | 21 | function isFunction(func) { 22 | return typeof func === 'function' 23 | } 24 | 25 | function createComputed(self, key) { 26 | return function() { 27 | return self[key].resolvedWith 28 | } 29 | } 30 | 31 | module.exports = { 32 | install: function(Vue, options) { 33 | options = options || {} 34 | options.getComputedName = options.getComputedName || function(vm, funcName) { 35 | var withoutPrefix = funcName.replace(/^(fetch|get|load)/, '') 36 | return withoutPrefix.slice(0, 1).toLowerCase() + withoutPrefix.slice(1) 37 | } 38 | 39 | function wrapMethod(func, vm, funcName) { 40 | function wrapped() { 41 | var args = [].slice.call(arguments) 42 | 43 | vm[funcName].isCalled = true 44 | vm[funcName].isPending = true 45 | vm[funcName].isResolved = false 46 | vm[funcName].isRejected = false 47 | vm[funcName].resolvedWith = null 48 | vm[funcName].resolvedWithSomething = false 49 | vm[funcName].resolvedWithEmpty = false 50 | vm[funcName].rejectedWith = null 51 | 52 | try { 53 | var result = func.apply(vm, args) 54 | if (result && result.then) { 55 | vm[funcName].promise = result.then(function(res) { 56 | vm[funcName].isPending = false 57 | vm[funcName].isResolved = true 58 | vm[funcName].resolvedWith = res 59 | 60 | var empty = isEmpty(res) 61 | vm[funcName].resolvedWithEmpty = empty 62 | vm[funcName].resolvedWithSomething = !empty 63 | 64 | return res 65 | }).catch(function(err) { 66 | vm[funcName].isPending = false 67 | vm[funcName].isRejected = true 68 | vm[funcName].rejectedWith = err 69 | 70 | if (isFunction(options.onError)) { 71 | options.onError(err, vm[funcName].handleErrorInView, vm, funcName, args) 72 | } 73 | 74 | throw err 75 | }) 76 | 77 | return vm[funcName].promise 78 | } else { 79 | // always return a promise for consistency 80 | vm[funcName].promise = new Promise(function(resolve) { 81 | vm[funcName].isPending = false 82 | vm[funcName].isResolved = true 83 | vm[funcName].resolvedWith = result 84 | 85 | var empty = isEmpty(result) 86 | vm[funcName].resolvedWithEmpty = empty 87 | vm[funcName].resolvedWithSomething = !empty 88 | 89 | resolve(result) 90 | }) 91 | 92 | return vm[funcName].promise 93 | } 94 | } catch (err) { 95 | // always return a promise for consistency 96 | vm[funcName].promise = new Promise(function(resolve, reject) { 97 | vm[funcName].isPending = false 98 | vm[funcName].isRejected = true 99 | vm[funcName].rejectedWith = err 100 | 101 | if (isFunction(options.onError)) { 102 | options.onError(err, vm[funcName].handleErrorInView, vm, funcName, args) 103 | } 104 | 105 | reject(err) 106 | }) 107 | 108 | return vm[funcName].promise 109 | } 110 | } 111 | 112 | return wrapped 113 | } 114 | 115 | Vue.component('catch-async-error', { 116 | props: { 117 | method: { 118 | required: true 119 | } 120 | }, 121 | render: function(h) { 122 | if (!this.error || !this.$slots || !this.$slots.default) return null 123 | 124 | if (this.$slots.default.length === 1) { 125 | return this.$slots.default[0] 126 | } 127 | 128 | var isAnyBlock = this.$slots.default.some(function(vNode) { 129 | return isBlockLevel(vNode.tag) 130 | }) 131 | var baseElement = isAnyBlock ? 'div' : 'span' 132 | return h(baseElement, this.$slots.default) 133 | }, 134 | data: function() { 135 | return { 136 | error: null 137 | } 138 | }, 139 | created: function() { 140 | this.method.handleErrorInView = true 141 | 142 | if (this.method.promise) { 143 | this.catchError() 144 | } 145 | }, 146 | watch: { 147 | 'method.promise': 'catchError' 148 | }, 149 | methods: { 150 | catchError: function() { 151 | var self = this 152 | this.error = null 153 | 154 | this.method.promise.catch(function(err) { 155 | self.error = err 156 | }) 157 | } 158 | } 159 | }) 160 | 161 | Vue.config.optionMergeStrategies.asyncMethods = Vue.config.optionMergeStrategies.methods 162 | 163 | Vue.mixin({ 164 | beforeCreate: function() { 165 | var self = this 166 | var asyncMethods = this.$options.asyncMethods || {} 167 | 168 | for (var key in asyncMethods) { 169 | var func = wrapMethod(asyncMethods[key], this, key) 170 | 171 | Vue.util.defineReactive(this, key, func) 172 | 173 | var extra = { 174 | promise: null, 175 | isCalled: false, 176 | isPending: false, 177 | isResolved: false, 178 | isRejected: false, 179 | resolvedWith: null, 180 | resolvedWithSomething: false, 181 | resolvedWithEmpty: false, 182 | rejectedWith: null, 183 | handleErrorInView: false 184 | } 185 | 186 | for (var prop in extra) { 187 | Vue.util.defineReactive(func, prop, extra[prop]) 188 | } 189 | 190 | // add computed 191 | if (options.createComputed) { 192 | this.$options.computed = this.$options.computed || {} 193 | var computedName = options.getComputedName(this, key) 194 | 195 | if (!computedName || !computedName.length) { 196 | throw new Error('Computed name for method ' + key + ' is empty, return a non zero length string') 197 | } 198 | 199 | this.$options.computed[computedName] = createComputed(self, key) 200 | } 201 | } 202 | } 203 | }) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Martin Hansen (martinhansen.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-async-methods", 3 | "version": "0.9.1", 4 | "description": "Vue async methods support", 5 | "license": "MIT", 6 | "repository": "mokkabonna/vue-async-methods", 7 | "author": { 8 | "name": "Martin Hansen", 9 | "email": "martin@martinhansen.no", 10 | "url": "martinhansen.com" 11 | }, 12 | "engines": { 13 | "node": ">=4" 14 | }, 15 | "scripts": { 16 | "test": "eslint . && mocha test", 17 | "develop": "mocha test --watch" 18 | }, 19 | "files": [ 20 | "index.js" 21 | ], 22 | "keywords": [ 23 | "" 24 | ], 25 | "dependencies": {}, 26 | "devDependencies": { 27 | "ava": "^0.20.0", 28 | "chai": "^4.1.2", 29 | "decache": "^4.1.0", 30 | "eslint": "^4.7.0", 31 | "eslint-config-standard": "^10.2.1", 32 | "eslint-plugin-node": "^5.1.1", 33 | "eslint-plugin-standard": "^3.0.1", 34 | "mocha": "^3.5.3", 35 | "nyc": "^11.0.0", 36 | "sinon": "^3.3.0", 37 | "vue": "^2.4.4", 38 | "xo": "^0.18.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, beforeEach */ 2 | var expect = require('chai').expect 3 | var sinon = require('sinon') 4 | var decache = require('decache') 5 | var asyncMethods = require('./index') 6 | var resolvePromise 7 | var rejectPromise 8 | 9 | function fetch() { 10 | return new Promise(function(resolve, reject) { 11 | resolvePromise = resolve 12 | rejectPromise = reject 13 | }) 14 | } 15 | 16 | describe('vue-async-methods custom options', function() { 17 | var vm 18 | var onError 19 | var Vue 20 | beforeEach(function() { 21 | decache('vue') 22 | Vue = require('vue') 23 | onError = sinon.stub() 24 | Vue.use(asyncMethods, { 25 | createComputed: true, 26 | onError: onError 27 | }) 28 | 29 | vm = new Vue({ 30 | asyncMethods: { 31 | fetchArticle: fetch 32 | } 33 | }) 34 | }) 35 | 36 | it('creates computeds based on prefix', function() { 37 | expect(vm.article).to.equal(null) 38 | }) 39 | 40 | it('does not create computed if only prefix', function() { 41 | function create() { 42 | vm = new Vue({ 43 | asyncMethods: { 44 | fetch: fetch 45 | } 46 | }) 47 | } 48 | 49 | expect(create).to.throw(/Computed name for method fetch is empty/) 50 | }) 51 | 52 | describe('direct call', function() { 53 | var article = {} 54 | beforeEach(function() { 55 | var call = vm.fetchArticle() 56 | resolvePromise(article) 57 | return call 58 | }) 59 | 60 | it('updates the computed', function() { 61 | expect(vm.article).to.equal(article) 62 | }) 63 | }) 64 | 65 | describe('when it succeds', function() { 66 | var article = {} 67 | beforeEach(function() { 68 | var call = vm.fetchArticle() 69 | resolvePromise(article) 70 | return call 71 | }) 72 | 73 | it('updates the computed', function() { 74 | expect(vm.article).to.equal(article) 75 | }) 76 | }) 77 | 78 | describe('when it fail', function() { 79 | var error = new Error('fail') 80 | beforeEach(function() { 81 | var call = vm.fetchArticle(1, 2, 3) 82 | rejectPromise(error) 83 | return call.catch(function () {}) 84 | }) 85 | 86 | it('calls the global error handler', function() { 87 | sinon.assert.calledOnce(onError) 88 | sinon.assert.calledWithMatch(onError, error, false, sinon.match.object, 'fetchArticle', [1, 2, 3]) 89 | }) 90 | }) 91 | }) 92 | 93 | describe('vue-async-methods default options', function() { 94 | var vm 95 | beforeEach(function() { 96 | decache('vue') 97 | var Vue = require('vue') 98 | Vue.use(asyncMethods) 99 | vm = new Vue({ 100 | asyncMethods: { 101 | fetch: fetch 102 | } 103 | }) 104 | }) 105 | 106 | it('exposes the initial state', function() { 107 | expect(vm.fetch.promise).to.equal(null) 108 | expect(vm.fetch.isCalled).to.equal(false) 109 | expect(vm.fetch.isPending).to.equal(false) 110 | expect(vm.fetch.isResolved).to.equal(false) 111 | expect(vm.fetch.isRejected).to.equal(false) 112 | expect(vm.fetch.resolvedWith).to.equal(null) 113 | expect(vm.fetch.resolvedWithSomething).to.equal(false) 114 | expect(vm.fetch.resolvedWithEmpty).to.equal(false) 115 | expect(vm.fetch.rejectedWith).to.equal(null) 116 | }) 117 | 118 | describe('after called', function() { 119 | var call 120 | beforeEach(function() { 121 | call = vm.fetch() 122 | }) 123 | 124 | it('is called', function() { 125 | expect(vm.fetch.promise).to.equal(call) 126 | expect(vm.fetch.isCalled).to.equal(true) 127 | expect(vm.fetch.isPending).to.equal(true) 128 | expect(vm.fetch.isResolved).to.equal(false) 129 | expect(vm.fetch.isRejected).to.equal(false) 130 | expect(vm.fetch.resolvedWith).to.equal(null) 131 | expect(vm.fetch.resolvedWithSomething).to.equal(false) 132 | expect(vm.fetch.resolvedWithEmpty).to.equal(false) 133 | expect(vm.fetch.rejectedWith).to.equal(null) 134 | }) 135 | 136 | describe('when resolved with empty', function() { 137 | var resolveResult = {} 138 | beforeEach(function() { 139 | resolvePromise(resolveResult) 140 | return call 141 | }) 142 | 143 | it('reflects status', function() { 144 | expect(vm.fetch.promise).to.equal(call) 145 | expect(vm.fetch.isCalled).to.equal(true) 146 | expect(vm.fetch.isPending).to.equal(false) 147 | expect(vm.fetch.isResolved).to.equal(true) 148 | expect(vm.fetch.isRejected).to.equal(false) 149 | expect(vm.fetch.resolvedWith).to.equal(resolveResult) 150 | expect(vm.fetch.resolvedWithSomething).to.equal(false) 151 | expect(vm.fetch.resolvedWithEmpty).to.equal(true) 152 | expect(vm.fetch.rejectedWith).to.equal(null) 153 | }) 154 | }) 155 | 156 | describe('when resolved with something', function() { 157 | var resolveResult = { 158 | foo: false 159 | } 160 | beforeEach(function() { 161 | resolvePromise(resolveResult) 162 | return call 163 | }) 164 | 165 | it('reflects status', function() { 166 | expect(vm.fetch.promise).to.equal(call) 167 | expect(vm.fetch.isCalled).to.equal(true) 168 | expect(vm.fetch.isPending).to.equal(false) 169 | expect(vm.fetch.isResolved).to.equal(true) 170 | expect(vm.fetch.isRejected).to.equal(false) 171 | expect(vm.fetch.resolvedWith).to.equal(resolveResult) 172 | expect(vm.fetch.resolvedWithSomething).to.equal(true) 173 | expect(vm.fetch.resolvedWithEmpty).to.equal(false) 174 | expect(vm.fetch.rejectedWith).to.equal(null) 175 | }) 176 | }) 177 | 178 | describe('when resolved with empty array', function() { 179 | var resolveResult = [] 180 | beforeEach(function() { 181 | resolvePromise(resolveResult) 182 | return call 183 | }) 184 | 185 | it('reflects status', function() { 186 | expect(vm.fetch.promise).to.equal(call) 187 | expect(vm.fetch.isCalled).to.equal(true) 188 | expect(vm.fetch.isPending).to.equal(false) 189 | expect(vm.fetch.isResolved).to.equal(true) 190 | expect(vm.fetch.isRejected).to.equal(false) 191 | expect(vm.fetch.resolvedWith).to.equal(resolveResult) 192 | expect(vm.fetch.resolvedWithSomething).to.equal(false) 193 | expect(vm.fetch.resolvedWithEmpty).to.equal(true) 194 | expect(vm.fetch.rejectedWith).to.equal(null) 195 | }) 196 | }) 197 | 198 | describe('when resolved with array', function() { 199 | var resolveResult = [1] 200 | beforeEach(function() { 201 | resolvePromise(resolveResult) 202 | return call 203 | }) 204 | 205 | it('reflects status', function() { 206 | expect(vm.fetch.promise).to.equal(call) 207 | expect(vm.fetch.isCalled).to.equal(true) 208 | expect(vm.fetch.isPending).to.equal(false) 209 | expect(vm.fetch.isResolved).to.equal(true) 210 | expect(vm.fetch.isRejected).to.equal(false) 211 | expect(vm.fetch.resolvedWith).to.equal(resolveResult) 212 | expect(vm.fetch.resolvedWithSomething).to.equal(true) 213 | expect(vm.fetch.resolvedWithEmpty).to.equal(false) 214 | expect(vm.fetch.rejectedWith).to.equal(null) 215 | }) 216 | }) 217 | 218 | describe('when rejected', function() { 219 | var rejectResult = new Error('msg') 220 | beforeEach(function() { 221 | rejectPromise(rejectResult) 222 | return call.catch(function () {}) // expect fail 223 | }) 224 | 225 | it('reflects status', function() { 226 | expect(vm.fetch.promise).to.equal(call) 227 | expect(vm.fetch.isCalled).to.equal(true) 228 | expect(vm.fetch.isPending).to.equal(false) 229 | expect(vm.fetch.isResolved).to.equal(false) 230 | expect(vm.fetch.isRejected).to.equal(true) 231 | expect(vm.fetch.resolvedWith).to.equal(null) 232 | expect(vm.fetch.resolvedWithSomething).to.equal(false) 233 | expect(vm.fetch.resolvedWithEmpty).to.equal(false) 234 | expect(vm.fetch.rejectedWith).to.equal(rejectResult) 235 | }) 236 | }) 237 | }) 238 | }) 239 | --------------------------------------------------------------------------------