├── .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 |
85 | -
86 | {{article.name}}
87 |
88 |
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 |
--------------------------------------------------------------------------------