Bug 1033841: Ported Loop panel views to React. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-07-04 17:08:55 +01:00
parent 56c15a6d64
commit ba32e5341f
14 changed files with 710 additions and 374 deletions

View File

@ -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

View File

@ -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 || {};

View File

@ -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 || {};

View File

@ -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 || {};

View File

@ -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([
'<label>',
' <input type="checkbox" <%- checked %>>',
' <span data-l10n-id="do_not_disturb"></span>',
'</label>',
].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([
'<p data-l10n-id="legal_text_and_links"',
' data-l10n-args=\'',
' {"terms_of_use_url": "https://accounts.firefox.com/legal/terms",',
' "privacy_notice_url": "www.mozilla.org/privacy/"',
' }\'></p>'
].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([
'<div class="description">',
' <p data-l10n-id="get_link_to_share"></p>',
'</div>',
'<div class="action">',
' <form class="invite">',
' <input type="text" name="caller" data-l10n-id="caller" required>',
' <button type="submit" class="get-url btn btn-success"',
' data-l10n-id="get_a_call_url"></button>',
' </form>',
' <p class="tos"></p>',
' <p class="result hide">',
' <input id="call-url" type="url" readonly>',
' <a class="go-back btn btn-info" href="" data-l10n-id="new_url"></a>',
' </p>',
' <p class="dnd"></p>',
'</div>',
].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
};

View File

@ -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 (
<p className="dnd">
<input type="checkbox" checked={this.state.doNotDisturb}
id="dnd-component" onChange={this.handleCheckboxChange} />
<label htmlFor="dnd-component">{__("do_not_disturb")}</label>
</p>
);
}
});
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 <p className="tos"
dangerouslySetInnerHTML={{__html: tosHTML}}></p>;
} else {
return <div />;
}
}
});
var PanelLayout = React.createClass({
propTypes: {
summary: React.PropTypes.string.isRequired
},
render: function() {
return (
<div className="share generate-url">
<div className="description">
<p>{this.props.summary}</p>
</div>
<div className="action">
{this.props.children}
</div>
</div>
);
}
});
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 (
<PanelLayout summary={__("share_link_url")}>
<div className="invite">
<input type="url" value={this.props.callUrl} readOnly="true" />
<button onClick={this.handleButtonClick}
className="btn btn-success">{__("new_url")}</button>
</div>
</PanelLayout>
);
}
});
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 (
<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")}>
<form className="invite" onSubmit={this.handleFormSubmit}>
<input type="text" name="caller" ref="caller" required="required"
className={cx({'pending': this.state.pending})}
onChange={this.handleTextChange}
placeholder={__("call_identifier_textinput_placeholder")} />
<button type="submit" className="get-url btn btn-success"
disabled={this.state.disabled}>
{__("get_a_call_url")}
</button>
</form>
<ToSView />
</PanelLayout>
);
}
});
/**
* Panel view.
*/
var PanelView = React.createClass({
propTypes: {
notifier: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
},
render: function() {
return (
<div>
<CallUrlForm client={this.props.client}
notifier={this.props.notifier} />
<DoNotDisturb />
</div>
);
}
});
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(<PanelView client={client}
notifier={this._notifier} />);
}
});
/**
* 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);

View File

@ -15,6 +15,7 @@
<div id="main"></div>
<script type="text/javascript" src="loop/shared/libs/react-0.10.0.js"></script>
<script type="text/javascript" src="loop/libs/l10n.js"></script>
<script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
<script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>

View File

@ -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 {

View File

@ -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);
},
/**

View File

@ -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
});
});

View File

@ -16,6 +16,7 @@
<div id="fixtures"></div>
<!-- libs -->
<script src="../../content/libs/l10n.js"></script>
<script src="../../content/shared/libs/react-0.10.0.js"></script>
<script src="../../content/shared/libs/jquery-2.1.0.js"></script>
<script src="../../content/shared/libs/lodash-2.4.1.js"></script>
<script src="../../content/shared/libs/backbone-1.1.2.js"></script>
@ -35,8 +36,8 @@
<script src="../../content/shared/js/router.js"></script>
<script src="../../content/shared/js/views.js"></script>
<script src="../../content/js/client.js"></script>
<script src="../../content/js/conversation.js"></script>
<script src="../../content/js/desktopRouter.js"></script>
<script src="../../content/js/conversation.js"></script>
<script src="../../content/js/panel.js"></script>
<!-- Test scripts -->

View File

@ -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('<div id="dnd-view"></div>');
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('<div id="messages"></div><div id="main"></div>');
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('<div id="#tos-view"></div>');
});
// 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();
}
});
});
});

View File

@ -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() {

View File

@ -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 <a \
href="{{terms_of_use_url}}">Terms of Use</a> and <a \
legal_text_and_links=By using this product you agree to the <a \
target="_blank" href="{{terms_of_use_url}}">Terms of Use</a> and <a \
href="{{privacy_notice_url}}">Privacy Notice</a>