UNPKG

vue-async-properties

Version:

Vue Component Plugin for asynchronous data and computed properties.

792 lines (636 loc) 19.6 kB
# vue-async-properties > Vue Component Plugin for asynchronous data and computed properties. **A Marketdial Project** <p> <a href="http://marketdial.com"> <img src="https://cdn.rawgit.com/marketdial/vue-async-properties/master/marketdial-logo.svg" alt="MarketDial logo" title="MarketDial" width="35%"> </a> </p> --- ```js new Vue({ props: { articleId: Number }, asyncData: { article() { return this.axios.get(`/articles/${this.articleId}`) } }, data: { query: '' }, asyncComputed: { searchResults: { get() { return this.axios.get(`/search/${this.query}`) }, watch: 'query' debounce: 500, } } }) ``` ```pug #article( v-if="!article$error", :class="{ 'loading': article$loading }") h1 {{article.title}} .content {{article.content}} #article(v-else) | There was an error while loading the article! | {{article$error.message}} button(@click="article$refresh") | Refresh the Article input.search(v-model="query") span(v-if="searchResults$pending") | Waiting for you to stop typing... span(v-if="searchResults$error") | There was an error while making your search! | {{searchResults$error.message}} #search-results(:class="{'loading': searchResults$loading}") .search-result(v-for="result in results") p {{result.text}} ``` Has convenient features for: - loading, pending, and error flags - ability to refresh data - debouncing, with `cancel` and `now` functions - defaults - response transformation - error handling The basic useage looks like this. ```bash npm install --save vue-async-properties ``` ```js // main.js import Vue from 'vue' // you can use whatever http library you prefer import axios from 'axios' import VueAxios from 'vue-axios' Vue.use(VueAxios, axios) Vue.axios.defaults.baseURL = '... whatever ...' import VueAsyncProperties from 'vue-async-properties' Vue.use(VueAsyncProperties) ``` Now `asyncData` and `asyncComputed` options are available on your components. What's the difference between the two? - `asyncData` only runs once automatically, during the component's `onCreated` hook. - `asyncComputed` runs automatically every time any of the things it depends on changes, with a default debounce of `1000` milliseconds. ## `asyncData` You can simply pass a function that returns a promise. ```js // in component new Vue({ props: ['articleId'], asyncData: { // when the component is created // a request will be made to // http://api.example.com/v1/articles/articleId // (or whatever baseURL you've configured) article() { return this.axios.get(`/articles/${this.articleId}`) } }, }) ``` ```pug //- in template (using the pug template language) #article( v-if="!article$error", :class="{ 'loading': article$loading }") h1 {{article.title}} .content {{article.content}} #article(v-else) | There was an error while loading the article! | {{article$error.message}} button(@click="article$refresh") | Refresh the Article ``` ## `asyncComputed` You have to provide a `get` function that returns a promise, and a `watch` parameter that's either a [string referring to a property on the vue instance, or a function that refers to the properties you want tracked](https://vuejs.org/v2/api/#vm-watch). ```js // in component new Vue({ data: { query: '' }, asyncComputed: { // whenever query changes, // a request will be made to // http://api.example.com/v1/search/articleId // (or wherever) // debounced by 1000 miliseconds searchResults: { // the function that returns a promise get() { return this.axios.get(`/search/${this.query}`) }, // the thing to watch for changes watch: 'query' // ... or ... watch() { // do this if you need to watch multiple things this.query } } } }) ``` ```pug //- in template (using the pug template language) input.search(v-model="query") span(v-if="searchResults$pending") | Waiting for you to stop typing... span(v-if="searchResults$error") | There was an error while making your search! | {{searchResults$error.message}} #search-results(v-else, :class="{'loading': searchResults$loading}") .search-result(v-for="result in results") p {{result.text}} ``` You might be asking "Why is the `watch` necessary? Why not just pass a function that's reactively watched?" Well, in order for Vue to reactively track a function, it has to invoke that function up front when you create the watcher. Since we have a function that performs an expensive async operation, which we also want to debounce, we can't really do that. ## Meta Properties Properties to show the status of your requests, and methods to manage them, are automatically added to the component. - `prop$loading`: if a request is currently in progress - `prop$error`: the error of the last request - `prop$default`: the default value you provided, if any **For `asyncData`** - `prop$refresh()`: perform the request again **For `asyncComputed`** - `prop$pending`: if a request is *queued*, but not yet sent because of debouncing - `prop$cancel()`: cancel any debounced requests - `prop$now()`: immediately perform the latest debounced request ## Debouncing It's always a good idea to debounce asynchronous functions that rely on user input. You can configure this both globally and at the property level. By default, anything you pass to `debounce` only applies to `asyncComputed`, since it's the only one that directly relies on input. ```js // global configuration Vue.use(VueAsyncProperties, { // if the value is just a number, it's used as the wait time debounce: 500, // you can pass an object for more complex situations debounce: { wait: 500, // these are the same options used in lodash debounce // https://lodash.com/docs/#debounce leading: false, // default trailing: true, // default maxWait: null // default } }) // property level configuration new Vue({ asyncComputed: { searchResults: { get() { /* ... */ }, watch: '...' // this will be 1000 // instead of the globally configured 500 debounce: 1000 } } }) ``` It is also allowed to pass `null` to debounce, to specify that no debounce should be applied. If this is done, `property$pending`, `property$cancel`, and `property$now` will not exist. The same rules that apply to other options holds here; the global setting will set all components, but it can be overridden by the local settings. ```js // no components will debounce Vue.use(VueAsyncProperties, { debounce: null }) // just this component won't have a debounce new Vue({ asyncComputed: { searchResults: { get() { /* ... */ }, watch: '...' debounce: null // this however would debounce, // since the local overrides the global debounce: 500 } } }) ``` This should only be done when the `asyncComputed` only watches values that aren't changed frequently by the user, otherwise a huge number of requests will be sent out. ### `watchClosely` Sometimes the method should debounce when some values change (things like key inputs or anything that might change rapidly), and *not debounce* when other values change (things like boolean switches that are more discrete, or things that are only changed programmatically). For these situations, you can set up a separate watcher called `watchClosely` that will trigger an immediate, undebounced invocation of the `asyncComputed`. ```js new Vue({ data: { query: '', includeInactiveResults: false }, asyncComputed: { searchResults: { get() { if (this.includeInactiveResults) return this.axios.get(`/search/all/${this.query}`) else return this.axios.get(`/search/${this.query}`) }, // the normal, debounced watcher watch: 'query', // whenever includeInactiveResults changes, // the method will be invoked immediately // without any debouncing watchClosely: 'includeInactiveResults' } } }) ``` Obviously, if you pass `debounce: null`, then `watchClosely` will be ignored, since invoking immediately without any debounce is the default behavior. Also, if you only pass `watchClosely`, that will automatically infer that debouncing should never be done. ```js new Vue({ data: { showOldPosts: false }, asyncComputed: { searchResults: { // a change to showOldPosts // should always immediately // retrigger a request watchClosely: 'showOldPosts', get() { if (this.showOldPosts) return this.axios.get('/posts') else return this.axios.get('/posts/new') } } } }) ``` ## Returning a Value Rather Than a Promise If you don't want a request to be performed, you can directly return a value instead of a promise. ```js new Vue({ props: ['articleId'], asyncData: { article: { get() { // if you return null // the default will be used // and no request will be performed if (!this.articleId) return null // ... or ... // doing this will directly set the value // and no request will be performed if (!this.articleId) return { title: "No Article ID!", content: "There's nothing there." } else return this.axios.get(`/articles/${this.articleId}`) }, // this will be used if null or undefined // are returned either by the get method // or by the request it returns // or if there's an error default: { title: "Default Title", content: "Default Content" } } } }) ``` ## Lazy and Eager `asyncData` allows the `lazy` param, which tells it to not perform its request immediately on creation, and instead set the property as `null` or the default if you've provided one. It will instead wait for the `$refresh` method to be called. ```js new Vue({ asyncData: { article: { get() { /* ... */ }, // won't be triggered until article$refresh is called lazy: true, // default 'false' // if a default is provided, // it will be used until article$refresh is called default: { title: "Default Title", content: "Default content" } } } }) ``` `asyncComputed` allows an `eager` param, which tells it to immediately perform its request on creation, rather than waiting for some user input. ```js new Vue({ data: { query: 'initial query' }, asyncComputed: { searchResults: { get() { /* ... */ }, watch: 'query', // will be triggered right away with 'initial query' eager: true // default 'false' } } }) ``` ## Transformation Functions Pass a `transform` function if you have some processing you'd always like to do with request results. This is convenient if you'd rather not chain `then` onto promises. You can provide this globally and locally. **Note:** this function will only be called if a request is actually made. So if you directly return a value rather than a promise from your `get` function, `transform` won't be called. ```js Vue.use(VueAsyncProperties, { // this is the default transform(result) { return result.data } // ... or ... // doing this will prevent any transforms // from being applied in any properties transform: null }) new Vue({ asyncData: { article: { get() { /* ... */ }, // this will override the global transform transform(result) { return doSomeTransforming(result) }, // ... or ... // doing this will prevent any transforms // from being applied to this property transform: null } } }) ``` ## Pagination Normal pagination is easy with this library, you just need to use some sort of limit and offset in your requests. With `asyncData`: ```js new Vue({ data() { return { pageSize: 10, pageNumber: 0 } }, asyncData: { posts() { return this.axios.get(`/posts`, { params: { limit: this.pageSize, offset: this.pageSize * this.pageNumber, } }) } }, methods: { goToPage(page) { this.pageNumber = page this.posts$refresh() } } }) ``` ... and with `asyncComputed`: ```js const pageSize = 10, new Vue({ data() { return { pageNumber: 0 } }, asyncComputed: { posts: { get() { return this.axios.get(`/posts`, { params: { limit: pageSize, offset: pageSize * this.pageNumber, } }) }, watchClosely: 'pageNumber' } } }) ``` ## Load More Doing a "load more" is interesting though, since you need to append new results onto the old ones. To make a load more situation, pass a `more` option to your property, giving a method that gets more results to add to the old ones. A `$more` method will be added to your component that you can call whenever you want to get more results. ```js const pageSize = 5 new Vue({ data() { return { filter: '' } }, asyncData: { posts: { get() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, } }) }, // this method will get results that will be appended to the old ones // it's triggered by the `posts$more` method more() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, offset: this.posts.length, } }) } // since sometimes the way you add new results // to the property won't be a basic array concat // you can pass a static concat method that // returns a collection with the new results added to it more: { // this is the default concat: (posts, newPosts) => posts.concat(newPosts), get() { const pageSize = 5 return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, offset: this.posts.length, } }) } } } } }) ``` Here's an example template: ```pug input.search(v-model="filter") .posts .post(v-for="post in posts") {{ post.title }} button.load-more(@click="posts$more") Get more posts ``` For `asyncComputed`, the `watch` and `watchClosely` parameters will still trigger a complete reset of the collection. Only the `$more` method appends new results. ```js const pageSize = 5 new Vue({ data() { return { filter: '' } }, asyncComputed: { posts: { get() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, } }) }, watch: 'filter', more() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, offset: this.posts.length, } }) } } } }) ``` All the other options like `transform`, `error`, `debounce`, will still work the same. ### `$more` Returns Last Response If you need to do some logic based on what the last load more request returned, you can wrap the `$more` method and get the last response the `$more` received. This returned value is the raw response, without the `transform` function called on it. ```js const pageSize = 10 new Vue({ data() { return { noMoreResults: false } }, asyncData: { posts: { get() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, } }) }, more() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, offset: this.posts.length, } }) } } }, async moreHandler() { // `$more` handles appending the results, // so don't worry about doing that here // this just allows you to inspect the last result let lastResponse = await this.posts$more() this.noMoreResults = lastResponse.data.length < pageSize } }) ``` ### Watching For Reset Events Since you might need to be notified when the collection resets based on a `watch` or `watchClosely`, you can watch for a `propertyName$reset` event. It passes the response that came for the reset. ```js const pageSize = 5 new Vue({ data() { return { noResultsReturned: false } }, asyncData: { posts: { get() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, } }) }, more() { return this.axios.get(`/posts/${this.filter}`, { params: { limit: pageSize, offset: this.posts.length, } }) } } }, created() { // whenever a watch or watchClosely resets the collection, // it will $emit this event this.$on('posts$reset', (resettingResponse) => { // here you can perform whatever logic // you need to with the resetttingResponse if (resettingResponse.data.length == 0) { this.noResultsReturned = true } this.resetScoller() // or whatever }) } }) ``` ## Error Handling You can set up error handling, either globally (maybe you have some sort of notification tray or alerts), or at the property level. ```js Vue.use(VueAsyncProperties, { error(error) { Notification.error({ title: "error", message: error.message, }) } }) new Vue({ asyncData: { article: { get() { /* ... */ }, // this will override the error handler error(error) { this.doErrorStuff(error) } } } }) ``` There is a global default, which simply logs the error to the console: ```js Vue.use(VueAsyncProperties, { error(error) { console.error(error) } }) ``` ### Different naming for Meta Properties The default naming strategy for the meta properties like `loading` and `pending` is `propName$metaName`. You may prefer a different naming strategy, and you can pass a function for a different one in the global config. ```js Vue.use(VueAsyncProperties, { // for "article" and "loading" // "article__Loading" meta: (propName, metaName) => `${propName}__${myCapitalize(metaName)}`, // ... or ... // "$loading_article" meta: (propName, metaName) => '$' + metaName + '_' + propName, // the default is: meta: (propName, metaName) => `${propName}$${metaName}`, }) ``` ## Contributing This package has testing set up with [mocha](https://mochajs.org/) and [chai expect](http://chaijs.com/api/bdd/). Since many of the tests are on the functionality of Vue components, the [vue testing docs](https://vuejs.org/v2/guide/unit-testing.html) are a good place to look for guidance. If you'd like to contribute, perhaps because you uncovered a bug or would like to add features: - fork the project - clone it locally - write tests to either to reveal the bug you've discovered or cover the features you're adding (write them in the `test` directory, and take a look at existing tests as well as the mocha, chai expect, and vue testing docs to understand how) - run those tests with `npm test` (use `npm test -- -g "text matching test description"` to only run particular tests) - once you're done with development and all tests are passing (including the old ones), submit a pull request!