Enyo: Up and Running (2015)
Chapter 5. Writing Data-Driven Applications
Enyo provides first-class support for creating rich, data-driven applications. Along with the data binding and observer features we touched on briefly in Chapter 2, there are models, collections, data-driven controls, and ways to synchronize data with remote data sources. In this chapter we’ll explore these concepts and components.
Models
The bindings in Enyo work with any Object, which makes it easy to associate the data from one component to another. Sometimes, however, the data that needs binding doesn’t live neatly within any one component in the app. To handle such situations, Enyo has Model. Model, which is not actually an Object but does support get() and set(), is designed to wrap plain-old JavaScript objects and make the data available for binding. The following illustrates the creation of a simple model:
var restaurant = new enyo.Model({
name: 'Orenchi',
cuisine: 'Japanese',
specialty: 'ramen'
});
You can derive from Model to create new model types and specify default attributes and values:
enyo.kind({
name: 'RestaurantModel',
kind: 'enyo.Model',
attributes: {
name: 'unknown',
cuisine: 'unknown',
specialty: 'unknown',
rating: 0
}
});
Whenever a RestaurantModel is instantiated, the defaults will be applied to any properties whose values are not explicitly defined:
var mcd = new RestaurantModel({ name: 'McDonalds' });
mcd.get('specialty');
// returns 'unknown'
TIP
TIP
In this sample and some that follow, there is an interactive console that allows you to experiment with the code. You can type JavaScript statements into the gray box and run them to see what happens. Try creating new models or changing some of the values around and see what happens. The console is based on a lightly modified version of JS Console by Remy Sharp.
In addition to defaults, you can add methods, computed properties (discussed later in this chapter), observers, and bindings to models. For example, to track how often the name of a restaurant has changed, you can add a nameChanged() method:
enyo.kind({
name: 'RestaurantModel',
kind: 'enyo.Model',
attributes: {
name: 'unknown',
cuisine: 'unknown',
specialty: 'unknown',
rating: 0
},
nameChanged: function(was, is) {
if(is) {
this.changeCount = this.changeCount ?
this.changeCount++ : 1;
}
}
});
TIP
The previous code checks to ensure that the name is being set to a new value and, if so, increments the count (unless it was undefined, in which case it is assigned a value of 1).
TIP
The nameChanged method is not invoked during model creation, as the example shows. Also, note that the changeCount property is not fetchable using get() because it was not declared in the attributes block. When calling get() or set() on a model, you are interacting with properties on the attributes member, not the model itself. Always use get() and set() when working with model properties.
It is very easy to design components that work with models. Let’s create a component to view our restaurant model:
enyo.kind({
name: 'RestaurantView',
components: [
{ name: 'name' },
{ name: 'cuisine' },
{ name: 'specialty' },
{ name: 'rating' }
],
bindings: [
{ from: 'model.name', to: '$.name.content' },
{ from: 'model.cuisine', to: '$.cuisine.content' },
{ from: 'model.specialty', to: '$.specialty.content' },
{ from: 'model.rating', to: '$.rating.content' }
]
});
TIP
The RestaurantView component uses bindings to map the fields from its model property to the appropriate controls. Whenever a new model is assigned or one of the properties in the assigned model changes, the contents of the control will be updated. To assign the model to the view, set themodel property during creation, use set(), or bind the model property to a model stored elsewhere.
Collections
While Model wraps a single object, Collection is a Component that wraps arrays of objects. A collection can be initialized with an array, in which case each object in the array is upgraded to a model as it’s added:
var fruits = new enyo.Collection([
{ name: 'apple' },
{ name: 'cherry' },
{ name: 'banana' }
]);
Individual models can be retrieved with the at() method. Models can be added by calling the add() method and passing in an object, a model, or an array of either:
fruits.at(0).get('name');
// returns "apple"
fruits.add({ name: 'rambutan' });
fruits.at(fruits.length-1).get('name');
// returns "rambutan"
TIP
In many ways, collections behave like arrays. They have a length property that reflects the number of items in the collection. They also support various Array methods like find() and forEach(). For a comprehensive list of methods supported by Collection, see the full API documentation.
Like Model, Collection can be subclassed to specify additional configuration and methods. For example, you can specify a default model to be used when objects are added to the array:
enyo.kind({
name: 'RestaurantCollection',
kind: 'enyo.Collection',
model: 'RestaurantModel'
});
Collections are very powerful when they are teamed up with data-aware components. We’ll explore those later in this chapter.
Computed Properties
Applications often need to alter or combine data before it can be used. For example, it is convenient to combine a person’s first and last names into a full name. Enyo provides computed properties to centralize this work instead of requiring you to write repetitive code. Computed properties can be used on any Object or Model and work just like other properties.
Let’s adjust the RestaurantModel to have a property that contains the rating expressed in number of stars:
enyo.kind({
name: 'RestaurantModel',
kind: 'enyo.Model',
attributes: {
name: 'unknown',
cuisine: 'unknown',
specialty: 'unknown',
rating: 0
},
computed: [
{ method: 'starRating', path: 'rating' }
],
starRating: function() {
var rating = this.get('rating');
return rating + ' star' + ((rating == 1) ? '' : 's');
}
});
var rest = new RestaurantModel({
name: 'The French Laundry',
rating: 5
});
rest.get('starRating');
// returns "5 stars"
TIP
A computed property requires a method with the same name to compute the value. The path in the declaration refers to the property (or properties) it is dependent upon, much like observers. The path can be either a string (if there is only one property) or an array of strings. If one of its pathproperties changes, a computed property is recalculated the next time it is needed.
Data-Aware Components
Using bindings, any component can be linked with data from another component or with a model. When dealing with collections, you need controls that know how to render the contents. Unsurprisingly, these components deal with displaying data in lists or tables. The core data-aware components include DataList, DataRepeater, and DataGridList. The Moonstone library includes some additional data-aware components.
Each of these components looks for a collection property that will contain the data to be rendered. Let’s implement a DataRepeater that can display the collection of restaurants we created earlier:
enyo.kind({
name: 'RestaurantRepeater',
kind: 'enyo.DataRepeater',
components: [{
components: [
{ name: 'name' },
{ name: 'cuisine' },
{ name: 'specialty' },
{ name: 'rating' }
],
bindings: [
{ from: 'model.name', to: '$.name.content' },
{ from: 'model.cuisine', to: '$.cuisine.content' },
{ from: 'model.specialty', to: '$.specialty.content' },
{ from: 'model.rating', to: '$.rating.content' }
]
}]
});
TIP
As with Repeater, the components block is the template for each row. The bindings section in the preceding code references a model property, which is automatically set from the collection for each row that needs to be rendered. This allows for a simple mapping from the properties of the model to the components in the DataRepeater.
We can simplify the previous code by reusing our RestaurantView component:
enyo.kind({
name: 'RestaurantRepeater',
kind: 'enyo.DataRepeater',
components: [{ kind: 'RestaurantView' }]
});
TIP
Some of the benefits of using data-aware components over their non-data-aware versions include automatic updates when any of the underlying models change, built-in support for selection, and simpler binding of data to the components. You can find out more about the data-aware components in the API viewer.
Fetching Remote Data
It’s a rare app these days that doesn’t interact with data stored somewhere in the cloud or locally in the browser. Enyo uses the concept of data sources to work with persistent data. There are three data sources included with Enyo: AjaxSource, JsonpSource, and LocalStorageSource. Apps can use or extend these to fetch and commit data.
TIP
The Ajax and JSONP sources are intended to be extended by app developers. They will work as-is in cases where the server interaction is very simple.
Let’s revisit the sample from Chapter 3 where we fetched the list of repos from GitHub. We’ll update that sample to use a collection, a source, and a DataRepeater:
enyo.ready(function() {
enyo.AjaxSource.create({ name: 'ajax' });
var collection = new enyo.Collection({
source: 'ajax',
url: 'https://api.github.com/users/enyojs/repos'
});
enyo.kind({
name: 'RepoView',
kind: 'DataRepeater',
collection: collection,
components: [{
components: [{ name: 'repoName' }],
bindings: [
{ from: 'model.name', to: '$.repoName.content' }
]
}]
});
new enyo.Application({ name: 'app', view: 'RepoView' });
collection.fetch();
});
TIP
In the preceding code, we created a new instance of AjaxSource (the Ajax source component) and assigned it to a new collection. We then assigned the collection to the collection attribute of the DataRepeater. Finally, we called the fetch() method on the collection to get the list of repositories from GitHub.
In addition to the fetch() method, models and collections support commit() and destroy(). All three methods can take an optional parameter hash that affects the way the source treats the data. If not supplied, the options will be taken from the model or collection’s options property. In this way, you can override the default options for a specific method call.
WARNING
fetch(), commit(), and destroy() require that the model or collection not be in an error state. You must call the clearError() method on a model after an error occurs. You should define an error handler either in the options hash for the collection or when passing the options to those methods.
TIP
Enyo has a feature that will attempt to consolidate models to reduce memory usage and avoid out-of-sync data. For example, if two collections use the same model, they will share any instances of models that have the same primaryKey (by default 'id').
Putting It All Together
To get a feel for what a full Enyo application is like, take a look at a restaurant list app online. This app implements several of the features we’ve covered in previous chapters, including the Onyx UI library, Router, Collections, and a collection-aware list. The app also uses local storage to persist the restaurants between loads. You can view the source on GitHub.
Summary
In this chapter, we touched on just a few of the features Enyo provides for creating data-driven applications. We covered models, the basic building blocks of data-driven applications, and collections. We discussed computed properties and how to use them. Lastly, we covered how to fetch data from remote sources. There are even more features that we didn’t get to explain, including collection filters and relational data. With all these rich features, it is easy to create data-driven applications with Enyo.