Bug 979880 - Implement basic UI+logic for initiating a call. r=Standard8
authorNicolas Perriault <nperriault@gmail.com>
Thu, 29 May 2014 21:20:11 +0100
changeset 187691 156565c17cbd049da892e6ec3ab862f85e5050a7
parent 187690 16e055e25d64cd133e5b093d73897f481dfe9863
child 187692 8e241bf1d48224974b02399f3389e4d75b489bcc
push id26931
push usermbanner@mozilla.com
push dateMon, 09 Jun 2014 22:07:01 +0000
treeherdermozilla-central@fc70d6d9a9b0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs979880
milestone32.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 979880 - Implement basic UI+logic for initiating a call. r=Standard8
browser/components/loop/standalone/.jshintignore
static/index.html
static/js/webapp.js
test/webapp_test.js
--- a/browser/components/loop/standalone/.jshintignore
+++ b/browser/components/loop/standalone/.jshintignore
@@ -1,4 +1,4 @@
 node_modules
-static/libs
+static/shared/libs
 test/vendor
 
--- a/static/index.html
+++ b/static/index.html
@@ -16,19 +16,21 @@
     <header>
       <h1>Loop</h1>
     </header>
 
     <div id="home">
       <p data-l10n-id="welcome">Welcome to the Loop web client.</p>
     </div>
 
-    <div id="call-launcher" class="hide">
-      <p><button class="btn btn-success" data-l10n-id="start_call">Start call</button></p>
-    </div>
+    <form id="conversation-form" class="hide">
+      <p>
+        <button class="btn btn-success" data-l10n-id="start_call">Start call</button>
+      </p>
+    </form>
 
     <div id="call" class="conversation hide">
       <nav class="controls">
         <button class="btn" data-l10n-id="start">Start</button>
         <button class="btn" data-l10n-id="stop">Stop</button>
       </nav>
       <div class="media nested">
         <video class="remote" src="http://v2v.cc/~j/theora_testsuite/320x240.ogg"></video>
