Collection Agents
1.2
Basics - Agent Adapters - Defining Classes - Initialization - Creating Resources - REST Operations - Callbacks and Error Handlers - Changing the Path Prefix - Cooperation with a Data Store
Basics
The collection agents are JavaScript objects that have following capabilities:
- Keeping an array of objects that represent a resource collection of the server.
- Creating, updating and deleting the members of this collection by sending Ajax requests to the server.
Cape.JS provides similar objects called data stores. But, they lack built-in Ajax functionalities so that you have to implement them on your own.
On the other hand, data stores work as the subjects, or observables, in the terms of observer pattern while collection agents do not.
However, you can combine a collection agent with a data store in order to notify its stage changes to the observers as explained later.
Note that the collection agents are introduced with the Cape.JS version 1.2.
Agent Adapters
Sometimes, the Ajax clients need to send a set of specific HTTP headers to the server.
A Rails application requires a correct X-CSRF-Token
header, for example.
In order to cope with the peculiarities of the server, you can use a agent adapter.
As of v1.2.0
, the only built-in adapter is Cape.AgentAdapters.RailsAdapter
.
If your server is built on the Ruby on Rails, put the following line at the very beginning of your JavaScript code:
Cape.defaultAgentAdapter = 'rails'
Defining Classes
In order to use collection agents, you should define a class inheriting the
Cape.CollectionAgent
class.
You can define such a class calling Cape.createCollectionAgentClass()
method:
var UserCollectionAgent = Cape.createCollectionAgentClass({
constructor: function(client, options) {
this.resourceName = 'users';
}
})
You can also define it using the ES6 syntax:
class UserCollectionAgent extends Cape.CollectionAgent {
constructor(client, options) {
super(client, options);
this.resourceName = 'users';
}
}
The collection agent uses the resourceName
property to construct the URL paths
as explained below.
Initialization
Suppose that when you send an HTTP request GET /users
to the server,
it returns the following JSON string:
"users" : [
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Anna" },
{ "id": 3, "name": "Tommy" }
]
Then, you can build a Cape.JS component like this:
class UserList extends Cape.Component {
init() {
this.agent = new UserCollectionAgent(this);
this.agent.refresh();
}
render(m) {
m.ul(m => {
this.agent.objects.forEach(user => {
m.li(user.name);
});
});
}
}
Note that the UserCollectionAgent.getInstance()
method is a singleton factory method,
which returns the same object always.
The attach
method register the argument as an event listener of the agent.
With this the component will get notified
when the objects managed by the agent are changed.
The refresh
method will trigger an Ajax request to the server and
initialize the objects
property of the component.
In response to the notification from the agent, the component will render the following HTML fragment:
<ul>
<li>John</li>
<li>Anna</li>
<li>Tommy</li>
</ul>
Creating Resources
Suppose that you can create a user by sending a POST
request to the /users
with the following body:
"user": { "name": "Nancy" }
Then, you can create a form for adding users like this:
class UserList extends Cape.Component {
init() {
this.agent = new UserCollectionAgent(this);
this.agent.refresh();
}
render(m) {
m.ul(m => {
this.agent.objects.forEach(user => {
m.li(user.name);
});
});
m.formFor('user', m => {
m.textField('name');
m.onclick(e => this.agent.create(this.paramsFor('user')));
m.btn('Create');
});
}
}
REST operations
Cape.JS provides four basic methods for REST operations:
- #index() to get a collection of resources
- #create() to create a resource
- #update() to update (modify) a resource
- #destroy() to delete a resource
These methods send an Ajax call using Rails like HTTP verbs and paths.
If the resource name is 'users'
, the HTTP verbs and paths will be as shown in the
following table, where :id
is an integer denoting the value of object’s id
.
Method | HTTP Verb | Path |
---|---|---|
#index() | GET | /users |
#create() | POST | /users |
#update() | PATCH | /users/:id |
#destroy() | DELETE | /users/:id |
The following is an example of code which modify the name of a user:
this.agent.update(1, { name: 'johnny' });
When you specify other combinations of HTTP verb and path, you should use one of following five methods:
- #get() to send a
GET
request - #head() to send a
HEAD
request - #post() to send a
POST
request - #patch() to send a
PATCH
request - #put() to send a
PUT
request - #delete() to send a
DELETE
request
For example, if you want to make a PATCH
request to the path /users/123/move_up
,
write a code like this:
this.agent.patch('move_up', 123, {});
Callbacks and Error Handlers
If you want the collection agent to perform any jobs after the Ajax request, you can pass a callback as the last argument to the methods:
m.onclick(e => this.agent.create(this.paramsFor('user'), data => {
if (data.result === 'OK') {
this.val('user.name', '');
}
else {
// Do something to handle validation errors, for example.
}
}));
Furthermore, you can specify an error handler after the callback:
m.onclick(e => this.agent.create(this.paramsFor('user'), data => {
if (data.result === 'OK') {
this.val('user.name', '');
}
else {
// Do something to handle validation errors, for example.
}
}, ex => {
// Do some error handling.
}));
This error hander is called when an exception is raised due to some reasons (network errors, syntax errors, etc.).
Changing the Path Prefix
If the paths of the server-side API has a prefix, you should set the basePath
and nestdIn
properties.
The value of basePath
property is prepended to the resource name
when the collection agent constructs the API paths. Its default value is /
.
The values of nestedIn
property is inserted between the base path and
the resource name. Its default value is “.
Suppose that you defined the ArticleCollectionAgent
class like this:
class ArticleCollectionAgent extends Cape.CollectionAgent {
constructor(options) {
super(options);
this.resourceName = 'articles';
this.basePath = '/api/v2/';
}
}
And, you instantiated it as follows:
class ArticleList extends Cape.Component {
init() {
this.agent = new ArticleCollectionAgent(this, { nestedIn: 'members/123/' });
this.agent.refresh();
}
render(m) {
// ...
}
}
Then, this.agent
will construct paths like these:
/api/v2/members/123/articles
/api/v2/members/123/articles/99
Note that you should not define the init()
method like this:
init() {
this.agent = new ArticleCollectionAgent(this);
this.agent.nestedIn = 'members/123/';
this.agent.refresh();
}
}
Superficially it may work well, but problems will occur when multiple components attach themselves to this agent.
The CollectionAgent
class keeps a map of named instances of
CollectionAgent
as key-value pairs in order to ensure a single instance
per key, which is constructed using the resourceName
, basePath
and nestedIn
options.
See Multiton pattern on Wikipedia for the technical background.
Cooperation with a Data Store
class TaskStore extends Cape.DataStore {
constructor(agent) {
super();
this.agent = agent;
this.tasks = agent.tasks;
}
}
class TaskCollectionAgent extends Cape.CollectionAgent {
constructor(client, dataStore, options) {
super(client, options);
this.dataStore = dataStore;
this.resourceName = 'tasks';
}
afterRefresh() {
// this.client.refresh();
this.dataStore.propagate();
}
}
class TaskList1 extends Cape.Component {
init() {
this.ds = new TaskStore();
this.ds.attach(this);
}
render(m) { ... }
}
class TaskList2 extends Cape.Component {
init() {
this.ds = new TaskStore();
this.ds.attach(this);
this.agent = new TaskCollectionAgent(this, this.ds);
this.refresh();
}
render(m) { ... }
}