Bug 972992 (Part 2): Loop desktop feedback add an API client to actually submit feedback. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-08-04 13:28:26 +01:00
parent 96c36a5dc5
commit 318c8e3cc7
22 changed files with 611 additions and 278 deletions

View File

@ -1579,6 +1579,8 @@ pref("loop.do_not_disturb", false);
pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/Firefox-Long.ogg");
pref("loop.retry_delay.start", 60000);
pref("loop.retry_delay.limit", 300000);
pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
pref("loop.feedback.product", "Loop");
// serverURL to be assigned by services team
pref("services.push.serverURL", "wss://push.services.mozilla.com/");

View File

@ -35,6 +35,7 @@
<script type="text/javascript" src="loop/shared/js/models.js"></script>
<script type="text/javascript" src="loop/shared/js/router.js"></script>
<script type="text/javascript" src="loop/shared/js/views.js"></script>
<script type="text/javascript" src="loop/shared/js/feedbackApiClient.js"></script>
<script type="text/javascript" src="loop/js/client.js"></script>
<script type="text/javascript" src="loop/js/desktopRouter.js"></script>
<script type="text/javascript" src="loop/js/conversation.js"></script>

View File

@ -131,7 +131,6 @@ loop.conversation = (function(OT, mozL10n) {
"call/accept": "accept",
"call/decline": "decline",
"call/ongoing": "conversation",
"call/ended": "ended",
"call/declineAndBlock": "declineAndBlock",
"call/feedback": "feedback"
},
@ -157,7 +156,7 @@ loop.conversation = (function(OT, mozL10n) {
* by the router from the URL.
*/
incoming: function(loopVersion) {
window.navigator.mozLoop.startAlerting();
navigator.mozLoop.startAlerting();
this._conversation.set({loopVersion: loopVersion});
this._conversation.once("accept", function() {
this.navigate("call/accept", {trigger: true});
@ -177,7 +176,7 @@ loop.conversation = (function(OT, mozL10n) {
* Accepts an incoming call.
*/
accept: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
this._conversation.initiate({
client: new loop.Client(),
outgoing: false
@ -188,7 +187,7 @@ loop.conversation = (function(OT, mozL10n) {
* Declines an incoming call.
*/
decline: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
// XXX For now, we just close the window
window.close();
},
@ -200,7 +199,7 @@ loop.conversation = (function(OT, mozL10n) {
* after a callUrl is received
*/
declineAndBlock: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
var token = navigator.mozLoop.getLoopCharPref('loopToken');
var client = new loop.Client();
client.deleteCallUrl(token, function(error) {
@ -234,13 +233,13 @@ loop.conversation = (function(OT, mozL10n) {
* Call has ended, display a feedback form.
*/
feedback: function() {
document.title = mozL10n.get("call_has_ended");
this.loadReactComponent(sharedViews.FeedbackView({
// XXX for now we pass in a fake feeback API client (see bug 972992)
feedbackApiClient: {
send: function(fields, cb) {
cb();
}
}
feedbackApiClient: new loop.FeedbackAPIClient({
baseUrl: navigator.mozLoop.getLoopCharPref("feedback.baseUrl"),
product: navigator.mozLoop.getLoopCharPref("feedback.product")
})
}));
}
});
@ -251,7 +250,7 @@ loop.conversation = (function(OT, mozL10n) {
function init() {
// Do the initial L10n setup, we do this before anything
// else to ensure the L10n environment is setup correctly.
mozL10n.initialize(window.navigator.mozLoop);
mozL10n.initialize(navigator.mozLoop);
document.title = mozL10n.get("incoming_call_title");

View File

@ -131,7 +131,6 @@ loop.conversation = (function(OT, mozL10n) {
"call/accept": "accept",
"call/decline": "decline",
"call/ongoing": "conversation",
"call/ended": "ended",
"call/declineAndBlock": "declineAndBlock",
"call/feedback": "feedback"
},
@ -157,7 +156,7 @@ loop.conversation = (function(OT, mozL10n) {
* by the router from the URL.
*/
incoming: function(loopVersion) {
window.navigator.mozLoop.startAlerting();
navigator.mozLoop.startAlerting();
this._conversation.set({loopVersion: loopVersion});
this._conversation.once("accept", function() {
this.navigate("call/accept", {trigger: true});
@ -177,7 +176,7 @@ loop.conversation = (function(OT, mozL10n) {
* Accepts an incoming call.
*/
accept: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
this._conversation.initiate({
client: new loop.Client(),
outgoing: false
@ -188,7 +187,7 @@ loop.conversation = (function(OT, mozL10n) {
* Declines an incoming call.
*/
decline: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
// XXX For now, we just close the window
window.close();
},
@ -200,7 +199,7 @@ loop.conversation = (function(OT, mozL10n) {
* after a callUrl is received
*/
declineAndBlock: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
var token = navigator.mozLoop.getLoopCharPref('loopToken');
var client = new loop.Client();
client.deleteCallUrl(token, function(error) {
@ -234,13 +233,13 @@ loop.conversation = (function(OT, mozL10n) {
* Call has ended, display a feedback form.
*/
feedback: function() {
document.title = mozL10n.get("call_has_ended");
this.loadReactComponent(sharedViews.FeedbackView({
// XXX for now we pass in a fake feeback API client (see bug 972992)
feedbackApiClient: {
send: function(fields, cb) {
cb();
}
}
feedbackApiClient: new loop.FeedbackAPIClient({
baseUrl: navigator.mozLoop.getLoopCharPref("feedback.baseUrl"),
product: navigator.mozLoop.getLoopCharPref("feedback.product")
})
}));
}
});
@ -251,7 +250,7 @@ loop.conversation = (function(OT, mozL10n) {
function init() {
// Do the initial L10n setup, we do this before anything
// else to ensure the L10n environment is setup correctly.
mozL10n.initialize(window.navigator.mozLoop);
mozL10n.initialize(navigator.mozLoop);
document.title = mozL10n.get("incoming_call_title");

View File

@ -77,22 +77,22 @@ loop.panel = (function(_, mozL10n) {
__("display_name_available_status");
return (
React.DOM.div({className: "footer component-spacer"},
React.DOM.div({className: "do-not-disturb"},
React.DOM.p({className: "dnd-status", onClick: this.showDropdownMenu},
React.DOM.span(null, availabilityText),
React.DOM.i({className: availabilityStatus})
),
React.DOM.ul({className: availabilityDropdown,
onMouseLeave: this.hideDropdownMenu},
React.DOM.li({onClick: this.changeAvailability("available"),
className: "dnd-menu-item dnd-make-available"},
React.DOM.i({className: "status status-available"}),
React.DOM.div( {className:"footer component-spacer"},
React.DOM.div( {className:"do-not-disturb"},
React.DOM.p( {className:"dnd-status", onClick:this.showDropdownMenu},
React.DOM.span(null, availabilityText),
React.DOM.i( {className:availabilityStatus})
),
React.DOM.ul( {className:availabilityDropdown,
onMouseLeave:this.hideDropdownMenu},
React.DOM.li( {onClick:this.changeAvailability("available"),
className:"dnd-menu-item dnd-make-available"},
React.DOM.i( {className:"status status-available"}),
React.DOM.span(null, __("display_name_available_status"))
),
React.DOM.li({onClick: this.changeAvailability("do-not-disturb"),
className: "dnd-menu-item dnd-make-unavailable"},
React.DOM.i({className: "status status-dnd"}),
),
React.DOM.li( {onClick:this.changeAvailability("do-not-disturb"),
className:"dnd-menu-item dnd-make-unavailable"},
React.DOM.i( {className:"status status-dnd"}),
React.DOM.span(null, __("display_name_dnd_status"))
)
)
@ -115,10 +115,10 @@ loop.panel = (function(_, mozL10n) {
if (this.state.seenToS == "unseen") {
navigator.mozLoop.setLoopCharPref('seenToS', 'seen');
return React.DOM.p({className: "terms-service",
dangerouslySetInnerHTML: {__html: tosHTML}});
return React.DOM.p( {className:"terms-service",
dangerouslySetInnerHTML:{__html: tosHTML}});
} else {
return React.DOM.div(null);
return React.DOM.div(null );
}
}
});
@ -130,11 +130,11 @@ loop.panel = (function(_, mozL10n) {
render: function() {
return (
React.DOM.div({className: "component-spacer share generate-url"},
React.DOM.div({className: "description"},
React.DOM.p({className: "description-content"}, this.props.summary)
),
React.DOM.div({className: "action"},
React.DOM.div( {className:"component-spacer share generate-url"},
React.DOM.div( {className:"description"},
React.DOM.p( {className:"description-content"}, this.props.summary)
),
React.DOM.div( {className:"action"},
this.props.children
)
)
@ -201,10 +201,10 @@ loop.panel = (function(_, mozL10n) {
// from the react lib.
var cx = React.addons.classSet;
return (
PanelLayout({summary: __("share_link_header_text")},
React.DOM.div({className: "invite"},
React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true",
className: cx({'pending': this.state.pending})})
PanelLayout( {summary:__("share_link_header_text")},
React.DOM.div( {className:"invite"},
React.DOM.input( {type:"url", value:this.state.callUrl, readOnly:"true",
className:cx({'pending': this.state.pending})} )
)
)
);
@ -223,10 +223,10 @@ loop.panel = (function(_, mozL10n) {
render: function() {
return (
React.DOM.div(null,
CallUrlResult({client: this.props.client,
notifier: this.props.notifier}),
ToSView(null),
AvailabilityDropdown(null)
CallUrlResult( {client:this.props.client,
notifier:this.props.notifier} ),
ToSView(null ),
AvailabilityDropdown(null )
)
);
}
@ -293,8 +293,8 @@ loop.panel = (function(_, mozL10n) {
var client = new loop.Client({
baseServerUrl: navigator.mozLoop.serverUrl
});
this.loadReactComponent(PanelView({client: client,
notifier: this._notifier}));
this.loadReactComponent(PanelView( {client:client,
notifier:this._notifier} ));
}
});

View File

@ -0,0 +1,92 @@
/* 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 */
var loop = loop || {};
loop.FeedbackAPIClient = (function($) {
"use strict";
/**
* Feedback API client. Sends feedback data to an input.mozilla.com compatible
* API.
*
* Available settings:
* - {String} baseUrl Base API url (required)
* - {String} product Product name (required)
*
* @param {Object} settings Settings.
* @link http://fjord.readthedocs.org/en/latest/api.html
*/
function FeedbackAPIClient(settings) {
settings = settings || {};
if (!settings.hasOwnProperty("baseUrl")) {
throw new Error("Missing required baseUrl setting.");
}
this._baseUrl = settings.baseUrl;
if (!settings.hasOwnProperty("product")) {
throw new Error("Missing required product setting.");
}
this._product = settings.product;
}
FeedbackAPIClient.prototype = {
/**
* Formats Feedback data to match the API spec.
*
* @param {Object} fields Feedback form data.
* @return {Object} Formatted data.
*/
_formatData: function(fields) {
var formatted = {};
if (typeof fields !== "object") {
throw new Error("Invalid feedback data provided.");
}
formatted.product = this._product;
formatted.happy = fields.happy;
formatted.category = fields.category;
// Default description field value
if (!fields.description) {
formatted.description = (fields.happy ? "Happy" : "Sad") + " User";
} else {
formatted.description = fields.description;
}
return formatted;
},
/**
* Sends feedback data.
*
* @param {Object} fields Feedback form data.
* @param {Function} cb Callback(err, result)
*/
send: function(fields, cb) {
var req = $.ajax({
url: this._baseUrl,
method: "POST",
contentType: "application/json",
dataType: "json",
data: JSON.stringify(this._formatData(fields))
});
req.done(function(result) {
console.info("User feedback data have been submitted", result);
cb(null, result);
});
req.fail(function(jqXHR, textStatus, errorThrown) {
var message = "Error posting user feedback data";
var httpError = jqXHR.status + " " + errorThrown;
console.error(message, httpError, JSON.stringify(jqXHR.responseJSON));
cb(new Error(message + ": " + httpError));
});
}
};
return FeedbackAPIClient;
})(jQuery);

View File

@ -166,7 +166,6 @@ loop.shared.router = (function(l10n) {
* Session has ended. Notifies the user and ends the call.
*/
_onSessionEnded: function() {
this._notifier.warnL10n("call_has_ended");
this.endCall();
},

View File

@ -396,34 +396,67 @@ loop.shared.views = (function(_, OT, l10n) {
},
getInitialState: function() {
return {reason: "", custom: ""};
return {category: "", description: ""};
},
getInitialProps: function() {
return {pending: false};
},
handleReasonChange: function(event) {
var reason = event.target.value;
if (reason === "other") {
this.setState({reason: reason});
} else {
// resets custom text field
this.setState({custom: ""});
_getCategories: function() {
return {
audio_quality: __("feedback_category_audio_quality"),
video_quality: __("feedback_category_video_quality"),
disconnected : __("feedback_category_was_disconnected"),
confusing: __("feedback_category_confusing"),
other: __("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
React.DOM.label( {key:key},
React.DOM.input( {type:"radio", ref:"category", name:"category",
value:category,
onChange:this.handleCategoryChange} ),
categories[category]
)
);
}, this);
},
/**
* Checks if the form is ready for submission:
* - a category (reason) must be chosen
* - no feedback submission should be pending
*
* @return {Boolean}
*/
_isFormReady: function() {
return this.state.category !== "" && !this.props.pending;
},
handleCategoryChange: function(event) {
var category = event.target.value;
if (category !== "other") {
// resets description text field
this.setState({description: ""});
}
this.setState({category: category});
},
handleCustomTextChange: function(event) {
this.setState({custom: event.target.value});
this.setState({description: event.target.value});
},
handleFormSubmit: function(event) {
event.preventDefault();
var reason = this.refs.reason.getDOMNode().value.trim();
var custom = this.refs.custom.getDOMNode().value.trim();
this.props.sendFeedback({
reason: reason,
custom: reason === "other" ? custom : ""
happy: false,
category: this.state.category,
description: this.state.description
});
},
@ -432,37 +465,13 @@ loop.shared.views = (function(_, OT, l10n) {
FeedbackLayout( {title:__("feedback_what_makes_you_sad"),
reset:this.props.reset},
React.DOM.form( {onSubmit:this.handleFormSubmit},
React.DOM.label(null,
React.DOM.input( {type:"radio", ref:"reason", name:"reason",
value:"audio_quality", onChange:this.handleReasonChange} ),
__("feedback_reason_audio_quality")
),
React.DOM.label(null,
React.DOM.input( {type:"radio", ref:"reason", name:"reason",
value:"video_quality", onChange:this.handleReasonChange} ),
__("feedback_reason_video_quality")
),
React.DOM.label(null,
React.DOM.input( {type:"radio", ref:"reason", name:"reason",
value:"disconnected", onChange:this.handleReasonChange} ),
__("feedback_reason_was_disconnected")
),
React.DOM.label(null,
React.DOM.input( {type:"radio", ref:"reason", name:"reason", value:"confusing",
onChange:this.handleReasonChange} ),
__("feedback_reason_confusing")
),
React.DOM.label(null,
React.DOM.input( {type:"radio", ref:"reason", name:"reason", value:"other",
onChange:this.handleReasonChange} ),
__("feedback_reason_other")
),
React.DOM.p(null, React.DOM.input( {type:"text", ref:"custom", name:"custom",
disabled:this.state.reason !== "other",
this._getCategoryFields(),
React.DOM.p(null, React.DOM.input( {type:"text", ref:"description", name:"description",
disabled:this.state.category !== "other",
onChange:this.handleCustomTextChange,
value:this.state.custom} )),
value:this.state.description} )),
React.DOM.button( {type:"submit", className:"btn btn-success",
disabled:this.props.pending},
disabled:!this._isFormReady()},
__("feedback_submit_button")
)
)
@ -530,7 +539,7 @@ loop.shared.views = (function(_, OT, l10n) {
},
handleHappyClick: function() {
this.setState({step: "finished"});
this.sendFeedback({happy: true}, this._onFeedbackSent);
},
handleSadClick: function() {

View File

@ -396,34 +396,67 @@ loop.shared.views = (function(_, OT, l10n) {
},
getInitialState: function() {
return {reason: "", custom: ""};
return {category: "", description: ""};
},
getInitialProps: function() {
return {pending: false};
},
handleReasonChange: function(event) {
var reason = event.target.value;
if (reason === "other") {
this.setState({reason: reason});
} else {
// resets custom text field
this.setState({custom: ""});
_getCategories: function() {
return {
audio_quality: __("feedback_category_audio_quality"),
video_quality: __("feedback_category_video_quality"),
disconnected : __("feedback_category_was_disconnected"),
confusing: __("feedback_category_confusing"),
other: __("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
<label key={key}>
<input type="radio" ref="category" name="category"
value={category}
onChange={this.handleCategoryChange} />
{categories[category]}
</label>
);
}, this);
},
/**
* Checks if the form is ready for submission:
* - a category (reason) must be chosen
* - no feedback submission should be pending
*
* @return {Boolean}
*/
_isFormReady: function() {
return this.state.category !== "" && !this.props.pending;
},
handleCategoryChange: function(event) {
var category = event.target.value;
if (category !== "other") {
// resets description text field
this.setState({description: ""});
}
this.setState({category: category});
},
handleCustomTextChange: function(event) {
this.setState({custom: event.target.value});
this.setState({description: event.target.value});
},
handleFormSubmit: function(event) {
event.preventDefault();
var reason = this.refs.reason.getDOMNode().value.trim();
var custom = this.refs.custom.getDOMNode().value.trim();
this.props.sendFeedback({
reason: reason,
custom: reason === "other" ? custom : ""
happy: false,
category: this.state.category,
description: this.state.description
});
},
@ -432,37 +465,13 @@ loop.shared.views = (function(_, OT, l10n) {
<FeedbackLayout title={__("feedback_what_makes_you_sad")}
reset={this.props.reset}>
<form onSubmit={this.handleFormSubmit}>
<label>
<input type="radio" ref="reason" name="reason"
value="audio_quality" onChange={this.handleReasonChange} />
{__("feedback_reason_audio_quality")}
</label>
<label>
<input type="radio" ref="reason" name="reason"
value="video_quality" onChange={this.handleReasonChange} />
{__("feedback_reason_video_quality")}
</label>
<label>
<input type="radio" ref="reason" name="reason"
value="disconnected" onChange={this.handleReasonChange} />
{__("feedback_reason_was_disconnected")}
</label>
<label>
<input type="radio" ref="reason" name="reason" value="confusing"
onChange={this.handleReasonChange} />
{__("feedback_reason_confusing")}
</label>
<label>
<input type="radio" ref="reason" name="reason" value="other"
onChange={this.handleReasonChange} />
{__("feedback_reason_other")}
</label>
<p><input type="text" ref="custom" name="custom"
disabled={this.state.reason !== "other"}
{this._getCategoryFields()}
<p><input type="text" ref="description" name="description"
disabled={this.state.category !== "other"}
onChange={this.handleCustomTextChange}
value={this.state.custom} /></p>
value={this.state.description} /></p>
<button type="submit" className="btn btn-success"
disabled={this.props.pending}>
disabled={!this._isFormReady()}>
{__("feedback_submit_button")}
</button>
</form>
@ -530,7 +539,7 @@ loop.shared.views = (function(_, OT, l10n) {
},
handleHappyClick: function() {
this.setState({step: "finished"});
this.sendFeedback({happy: true}, this._onFeedbackSent);
},
handleSadClick: function() {

View File

@ -41,10 +41,11 @@ browser.jar:
content/browser/loop/shared/img/dropdown-inverse@2x.png (content/shared/img/dropdown-inverse@2x.png)
# Shared scripts
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/router.js (content/shared/js/router.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/router.js (content/shared/js/router.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
# Shared libs
content/browser/loop/shared/libs/react-0.10.0.js (content/shared/libs/react-0.10.0.js)

View File

@ -100,15 +100,15 @@ loop.webapp = (function($, _, OT, webL10n) {
return (
/* jshint ignore:start */
React.DOM.header({className: "container-box"},
React.DOM.h1({className: "light-weight-font"},
React.DOM.header( {className:"container-box"},
React.DOM.h1( {className:"light-weight-font"},
React.DOM.strong(null, __("brandShortname")), " ", __("clientShortname")
),
React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}),
React.DOM.h3({className: "call-url"},
),
React.DOM.div( {className:"loop-logo", title:"Firefox WebRTC! logo"}),
React.DOM.h3( {className:"call-url"},
conversationUrl
),
React.DOM.h4({className: urlCreationDateClasses},
),
React.DOM.h4( {className:urlCreationDateClasses} ,
callUrlCreationDateString
)
)
@ -120,8 +120,8 @@ loop.webapp = (function($, _, OT, webL10n) {
var ConversationFooter = React.createClass({displayName: 'ConversationFooter',
render: function() {
return (
React.DOM.div({className: "footer container-box"},
React.DOM.div({title: "Mozilla Logo", className: "footer-logo"})
React.DOM.div( {className:"footer container-box"},
React.DOM.div( {title:"Mozilla Logo", className:"footer-logo"})
)
);
}
@ -217,34 +217,34 @@ loop.webapp = (function($, _, OT, webL10n) {
return (
/* jshint ignore:start */
React.DOM.div({className: "container"},
React.DOM.div({className: "container-box"},
React.DOM.div( {className:"container"},
React.DOM.div( {className:"container-box"},
ConversationHeader({
urlCreationDateString: this.state.urlCreationDateString}),
ConversationHeader(
{urlCreationDateString:this.state.urlCreationDateString} ),
React.DOM.p({className: "large-font light-weight-font"},
React.DOM.p( {className:"large-font light-weight-font"},
__("initiate_call_button_label")
),
),
React.DOM.div({id: "messages"}),
React.DOM.div( {id:"messages"}),
React.DOM.div({className: "button-group"},
React.DOM.div({className: "flex-padding-1"}),
React.DOM.button({ref: "submitButton", onClick: this._initiate,
className: callButtonClasses,
disabled: this.state.disableCallButton},
__("initiate_call_button"),
React.DOM.i({className: "icon icon-video"})
),
React.DOM.div({className: "flex-padding-1"})
),
React.DOM.div( {className:"button-group"},
React.DOM.div( {className:"flex-padding-1"}),
React.DOM.button( {ref:"submitButton", onClick:this._initiate,
className:callButtonClasses,
disabled:this.state.disableCallButton},
__("initiate_call_button"),
React.DOM.i( {className:"icon icon-video"})
),
React.DOM.div( {className:"flex-padding-1"})
),
React.DOM.p({className: "terms-service",
dangerouslySetInnerHTML: {__html: tosHTML}})
),
React.DOM.p( {className:"terms-service",
dangerouslySetInnerHTML:{__html: tosHTML}})
),
ConversationFooter(null)
ConversationFooter(null )
)
/* jshint ignore:end */
);

View File

@ -47,7 +47,7 @@ describe("loop.conversation", function() {
});
afterEach(function() {
delete window.navigator.mozLoop;
delete navigator.mozLoop;
sandbox.restore();
});
@ -79,7 +79,7 @@ describe("loop.conversation", function() {
sinon.assert.calledOnce(document.mozL10n.initialize);
sinon.assert.calledWithExactly(document.mozL10n.initialize,
window.navigator.mozLoop);
navigator.mozLoop);
});
it("should set the document title", function() {
@ -165,10 +165,10 @@ describe("loop.conversation", function() {
});
it("should start alerting", function() {
sandbox.stub(window.navigator.mozLoop, "startAlerting");
sandbox.stub(navigator.mozLoop, "startAlerting");
router.incoming("fakeVersion");
sinon.assert.calledOnce(window.navigator.mozLoop.startAlerting);
sinon.assert.calledOnce(navigator.mozLoop.startAlerting);
});
});
@ -187,10 +187,10 @@ describe("loop.conversation", function() {
});
it("should stop alerting", function() {
sandbox.stub(window.navigator.mozLoop, "stopAlerting");
sandbox.stub(navigator.mozLoop, "stopAlerting");
router.accept();
sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
});
});
@ -241,18 +241,30 @@ describe("loop.conversation", function() {
});
it("should stop alerting", function() {
sandbox.stub(window.navigator.mozLoop, "stopAlerting");
sandbox.stub(navigator.mozLoop, "stopAlerting");
router.decline();
sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
});
});
describe("#feedback", function() {
var oldTitle;
beforeEach(function() {
oldTitle = document.title;
sandbox.stub(document.mozL10n, "get").returns("Call ended");
});
beforeEach(function() {
sandbox.stub(loop, "FeedbackAPIClient");
sandbox.stub(router, "loadReactComponent");
});
afterEach(function() {
document.title = oldTitle;
});
// XXX When the call is ended gracefully, we should check that we
// close connections nicely (see bug 1046744)
it("should display a feedback form view", function() {
@ -265,14 +277,20 @@ describe("loop.conversation", function() {
loop.shared.views.FeedbackView);
}));
});
it("should update the conversation window title", function() {
router.feedback();
expect(document.title).eql("Call ended");
});
});
describe("#blocked", function() {
it("should call mozLoop.stopAlerting", function() {
sandbox.stub(window.navigator.mozLoop, "stopAlerting");
sandbox.stub(navigator.mozLoop, "stopAlerting");
router.declineAndBlock();
sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
});
it("should call delete call", function() {

View File

@ -33,6 +33,7 @@
<!-- App scripts -->
<script src="../../content/shared/js/utils.js"></script>
<script src="../../content/shared/js/feedbackApiClient.js"></script>
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/router.js"></script>
<script src="../../content/shared/js/views.js"></script>

View File

@ -0,0 +1,139 @@
/* 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, sinon, it, beforeEach, afterEach, describe */
var expect = chai.expect;
describe("loop.FeedbackAPIClient", function() {
"use strict";
var sandbox,
fakeXHR,
requests = [];
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeXHR = sandbox.useFakeXMLHttpRequest();
requests = [];
// https://github.com/cjohansen/Sinon.JS/issues/393
fakeXHR.xhr.onCreate = function (xhr) {
requests.push(xhr);
};
});
afterEach(function() {
sandbox.restore();
});
describe("#constructor", function() {
it("should require a baseUrl setting", function() {
expect(function() {
return new loop.FeedbackAPIClient();
}).to.Throw(/required baseUrl/);
});
it("should require a product setting", function() {
expect(function() {
return new loop.FeedbackAPIClient({baseUrl: "http://fake"});
}).to.Throw(/required product/);
});
});
describe("constructed", function() {
var client;
beforeEach(function() {
client = new loop.FeedbackAPIClient({
baseUrl: "http://fake/feedback",
product: "Hello"
});
});
describe("#send", function() {
it("should send happy feedback data", function() {
var feedbackData = {
happy: true,
description: "Happy User"
};
client.send(feedbackData, function(){});
expect(requests).to.have.length.of(1);
expect(requests[0].url).to.be.equal("http://fake/feedback");
expect(requests[0].method).to.be.equal("POST");
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.happy).eql(true);
expect(parsed.description).eql("Happy User");
});
it("should send sad feedback data", function() {
var feedbackData = {
happy: false,
category: "confusing"
};
client.send(feedbackData, function(){});
expect(requests).to.have.length.of(1);
expect(requests[0].url).to.be.equal("http://fake/feedback");
expect(requests[0].method).to.be.equal("POST");
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.happy).eql(false);
expect(parsed.product).eql("Hello");
expect(parsed.category).eql("confusing");
expect(parsed.description).eql("Sad User");
});
it("should send formatted feedback data", function() {
client.send({
happy: false,
category: "other",
description: "it's far too awesome!"
}, function(){});
expect(requests).to.have.length.of(1);
expect(requests[0].url).eql("http://fake/feedback");
expect(requests[0].method).eql("POST");
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.happy).eql(false);
expect(parsed.product).eql("Hello");
expect(parsed.category).eql("other");
expect(parsed.description).eql("it's far too awesome!");
});
it("should throw on invalid feedback data", function() {
expect(function() {
client.send("invalid data", function(){});
}).to.Throw(/Invalid/);
});
it("should call passed callback on success", function() {
var cb = sandbox.spy();
var fakeResponseData = {description: "confusing"};
client.send({reason: "confusing"}, cb);
requests[0].respond(200, {"Content-Type": "application/json"},
JSON.stringify(fakeResponseData));
sinon.assert.calledOnce(cb);
sinon.assert.calledWithExactly(cb, null, fakeResponseData);
});
it("should call passed callback on error", function() {
var cb = sandbox.spy();
var fakeErrorData = {error: true};
client.send({reason: "confusing"}, cb);
requests[0].respond(400, {"Content-Type": "application/json"},
JSON.stringify(fakeErrorData));
sinon.assert.calledOnce(cb);
sinon.assert.calledWithExactly(cb, sinon.match(function(err) {
return /Bad Request/.test(err);
}));
});
});
});
});

View File

@ -36,11 +36,13 @@
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/views.js"></script>
<script src="../../content/shared/js/router.js"></script>
<script src="../../content/shared/js/feedbackApiClient.js"></script>
<!-- Test scripts -->
<script src="models_test.js"></script>
<script src="views_test.js"></script>
<script src="router_test.js"></script>
<script src="feedbackApiClient_test.js"></script>
<script>
mocha.run(function () {
$("#mocha").append("<p id='complete'>Complete.</p>");

View File

@ -161,14 +161,6 @@ describe("loop.shared.router", function() {
sinon.assert.calledOnce(router.endCall);
});
it("should warn the user that the session has ended", function() {
conversation.trigger("session:ended");
sinon.assert.calledOnce(notifier.warnL10n);
sinon.assert.calledWithExactly(notifier.warnL10n,
"call_has_ended");
});
it("should warn the user when peer hangs up", function() {
conversation.trigger("session:peer-hungup");

View File

@ -411,76 +411,131 @@ describe("loop.shared.views", function() {
}));
});
it("should thank the user for feedback when clicking on the happy face",
function() {
var happyFace = comp.getDOMNode().querySelector(".face-happy");
// local test helpers
function clickHappyFace(comp) {
var happyFace = comp.getDOMNode().querySelector(".face-happy");
TestUtils.Simulate.click(happyFace);
}
TestUtils.Simulate.click(happyFace);
function clickSadFace(comp) {
var sadFace = comp.getDOMNode().querySelector(".face-sad");
TestUtils.Simulate.click(sadFace);
}
function fillSadFeedbackForm(comp, category, text) {
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[value='" + category + "']"));
if (text) {
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[name='description']"), {
target: {value: "fake reason"}
});
}
}
function submitSadFeedbackForm(comp, category, text) {
TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
}
describe("Happy feedback", function() {
it("should send feedback data when clicking on the happy face",
function() {
clickHappyFace(comp);
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWith(fakeFeedbackApiClient.send, {happy: true});
});
it("should thank the user once happy feedback data is sent", function() {
fakeFeedbackApiClient.send = function(data, cb) {
cb();
};
clickHappyFace(comp);
expect(comp.getDOMNode()
.querySelectorAll(".feedback .thank-you").length).eql(1);
expect(comp.getDOMNode().querySelector("button.back")).to.be.a("null");
});
it("should bring the user to feedback form when clicking on the sad face",
function() {
var sadFace = comp.getDOMNode().querySelector(".face-sad");
TestUtils.Simulate.click(sadFace);
expect(comp.getDOMNode().querySelectorAll("form").length).eql(1);
});
it("should disable the form submit button once the form is first submitted",
function() {
var sadFace = comp.getDOMNode().querySelector(".face-sad");
TestUtils.Simulate.click(sadFace);
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[value='confusing']"), {
target: {checked: true}
});
TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1033841#c10
expect(comp.getDOMNode()
.querySelector("form button")
.getAttribute("disabled")).eql("true");
});
it("should send feedback data over the feedback API client", function() {
TestUtils.Simulate.click(comp.getDOMNode().querySelector(".face-sad"));
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[value='other']"), {
target: {checked: true}
});
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[name='custom']"), {
target: {value: "fake"}
});
TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWith(fakeFeedbackApiClient.send, {
reason: "other",
custom: "fake"
});
});
it("should thank the user when feedback data has been sent", function() {
fakeFeedbackApiClient.send = function(data, cb) {
cb();
};
TestUtils.Simulate.click(comp.getDOMNode().querySelector(".face-sad"));
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[value='confusing']"), {
target: {checked: true}
});
TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
describe("Sad feedback", function() {
it("should bring the user to feedback form when clicking on the sad face",
function() {
clickSadFace(comp);
expect(comp.getDOMNode()
.querySelectorAll(".feedback .thank-you").length).eql(1);
expect(comp.getDOMNode().querySelectorAll("form").length).eql(1);
});
it("should disable the form submit button when no category is chosen",
function() {
clickSadFace(comp);
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(true);
});
it("should enable the form submit button once a choice is made",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(false);
});
it("should disable the form submit button once the form is submitted",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(true);
});
it("should send feedback data when the form is submitted", function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWithMatch(fakeFeedbackApiClient.send, {
happy: false,
category: "confusing"
});
});
it("should send feedback data when user has entered a custom description",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "other", "fake reason");
submitSadFeedbackForm(comp);
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWith(fakeFeedbackApiClient.send, {
happy: false,
category: "other",
description: "fake reason"
});
});
it("should thank the user when feedback data has been sent", function() {
fakeFeedbackApiClient.send = function(data, cb) {
cb();
};
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
expect(comp.getDOMNode()
.querySelectorAll(".feedback .thank-you").length).eql(1);
});
});
});

View File

@ -20,6 +20,8 @@
<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>
<script src="../content/shared/js/feedbackApiClient.js"></script>
<script src="../content/shared/js/utils.js"></script>
<script src="../content/shared/js/models.js"></script>
<script src="../content/shared/js/router.js"></script>
<script src="../content/shared/js/views.js"></script>

View File

@ -45,3 +45,10 @@
.showcase > section .example > h3 {
border-bottom: 1px dashed #aaa;
}
.showcase p.note {
margin: 0;
padding: 0;
color: #666;
font-style: italic;
}

View File

@ -33,13 +33,12 @@
return false;
}
// local fakes
var fakeFeedbackApiClient = {
send: function(fields, cb) {
alert("Sent feedback data: " + JSON.stringify(fields));
cb();
}
};
// Feedback API client configured to send data to the stage input server,
// which is available at https://input.allizom.org
var stageFeedbackApiClient = new loop.FeedbackAPIClient({
baseUrl: "https://input.allizom.org/api/v1/feedback",
product: "Loop"
});
var Example = React.createClass({displayName: 'Example',
render: function() {
@ -124,8 +123,12 @@
),
Section( {name:"FeedbackView"},
React.DOM.p( {className:"note"},
React.DOM.strong(null, "Note:"), " For the useable demo, you can access submitted data at ",
React.DOM.a( {href:"https://input.allizom.org/"}, "input.allizom.org"),"."
),
Example( {summary:"Default (useable demo)", dashed:"true", style:{width: "280px"}},
FeedbackView( {feedbackApiClient:fakeFeedbackApiClient} )
FeedbackView( {feedbackApiClient:stageFeedbackApiClient} )
),
Example( {summary:"Detailed form", dashed:"true", style:{width: "280px"}},
FeedbackView( {step:"form"} )

View File

@ -33,13 +33,12 @@
return false;
}
// local fakes
var fakeFeedbackApiClient = {
send: function(fields, cb) {
alert("Sent feedback data: " + JSON.stringify(fields));
cb();
}
};
// Feedback API client configured to send data to the stage input server,
// which is available at https://input.allizom.org
var stageFeedbackApiClient = new loop.FeedbackAPIClient({
baseUrl: "https://input.allizom.org/api/v1/feedback",
product: "Loop"
});
var Example = React.createClass({
render: function() {
@ -124,8 +123,12 @@
</Section>
<Section name="FeedbackView">
<p className="note">
<strong>Note:</strong> For the useable demo, you can access submitted data at&nbsp;
<a href="https://input.allizom.org/">input.allizom.org</a>.
</p>
<Example summary="Default (useable demo)" dashed="true" style={{width: "280px"}}>
<FeedbackView feedbackApiClient={fakeFeedbackApiClient} />
<FeedbackView feedbackApiClient={stageFeedbackApiClient} />
</Example>
<Example summary="Detailed form" dashed="true" style={{width: "280px"}}>
<FeedbackView step="form" />

View File

@ -42,11 +42,11 @@ legal_text_and_links=By using this product you agree to the <a \
feedback_call_experience_heading=How was your call experience?
feedback_what_makes_you_sad=What makes you sad?
feedback_thank_you_heading=Thank you for your feedback!
feedback_reason_audio_quality=Audio quality
feedback_reason_video_quality=Video quality
feedback_reason_was_disconnected=Was disconnected
feedback_reason_confusing=Confusing
feedback_reason_other=Other:
feedback_category_audio_quality=Audio quality
feedback_category_video_quality=Video quality
feedback_category_was_disconnected=Was disconnected
feedback_category_confusing=Confusing
feedback_category_other=Other:
feedback_submit_button=Submit
feedback_back_button=Back
## LOCALIZATION NOTE (feedback_window_will_close_in): In this item, don't