--- a/static/js/webapp.js
+++ b/static/js/webapp.js
@@ -3,123 +3,258 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /*global loop:true*/
 
 var loop = loop || {};
 loop.webapp = (function() {
   "use strict";
 
+  /**
+   * Base Loop server URL.
+   *
+   * XXX: should be configurable, but how?
+   *
+   * @type {String}
+   */
+  var baseApiUrl = "http://localhost:5000";
+
+  /**
+   * App router.
+   * @type {loop.webapp.Router}
+   */
   var router;
 
   /**
+   * Current conversation model instance.
+   * @type {loop.webapp.ConversationModel}
+   */
+  var conversation;
+
+  /**
+   * Conversation model.
+   */
+  var ConversationModel = Backbone.Model.extend({
+    defaults: {
+      loopToken:    undefined, // Loop conversation token
+      sessionId:    undefined, // TB session id
+      sessionToken: undefined, // TB session token
+      apiKey:       undefined  // TB api key
+    },
+
+    /**
+     * Initiates a conversation, requesting call session information to the Loop
+     * server and updates appropriately the current model attributes with the
+     * data.
+     *
+     * Triggered events:
+     *
+     * - `session:ready` when the session information have been succesfully
+     *   retrieved from the server;
+     * - `session:error` when the request failed.
+     *
+     * @param  {Object} options Conversation options.
+     * @throws {Error} If no conversation token is set
+     */
+    initiate: function(options) {
+      options = options || {};
+
+      if (!this.get("loopToken")) {
+        throw new Error("missing required attribute loopToken");
+      }
+
+      var request = $.ajax({
+        url:         baseApiUrl + "/calls/" + this.get("loopToken"),
+        method:      "POST",
+        contentType: "application/json",
+        data:        JSON.stringify({}),
+        dataType:    "json"
+      });
+
+      request.done(this.setReady.bind(this));
+
+      request.fail(function(xhr, _, statusText) {
+        var serverError = xhr.status + " " + statusText;
+        if (typeof xhr.responseJSON === "object" && xhr.responseJSON.error) {
+          serverError += "; " + xhr.responseJSON.error;
+        }
+        this.trigger("session:error", new Error(
+          "Retrieval of session information failed: HTTP " + serverError));
+      }.bind(this));
+    },
+
+    /**
+     * Sets session information and triggers the `session:ready` event.
+     *
+     * @param {Object} sessionData Conversation session information.
+     */
+    setReady: function(sessionData) {
+      // Explicit property assignment to prevent later "surprises"
+      this.set({
+        sessionId:    sessionData.sessionId,
+        sessionToken: sessionData.sessionToken,
+        apiKey:       sessionData.apiKey
+      }).trigger("session:ready", this);
+      return this;
+    }
+  });
+
+  /**
    * Base Backbone view.
    */
   var BaseView = Backbone.View.extend({
+    /**
+     * Hides view element.
+     *
+     * @return {BaseView}
+     */
     hide: function() {
       this.$el.hide();
       return this;
     },
 
+    /**
+     * Shows view element.
+     *
+     * @return {BaseView}
+     */
     show: function() {
       this.$el.show();
       return this;
     }
   });
 
   /**
    * Homepage view.
    */
   var HomeView = BaseView.extend({
     el: "#home"
   });
 
   /**
-   * Call launcher view.
+   * Conversation launcher view. A ConversationModel is associated and attached
+   * as a `model` property.
    */
-  var CallLauncherView = BaseView.extend({
-    el: "#call-launcher",
+  var ConversationFormView = BaseView.extend({
+    el: "#conversation-form",
 
     events: {
-      "click button": "launchCall"
+      "submit": "initiate"
     },
 
-    initialize: function(options) {
-      options = options || {};
-      if (!options.token) {
-        throw new Error("missing required token");
-      }
-      this.token = options.token;
+    initialize: function() {
+      this.listenTo(this.model, "session:error", function(error) {
+        // XXX: display a proper error notification to end user, probably
+        //      reusing the BB notification system from the Loop desktop client.
+        alert(error);
+      });
     },
 
-    launchCall: function(event) {
+    initiate: function(event) {
       event.preventDefault();
-      // XXX: request the loop server for call information using this.token
+      this.model.initiate();
     }
   });
 
   /**
-   * Call view.
+   * Conversation view.
    */
-  var CallView = BaseView.extend({
-    el: "#call"
+  var ConversationView = BaseView.extend({
+    el: "#conversation"
   });
 
   /**
    * App Router. Allows defining a main active view and ease toggling it when
    * the active route changes.
    * @link http://mikeygee.com/blog/backbone.html
    */
   var Router = Backbone.Router.extend({
-    view: undefined,
+    _conversation: undefined,
+    activeView: undefined,
 
     routes: {
-        "": "home",
-        "call/:token": "call"
+      "": "home",
+      "call/:token": "initiate"
     },
 
-    initialize: function() {
+    initialize: function(options) {
+      options = options || {};
+      if (!options.conversation) {
+        throw new Error("missing required conversation");
+      }
+      this._conversation = options.conversation;
+      this.listenTo(this._conversation, "session:ready",
+                    this._onConversationSessionReady);
+
+      // Load default view
       this.loadView(new HomeView());
     },
 
     /**
+     * Called when a conversation session is ready.
+     *
+     * @param  {ConversationModel} conversation Conversation model instance.
+     */
+    _onConversationSessionReady: function(conversation) {
+      // XXX: navigate to the conversation route
+      //      establish conversation with TB sdk
+      //      setup conversation view accordingly
+      alert("conversation session ready");
+      console.log("conversation session info", conversation);
+    },
+
+    /**
      * Loads and render current active view.
      *
      * @param {BaseView} view View.
      */
     loadView : function(view) {
-      this.view && this.view.hide();
-      this.view = view.render().show();
+      if (this.activeView) {
+        this.activeView.hide();
+      }
+      this.activeView = view.render().show();
     },
 
     /**
-     * Main entry point.
+     * Default entry point.
      */
     home: function() {
       this.loadView(new HomeView());
     },
 
     /**
-     * Call setup view.
+     * Loads conversation launcher view, setting the received conversation token
+     * to the current conversation model.
      *
-     * @param  {String} token Call token.
+     * @param  {String} loopToken Loop conversation token.
      */
-    call: function(token) {
-      this.loadView(new CallLauncherView({token: token}));
+    initiate: function(loopToken) {
+      this._conversation.set("loopToken", loopToken);
+      this.loadView(new ConversationFormView({model: this._conversation}));
+    },
+
+    /**
+     * Loads conversation establishment view.
+     *
+     */
+    conversation: function() {
+      this.loadView(new ConversationView({model: this._conversation}));
     }
   });
 
   /**
    * App initialization.
    */
   function init() {
-    router = new Router();
+    conversation = new ConversationModel();
+    router = new Router({conversation: conversation});
     Backbone.history.start();
   }
 
   return {
-    init: init,
     BaseView: BaseView,
+    ConversationFormView: ConversationFormView,
+    ConversationModel: ConversationModel,
     HomeView: HomeView,
-    Router: Router,
-    CallLauncherView: CallLauncherView
+    init: init,
+    Router: Router
   };
 })();
--- a/test/webapp_test.js
+++ b/test/webapp_test.js
@@ -1,43 +1,141 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-/* global loop */
+/* global loop, sinon */
 
 var expect = chai.expect;
 
-describe("Router", function() {
+describe("loop.webapp", function() {
   "use strict";
 
-  var router;
+  var sandbox, fakeXHR, requests = [];
 
   beforeEach(function() {
-    router = new loop.webapp.Router();
+    sandbox = sinon.sandbox.create();
+    fakeXHR = sandbox.useFakeXMLHttpRequest();
+    requests = [];
+    // https://github.com/cjohansen/Sinon.JS/issues/393
+    fakeXHR.xhr.onCreate = function(xhr) {
+      requests.push(xhr);
+    };
+  });
+
+  afterEach(function() {
+    sandbox.restore();
   });
 
-  describe("#constructor", function() {
-    it("should define a default active view", function() {
-      expect(router.view).to.be.an.instanceOf(loop.webapp.HomeView);
+  describe("ConversationModel", function() {
+    var conversation, fakeSessionData;
+
+    beforeEach(function() {
+      conversation = new loop.webapp.ConversationModel();
+      fakeSessionData = {
+        sessionId:    "sessionId",
+        sessionToken: "sessionToken",
+        apiKey:       "apiKey"
+      };
+    });
+
+    describe("#initiate", function() {
+      it("should prevent launching a conversation when token is missing",
+        function() {
+          expect(function() {
+            conversation.initiate();
+          }).to.Throw(Error, /missing required attribute loopToken/);
+        });
+
+      it("should update conversation session information from server data",
+        function() {
+          conversation.set("loopToken", "fakeToken");
+          conversation.initiate();
+
+          expect(requests).to.have.length.of(1);
+
+          requests[0].respond(200, {"Content-Type": "application/json"},
+                                   JSON.stringify(fakeSessionData));
+
+          expect(conversation.get("sessionId")).eql("sessionId");
+          expect(conversation.get("sessionToken")).eql("sessionToken");
+          expect(conversation.get("apiKey")).eql("apiKey");
+        });
+
+      it("should trigger a `session:error` on failure", function(done) {
+        conversation.set("loopToken", "fakeToken");
+        conversation.initiate();
+
+        conversation.on("session:error", function(err) {
+          expect(err.message).to.match(/failed: HTTP 400 Bad Request; fake/);
+          done();
+        });
+
+        requests[0].respond(400, {"Content-Type": "application/json"},
+                                  JSON.stringify({error: "fake"}));
+      });
+    });
+
+    describe("#setReady", function() {
+      it("should update conversation session information", function() {
+        conversation.setReady(fakeSessionData);
+
+        expect(conversation.get("sessionId")).eql("sessionId");
+        expect(conversation.get("sessionToken")).eql("sessionToken");
+        expect(conversation.get("apiKey")).eql("apiKey");
+      });
+
+      it("should trigger a `session:ready` event", function(done) {
+        conversation.on("session:ready", function() {
+          done();
+        }).setReady(fakeSessionData);
+      });
     });
   });
 
-  describe("#loadView", function() {
-    it("should set the active view", function() {
-      router.loadView(new loop.webapp.CallLauncherView({token: "fake"}));
+  describe("Router", function() {
+    var router, conversation;
+
+    beforeEach(function() {
+      conversation = new loop.webapp.ConversationModel({loopToken: "fake"});
+      router = new loop.webapp.Router({conversation: conversation});
+    });
+
+    describe("#constructor", function() {
+      it("should define a default active view", function() {
+        expect(router.activeView).to.be.an.instanceOf(loop.webapp.HomeView);
+      });
+    });
+
+    describe("#loadView", function() {
+      it("should set the active view", function() {
+        router.loadView(new loop.webapp.ConversationFormView({
+          model: conversation
+        }));
 
-      expect(router.view).to.be.an.instanceOf(loop.webapp.CallLauncherView);
+        expect(router.activeView).to.be.an.instanceOf(
+          loop.webapp.ConversationFormView);
+      });
+    });
+  });
+
+  describe("ConversationFormView", function() {
+    describe("#initiate", function() {
+      var conversation, initiate, view, fakeSubmitEvent;
+
+      beforeEach(function() {
+        conversation = new loop.webapp.ConversationModel();
+        view = new loop.webapp.ConversationFormView({model: conversation});
+        fakeSubmitEvent = {preventDefault: sinon.spy()};
+      });
+
+      it("should start the conversation establishment process", function() {
+        initiate = sinon.stub(conversation, "initiate");
+        conversation.set("loopToken", "fake");
+
+        view.initiate(fakeSubmitEvent);
+
+        sinon.assert.calledOnce(fakeSubmitEvent.preventDefault);
+        sinon.assert.calledOnce(initiate);
+      });
     });
   });
 });
-
-describe("CallLauncherView", function() {
-  "use strict";
-
-  describe("#constructor", function() {
-    it("should require a token option", function() {
-      expect(function() {
-        new loop.webapp.CallLauncherView();
-      }).to.Throw(Error, /missing required token/);
-    });
-  });
-});