State management
Using Models and Services and ORMs for state management.
- Simple Models and JSON services
- Aurelia ORM
Simple Models and JSON services
In the past with JavaScript we've been limited by how well we can solve for the model aspect in the MVVM pattern. Traditionally we've been limited to creating a function that we pass off as a class or using a more robust client-side ORM such as Breeze.js. The function was a little dumbed down and sometimes an ORM is overkill when starting a new project.
What about the middle ground where we want to have a model we can work with?
ES6 Classes to the rescue
With the newest standard being approved we now have ES6 classes. With classes we can have a single area to start with to extend our models.
export class Person {
constructor(data){
Object.assign(this, data);
}
}
Object.assign()
Now we've created a model for a Person
. We know that there is some JSON we are getting from the server and we want to 'cast' that data into an instance of a Person
. We can use `Object.assign()`` here in our constructor to tell our model that whatever properties the server is returning we want to keep. This means that if our person's data is returned with a property like firstName it will also exist in our model. Then we can extend it.
That works great, but doesn't really offer us any tangible benefits yet.
Adding properties
Our person is lonely and broke. Whenever we create a new person let's give them no money whatsoever and make them understand the hard lessons of life and respect what it means to earn a buck -
export class Person {
constructor(data){
Object.assign(this, data);
this.money = 0;
}
}
Now anywhere that we reference our person in our application we can rely on him to have a property called money
.
But what if we want to give our person some money?
Fat models
Let's fatten up our model with accessors and behaviour
export class Person {
constructor(data){
Object.assign(this, data);
this.money = 0;
}
giveMoney(amount){
this.money += amount;
}
}
We want to put some business logic in for when we give money to our person.
Imagine that when we give our person some money we also want to improve his credit score.
export class Person {
constructor(data){
Object.assign(this, data);
this.money = 0;
this.creditScore = 500;
}
giveMoney(amount){
this.money += amount;
this.creditScore += amount / 2;
}
}
Now whenever we give a person some money the appropriate change to credit score is applied.
Casting our JSON as a Person
We will now use the HttpClient
that Aurelia provides to retrieve the JSON data from the server, then 'cast' the data to our Person
class for each entry retrieved.
Service
Let's create a simple service which retrieves some people and casts them as a Person
.
import {HttpClient} from 'aurelia-http-client';
import {Person} from './models';
export class PersonService {
constructor(){
this.http = new HttpClient().configure(x=> {
x.withReviver((k,v) => {
return typeof v === 'object' ? new Person(v) : v;
});
});
}
getPeople(){
return this.http.get('/people');
}
}
We import our Person
from our models.js
file where we exported it.
We instantiated an instance of the HttpClient
provided by Aurelia.
We configure our HttpClient
to always use a reviver
.
We added a method on our service to use the HttpClient we've created to get people from our resource on the server.
Reviver
A reviver
will be called once for each item in the array when our JSON is parsed from the server. By using `withReviver()`` and passing in a function that casts the value to a Person, it helps us do something like this:
JSON.parse(response, (key, value) => {
if (typeof value === 'object') {
return new Person(value);
}
return value;
});
Sample JSON payload
If you want to try this out, use this example JSON payload -
[{"name": "Jane"},{"name": "Bob"}]
We've created a model, we've used the HttpClient
to cast it, and we've touched on some topics that are a bit risque.
Let's end on a high note by showing our usage from our view-model
import {PersonService} from './person-service';
import {inject} from 'aurelia-framework';
@inject(PersonService)
export class MyViewModel {
constructor(personService){
this.jane = {};
this.personService = personService;
this.personService.getPeople().then(response => {
this.jane = response.content[0];
this.jane.giveMoney(100);
});
}
}
Aurelia ORM
Working with endpoints and client-side entities is unavoidable when doing API driven development. You end up writing a small wrapper for your XHRs / websocket events and putting your request methods in one place. Another option is just using Breeze, which is large, complex and not meant for most applications. Even though your endpoints are important, you still end up neglecting them.
Enter aurelia-orm. This module provides you with some useful and cool features, such as:
- Entity definitions
- Repositories
- Associations
- Validation
- Type casting
- Self-populating select element
- And more
This makes it easier to focus on your application and organize your code.
Installation
npm i aurelia-orm --save
Aurelia-orm uses aurelia-api to talk to the server.
Aurelia-api is a module wrapped around aurelia-fetch-client that allows you to:
- Perform the usual CRUD
- Supply criteria for your api
- Manage more than one endpoint
- Add defaults
- Add interceptors
- And more
Please read more about aurelia-api
Add the following to the build.bundles.dependencies
section of aurelia-project/aurelia.json
to install and configure dependencies.
"dependencies": [
// ...
"get-prop",
"typer",
{
"name": "aurelia-orm",
"path": "../node_modules/aurelia-orm/dist/amd",
"main": "aurelia-orm",
"resources": [
"component/association-select.html",
"component/paged.html"
]},
{
"name": "aurelia-validation",
"path": "../node_modules/aurelia-validation/dist/amd",
"main": "index"
},
// ...
],
Configuration
Configure aurelia-api and register a new endpoint.
// Load the plugin, and set the base url.
.plugin('aurelia-api', config => {
config
.registerEndpoint('github', 'https://api.github.com/')
.setDefaultEndpoint('github');
});
Configure with model Entities (similar to Breeze Entities)
// Import your entities
import * as entities from 'config/entities';
export function configure(aurelia) {
aurelia.use
.standardConfiguration()
.developmentLogging()
// Register the plugin, and register your entities
.plugin('aurelia-orm', builder => {
builder.registerEntities(entities);
});
aurelia.start().then(a => a.setRoot());
}
Here's what your config/entities.js
file might look like:
export {User} from 'entity/user';
export {Article} from 'entity/article';
export {Category} from 'entity/category';
Resource
The name of a resource located at the API.
Some examples of resources are: user, category and product. Each resource has an associated Entity and Repository.
Entity
An instance or class of type Entity that defines, and holds the data of a single resource record.
An entity defines what a record looks like. Which properties it has and which validations apply.
Collection
An array of entities. A collection is a simple javascript array containing one or more entity instances.
Repository
Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects. The repository handles the logic, and returns entity instances. Some ORMs put this logic on the entity and call them "models". An example of such a method is:
repository.findBySomething(specialCriteria).then(result => doSomethingWithTheResponse)
In summary: all fetch methods and response mutations belong in a repository.
Association
The relationship between two or more entities. Some entities have a relationship with each other. For instance, entity User has many groups of type Group. The term for this is collection association.
Entity manager
Manages Repository and Entity instances for resources. Each resource has an entity, and a repository. They are all registered with the entity manager.
Populate
To build up an instance with the provided data. Entities can be populated with data. This means they get data assigned to them.
Entities
Entities represent the schema of your resources. They can hold validation rules, associations, type hinting and more. In this chapter, we'll be looking at what an entity looks like, how you can create one and what they're responsible for.
Creating an Entity
import {Entity} from 'aurelia-orm';
export class Product extends Entity {
}
After creating your gem of an Entity
, you'll want to register it with the EntityManager
. Then the Repository
responsible for your resource will populate using your new entity!
Here's how:
import {EntityManager} from 'aurelia-orm';
import {inject} from 'aurelia-dependency-injection';
import {Product} from './entity/product';
@inject(EntityManager)
class SomeClass {
constructor(entityManager) {
entityManager.registerEntity(Product);
}
}
There's an easier way to do this, as described in the chapter Configuration.
Repositories
Creating a custom Repository is simple.
import {Repository} from 'aurelia-orm';
export class Custom extends Repository {
}
A repository referenced by entities. Here's an example of how to do this:
import {Entity, repository} from 'aurelia-orm';
import {Custom} from 'repository/custom';
@repository(Custom)
class Product extends Entity {
}
And done! You can now start adding magical methods to your repository, and separate logic. Here's an example that's a bit more extended:
import {Repository} from 'aurelia-orm';
export class Custom extends Repository {
findUserPosts(user) {
return this.find({user: user.id});
}
findNewPosts() {
return this.find({read: false});
}
}
Entity Manager
The EntityManager
has a special job: it links entities, repositories and resources together. When you want to start working with a resource, you ask the EntityManager
to give you what you need.
Create new record
import {inject} from 'aurelia-framework';
import {EntityManager} from 'aurelia-orm';
@inject(EntityManager)
export class SomeViewModel {
constructor (entityManager) {
let entity = entityManager.getEntity('user');
// Note: You could also do `user.username='';`. `.setData()` is optional.
entity.setData({username: 'Bob', password: 'Burger'}).save()
.then(result => {
console.log('Created a new user!');
});
}
}
Fetch records
import {inject} from 'aurelia-framework';
import {EntityManager} from 'aurelia-orm';
@inject(EntityManager)
export class SomeViewModel {
constructor (entityManager) {
let repository = entityManager.getRepository('user');
repository.find({username: 'bob'})
.then(result => {
console.log('Found User!', result);
});
}
}
Decorators
Here's an example using many of the decorators available. See here for more details.
import {Entity, resource, repository, validation, association, type} from 'aurelia-orm';
import {ensure} from 'aurelia-validation';
import {CustomRepository} from 'repository/custom-repository';
@resource('my-endpoint')
@repository(CustomRepository)
@validation()
export class MyEntity extends Entity {
@ensure(it => it.isNotEmpty().hasLengthBetween(3, 20))
@type('string')
name = null;
// Will use string 'onetoone' as resource name.
@association()
oneToOne = null
// Will use 'multiple' as resource name.
@association('multiple')
oneManyToMany = null
}
Components
The <association-select />
component composes a <select />
element, of which the options have been populated from an endpoint. The selects value is the id
property and the displayed textContents are the name
properties of the resource. You can change the textContent by setting the property
attribute of the association-select
Basic example
Get all entries of the view-models 'userRepository' repository property and populate the select using the 'id' and the 'fullName' properties of your resource for the value and the textContent respectively. The selects current value is two-way bound to the 'data.author' property of your view-model.
<association-select
property="fullName"
value.bind="data.author"
repository.bind="userRepository"
></association-select>
Extended example
<!-- First, populate a list of categories -->
<association-select
value.bind="data.category.id"
repository.bind="categoryRepository"
></association-select>
<!-- Then populate a list of pages -->
<association-select
value.bind="data.page.id"
repository.bind="pageRepository"
></association-select>
<!-- Then populate a list of groups -->
<association-select
value.bind="data.group.id"
repository.bind="groupRepository"
></association-select>
<!-- And finally, populate a list of authors based on the previous selects -->
<!-- With custom selectable placeholder (value===0) -->
<association-select
value.bind="data.author.id"
error.bind="error"
repository.bind="userRepository"
property="username"
association.bind="[data.page, data.group]"
manyAssociation.bind="data.category"
criteria.bind='{where:{age:{">":18}}}'
selectablePlaceholder="true"
placeholderText="- Any -"
if.bind="!error"
></association-select>
<div class="alert alert-warning" if.bind="!!error">
Server error:${error.statusText}
</div>