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:

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:

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