The basic idea is simple: the user searches for the name of someone on GitHub in the input box. When he enters a name, we fire a request to the API designed earlier in this chapter. When the response from the API returns, the program binds that response to a model and emits an event notifying that the model has been changed. The views listen for this event and refresh from the model in response.
Let's start by defining the client-side model. The model holds information regarding the repos of the user currently displayed. It gets filled in after the first search.
// public/javascripts/model.js define([], function(){ return { ghubUser: "", // last name that was searched for exists: true, // does that person exist on github? repos: [] // list of repos } ; });
To see a populated value of the model, head to the complete application example on app.scala4datascience.com
, open a JavaScript console in your browser, search for a user (for example, odersky
) in the application and type the following in the console:
> require(["model"], function(model) { console.log(model) ; }) {ghubUser: "odersky", exists: true, repos: Array} > require(["model"], function(model) { console.log(model.repos[0]); }) {name: "dotty", language: "Scala", is_fork: true, size: 14653}
These import the "model"
module, bind it to the variable model
, and then print information to the console.
We need a mechanism for informing the views when the model is updated, since the views need to refresh from the new model. This is commonly handled through events in web applications. JQuery lets us bind callbacks to specific events. The callback is executed when that event occurs.
For instance, to bind a callback to the event "custom-event"
, enter the following in a JavaScript console:
> $(window).on("custom-event", function() { console.log("custom event received") ; });
We can fire the event using:
> $(window).trigger("custom-event"); custom event received
Events in JQuery require an event bus, a DOM element on which the event is registered. In this case, we used the window
DOM element as our event bus, but any JQuery element would have served. Centralizing event definitions to a single module is helpful. We will, therefore, create an events
module containing two functions: trigger
, which triggers an event (specified by a string) and on
, which binds a callback to a specific event:
// public/javascripts/events.js define(["jquery"], function($) { var bus = $(window) ; // widget to use as an event bus function trigger(eventType) { $(bus).trigger(eventType) ; } function on(eventType, f) { $(bus).on(eventType, f) ; } return { "trigger": trigger, "on": on } ; });
We can now emit and receive events using the events
module. You can test this out in a JavaScript console on the live version of the application (at app.scala4datascience.com
). Let's start by registering a listener:
> require(["events"], function(events) { // register event listener events.on("hello_event", function() { console.log("Received event") ; }) ; });
If we now trigger the event "hello_event"
, the listener prints "Received event"
:
> require(["events"], function(events) { // trigger the event events.trigger("hello_event") ; }) ;
Using events allows us to decouple the controller from the views. The controller does not need to know anything about the views, and vice-versa. The controller just needs to emit a "model_updated"
event when the model is updated, and the views need to refresh from the model when they receive that event.
We can now write the controller for our application. When the user enters a name in the text input, we query the API, update the model and trigger a model_updated
event.
We use JQuery's $.getJSON
function to query our API. This function takes a URL as its first argument, and a callback as its second argument. The API call is asynchronous: $.getJSON
returns immediately after execution. All request processing must, therefore, be done in the callback. The callback is called if the request is successful, but we can define additional handlers that are always called, or called on failure. Let's try this out in the browser console (either your own, if you are running the API developed in the previous chapter, or on app.scala4datascience.com
). Recall that the API is listening to the end-point /api/repos/:user
:
> $.getJSON("/api/repos/odersky", function(data) { console.log("API response:"); console.log(data); console.log(data[0]); }) ; {readyState: 1, getResponseHeader: function, ...} API response: [Object, Object, Object, Object, Object, ...] {name: "dotty", language: "Scala", is_fork: true, size: 14653}
getJSON
returns immediately. A few tenths of a second later, the API responds, at which point the response gets fed through the callback.
The callback only gets executed on success. It takes, as its argument, the JSON object returned by the API. To bind a callback that is executed when the API request fails, call the .fail
method on the return value of getJSON
:
> $.getJSON("/api/repos/junk123456", function(data) { console.log("called on success"); }).fail(function() { console.log("called on failure") ; }) ; {readyState: 1, getResponseHeader: function, ...} called on failure
We can also use the .always
method on the return value of getJSON
to specify a callback that is executed, whether the API query was successful or not.
Now that we know how to use $.getJSON
to query our API, we can write the controller. The controller listens for changes to the #user-selection
input field. When a change occurs, it fires an AJAX request to the API for information on that user. It binds a callback which updates the model when the API replies with a list of repositories. We will define a
controller
module that exports a single function, initialize
, that creates the event listeners:
// public/javascripts/controller.js define(["jquery", "events", "model"], function($, events, model) { function initialize() { $("#user-selection").change(function() { var user = $("#user-selection").val() ; console.log("Fetching information for " + user) ; // Change cursor to a 'wait' symbol // while we wait for the API to respond $("*").css({"cursor": "wait"}) ; $.getJSON("/api/repos/" + user, function(data) { // Executed on success model.exists = true ; model.repos = data ; }).fail(function() { // Executed on failure model.exists = false ; model.repos = [] ; }).always(function() { // Always executed model.ghubUser = user ; // Restore cursor $("*").css({"cursor": "initial"}) ; // Tell the rest of the application // that the model has been updated. events.trigger("model_updated") ; }); }) ; } ; return { "initialize": initialize }; });
Our controller module just exposes the initialize
method. Once the initialization is performed, the controller interacts with the rest of the application through event listeners. We will call the controller's initialize
method in main.js
. Currently, the last lines of that file are just an empty require
block. Let's import our controller and initialize it:
// public/javascripts/main.js require(["controller"], function(controller) { controller.initialize(); });
To test that this works, we can bind a dummy listener to the "model_updated"
event. For instance, we could log the current model to the browser JavaScript console with the following snippet (which you can write directly in the JavaScript console):
> require(["events", "model"], function(events, model) { events.on("model_updated", function () { console.log("model_updated event received"); console.log(model); }); });
If you then search for a user, the model will be printed to the console. We now have the controller in place. The last step is writing the views.
If the request fails, we just display Not found in the response div. This part is the easiest to code up, so let's do that first. We define an initialize
method that generates the view. The view then listens for the "model_updated"
event, which is fired by the controller after it updates the model. Once the initialization is complete, the only way to interact with the response view is through "model_updated"
events:
// public/javascripts/responseView.js define(["jquery", "model", "events"], function($, model, events) { var failedResponseHtml = "<div class='col-md-12'>Not found</div>" ; function initialize() { events.on("model_updated", function() { if (model.exists) { // success – we will fill this in later. console.log("model exists") } else { // failure – the user entered // is not a valid GitHub login $("#response").html(failedResponseHtml) ; } }) ; } return { "initialize": initialize } ; });
To bootstrap the view, we must call the initialize function from
main.js
. Just add a dependency on responseView
in the require block, and call responseView.initialize()
. With these modifications, the final require
block in main.js
is:
// public/javascripts/main.js require(["controller", "responseView"], function(controller, responseView) { controller.initialize(); responseView.initialize() ; }) ;
You can check that this all works by entering junk in the user input to deliberately cause the API request to fail.
When the user enters a valid GitHub login name and the API returns a list of repos, we must display those on the screen. We display a table and a pie chart that aggregates the repository sizes by language. We will define the pie chart and the table in two separate modules, called repoGraph.js
and repoTable.js
. Let's assume those exist for now and that they expose a build
method that accepts a model
and the name of a div
in which to appear.
Let's update the code for responseView
to accommodate the user entering a valid GitHub user name:
// public/javascripts/responseView.js define(["jquery", "model", "events", "repoTable", "repoGraph"], function($, model, events, repoTable, repoGraph) { // HTHML to inject when the model represents a valid user var successfulResponseHtml = "<div class='col-md-6' id='response-table'></div>" + "<div class='col-md-6' id='response-graph'></div>" ; // HTML to inject when the model is for a non-existent user var failedResponseHtml = "<div class='col-md-12'>Not found</div>" ; function initialize() { events.on("model_updated", function() { if (model.exists) { $("#response").html(successfulResponseHtml) ; repoTable.build(model, "#response-table") ; repoGraph.build(model, "#response-graph") ; } else { $("#response").html(failedResponseHtml) ; } }) ; } return { "initialize": initialize } ; });
Let's walk through what happens in the event of a successful API call. We inject the following bit of HTML in the #response
div:
var successfulResponseHtml = "<div class='col-md-6' id='response-table'></div>" + "<div class='col-md-6' id='response-graph'></div>" ;
This adds two HTML divs, one for the table of repositories, and the other for the graph. We use Bootstrap classes to split the response div vertically.
Let's now turn our attention to the table view, which needs to expose a single build
method, as described in the previous section. We will just display the repositories in an HTML table. We will use Underscore templates to build the table dynamically. Underscore templates work much like string interpolation in Scala: we define a template with placeholders. Let's try this in a browser console:
> require(["underscore"], function(_) {
var myTemplate = _.template(
"Hello, <%= title %> <%= name %>!"
) ;
});
This creates a
myTemplate
function which accepts an object with attributes title
and name
:
> require(["underscore"], function(_) {
var myTemplate = _.template( ... );
var person = { title: "Dr.", name: "Odersky" } ;
console.log(myTemplate(person)) ;
});
Underscore templates thus provide a convenient mechanism for formatting an object as a string. We will create a template for each row in our table, and pass the model for each repository to the template:
// public/javascripts/repoTable.js define(["underscore", "jquery"], function(_, $) { // Underscore template for each row var rowTemplate = _.template("<tr>" + "<td><%= name %></td>" + "<td><%= language %></td>" + "<td><%= size %></td>" + "</tr>") ; // template for the table var repoTable = _.template( "<table id='repo-table' class='table'>" + "<thead>" + "<tr>" + "<th>Name</th><th>Language</th><th>Size</th>" + "</tr>" + "</thead>" + "<tbody>" + "<%= tbody %>" + "</tbody>" + "</table>") ; // Builds a table for a model function build(model, divName) { var tbody = "" ; _.each(model.repos, function(repo) { tbody += rowTemplate(repo) ; }) ; var table = repoTable({tbody: tbody}) ; $(divName).html(table) ; } return { "build": build } ; }) ;