blainehansen / vue-async-properties

Vue Component Plugin for asynchronous data and computed properties.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

vue-async-properties

Vue Component Plugin for asynchronous data and computed properties.

A Marketdial Project

MarketDial logo


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,
    }
  }
})
#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.

npm install --save vue-async-properties
// 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.

// 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}`)
    }
  },
})
//- 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.

// 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
      }
    }
  }
})
//- 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.

// 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.

// 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.

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.

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.

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.

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.

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.

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:

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:

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.

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:

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.

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.

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.

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.

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:

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.

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 and chai expect. Since many of the tests are on the functionality of Vue components, the vue testing docs 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!

About

Vue Component Plugin for asynchronous data and computed properties.

License:MIT License


Languages

Language:JavaScript 100.0%