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 (
+
+
+ {__("do_not_disturb")}
+
+ );
+ }
+ });
+
+ 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.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 (
+
+
+
+ {__("new_url")}
+
+
+ );
+ }
+ });
+
+ 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