Bug 972992 (Part 1): Loop desktop client user feedback form. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-07-23 19:27:55 +02:00
parent 3e6505c039
commit 96c36a5dc5
14 changed files with 832 additions and 172 deletions

View File

@ -86,27 +86,27 @@ loop.conversation = (function(OT, mozL10n) {
"visually-hidden": !this.state.showDeclineMenu
});
return (
React.DOM.div({className: conversationPanelClass},
React.DOM.h2(null, __("incoming_call")),
React.DOM.div({className: "button-group incoming-call-action-group"},
React.DOM.div({className: "button-chevron-menu-group"},
React.DOM.div({className: "button-group-chevron"},
React.DOM.div({className: "button-group"},
React.DOM.button({className: btnClassDecline, onClick: this._handleDecline},
React.DOM.div( {className:conversationPanelClass},
React.DOM.h2(null, __("incoming_call")),
React.DOM.div( {className:"button-group incoming-call-action-group"},
React.DOM.div( {className:"button-chevron-menu-group"},
React.DOM.div( {className:"button-group-chevron"},
React.DOM.div( {className:"button-group"},
React.DOM.button( {className:btnClassDecline, onClick:this._handleDecline},
__("incoming_call_decline_button")
),
React.DOM.div({className: "btn-chevron",
onClick: this._toggleDeclineMenu}
),
React.DOM.div( {className:"btn-chevron",
onClick:this._toggleDeclineMenu}
)
),
React.DOM.ul({className: declineDropdownMenuClasses},
React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock},
),
React.DOM.ul( {className:declineDropdownMenuClasses},
React.DOM.li( {className:"btn-block", onClick:this._handleDeclineBlock},
__("incoming_call_decline_and_block_button")
)
)
)
),
React.DOM.button({className: btnClassAccept, onClick: this._handleAccept},
),
React.DOM.button( {className:btnClassAccept, onClick:this._handleAccept},
__("incoming_call_answer_button")
)
)
@ -116,30 +116,6 @@ loop.conversation = (function(OT, mozL10n) {
}
});
/**
* Call ended view.
* @type {loop.shared.views.BaseView}
*/
var EndedCallView = sharedViews.BaseView.extend({
template: _.template([
'<p>',
' <button class="btn btn-info" data-l10n-id="close_window"></button>',
'</p>'
].join("")),
className: "call-ended",
events: {
"click button": "closeWindow"
},
closeWindow: function(event) {
event.preventDefault();
// XXX For now, we just close the window.
window.close();
}
});
/**
* Conversation router.
*
@ -156,7 +132,8 @@ loop.conversation = (function(OT, mozL10n) {
"call/decline": "decline",
"call/ongoing": "conversation",
"call/ended": "ended",
"call/declineAndBlock": "declineAndBlock"
"call/declineAndBlock": "declineAndBlock",
"call/feedback": "feedback"
},
/**
@ -170,7 +147,7 @@ loop.conversation = (function(OT, mozL10n) {
* @override {loop.shared.router.BaseConversationRouter.endCall}
*/
endCall: function() {
this.navigate("call/ended", {trigger: true});
this.navigate("call/feedback", {trigger: true});
},
/**
@ -254,10 +231,17 @@ loop.conversation = (function(OT, mozL10n) {
},
/**
* XXX: load a view with a close button for now?
* Call has ended, display a feedback form.
*/
ended: function() {
this.loadView(new EndedCallView());
feedback: function() {
this.loadReactComponent(sharedViews.FeedbackView({
// XXX for now we pass in a fake feeback API client (see bug 972992)
feedbackApiClient: {
send: function(fields, cb) {
cb();
}
}
}));
}
});
@ -280,7 +264,6 @@ loop.conversation = (function(OT, mozL10n) {
return {
ConversationRouter: ConversationRouter,
EndedCallView: EndedCallView,
IncomingCallView: IncomingCallView,
init: init
};

View File

@ -116,30 +116,6 @@ loop.conversation = (function(OT, mozL10n) {
}
});
/**
* Call ended view.
* @type {loop.shared.views.BaseView}
*/
var EndedCallView = sharedViews.BaseView.extend({
template: _.template([
'<p>',
' <button class="btn btn-info" data-l10n-id="close_window"></button>',
'</p>'
].join("")),
className: "call-ended",
events: {
"click button": "closeWindow"
},
closeWindow: function(event) {
event.preventDefault();
// XXX For now, we just close the window.
window.close();
}
});
/**
* Conversation router.
*
@ -156,7 +132,8 @@ loop.conversation = (function(OT, mozL10n) {
"call/decline": "decline",
"call/ongoing": "conversation",
"call/ended": "ended",
"call/declineAndBlock": "declineAndBlock"
"call/declineAndBlock": "declineAndBlock",
"call/feedback": "feedback"
},
/**
@ -170,7 +147,7 @@ loop.conversation = (function(OT, mozL10n) {
* @override {loop.shared.router.BaseConversationRouter.endCall}
*/
endCall: function() {
this.navigate("call/ended", {trigger: true});
this.navigate("call/feedback", {trigger: true});
},
/**
@ -254,10 +231,17 @@ loop.conversation = (function(OT, mozL10n) {
},
/**
* XXX: load a view with a close button for now?
* Call has ended, display a feedback form.
*/
ended: function() {
this.loadView(new EndedCallView());
feedback: function() {
this.loadReactComponent(sharedViews.FeedbackView({
// XXX for now we pass in a fake feeback API client (see bug 972992)
feedbackApiClient: {
send: function(fields, cb) {
cb();
}
}
}));
}
});
@ -280,7 +264,6 @@ loop.conversation = (function(OT, mozL10n) {
return {
ConversationRouter: ConversationRouter,
EndedCallView: EndedCallView,
IncomingCallView: IncomingCallView,
init: init
};

View File

@ -238,3 +238,105 @@
color: #FFF;
background: #111;
}
/* Expired call url page */
.expired-url-info {
width: 400px;
margin: 0 auto;
}
.promote-firefox {
text-align: center;
font-size: 18px;
line-height: 24px;
margin: 2em 0;
}
.promote-firefox h3 {
font-weight: 300;
}
/* Feedback form */
.feedback {
padding: 1em;
}
.feedback h3 {
color: #666;
font-size: 12px;
font-weight: 700;
text-align: center;
margin-bottom: 10px;
margin-top: 15px;
}
.feedback .faces {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.feedback .face {
border: 1px solid transparent;
box-shadow: 0px 1px 2px #CCC;
cursor: pointer;
border-radius: 4px;
margin: 0px 10px;
width: 80px;
height: 80px;
background-color: #fbfbfb;
background-size: 60px auto;
background-position: center center;
background-repeat: no-repeat;
}
.feedback .face:hover {
border: 1px solid #DDD;
background-color: #FEFEFE;
}
.feedback .face.face-happy {
background-image: url("../img/happy.png");
}
.feedback .face.face-sad {
background-image: url("../img/sad.png");
}
.feedback button.back {
border-radius: 2px;
border: 1px solid #CCC;
color: #CCC;
font-size: 11px;
cursor: pointer;
padding: 3px 10px;
display: inline;
margin-bottom: 1em;
}
.feedback label {
display: block;
}
.feedback form input[type="radio"] {
margin-right: .5em;
}
.feedback form button[type="submit"] {
width: 100%;
}
.feedback form input[type="text"] {
width: 100%;
}
.feedback .info {
display: block;
font-size: 10px;
color: #CCC;
text-align: center;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -13,6 +13,7 @@ loop.shared.views = (function(_, OT, l10n) {
var sharedModels = loop.shared.models;
var __ = l10n.get;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
/**
* L10n view. Translates resulting view DOM fragment once rendered.
@ -139,9 +140,9 @@ loop.shared.views = (function(_, OT, l10n) {
render: function() {
return (
/* jshint ignore:start */
React.DOM.button({className: this._getClasses(),
title: this._getTitle(),
onClick: this.handleClick})
React.DOM.button( {className:this._getClasses(),
title:this._getTitle(),
onClick:this.handleClick})
/* jshint ignore:end */
);
}
@ -180,16 +181,16 @@ loop.shared.views = (function(_, OT, l10n) {
render: function() {
/* jshint ignore:start */
return (
React.DOM.ul({className: "conversation-toolbar"},
React.DOM.li(null, React.DOM.button({className: "btn btn-hangup",
onClick: this.handleClickHangup,
title: __("hangup_button_title")})),
React.DOM.li(null, MediaControlButton({action: this.handleToggleVideo,
enabled: this.props.video.enabled,
scope: "local", type: "video"})),
React.DOM.li(null, MediaControlButton({action: this.handleToggleAudio,
enabled: this.props.audio.enabled,
scope: "local", type: "audio"}))
React.DOM.ul( {className:"conversation-toolbar"},
React.DOM.li(null, React.DOM.button( {className:"btn btn-hangup",
onClick:this.handleClickHangup,
title:__("hangup_button_title")})),
React.DOM.li(null, MediaControlButton( {action:this.handleToggleVideo,
enabled:this.props.video.enabled,
scope:"local", type:"video"} )),
React.DOM.li(null, MediaControlButton( {action:this.handleToggleAudio,
enabled:this.props.audio.enabled,
scope:"local", type:"audio"} ))
)
);
/* jshint ignore:end */
@ -335,16 +336,16 @@ loop.shared.views = (function(_, OT, l10n) {
render: function() {
/* jshint ignore:start */
return (
React.DOM.div({className: "conversation"},
ConversationToolbar({video: this.state.video,
audio: this.state.audio,
publishStream: this.publishStream,
hangup: this.hangup}),
React.DOM.div({className: "media nested"},
React.DOM.div({className: "video_wrapper remote_wrapper"},
React.DOM.div({className: "video_inner remote"})
),
React.DOM.div({className: "local"})
React.DOM.div( {className:"conversation"},
ConversationToolbar( {video:this.state.video,
audio:this.state.audio,
publishStream:this.publishStream,
hangup:this.hangup} ),
React.DOM.div( {className:"media nested"},
React.DOM.div( {className:"video_wrapper remote_wrapper"},
React.DOM.div( {className:"video_inner remote"})
),
React.DOM.div( {className:"local"})
)
)
);
@ -352,6 +353,230 @@ loop.shared.views = (function(_, OT, l10n) {
}
});
/**
* Feedback outer layout.
*
* Props:
* -
*/
var FeedbackLayout = React.createClass({displayName: 'FeedbackLayout',
propTypes: {
children: React.PropTypes.component.isRequired,
title: React.PropTypes.string.isRequired,
reset: React.PropTypes.func // if not specified, no Back btn is shown
},
render: function() {
var backButton = React.DOM.div(null );
if (this.props.reset) {
backButton = (
React.DOM.button( {className:"back", type:"button", onClick:this.props.reset},
"« ",__("feedback_back_button")
)
);
}
return (
React.DOM.div( {className:"feedback"},
backButton,
React.DOM.h3(null, this.props.title),
this.props.children
)
);
}
});
/**
* Detailed feedback form.
*/
var FeedbackForm = React.createClass({displayName: 'FeedbackForm',
propTypes: {
pending: React.PropTypes.bool,
sendFeedback: React.PropTypes.func,
reset: React.PropTypes.func
},
getInitialState: function() {
return {reason: "", custom: ""};
},
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: ""});
}
},
handleCustomTextChange: function(event) {
this.setState({custom: 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 : ""
});
},
render: function() {
return (
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",
onChange:this.handleCustomTextChange,
value:this.state.custom} )),
React.DOM.button( {type:"submit", className:"btn btn-success",
disabled:this.props.pending},
__("feedback_submit_button")
)
)
)
);
}
});
/**
* Feedback received view.
*/
var FeedbackReceived = React.createClass({displayName: 'FeedbackReceived',
getInitialState: function() {
return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
},
componentDidMount: function() {
this._timer = setInterval(function() {
this.setState({countdown: this.state.countdown - 1});
}.bind(this), 1000);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
window.close();
}
return (
FeedbackLayout( {title:__("feedback_thank_you_heading")},
React.DOM.p( {className:"info thank-you"}, __("feedback_window_will_close_in", {
countdown: this.state.countdown
}))
)
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({displayName: 'FeedbackView',
propTypes: {
// A loop.FeedbackAPIClient instance
feedbackApiClient: React.PropTypes.object.isRequired,
// The current feedback submission flow step name
step: React.PropTypes.oneOf(["start", "form", "finished"])
},
getInitialState: function() {
return {pending: false, step: this.props.step || "start"};
},
getInitialProps: function() {
return {step: "start"};
},
reset: function() {
this.setState(this.getInitialState());
},
handleHappyClick: function() {
this.setState({step: "finished"});
},
handleSadClick: function() {
this.setState({step: "form"});
},
sendFeedback: function(fields) {
// Setting state.pending to true will disable the submit button to avoid
// multiple submissions
this.setState({pending: true});
// Sends feedback data
this.props.feedbackApiClient.send(fields, this._onFeedbackSent);
},
_onFeedbackSent: function(err) {
if (err) {
// XXX better end user error reporting, see bug 1046738
console.error("Unable to send user feedback", err);
}
this.setState({pending: false, step: "finished"});
},
render: function() {
switch(this.state.step) {
case "finished":
return FeedbackReceived(null );
case "form":
return FeedbackForm( {feedbackApiClient:this.props.feedbackApiClient,
sendFeedback:this.sendFeedback,
reset:this.reset,
pending:this.state.pending} );
default:
return (
FeedbackLayout( {title:__("feedback_call_experience_heading")},
React.DOM.div( {className:"faces"},
React.DOM.button( {className:"face face-happy",
onClick:this.handleHappyClick}),
React.DOM.button( {className:"face face-sad",
onClick:this.handleSadClick})
)
)
);
}
}
});
/**
* Notification view.
*/
@ -518,6 +743,7 @@ loop.shared.views = (function(_, OT, l10n) {
BaseView: BaseView,
ConversationView: ConversationView,
ConversationToolbar: ConversationToolbar,
FeedbackView: FeedbackView,
MediaControlButton: MediaControlButton,
NotificationListView: NotificationListView,
NotificationView: NotificationView,

View File

@ -13,6 +13,7 @@ loop.shared.views = (function(_, OT, l10n) {
var sharedModels = loop.shared.models;
var __ = l10n.get;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
/**
* L10n view. Translates resulting view DOM fragment once rendered.
@ -352,6 +353,230 @@ loop.shared.views = (function(_, OT, l10n) {
}
});
/**
* Feedback outer layout.
*
* Props:
* -
*/
var FeedbackLayout = React.createClass({
propTypes: {
children: React.PropTypes.component.isRequired,
title: React.PropTypes.string.isRequired,
reset: React.PropTypes.func // if not specified, no Back btn is shown
},
render: function() {
var backButton = <div />;
if (this.props.reset) {
backButton = (
<button className="back" type="button" onClick={this.props.reset}>
&laquo;&nbsp;{__("feedback_back_button")}
</button>
);
}
return (
<div className="feedback">
{backButton}
<h3>{this.props.title}</h3>
{this.props.children}
</div>
);
}
});
/**
* Detailed feedback form.
*/
var FeedbackForm = React.createClass({
propTypes: {
pending: React.PropTypes.bool,
sendFeedback: React.PropTypes.func,
reset: React.PropTypes.func
},
getInitialState: function() {
return {reason: "", custom: ""};
},
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: ""});
}
},
handleCustomTextChange: function(event) {
this.setState({custom: 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 : ""
});
},
render: function() {
return (
<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"}
onChange={this.handleCustomTextChange}
value={this.state.custom} /></p>
<button type="submit" className="btn btn-success"
disabled={this.props.pending}>
{__("feedback_submit_button")}
</button>
</form>
</FeedbackLayout>
);
}
});
/**
* Feedback received view.
*/
var FeedbackReceived = React.createClass({
getInitialState: function() {
return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
},
componentDidMount: function() {
this._timer = setInterval(function() {
this.setState({countdown: this.state.countdown - 1});
}.bind(this), 1000);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
window.close();
}
return (
<FeedbackLayout title={__("feedback_thank_you_heading")}>
<p className="info thank-you">{__("feedback_window_will_close_in", {
countdown: this.state.countdown
})}</p>
</FeedbackLayout>
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({
propTypes: {
// A loop.FeedbackAPIClient instance
feedbackApiClient: React.PropTypes.object.isRequired,
// The current feedback submission flow step name
step: React.PropTypes.oneOf(["start", "form", "finished"])
},
getInitialState: function() {
return {pending: false, step: this.props.step || "start"};
},
getInitialProps: function() {
return {step: "start"};
},
reset: function() {
this.setState(this.getInitialState());
},
handleHappyClick: function() {
this.setState({step: "finished"});
},
handleSadClick: function() {
this.setState({step: "form"});
},
sendFeedback: function(fields) {
// Setting state.pending to true will disable the submit button to avoid
// multiple submissions
this.setState({pending: true});
// Sends feedback data
this.props.feedbackApiClient.send(fields, this._onFeedbackSent);
},
_onFeedbackSent: function(err) {
if (err) {
// XXX better end user error reporting, see bug 1046738
console.error("Unable to send user feedback", err);
}
this.setState({pending: false, step: "finished"});
},
render: function() {
switch(this.state.step) {
case "finished":
return <FeedbackReceived />;
case "form":
return <FeedbackForm feedbackApiClient={this.props.feedbackApiClient}
sendFeedback={this.sendFeedback}
reset={this.reset}
pending={this.state.pending} />;
default:
return (
<FeedbackLayout title={__("feedback_call_experience_heading")}>
<div className="faces">
<button className="face face-happy"
onClick={this.handleHappyClick}></button>
<button className="face face-sad"
onClick={this.handleSadClick}></button>
</div>
</FeedbackLayout>
);
}
}
});
/**
* Notification view.
*/
@ -518,6 +743,7 @@ loop.shared.views = (function(_, OT, l10n) {
BaseView: BaseView,
ConversationView: ConversationView,
ConversationToolbar: ConversationToolbar,
FeedbackView: FeedbackView,
MediaControlButton: MediaControlButton,
NotificationListView: NotificationListView,
NotificationView: NotificationView,

View File

@ -22,6 +22,8 @@ browser.jar:
content/browser/loop/shared/css/conversation.css (content/shared/css/conversation.css)
# Shared images
content/browser/loop/shared/img/happy.png (content/shared/img/happy.png)
content/browser/loop/shared/img/sad.png (content/shared/img/sad.png)
content/browser/loop/shared/img/icon_32.png (content/shared/img/icon_32.png)
content/browser/loop/shared/img/icon_64.png (content/shared/img/icon_64.png)
content/browser/loop/shared/img/loading-icon.gif (content/shared/img/loading-icon.gif)

View File

@ -42,14 +42,14 @@ loop.webapp = (function($, _, OT, webL10n) {
render: function() {
if (this.props.helper.isFirefox(navigator.userAgent)) {
return React.DOM.div(null);
return React.DOM.div(null );
}
return (
React.DOM.div({className: "promote-firefox"},
React.DOM.h3(null, __("promote_firefox_hello_heading")),
React.DOM.div( {className:"promote-firefox"},
React.DOM.h3(null, __("promote_firefox_hello_heading")),
React.DOM.p(null,
React.DOM.a({className: "btn btn-large btn-success",
href: "https://www.mozilla.org/firefox/"},
React.DOM.a( {className:"btn btn-large btn-success",
href:"https://www.mozilla.org/firefox/"},
__("get_firefox_button")
)
)
@ -69,13 +69,13 @@ loop.webapp = (function($, _, OT, webL10n) {
render: function() {
/* jshint ignore:start */
return (
React.DOM.div({className: "expired-url-info"},
React.DOM.div({className: "info-panel"},
React.DOM.div({className: "firefox-logo"}),
React.DOM.h1(null, __("call_url_unavailable_notification_heading")),
React.DOM.div( {className:"expired-url-info"},
React.DOM.div( {className:"info-panel"},
React.DOM.div( {className:"firefox-logo"} ),
React.DOM.h1(null, __("call_url_unavailable_notification_heading")),
React.DOM.h4(null, __("call_url_unavailable_notification_message"))
),
PromoteFirefoxView({helper: this.props.helper})
),
PromoteFirefoxView( {helper:this.props.helper} )
)
);
/* jshint ignore:end */

View File

@ -248,10 +248,23 @@ describe("loop.conversation", function() {
});
});
describe("#ended", function() {
describe("#feedback", function() {
beforeEach(function() {
sandbox.stub(router, "loadReactComponent");
});
// XXX When the call is ended gracefully, we should check that we
// close connections nicely
it("should close the window");
// close connections nicely (see bug 1046744)
it("should display a feedback form view", function() {
router.feedback();
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return TestUtils.isComponentOfType(value,
loop.shared.views.FeedbackView);
}));
});
});
describe("#blocked", function() {
@ -319,44 +332,31 @@ describe("loop.conversation", function() {
sinon.assert.calledWith(router.navigate, "call/ongoing");
});
it("should navigate to call/ended when the call session ends",
it("should navigate to call/feedback when the call session ends",
function() {
conversation.trigger("session:ended");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWith(router.navigate, "call/ended");
sinon.assert.calledWith(router.navigate, "call/feedback");
});
it("should navigate to call/ended when peer hangs up", function() {
it("should navigate to call/feedback when peer hangs up", function() {
conversation.trigger("session:peer-hungup");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWith(router.navigate, "call/ended");
sinon.assert.calledWith(router.navigate, "call/feedback");
});
it("should navigate to call/{token} when network disconnects",
it("should navigate to call/feedback when network disconnects",
function() {
conversation.trigger("session:network-disconnected");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWith(router.navigate, "call/ended");
sinon.assert.calledWith(router.navigate, "call/feedback");
});
});
});
describe("EndedCallView", function() {
describe("#closeWindow", function() {
it("should close the conversation window", function() {
sandbox.stub(window, "close");
var view = new loop.conversation.EndedCallView();
view.closeWindow({preventDefault: sandbox.spy()});
sinon.assert.calledOnce(window.close);
});
});
});
describe("IncomingCallView", function() {
var view, model;

View File

@ -401,6 +401,89 @@ describe("loop.shared.views", function() {
});
});
describe("FeedbackView", function() {
var comp, fakeFeedbackApiClient;
beforeEach(function() {
fakeFeedbackApiClient = {send: sandbox.stub()};
comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
feedbackApiClient: fakeFeedbackApiClient
}));
});
it("should thank the user for feedback when clicking on the happy face",
function() {
var happyFace = comp.getDOMNode().querySelector(".face-happy");
TestUtils.Simulate.click(happyFace);
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"));
expect(comp.getDOMNode()
.querySelectorAll(".feedback .thank-you").length).eql(1);
});
});
describe("NotificationView", function() {
var collection, model, view;

View File

@ -22,6 +22,7 @@
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Local helpers
function returnTrue() {
@ -32,14 +33,22 @@
return false;
}
// local fakes
var fakeFeedbackApiClient = {
send: function(fields, cb) {
alert("Sent feedback data: " + JSON.stringify(fields));
cb();
}
};
var Example = React.createClass({displayName: 'Example',
render: function() {
var cx = React.addons.classSet;
return (
React.DOM.div({className: "example"},
React.DOM.h3(null, this.props.summary),
React.DOM.div({className: cx({comp: true, dashed: this.props.dashed}),
style: this.props.style || {}},
React.DOM.div( {className:"example"},
React.DOM.h3(null, this.props.summary),
React.DOM.div( {className:cx({comp: true, dashed: this.props.dashed}),
style:this.props.style || {}},
this.props.children
)
)
@ -50,8 +59,8 @@
var Section = React.createClass({displayName: 'Section',
render: function() {
return (
React.DOM.section({id: this.props.name},
React.DOM.h1(null, this.props.name),
React.DOM.section( {id:this.props.name},
React.DOM.h1(null, this.props.name),
this.props.children
)
);
@ -61,19 +70,19 @@
var ShowCase = React.createClass({displayName: 'ShowCase',
render: function() {
return (
React.DOM.div({className: "showcase"},
React.DOM.div( {className:"showcase"},
React.DOM.header(null,
React.DOM.h1(null, "Loop UI Components Showcase"),
React.DOM.nav({className: "menu"},
React.DOM.h1(null, "Loop UI Components Showcase"),
React.DOM.nav( {className:"menu"},
React.Children.map(this.props.children, function(section) {
return (
React.DOM.a({className: "btn btn-info", href: "#" + section.props.name},
React.DOM.a( {className:"btn btn-info", href:"#" + section.props.name},
section.props.name
)
);
})
)
),
),
this.props.children
)
);
@ -84,42 +93,54 @@
render: function() {
return (
ShowCase(null,
Section({name: "PanelView"},
Example({summary: "332px wide", dashed: "true", style: {width: "332px"}},
PanelView(null)
Section( {name:"PanelView"},
Example( {summary:"332px wide", dashed:"true", style:{width: "332px"}},
PanelView(null )
)
),
),
Section({name: "IncomingCallView"},
Example({summary: "Default", dashed: "true", style: {width: "280px"}},
IncomingCallView(null)
Section( {name:"IncomingCallView"},
Example( {summary:"Default", dashed:"true", style:{width: "280px"}},
IncomingCallView(null )
)
),
),
Section({name: "ConversationToolbar"},
Example({summary: "Default"},
ConversationToolbar({video: {enabled: true}, audio: {enabled: true}})
),
Example({summary: "Video muted"},
ConversationToolbar({video: {enabled: false}, audio: {enabled: true}})
),
Example({summary: "Audio muted"},
ConversationToolbar({video: {enabled: true}, audio: {enabled: false}})
Section( {name:"ConversationToolbar"},
Example( {summary:"Default"},
ConversationToolbar( {video:{enabled: true}, audio:{enabled: true}} )
),
Example( {summary:"Video muted"},
ConversationToolbar( {video:{enabled: false}, audio:{enabled: true}} )
),
Example( {summary:"Audio muted"},
ConversationToolbar( {video:{enabled: true}, audio:{enabled: false}} )
)
),
),
Section({name: "ConversationView"},
Example({summary: "Default"},
ConversationView({video: {enabled: true}, audio: {enabled: true}})
Section( {name:"ConversationView"},
Example( {summary:"Default"},
ConversationView( {video:{enabled: true}, audio:{enabled: true}} )
)
),
),
Section({name: "CallUrlExpiredView"},
Example({summary: "Firefox User"},
CallUrlExpiredView({helper: {isFirefox: returnTrue}})
),
Example({summary: "Non-Firefox User"},
CallUrlExpiredView({helper: {isFirefox: returnFalse}})
Section( {name:"FeedbackView"},
Example( {summary:"Default (useable demo)", dashed:"true", style:{width: "280px"}},
FeedbackView( {feedbackApiClient:fakeFeedbackApiClient} )
),
Example( {summary:"Detailed form", dashed:"true", style:{width: "280px"}},
FeedbackView( {step:"form"} )
),
Example( {summary:"Thank you!", dashed:"true", style:{width: "280px"}},
FeedbackView( {step:"finished"} )
)
),
Section( {name:"CallUrlExpiredView"},
Example( {summary:"Firefox User"},
CallUrlExpiredView( {helper:{isFirefox: returnTrue}} )
),
Example( {summary:"Non-Firefox User"},
CallUrlExpiredView( {helper:{isFirefox: returnFalse}} )
)
)
)
@ -128,6 +149,6 @@
});
window.addEventListener("DOMContentLoaded", function() {
React.renderComponent(App(null), document.body);
React.renderComponent(App(null ), document.body);
});
})();

View File

@ -22,6 +22,7 @@
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Local helpers
function returnTrue() {
@ -32,6 +33,14 @@
return false;
}
// local fakes
var fakeFeedbackApiClient = {
send: function(fields, cb) {
alert("Sent feedback data: " + JSON.stringify(fields));
cb();
}
};
var Example = React.createClass({
render: function() {
var cx = React.addons.classSet;
@ -114,6 +123,18 @@
</Example>
</Section>
<Section name="FeedbackView">
<Example summary="Default (useable demo)" dashed="true" style={{width: "280px"}}>
<FeedbackView feedbackApiClient={fakeFeedbackApiClient} />
</Example>
<Example summary="Detailed form" dashed="true" style={{width: "280px"}}>
<FeedbackView step="form" />
</Example>
<Example summary="Thank you!" dashed="true" style={{width: "280px"}}>
<FeedbackView step="finished" />
</Example>
</Section>
<Section name="CallUrlExpiredView">
<Example summary="Firefox User">
<CallUrlExpiredView helper={{isFirefox: returnTrue}} />

View File

@ -28,7 +28,6 @@ unmute_local_video_button_title=Unmute your video
peer_ended_conversation=Your peer ended the conversation.
call_has_ended=Your call has ended.
close_window=Close this window
cannot_start_call_session_not_ready=Can't start call, session is not ready.
network_disconnected=The network connection terminated abruptly.
@ -39,3 +38,17 @@ connection_error_see_console_notification=Call failed; see console for details.
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>.
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_submit_button=Submit
feedback_back_button=Back
## LOCALIZATION NOTE (feedback_window_will_close_in): In this item, don't
## translate the part between {{..}}
feedback_window_will_close_in=This window will close in {{countdown}} seconds