vue-modelModel component for Vue.js
About
Vue-model is a Javascript plugin for Vue.js that gives you the ability to transform your plain data into rich models with built-in and customizable HTTP actions, computed properties, and methods.
This project started because I work in Vue relatively often and really wanted to be able to call customer.save()
, have it POST
the data to the server, show the user feedback that the action was in progress, and then apply the server's results to the model.
So that's what this plugin does.
TOC
- Installation
- Quick Examples
- Defining Models
- Registering Models
- Creating Models
- Performing HTTP Actions
- Busy Indicators
- Events
- Testing
Installation
> npm install --save vue-model
Vue.use(require('vue-model'));
Quick Examples
Here are a few very basic examples to show you a little bit of what you can do with vue-model.
Create a Model
POST
the model to the backend and show a spinner while it's happening.
<button @click.prevent='customer.http.store()'>
<template v-if='customer.http.createInProgress'>
<i class='fa fa-spinner fa-spin'></i>
Creating...
</template>
<template v-if='!customer.http.createInProgress'>
Create Customer
</template>
</button>
Update a Model
The same as creating, but instead it issues a PUT
.
<button @click.prevent='customer.http.update()'>
<template v-if='customer.http.updateInProgress'>
<i class='fa fa-spinner fa-spin'></i>
Saving...
</template>
<template v-if='!customer.http.updateInProgress'>
Save Customer
</template>
</button>
Fetch or Delete a Model
Some of the other default actions you can execute, right out of the box.
<div v-for='customer in customers'>
{{ customer.name }}
(<a href='#' @click.prevent='customer.http.fetch()'>Refresh</a>)
(<a href='#' @click.prevent='customer.http.destroy()'>Delete</a>)
</div>
Defining Models
Defining your models is as simple as providing a plain JSON object, but it's often better to keep them in dedicated files.
Here's an example of a customer
model that defines a few of the model's attributes, some http
options, methods, and computed properties.
var Customer = module.exports = {
attributes: [
'id',
'name',
'email',
],
http: {
baseRoute: '/api/customers/',
actions: {
// Don't expose the destroy action
destroy: false
}
},
methods: {
sayHello() {
alert('Hello ' + this.name);
}
},
computed: {
is_aaron: function () {
return this.email === 'aarondfrancis@gmail.com'
}
}
}
Once you create a model (which we'll talk about in a bit), you now have a super powerful object you can play with in your views.
Here's a simple form that allows a user to update their name and persist it to the backend. (Bonus: it also shows a loading indicator!)
Change your name:
<input v-model='customer.name'>
<button @click='customer.http.update()'>
<template v-if='customer.http.updateInProgress'>
<i class='fa fa-spinner fa-spin'></i>
Saving...
</template>
<template v-if='!customer.http.updateInProgress'>
Save
</template>
</button>
And here's the same form, with feedback for errors:
Change your name:
<input v-model='customer.name' :class="{'error': customer.http.errors.has('name') }">
<div v-if='customer.http.errors.any()'>
Uh oh!
<ul>
<li v-for='error in customer.http.errors.flat()'>
{{ error.field }}: {{ error.message }}
</li>
</ul>
</div>
<button @click='customer.http.update()'>
<template v-if='customer.http.updateInProgress'>
<i class='fa fa-spinner fa-spin'></i>
Saving...
</template>
<template v-if='!customer.http.updateInProgress'>
Save
</template>
</button>
The crazy thing about this it's all done right there with our model: putting the data, showing loading indicators, receiving the response, and mapping the validation errors!
Definition Options
Your model definitions are merged with the values in Defaults.js
to create a fully fleshed out model. Let's take a look at some of the options you can define.
Attributes
The top level attributes
key defines your model's... attributes. When a model is created, we'll default all these keys to null
so that Vue tracks them. Just like plain objects in Vue, if you set a previously undefined key on an object, Vue may not track it and you'll run into unexpected (and frustrating) results.
The attributes
array also forms the basis for what data is sent to the server for HTTP requests. You can of course modify what data is sent, but the starting point is the keys in the attributes
array.
Methods & Computed
This will be familiar to you if you're familiar with Vue. They work the same way.
HTTP
Now we're getting to the really good stuff. The top level http
key holds all of our configuration for making requests. Let's look at some of the keys in the http
object.
baseRoute
: This may be something like /customers/
, or /api/customers/
, resulting in final urls like /customers/{id}
eventPrefix
: The namespace for events sent from this model. If you don't define a value for this, then it is automatically set to the name
that you register your model with. (See the Events section for more on this.)
takeAtLeast
: If you want your actions to take at least a certain amount of time, you can set this value to something greater than 0. Sometimes if an action takes < 100ms, it can seem like it didn't work. I learned this from watching an Adam Wathan live-stream, and then later saw it on his twitter. By default it's set to 0ms.
axios
: a function that lets you customize the axios request configuration option, if you want.
getDataFromResponse
: this is the function that takes the axios response from the server and turns it into a key-value map of data. This just depends on how your backend returns the data. You may return it in a nested data
key, or you may return it with no nesting at all.
getErrorsFromResponse
: same idea as getDataFromResponse
, but for validation errors. This function takes the response
and returns a map where keys are field names, and values are arrays of errors. By default this is set up for Laravel 5.5 by returning response.data.errors
.
isValidationError
: there are lots of things that can go wrong with a request, but we only want to try to map validation errors when the thing that went wrong was validation. This function receives an error and returns a bool if it was a validation error. By default, this is set up to return true
if the response code is 422
, which is what Laravel sends when validation fails.
errorKey
: by default, we nest the errors on the http
key, making access from your model look something like customer.http.errors.{method}
. If you don't like that, you can change it! You can set this value to e.g. errors
, and it will live at the top of your model, making access a little easier: customer.errors.{method}
. (Make sure your model doesn't have an errors
attribute, method, or computed property though!)
Action Definitions
actionDefaults
the defaults that are applied to every action:
{
// Apply the response data to the model
apply: false,
// The server validates this request and could
// return a validation error in response
validation: true,
// Modify what data we should send to the server
data: {
// only: [],
// with: [],
// without: [],
// custom: function(data, definition) {
// return data;
// }
}
}
The actions live in the actions
key. This defines the http actions available on your model. You can simply use the defaults, or make up your own! By default, we have index
, store
, fetch
, update
, and destroy
.
If you wanted to make your own, you could do so like this:
{
actions: {
favorite: {
method: 'PUT',
route: '{id}/favorite',
apply: true,
data: false
},
unfavorite: {
method: 'DELETE',
route: '{id}/favorite',
apply: true,
data: false
}
}
}
Now you have two new methods on your model, favorite
and unfavorite
, which you'd call by e.g.: customer.http.favorite()
. (Why you would favorite a customer is beyond me, but you get the idea.)
Assuming your baseRoute
is /customers/
and its id
is 1, the favorite action will PUT
to /customers/1/favorite
with no data (data
: false), and apply whatever response comes back from the server. (The server could respond with {favorited: true}
for example.)
If you want to disable one of the default actions, just set the value to false
{
actions: {
destroy: false
}
}
customer.http.destroy
will be undefined now.
Data
By default, we'll send all the keys + values from your model's attributes
array. This may not be ideal in many cases though, so you can modify what data gets sent for each action. (Some of these examples may be contrived, but bear with me...)
-
false
means no data will be sent at all{ favorite: { data: false } }
-
only: []
defines the only keys from theattributes
you want sent.{ setName: { data: { only: ['name'] } } }
-
without: []
defines which keys you want to exclude{ update: { data: { // Maybe this a separate resource? without: ['favorited'] } } }
-
with: []
keys present onthis
but not inattributes
that you want to include, perhaps a computed property or method{ update: { data: { with: ['computed_property', 'method'] } } }
-
custom: function
after all of the above are computed, as the very last step we'll check for acustom
function. The function receives thepayload
we're about send as well as the action's definition. You must return a payload, otherwise nothing will be sent.{ recent: { data: { custom: function(payload, definition) { payload.lastFetched = localStorage.get('last_fetched'); return payload; } } } }
only
, with
, and without
can be arrays, or functions that return arrays.
The arrays themselves can be full of strings, which are accessible from the model (attributes, methods, computed properties) or they can be plain objects.
For example:
{
favorited: {
route: '',
method: 'GET',
data: {
only: [],
with: [{
active: 1,
favorited: 1
}]
}
}
}
That action would send a GET
to /customers/?active=1&favorited=1
. We dropped every key via the only:[]
, and then added some static data via with
.
Registering Models
Now that we know alllll about model definition, let's talk about registration. The registration process is simple using the Vue.models.register
method, eg:
var VueModel = require('vue-model');
Vue.use(VueModel);
Vue.models.register('customer', {
// model definition
});
Vue.models.register('foo', {
// model definition
});
The first argument is the name
argument, the second argument is a plain object that lets you define some options that are specific to your model.
Here's an example of registering a customer
model that has a base route of /customers
:
Vue.models.register('customer', {
http: {
baseRoute: '/customers'
}
});
You can also register many at once during the plugin install:
var VueModel = require('vue-model');
Vue.use(VueModel, {
customer: require('./models/customer'),
foo: require('./models/foo'),
bar: require('./models/bar'),
});
In this case, they key is the name
and the value is the model's definition object.
Now you're ready to start creating and using your models.
Creating Models
There are two different ways to create models in vue-model: You can create them manually whenever you please, or you can have vue-model create them automatically.
Manually
A Single Model
To manually create a model, use the $model()
Vue Instance method.
Within a Vue Instance:
this.$model(name, data, options);
The $model()
method accepts 3 parameters:
-
name
: (string) The type of model. This is the same key you used to register the model -
data
: (object) The model data -
options
: (object) Any instance specific model definitions
You can create the model wherever you please. For example, you can call the method inside the data
function:
new Vue({
el: '#app',
data: function() {
return {
customer: this.$model('customer', {
name: 'Aaron'
})
};
}
});
Or you can call it anywhere else! Here's an example where we instantiate a model within Vue's created
lifecycle hook.
new Vue({
el: '#app',
data: {
customer: {
name: 'Aaron'
}
},
created: function() {
this.customer = this.$model('customer', this.customer);
}
});
Multiple Models
Do you have a whole set of models that need to be created? Great! Just use the this.$models
method. It's the same as this.$model
except the data parameter is an array of objects.
-
name
: (string) The type of model. This is the same key you used to register the model -
data
: (array) A collection model data -
options
: (object) Any instance specific model definitions
This will return an array of models.
You can definitely do this yourself using a
for
loop and thethis.$model
method,this.$models
is just a little more convenient.
Automatically
Manually creating models gives you ultimate flexibility, but sometimes you just want it to work right away. That's where automatic model creation comes into play.
To automatically create models, you simply need to add a models
array to your Vue Instance. A models
array element can take two forms. The first form is just a string:
new Vue({
el: '#app',
models: ['customer'],
data: {
customer: {
name: 'Aaron'
}
}
});
When you pass a string in, the model type the data key must be the same. In the example above, the model type must be customer
, and the data key must also be customer
.
If you need more flexibility in naming, you can pass in a proper object.
new Vue({
el: 'body',
models: [{
type: 'customer'
key: 'newCustomer'
}],
data: {
newCustomer: {
name: 'Aaron'
}
}
});
In this example, the model type is still customer
, but the actual data lives on the data key newCustomer
.
Under the hood, vue-model adds a mixin that latches on to the
created
lifecycle event to create models automatically. Read more about the Vue Instance lifecycle
Depending on what the data is will depend on whether one or many models are automatically created. If the data is an array, many model will be created. For example:
new Vue({
el: '#app',
models: [{
type: 'customer'
key: 'customers'
}],
data: {
customers: [{
name: 'Aaron'
},{
name: 'Evan'
}]
}
});
We told vue-model to automatically create the customer
model for the data that lives at the customers
key. Because the data at customers
is an array, vue-model will loop through and make each item a model.
Performing HTTP Actions
Performing HTTP Actions is the heart of vue-model. The whole purpose of this plugin is to make it painless for your models to interact with your application's backend.
All the actions are available on the http
key. To perform an action, you just need to call the corresponding method.
Examples:
// Create a new customer
customer.http.store();
// Fetch this customer from the server
customer.http.fetch();
// Save this customer
customer.http.update();
// Delete this customer
customer.http.destroy();
// Retrieve a list of customers
customer.http.index()
These are the 5 actions that vue-model ships with, but you are welcome to disable those and/or set up your own.
Route Interpolation
All of your action's routes will be interpolated with your model's data. So if your model has an id
of 10
, a route of
/videos/{id}
becomes
/videos/10
You can do this with any attribute from your model. If your model's type
has a value of watched
, a route defined as
/videos/{type}/increment
would become
/videos/watched/increment
Applying Response Data (or Not)
Another great thing about vue-model is that you can automatically update your models with the response that comes back from the server.
If you define your action with apply = true
, vue-model will take the response from the server, loop through all the data and set the values.
{
favorite: {
method: 'PUT',
route: '{id}/favorite',
apply: true,
data: false
}
}
If the server returns
{
favorited: 1
}
as its payload from the complete
action, then the completed
attribute on our model will automatically be updated.
customer.http.favorite();
// Once it finishes...
console.log(customer.favorited);
// > 1
That lets us create toggle buttons very easily, all in HTML.
<button v-if="customer.favorited" @click.prevent="video.$.unfavorite()">
Favorited
</button>
<button v-if="!customer.favorited" @click.prevent="video.$.favorite()">
Mark as Favorite
</button>
Busy Indicators
You'll often want to know when the model is busy, so that you can show loading indicators or prevent other actions. Vue-model provides two types of busy indicators: Global, and Action Specific.
Global
The global busy indicator lives in the http object under the inProgress
key.
For example, if you have a model named customer
, you can observe the customer.http.inProgress
attribute. This is helpful for showing/hiding elements or disabling buttons.
Here's one way you can disable a button, should the model be busy performing an HTTP action:
<button @click='customer.http.complete()' :disabled='customer.http.inProgress'>
Mark as Favorite
</button>
Action-Specific
If you have loading indicators scattered across the page and only want to show the correct indicator based on the specific action, then you should use an action-specific busy indicator.
For every action, there is a corresponding property that indicates whether or not that action is currently in process. For example, if the action is named update
, then the property would be named updateInProgress
.
Consider a case where you have a complete
action for a video
model and would like to show a loading indicator on the button.
<button @click='video.http.complete()' :disabled='video.http.completeInProgress'>
<i v-if='video.http.completeInProgress' class='fa fa-spinner fa-spin'></i>
Mark as Complete
</button>
This button will disable itself and show the lovely Font Awesome loading indicator while the model finishes the complete
action. This provides feedback and a good experience for your users. However, in this example if a different action is being performed, say a favorite
action, the button will not show the loading indicator because it is bound to completeInProgress
and not inProgress
or favoriteInProgress
.
When any of the action-specific loading indicators (
{action}InProgress
) aretrue
, the globalinProgress
indicator will also betrue
.
Events
Vue-model emits several events that you can listen for and respond to, giving you many different ways to seamlessly tie your app into vue-model.
Naming
Vue-model events follow a naming scheme of {eventPrefix}.{action}.{result}
. The eventPrefix
can be set in your model definition.
By default, if you don't pass in an eventPrefix
while registering your model, vue-model will set it to the name
of model you register.
// No eventPrefix, model type is 'customer'
Vue.models.register('customer', {});
// --> eventPrefix is equal to 'customer'
// Explicit eventPrefix passed in
Vue.models.register('customer', {
http: {
eventPrefix: 'cst'
}
});
// --> eventPrefix is equal to 'cst'
{action}
is always equal to the name of the action on your API. If you call customer.http.update()
, action
will be equal to update
.
{result}
is one of the following:
-
before
- Before the action takes place -
success
- Successful completion of the action -
complete
- Action finished, regardless of outcome -
error
- Action failed
Putting it all together, the event name will look similar to the following examples:
customer.update.before
customer.destroy.success
customer.list.complete
customer.fetch.error
Data
Each event comes with data
payload:
-
{eventPrefix}.{action}.before
- Before the action takes place. Receives a plain object with a function you can call to prevent the action from happening.{ cancel: fn }
-
{eventPrefix}.{action}.success
- Successful completion of the action. Receives theresponse
from the server. -
{eventPrefix}.{action}.error
- Action failed. Receives the axioserror
object. -
{eventPrefix}.{action}.complete
- Action finished, regardless of outcome. No data. (Not fired if the action was cancelled manually by calling thecancel
function in thebefore
event.)
Listeners
Automatically Adding Listeners
Events aren't much good without event listeners. To add event listeners, define a listeners
key in your models
option. Any model registration then moves to a register
key. E.g:
new Vue({
el: '#app',
models: {
register: ['customer']
listeners: [
'customer.update.success'
]
},
});
When you add listeners automatically like this, they are bound during the Vue instance's mounted
hook and automatically destroyed in the beforeDestroy
lifecycle hook.
There are a couple different ways to register listeners within the listeners
array. The first is a simple string, e.g. customer.update.success
. This serves as both the event and the handler. Vue-model will try to call a method named customer.update.success
.
In that case, you'd set your Vue instance up like this:
new Vue({
el: '#app',
models: {
register: ['customer']
listeners: [
'customer.update.success'
]
},
methods: {
'customer.update.success': function() {
console.log('great success!')
}
}
});
If you don't like that, then you can add listeners as a map, with the key being the event and the value being the method to call, either a string or a function.
new Vue({
el: '#app',
models: {
register: ['customer']
listeners: [{
'customer.update.success': 'customerUpdated',
'customer.update.error': function() {
console.log('oh no!');
}
}]
},
methods: {
customerUpdated: function() {
console.log('great success!')
}
}
});
You can mix and match however you please. If you want to map multiple handlers to a single event, you're free to just keep adding elements to the array.
new Vue({
el: '#app',
models: {
register: ['customer']
listeners: [
'customer.update.success',
{
'customer.update.error': function() {
console.log('oh no!');
}
}, {
'customer.update.error': function() {
console.log('two handlers for the same event, why not');
}
}]
},
methods: {
'customer.update.success': function() {
console.log('great success!')
}
}
});
Manually Adding Listeners
There may be times when you don't want to add listeners when your instance is mounted, which is when the automatic listeners are bound. If that's the case, you're in luck because you can use the $addModelListeners
and $removeModelListeners
methods.
new Vue({
el: '#app',
// No automatic listeners
models: {
register: ['customer']
},
watch: {
// Some property that determines if this
// component is "active"
componentIsActive: function(val) {
var method = val ? '$add' : '$remove';
// Add or remove our listeners
this[method + 'ModelListeners']([
'customer.update.success'
]);
}
}
methods: {
'customer.update.success': function() {
console.log('great success!')
}
}
});
Remember: When you don't use automatic event listeners, you are responsible for cleaning up your event listeners.
Testing
- Run
npm t
ornpm test
to run all tests once. - Run
npm run test:watch
to watch and rerun tests.