diff --git a/browser/components/loop/README.txt b/browser/components/loop/README.txt index c0fa663d1edd..45e87eb5b653 100644 --- a/browser/components/loop/README.txt +++ b/browser/components/loop/README.txt @@ -6,3 +6,16 @@ The standalone client is a set of web pages intended to be hosted on a standalon The standalone client exists in standalone/ but shares items (from content/shared/) with the desktop implementation. See the README.md file in the standalone/ directory for how to run the server locally. +Working with JSX +================ + +You need to install the JSX compiler in order to compile the .jsx files into regular .js ones. + +The JSX compiler is installable using npm: + + npm install -g react-tools + +Once installed, run it with the --watch option, eg.: + + jsx --watch --x jsx browser/components/loop/content/js/src \ + browser/components/loop/content/js diff --git a/browser/components/loop/content/js/client.js b/browser/components/loop/content/js/client.js index bf209b017bce..33fbe29e0519 100644 --- a/browser/components/loop/content/js/client.js +++ b/browser/components/loop/content/js/client.js @@ -2,6 +2,7 @@ * 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/. */ +/* jshint esnext:true */ /* global loop:true, hawk, deriveHawkCredentials */ var loop = loop || {}; diff --git a/browser/components/loop/content/js/conversation.js b/browser/components/loop/content/js/conversation.js index 2dedda37d382..e4310c13dee7 100644 --- a/browser/components/loop/content/js/conversation.js +++ b/browser/components/loop/content/js/conversation.js @@ -2,6 +2,7 @@ * 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/. */ +/* jshint esnext:true */ /* global loop:true */ var loop = loop || {}; diff --git a/browser/components/loop/content/js/desktopRouter.js b/browser/components/loop/content/js/desktopRouter.js index 294418685d5a..8adc7da2d877 100644 --- a/browser/components/loop/content/js/desktopRouter.js +++ b/browser/components/loop/content/js/desktopRouter.js @@ -2,6 +2,7 @@ * 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/. */ +/* jshint esnext:true */ /* global loop:true */ var loop = loop || {}; diff --git a/browser/components/loop/content/js/panel.js b/browser/components/loop/content/js/panel.js index 863df1d059f6..f28380a8304f 100644 --- a/browser/components/loop/content/js/panel.js +++ b/browser/components/loop/content/js/panel.js @@ -1,8 +1,11 @@ +/** @jsx React.DOM */ + /* 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:true */ +/*jshint newcap:false*/ +/*global loop:true, React */ var loop = loop || {}; loop.panel = (function(_, mozL10n) { @@ -21,162 +24,188 @@ loop.panel = (function(_, mozL10n) { /** * Do not disturb panel subview. */ - var DoNotDisturbView = sharedViews.BaseView.extend({ - template: _.template([ - '', - ].join('')), - - events: { - "click input[type=checkbox]": "toggle" + var DoNotDisturb = React.createClass({displayName: 'DoNotDisturb', + getInitialState: function() { + return {doNotDisturb: navigator.mozLoop.doNotDisturb}; }, - /** - * Toggles mozLoop activation status. - */ - toggle: function() { + handleCheckboxChange: function() { + // Note: side effect! navigator.mozLoop.doNotDisturb = !navigator.mozLoop.doNotDisturb; - this.render(); + this.setState({doNotDisturb: navigator.mozLoop.doNotDisturb}); }, render: function() { - this.$el.html(this.template({ - checked: navigator.mozLoop.doNotDisturb ? "checked" : "" - })); - return this; + // XXX https://github.com/facebook/react/issues/310 for === htmlFor + return ( + React.DOM.p( {className:"dnd"}, + React.DOM.input( {type:"checkbox", checked:this.state.doNotDisturb, + id:"dnd-component", onChange:this.handleCheckboxChange} ), + React.DOM.label( {htmlFor:"dnd-component"}, __("do_not_disturb")) + ) + ); } }); - var ToSView = sharedViews.BaseView.extend({ - template: _.template([ - '

' - ].join('')), + var ToSView = React.createClass({displayName: 'ToSView', + getInitialState: function() { + return {seenToS: navigator.mozLoop.getLoopCharPref('seenToS')}; + }, render: function() { - if (navigator.mozLoop.getLoopCharPref('seenToS') === null) { - this.$el.html(this.template()); + var tosHTML = __("legal_text_and_links", { + "terms_of_use_url": "https://accounts.firefox.com/legal/terms", + "privacy_notice_url": "www.mozilla.org/privacy/" + }); + + if (!this.state.seenToS) { navigator.mozLoop.setLoopCharPref('seenToS', 'seen'); + return React.DOM.p( {className:"tos", + dangerouslySetInnerHTML:{__html: tosHTML}}); + } else { + return React.DOM.div(null ); } - return this; + } + }); + + var PanelLayout = React.createClass({displayName: 'PanelLayout', + propTypes: { + summary: React.PropTypes.string.isRequired + }, + + render: function() { + return ( + React.DOM.div( {className:"share generate-url"}, + React.DOM.div( {className:"description"}, + React.DOM.p(null, this.props.summary) + ), + React.DOM.div( {className:"action"}, + this.props.children + ) + ) + ); + } + }); + + var CallUrlResult = React.createClass({displayName: 'CallUrlResult', + propTypes: { + callUrl: React.PropTypes.string.isRequired, + retry: React.PropTypes.func.isRequired + }, + + handleButtonClick: function() { + this.props.retry(); + }, + + render: function() { + // XXX setting elem value from a state (in the callUrl input) + // makes it immutable ie read only but that is fine in our case. + // readOnly attr will suppress a warning regarding this issue + // from the react lib. + return ( + PanelLayout( {summary:__("share_link_url")}, + React.DOM.div( {className:"invite"}, + React.DOM.input( {type:"url", value:this.props.callUrl, readOnly:"true"} ), + React.DOM.button( {onClick:this.handleButtonClick, + className:"btn btn-success"}, __("new_url")) + ) + ) + ); + } + }); + + var CallUrlForm = React.createClass({displayName: 'CallUrlForm', + propTypes: { + client: React.PropTypes.object.isRequired, + notifier: React.PropTypes.object.isRequired + }, + + getInitialState: function() { + return { + pending: false, + disabled: true, + callUrl: false + }; + }, + + retry: function() { + this.setState(this.getInitialState()); + }, + + handleTextChange: function(event) { + this.setState({disabled: !event.currentTarget.value}); + }, + + handleFormSubmit: function(event) { + event.preventDefault(); + + this.setState({pending: true}); + + this.props.client.requestCallUrl( + this.refs.caller.getDOMNode().value, this._onCallUrlReceived); + }, + + _onCallUrlReceived: function(err, callUrlData) { + var callUrl = false; + + this.props.notifier.clear(); + + if (err) { + this.props.notifier.errorL10n("unable_retrieve_url"); + } else { + callUrl = callUrlData.callUrl || callUrlData.call_url; + } + + this.setState({pending: false, callUrl: callUrl}); + }, + + render: function() { + // If we have a call url, render result + if (this.state.callUrl) { + return ( + CallUrlResult( {callUrl:this.state.callUrl, retry:this.retry}) + ); + } + + // If we don't display the form + var cx = React.addons.classSet; + return ( + PanelLayout( {summary:__("get_link_to_share")}, + React.DOM.form( {className:"invite", onSubmit:this.handleFormSubmit}, + + React.DOM.input( {type:"text", name:"caller", ref:"caller", required:"required", + className:cx({'pending': this.state.pending}), + onChange:this.handleTextChange, + placeholder:__("call_identifier_textinput_placeholder")} ), + + React.DOM.button( {type:"submit", className:"get-url btn btn-success", + disabled:this.state.disabled}, + __("get_a_call_url") + ) + ), + ToSView(null ) + ) + ); } }); /** * Panel view. */ - var PanelView = sharedViews.BaseView.extend({ - template: _.template([ - '
', - '

', - '
', - '
', - '
', - ' ', - ' ', - '
', - '

', - '

', - ' ', - ' ', - '

', - '

', - '
', - ].join("")), - - className: "share generate-url", - - /** - * Do not disturb view. - * @type {DoNotDisturbView|undefined} - */ - dndView: undefined, - - events: { - "keyup input[name=caller]": "changeButtonState", - "submit form.invite": "getCallUrl", - "click a.go-back": "goBack" - }, - - initialize: function(options) { - options = options || {}; - if (!options.notifier) { - throw new Error("missing required notifier"); - } - this.notifier = options.notifier; - this.client = new loop.Client(); - }, - - getNickname: function() { - return this.$("input[name=caller]").val(); - }, - - getCallUrl: function(event) { - this.notifier.clear(); - event.preventDefault(); - var callback = function(err, callUrlData) { - this.clearPending(); - if (err) { - this.notifier.errorL10n("unable_retrieve_url"); - this.render(); - return; - } - this.onCallUrlReceived(callUrlData); - }.bind(this); - - this.setPending(); - this.client.requestCallUrl(this.getNickname(), callback); - }, - - goBack: function(event) { - event.preventDefault(); - this.$(".action .result").hide(); - this.$(".action .invite").show(); - this.$(".description p").text(__("get_link_to_share")); - this.changeButtonState(); - }, - - onCallUrlReceived: function(callUrlData) { - this.notifier.clear(); - this.$(".action .invite").hide(); - this.$(".action .invite input").val(""); - this.$(".action .result input").val(callUrlData.callUrl); - this.$(".action .result").show(); - this.$(".description p").text(__("share_link_url")); - }, - - setPending: function() { - this.$("[name=caller]").addClass("pending"); - this.$(".get-url").addClass("disabled").attr("disabled", "disabled"); - }, - - clearPending: function() { - this.$("[name=caller]").removeClass("pending"); - this.changeButtonState(); - }, - - changeButtonState: function() { - var enabled = !!this.$("input[name=caller]").val(); - if (enabled) { - this.$(".get-url").removeClass("disabled") - .removeAttr("disabled", "disabled"); - } else { - this.$(".get-url").addClass("disabled").attr("disabled", "disabled"); - } + var PanelView = React.createClass({displayName: 'PanelView', + propTypes: { + notifier: React.PropTypes.object.isRequired, + client: React.PropTypes.object.isRequired }, render: function() { - this.$el.html(this.template()); - // Do not Disturb sub view - this.dndView = new DoNotDisturbView({el: this.$(".dnd")}).render(); - this.tosView = new ToSView({el: this.$(".tos")}).render(); - return this; + return ( + React.DOM.div(null, + CallUrlForm( {client:this.props.client, + notifier:this.props.notifier} ), + DoNotDisturb(null ) + ) + ); } }); @@ -230,10 +259,12 @@ loop.panel = (function(_, mozL10n) { * Resets this router to its initial state. */ reset: function() { - // purge pending notifications this._notifier.clear(); - // reset home view - this.loadView(new PanelView({notifier: this._notifier})); + var client = new loop.Client({ + baseServerUrl: navigator.mozLoop.serverUrl + }); + this.loadReactComponent(PanelView( {client:client, + notifier:this._notifier} )); } }); @@ -259,8 +290,9 @@ loop.panel = (function(_, mozL10n) { return { init: init, + DoNotDisturb: DoNotDisturb, + CallUrlForm: CallUrlForm, PanelView: PanelView, - DoNotDisturbView: DoNotDisturbView, PanelRouter: PanelRouter, ToSView: ToSView }; diff --git a/browser/components/loop/content/js/src/panel.jsx b/browser/components/loop/content/js/src/panel.jsx new file mode 100644 index 000000000000..71f70d885101 --- /dev/null +++ b/browser/components/loop/content/js/src/panel.jsx @@ -0,0 +1,299 @@ +/** @jsx React.DOM */ + +/* 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/. */ + +/*jshint newcap:false*/ +/*global loop:true, React */ + +var loop = loop || {}; +loop.panel = (function(_, mozL10n) { + "use strict"; + + var sharedViews = loop.shared.views, + // aliasing translation function as __ for concision + __ = mozL10n.get; + + /** + * Panel router. + * @type {loop.desktopRouter.DesktopRouter} + */ + var router; + + /** + * Do not disturb panel subview. + */ + var DoNotDisturb = React.createClass({ + getInitialState: function() { + return {doNotDisturb: navigator.mozLoop.doNotDisturb}; + }, + + handleCheckboxChange: function() { + // Note: side effect! + navigator.mozLoop.doNotDisturb = !navigator.mozLoop.doNotDisturb; + this.setState({doNotDisturb: navigator.mozLoop.doNotDisturb}); + }, + + render: function() { + // XXX https://github.com/facebook/react/issues/310 for === htmlFor + return ( +

+ + +

+ ); + } + }); + + var ToSView = React.createClass({ + getInitialState: function() { + return {seenToS: navigator.mozLoop.getLoopCharPref('seenToS')}; + }, + + render: function() { + var tosHTML = __("legal_text_and_links", { + "terms_of_use_url": "https://accounts.firefox.com/legal/terms", + "privacy_notice_url": "www.mozilla.org/privacy/" + }); + + if (!this.state.seenToS) { + navigator.mozLoop.setLoopCharPref('seenToS', 'seen'); + return

; + } else { + return
; + } + } + }); + + var PanelLayout = React.createClass({ + propTypes: { + summary: React.PropTypes.string.isRequired + }, + + render: function() { + return ( +
+
+

{this.props.summary}

+
+
+ {this.props.children} +
+
+ ); + } + }); + + var CallUrlResult = React.createClass({ + propTypes: { + callUrl: React.PropTypes.string.isRequired, + retry: React.PropTypes.func.isRequired + }, + + handleButtonClick: function() { + this.props.retry(); + }, + + render: function() { + // XXX setting elem value from a state (in the callUrl input) + // makes it immutable ie read only but that is fine in our case. + // readOnly attr will suppress a warning regarding this issue + // from the react lib. + return ( + +
+ + +
+
+ ); + } + }); + + var CallUrlForm = React.createClass({ + propTypes: { + client: React.PropTypes.object.isRequired, + notifier: React.PropTypes.object.isRequired + }, + + getInitialState: function() { + return { + pending: false, + disabled: true, + callUrl: false + }; + }, + + retry: function() { + this.setState(this.getInitialState()); + }, + + handleTextChange: function(event) { + this.setState({disabled: !event.currentTarget.value}); + }, + + handleFormSubmit: function(event) { + event.preventDefault(); + + this.setState({pending: true}); + + this.props.client.requestCallUrl( + this.refs.caller.getDOMNode().value, this._onCallUrlReceived); + }, + + _onCallUrlReceived: function(err, callUrlData) { + var callUrl = false; + + this.props.notifier.clear(); + + if (err) { + this.props.notifier.errorL10n("unable_retrieve_url"); + } else { + callUrl = callUrlData.callUrl || callUrlData.call_url; + } + + this.setState({pending: false, callUrl: callUrl}); + }, + + render: function() { + // If we have a call url, render result + if (this.state.callUrl) { + return ( + + ); + } + + // If we don't display the form + var cx = React.addons.classSet; + return ( + +
+ + + + +
+ +
+ ); + } + }); + + /** + * Panel view. + */ + var PanelView = React.createClass({ + propTypes: { + notifier: React.PropTypes.object.isRequired, + client: React.PropTypes.object.isRequired + }, + + render: function() { + return ( +
+ + +
+ ); + } + }); + + var PanelRouter = loop.desktopRouter.DesktopRouter.extend({ + /** + * DOM document object. + * @type {HTMLDocument} + */ + document: undefined, + + routes: { + "": "home" + }, + + initialize: function(options) { + options = options || {}; + if (!options.document) { + throw new Error("missing required document"); + } + this.document = options.document; + + this._registerVisibilityChangeEvent(); + + this.on("panel:open panel:closed", this.reset, this); + }, + + /** + * Register the DOM visibility API event for the whole document, and trigger + * appropriate events accordingly: + * + * - `panel:opened` when the panel is open + * - `panel:closed` when the panel is closed + * + * @link http://www.w3.org/TR/page-visibility/ + */ + _registerVisibilityChangeEvent: function() { + this.document.addEventListener("visibilitychange", function(event) { + this.trigger(event.currentTarget.hidden ? "panel:closed" + : "panel:open"); + }.bind(this)); + }, + + /** + * Default entry point. + */ + home: function() { + this.reset(); + }, + + /** + * Resets this router to its initial state. + */ + reset: function() { + this._notifier.clear(); + var client = new loop.Client({ + baseServerUrl: navigator.mozLoop.serverUrl + }); + this.loadReactComponent(); + } + }); + + /** + * Panel initialisation. + */ + function init() { + // Do the initial L10n setup, we do this before anything + // else to ensure the L10n environment is setup correctly. + mozL10n.initialize(navigator.mozLoop); + + router = new PanelRouter({ + document: document, + notifier: new sharedViews.NotificationListView({el: "#messages"}) + }); + Backbone.history.start(); + + // Notify the window that we've finished initalization and initial layout + var evtObject = document.createEvent('Event'); + evtObject.initEvent('loopPanelInitialized', true, false); + window.dispatchEvent(evtObject); + } + + return { + init: init, + DoNotDisturb: DoNotDisturb, + CallUrlForm: CallUrlForm, + PanelView: PanelView, + PanelRouter: PanelRouter, + ToSView: ToSView + }; +})(_, document.mozL10n); diff --git a/browser/components/loop/content/panel.html b/browser/components/loop/content/panel.html index c2ed568f20b1..7b4a79c646c3 100644 --- a/browser/components/loop/content/panel.html +++ b/browser/components/loop/content/panel.html @@ -15,6 +15,7 @@
+ diff --git a/browser/components/loop/content/shared/css/panel.css b/browser/components/loop/content/shared/css/panel.css index bb11f19951dc..a9795475fbd4 100644 --- a/browser/components/loop/content/shared/css/panel.css +++ b/browser/components/loop/content/shared/css/panel.css @@ -53,8 +53,11 @@ a { margin: 0 0 1em 0; } -.share .action p.dnd { - margin-top: 1em; +p.dnd { + margin: 0 10px 10px 10px; + /* The panel won't increase its height when using a bottom margin, while it + works using a padding */ + padding-bottom: 10px; } .share .action input[type="text"], @@ -65,6 +68,7 @@ a { font-size: .9em; width: 65%; padding: .5em; + margin-right: .35em; } .share .action input.pending { diff --git a/browser/components/loop/content/shared/js/router.js b/browser/components/loop/content/shared/js/router.js index 8664b3d69ece..8d51c78344b3 100644 --- a/browser/components/loop/content/shared/js/router.js +++ b/browser/components/loop/content/shared/js/router.js @@ -18,7 +18,7 @@ loop.shared.router = (function(l10n) { var BaseRouter = Backbone.Router.extend({ /** * Active view. - * @type {loop.shared.views.BaseView} + * @type {Object} */ _activeView: undefined, @@ -51,12 +51,38 @@ loop.shared.router = (function(l10n) { * * @param {loop.shared.views.BaseView} view View. */ - loadView : function(view) { - if (this._activeView) { - this._activeView.remove(); + loadView: function(view) { + this.clearActiveView(); + this._activeView = {type: "backbone", view: view.render().show()}; + this.updateView(this._activeView.view.$el); + }, + + /** + * Renders a React component as current active view. + * + * @param {React} reactComponent React component. + */ + loadReactComponent: function(reactComponent) { + this.clearActiveView(); + this._activeView = { + type: "react", + view: React.renderComponent(reactComponent, + document.querySelector("#main")) + }; + }, + + /** + * Clears current active view. + */ + clearActiveView: function() { + if (!this._activeView) { + return; + } + if (this._activeView.type === "react") { + React.unmountComponentAtNode(document.querySelector("#main")); + } else { + this._activeView.view.remove(); } - this._activeView = view.render().show(); - this.updateView(this._activeView.$el); }, /** diff --git a/browser/components/loop/test/desktop-local/conversation_test.js b/browser/components/loop/test/desktop-local/conversation_test.js index 46c0dbdc9534..7f883974b331 100644 --- a/browser/components/loop/test/desktop-local/conversation_test.js +++ b/browser/components/loop/test/desktop-local/conversation_test.js @@ -139,8 +139,11 @@ describe("loop.conversation", function() { router.accept(); sinon.assert.calledOnce(conversation.initiate); - sinon.assert.calledWithExactly(conversation.initiate, { - baseServerUrl: "http://example.com", + sinon.assert.calledWithMatch(conversation.initiate, { + client: { + mozLoop: navigator.mozLoop, + settings: {} + }, outgoing: false }); }); diff --git a/browser/components/loop/test/desktop-local/index.html b/browser/components/loop/test/desktop-local/index.html index 81b0f7a39458..04119df6613b 100644 --- a/browser/components/loop/test/desktop-local/index.html +++ b/browser/components/loop/test/desktop-local/index.html @@ -16,6 +16,7 @@
+ @@ -35,8 +36,8 @@ - + diff --git a/browser/components/loop/test/desktop-local/panel_test.js b/browser/components/loop/test/desktop-local/panel_test.js index 4d07a41231f9..efe35490bb19 100644 --- a/browser/components/loop/test/desktop-local/panel_test.js +++ b/browser/components/loop/test/desktop-local/panel_test.js @@ -5,12 +5,12 @@ /*global loop, sinon */ var expect = chai.expect; +var TestUtils = React.addons.TestUtils; describe("loop.panel", function() { "use strict"; - var sandbox, notifier, fakeXHR, requests = [], savedMozLoop, - fakeSeenToSPref = 0; + var sandbox, notifier, fakeXHR, requests = []; function createTestRouter(fakeDocument) { return new loop.panel.PanelRouter({ @@ -42,18 +42,13 @@ describe("loop.panel", function() { return "http://example.com"; }, getStrings: function() { - return "{}"; + return JSON.stringify({textContent: "fakeText"}); }, get locale() { return "en-US"; }, setLoopCharPref: sandbox.stub(), - getLoopCharPref: function () { - if (fakeSeenToSPref === 0) { - return null; - } - return 'seen'; - } + getLoopCharPref: sandbox.stub() }; document.mozL10n.initialize(navigator.mozLoop); @@ -61,7 +56,6 @@ describe("loop.panel", function() { afterEach(function() { delete navigator.mozLoop; - $("#fixtures").empty(); sandbox.restore(); }); @@ -90,6 +84,7 @@ describe("loop.panel", function() { }); sandbox.stub(router, "loadView"); + sandbox.stub(router, "loadReactComponent"); }); describe("#home", function() { @@ -112,277 +107,233 @@ describe("loop.panel", function() { it("should load the home view", function() { router.reset(); - sinon.assert.calledOnce(router.loadView); - sinon.assert.calledWithExactly(router.loadView, - sinon.match.instanceOf(loop.panel.PanelView)); + sinon.assert.calledOnce(router.loadReactComponent); + sinon.assert.calledWithExactly(router.loadReactComponent, + sinon.match(function(value) { + return React.addons.TestUtils.isComponentOfType( + value, loop.panel.PanelView); + })); }); }); + }); - describe("Events", function() { - it("should listen to document visibility changes", function() { - var fakeDocument = { + describe("Events", function() { + beforeEach(function() { + sandbox.stub(loop.panel.PanelRouter.prototype, "trigger"); + }); + + it("should listen to document visibility changes", function() { + var fakeDocument = { + hidden: true, + addEventListener: sandbox.spy() + }; + + var router = createTestRouter(fakeDocument); + + sinon.assert.calledOnce(fakeDocument.addEventListener); + sinon.assert.calledWith(fakeDocument.addEventListener, + "visibilitychange"); + }); + + it("should trigger panel:open when the panel document is visible", + function() { + var router = createTestRouter({ + hidden: false, + addEventListener: function(name, cb) { + cb({currentTarget: {hidden: false}}); + } + }); + + sinon.assert.calledOnce(router.trigger); + sinon.assert.calledWithExactly(router.trigger, "panel:open"); + }); + + it("should trigger panel:closed when the panel document is hidden", + function() { + var router = createTestRouter({ hidden: true, - addEventListener: sandbox.spy() - }; + addEventListener: function(name, cb) { + cb({currentTarget: {hidden: true}}); + } + }); - var router = createTestRouter(fakeDocument); - - sinon.assert.calledOnce(fakeDocument.addEventListener); - sinon.assert.calledWith(fakeDocument.addEventListener, - "visibilitychange"); + sinon.assert.calledOnce(router.trigger); + sinon.assert.calledWithExactly(router.trigger, "panel:closed"); }); - - it("should trigger panel:open when the panel document is visible", - function(done) { - var router = createTestRouter({ - hidden: false, - addEventListener: function(name, cb) { - setTimeout(function() { - cb({currentTarget: {hidden: false}}); - }, 0); - } - }); - - router.once("panel:open", function() { - done(); - }); - }); - - it("should trigger panel:closed when the panel document is hidden", - function(done) { - var router = createTestRouter({ - addEventListener: function(name, cb) { - hidden: true, - setTimeout(function() { - cb({currentTarget: {hidden: true}}); - }, 0); - } - }); - - router.once("panel:closed", function() { - done(); - }); - }); - }); }); }); - describe("loop.panel.DoNotDisturbView", function() { + describe("loop.panel.DoNotDisturb", function() { var view; beforeEach(function() { - $("#fixtures").append('
'); - view = new loop.panel.DoNotDisturbView({el: $("#dnd-view")}); + view = TestUtils.renderIntoDocument(loop.panel.DoNotDisturb()); }); - describe("#toggle", function() { + describe("Checkbox change event", function() { beforeEach(function() { navigator.mozLoop.doNotDisturb = false; + + var checkbox = TestUtils.findRenderedDOMComponentWithTag(view, "input"); + TestUtils.Simulate.change(checkbox); }); it("should toggle the value of mozLoop.doNotDisturb", function() { - view.toggle(); - expect(navigator.mozLoop.doNotDisturb).eql(true); }); it("should update the DnD checkbox value", function() { - view.toggle(); - - expect(view.$("input").is(":checked")).eql(true); - }); - }); - - describe("render", function() { - it("should check the dnd checkbox when dnd is enabled", function() { - navigator.mozLoop.doNotDisturb = false; - - view.render(); - - expect(view.$("input").is(":checked")).eql(false); - }); - - it("should uncheck the dnd checkbox when dnd is disabled", function() { - navigator.mozLoop.doNotDisturb = true; - - view.render(); - - expect(view.$("input").is(":checked")).eql(true); + expect(view.getDOMNode().querySelector("input").checked).eql(true); }); }); }); - describe("loop.panel.PanelView", function() { + describe("loop.panel.CallUrlForm", function() { + var fakeClient, callUrlData, view; + beforeEach(function() { - $("#fixtures").append('
'); + callUrlData = { + call_url: "http://call.invalid/", + expiresAt: 1000 + }; + + fakeClient = { + requestCallUrl: function(_, cb) { + cb(null, callUrlData); + } + }; + + view = TestUtils.renderIntoDocument(loop.panel.CallUrlForm({ + notifier: notifier, + client: fakeClient + })); }); - describe("#getCallUrl", function() { + describe("#render", function() { + it("should render a ToSView", function() { + TestUtils.findRenderedComponentWithType(view, loop.panel.ToSView); + }); + }); + + describe("Form submit event", function() { + + function submitForm(callerValue) { + // fill caller field + TestUtils.Simulate.change( + TestUtils.findRenderedDOMComponentWithTag(view, "input"), { + target: {value: callerValue} + }); + + // submit form + TestUtils.Simulate.submit( + TestUtils.findRenderedDOMComponentWithTag(view, "form")); + } + it("should reset all pending notifications", function() { - var requestCallUrl = sandbox.stub(loop.Client.prototype, - "requestCallUrl"); - var view = new loop.panel.PanelView({notifier: notifier}).render(); + submitForm("foo"); - view.getCallUrl({preventDefault: sandbox.spy()}); - - sinon.assert.calledOnce(view.notifier.clear, "clear"); + sinon.assert.calledOnce(notifier.clear, "clear"); }); it("should request a call url to the server", function() { - var requestCallUrl = sandbox.stub(loop.Client.prototype, - "requestCallUrl"); - var view = new loop.panel.PanelView({notifier: notifier}); - sandbox.stub(view, "getNickname").returns("foo"); + fakeClient.requestCallUrl = sandbox.stub(); - view.getCallUrl({preventDefault: sandbox.spy()}); + submitForm("foo"); - sinon.assert.calledOnce(requestCallUrl); - sinon.assert.calledWith(requestCallUrl, "foo"); + sinon.assert.calledOnce(fakeClient.requestCallUrl); + sinon.assert.calledWith(fakeClient.requestCallUrl, "foo"); }); it("should set the call url form in a pending state", function() { - var requestCallUrl = sandbox.stub(loop.Client.prototype, - "requestCallUrl"); - sandbox.stub(loop.panel.PanelView.prototype, "setPending"); + // Cancel requestCallUrl effect to keep the state pending + fakeClient.requestCallUrl = sandbox.stub(); - var view = new loop.panel.PanelView({notifier: notifier}); + submitForm("foo"); - view.getCallUrl({preventDefault: sandbox.spy()}); + expect(view.state.pending).eql(true); + }); - sinon.assert.calledOnce(view.setPending); + it("should update state with the call url received", function() { + submitForm("foo"); + + expect(view.state.pending).eql(false); + expect(view.state.callUrl).eql(callUrlData.call_url); }); it("should clear the pending state when a response is received", function() { - sandbox.stub(loop.panel.PanelView.prototype, - "clearPending"); - var requestCallUrl = sandbox.stub( - loop.Client.prototype, "requestCallUrl", function(_, cb) { - cb("fake error"); - }); - var view = new loop.panel.PanelView({notifier: notifier}); + submitForm("foo"); - view.getCallUrl({preventDefault: sandbox.spy()}); - - sinon.assert.calledOnce(view.clearPending); + expect(view.state.pending).eql(false); }); - it("should notify the user when the operation failed", function() { - var requestCallUrl = sandbox.stub( - loop.Client.prototype, "requestCallUrl", function(_, cb) { - cb("fake error"); - }); - var view = new loop.panel.PanelView({notifier: notifier}); + it("should update CallUrlResult with the call url", function() { + submitForm("foo"); - view.getCallUrl({preventDefault: sandbox.spy()}); + var urlField = view.getDOMNode().querySelector("input[type='url']"); - sinon.assert.calledOnce(view.notifier.errorL10n); - sinon.assert.calledWithExactly(view.notifier.errorL10n, - "unable_retrieve_url"); - }); - }); - - describe("#onCallUrlReceived", function() { - var callUrlData; - - beforeEach(function() { - callUrlData = { - callUrl: "http://call.me/", - expiresAt: 1000 - }; - }); - - it("should update the text field with the call url", function() { - var view = new loop.panel.PanelView({notifier: notifier}); - view.render(); - - view.onCallUrlReceived(callUrlData); - - expect(view.$("#call-url").val()).eql("http://call.me/"); + expect(urlField.value).eql(callUrlData.call_url); }); it("should reset all pending notifications", function() { - var view = new loop.panel.PanelView({notifier: notifier}).render(); + submitForm("foo"); - view.onCallUrlReceived(callUrlData); - - sinon.assert.calledOnce(view.notifier.clear); - }); - }); - - describe("events", function() { - describe("goBack", function() { - it("should update the button state"); + sinon.assert.calledOnce(view.props.notifier.clear); }); - describe("changeButtonState", function() { - it("should do set the disabled state if there is no text"); - it("should do set the enabled state if there is text"); - }); - }); + it("should notify the user when the operation failed", function() { + fakeClient.requestCallUrl = function(_, cb) { + cb("fake error"); + }; - describe("#render", function() { - it("should render a DoNotDisturbView", function() { - var renderDnD = sandbox.stub(loop.panel.DoNotDisturbView.prototype, - "render"); - var view = new loop.panel.PanelView({notifier: notifier}); + submitForm("foo"); - view.render(); - - sinon.assert.calledOnce(renderDnD); - }); - - it("should render a ToSView", function() { - var renderToS = sandbox.stub(loop.panel.ToSView.prototype, "render"); - var view = new loop.panel.PanelView({notifier: notifier}); - - view.render(); - - sinon.assert.calledOnce(renderToS); - }); - }); - - describe('loop.panel.ToSView', function() { - - beforeEach(function() { - - $('#fixtures').append('
'); - - }); - - // XXX Until it's possible to easily test creation of text, - // not doing so. As it stands, the magic in the L10nView - // class makes stubbing BaseView.render impractical. - - it("should set the value of the loop.seenToS preference to 'seen'", - function() { - var ToSView = new loop.panel.ToSView({el: $("#tos-view")}); - - ToSView.render(); - - sinon.assert.calledOnce(navigator.mozLoop.setLoopCharPref); - sinon.assert.calledWithExactly(navigator.mozLoop.setLoopCharPref, - 'seenToS', 'seen'); - }); - - it("should render when the value of loop.seenToS is not set", function() { - var renderToS = sandbox.spy(loop.panel.ToSView.prototype, "render"); - var ToSView = new loop.panel.ToSView({el: $('#tos-view')}); - - ToSView.render(); - - sinon.assert.calledOnce(renderToS); - }); - - it("should not render when the value of loop.seenToS is set to 'seen'", - function() { - var ToSView = new loop.panel.ToSView({el: $('#tos-view')}); - fakeSeenToSPref = 1; - - ToSView.render(); - - sinon.assert.notCalled(navigator.mozLoop.setLoopCharPref); + sinon.assert.calledOnce(notifier.errorL10n); + sinon.assert.calledWithExactly(notifier.errorL10n, + "unable_retrieve_url"); }); }); }); + + describe('loop.panel.ToSView', function() { + + it("should set the value of the loop.seenToS preference to 'seen'", + function() { + TestUtils.renderIntoDocument(loop.panel.ToSView()); + + sinon.assert.calledOnce(navigator.mozLoop.setLoopCharPref); + sinon.assert.calledWithExactly(navigator.mozLoop.setLoopCharPref, + 'seenToS', 'seen'); + }); + + it("should not set the value of loop.seenToS when it's already set", + function() { + navigator.mozLoop.getLoopCharPref = function() { + return "seen"; + }; + + TestUtils.renderIntoDocument(loop.panel.ToSView()); + + sinon.assert.notCalled(navigator.mozLoop.setLoopCharPref); + }); + + it("should render when the value of loop.seenToS is not set", function() { + var view = TestUtils.renderIntoDocument(loop.panel.ToSView()); + + TestUtils.findRenderedDOMComponentWithClass(view, "tos"); + }); + + it("should not render when the value of loop.seenToS is set to 'seen'", + function(done) { + navigator.mozLoop.getLoopCharPref = function() { + return "seen"; + }; + + try { + TestUtils.findRenderedDOMComponentWithClass(view, "tos"); + } catch (err) { + done(); + } + }); + }); }); diff --git a/browser/components/loop/test/shared/router_test.js b/browser/components/loop/test/shared/router_test.js index 012bc6c795e2..1ec21c276d38 100644 --- a/browser/components/loop/test/shared/router_test.js +++ b/browser/components/loop/test/shared/router_test.js @@ -69,7 +69,10 @@ describe("loop.shared.router", function() { it("should set the active view", function() { router.loadView(view); - expect(router._activeView).eql(view); + expect(router._activeView).eql({ + type: "backbone", + view: view + }); }); it("should load and render the passed view", function() { diff --git a/browser/components/loop/test/xpcshell/test_loopservice_locales.js b/browser/components/loop/test/xpcshell/test_loopservice_locales.js index c500002a62cf..f5829b50cfa4 100644 --- a/browser/components/loop/test/xpcshell/test_loopservice_locales.js +++ b/browser/components/loop/test/xpcshell/test_loopservice_locales.js @@ -14,10 +14,10 @@ function test_getStrings() { // Try an invalid string Assert.equal(MozLoopService.getStrings("invalid_not_found_string"), ""); - // Get a string that has sub-items to test the function more fully. // XXX This depends on the L10n values, which I'd prefer not to do, but is the // simplest way for now. - Assert.equal(MozLoopService.getStrings("caller"), '{"placeholder":"Identify this call"}'); + Assert.equal(MozLoopService.getStrings("get_link_to_share"), + '{"textContent":"Get a link and invite someone to talk"}'); } function run_test() diff --git a/browser/locales/en-US/chrome/browser/loop/loop.properties b/browser/locales/en-US/chrome/browser/loop/loop.properties index 9137ae4f3842..50b61102ddab 100644 --- a/browser/locales/en-US/chrome/browser/loop/loop.properties +++ b/browser/locales/en-US/chrome/browser/loop/loop.properties @@ -10,7 +10,7 @@ do_not_disturb=Do not disturb get_a_call_url=Get a call url new_url=New url -caller.placeholder=Identify this call +call_identifier_textinput_placeholder=Identify this call unable_retrieve_url=Sorry, we were unable to retrieve a call url. @@ -32,6 +32,6 @@ network_disconnected=The network connection terminated abruptly. connection_error_see_console_notification=Call failed; see console for details. ## LOCALIZATION NOTE (legal_text_and_links): In this item, don't translate the ## part between {{..}} -legal_text_and_links.innerHTML=By using this product you agree to the Terms of Use and Terms of Use and Privacy Notice