feat(support form): Add subscription support form

Fixes #797
This commit is contained in:
Barry Chen 2019-07-03 11:39:18 -05:00
Родитель 26259c6659
Коммит c7ec3143df
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 228DB2785954A0D0
30 изменённых файлов: 1094 добавлений и 40 удалений

Просмотреть файл

@ -45,7 +45,10 @@ module.exports = (log, db, config, customs, zendeskClient) => {
validate: {
payload: isA.object().keys({
topic: isA.string().required(),
subject: isA.string().optional(),
subject: isA
.string()
.allow('')
.optional(),
message: isA.string().required(),
}),
},

Просмотреть файл

@ -5,13 +5,27 @@
'use strict';
const PaymentServer = {
navigateToPaymentServer(view, subscriptionsConfig, redirectPath) {
navigateToPaymentServer(
view,
subscriptionsConfig,
redirectPath,
queryParams
) {
const {
managementClientId,
managementScopes,
managementTokenTTL,
managementUrl,
} = subscriptionsConfig;
const queryString =
typeof queryParams === 'object' &&
Object.keys(queryParams)
.filter(k => queryParams[k] !== null && queryParams[k] !== '')
.map(
k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`
)
.join('&');
const qs = queryString.length ? `?${queryString}` : '';
return view
.getSignedInAccount()
.createOAuthToken(managementClientId, {
@ -19,7 +33,7 @@ const PaymentServer = {
ttl: managementTokenTTL,
})
.then(accessToken => {
const url = `${managementUrl}/${redirectPath}#accessToken=${encodeURIComponent(
const url = `${managementUrl}/${redirectPath}${qs}#accessToken=${encodeURIComponent(
accessToken.get('token')
)}`;
view.navigateAway(url);

Просмотреть файл

@ -32,6 +32,7 @@ import ForceAuthView from '../views/force_auth';
import IndexView from '../views/index';
import OAuthIndexView from '../views/oauth_index';
import PermissionsView from '../views/permissions';
import SupportView from '../views/support';
import ReadyView from '../views/ready';
import RecoveryCodesView from '../views/settings/recovery_codes';
import RedirectAuthView from '../views/authorization';
@ -261,9 +262,8 @@ const Router = Backbone.Router.extend({
'subscriptions/products/:productId': createViewHandler(
SubscriptionsProductRedirectView
),
'subscriptions(/)': createViewHandler(
SubscriptionsManagementRedirectView
),
'subscriptions(/)': createViewHandler(SubscriptionsManagementRedirectView),
'support(/)': createViewHandler(SupportView),
'verify_email(/)': createViewHandler(CompleteSignUpView, {
type: VerificationReasons.SIGN_UP,
}),

Просмотреть файл

@ -0,0 +1,5 @@
<div class="modal dialog-error">
<div class="message">
<p>{{#t}}Hmm, we're having trouble with our system. We're working on fixing it for you and apologize for the inconvenience. Please try again later.{{/t}}</p>
</div>
</div>

Просмотреть файл

@ -0,0 +1,78 @@
<div id="fxa-settings-header-wrapper">
<header id="fxa-settings-header">
<h1 id="fxa-manage-account"><span class="fxa-account-title">{{#t}}Firefox Accounts{{/t}}</span></h1>
</header>
<div class="settings-success-wrapper">
<div class="success settings-success"></div>
</div>
</div>
<div id="fxa-settings">
<div id="fxa-settings-content" class="card">
<header id="fxa-settings-profile-header-wrapper">
<div class="avatar-wrapper avatar-settings-view nohover"></div>
<div id="fxa-settings-profile-header">
{{{ unsafeHeaderHTML }}}
</div>
</header>
<div class="child-views">
<div class="settings-child-view support">
<div class="settings-unit">
<div class="settings-unit-stub">
<header class="settings-unit-summary">
<h2 class="settings-unit-title">Subscriptions</h2>
</header>
</div>
</div>
<div class="settings-unit">
<div class="support-form">
<header>
<h3>{{#t}}Contact Us{{/t}}</h3>
</header>
<form id="fxa-support" novalidate>
<div>
<div class="form-label">
<label for="topic">{{#t}}What do you need help with?{{/t}}</label>
</div>
<div class="input-row">
<select id="topic" name="topic" data-placeholder="{{topicPlaceHolder}}">
<option value=""></option>
<option value="General inquiries">{{#t}}General inquiries{{/t}}</option>
<option value="Payment & billing">{{#t}}Payment & billing{{/t}}</option>
<option value="Connection issues">{{#t}}Connection issues{{/t}}</option>
<option value="Getting started">{{#t}}Getting started{{/t}}</option>
<option value="Account issues">{{#t}}Account issues{{/t}}</option>
</select>
</div>
</div>
<div>
<div class="form-label">
<label for="subject">{{#t}}Subject (optional){{/t}}</label>
</div>
<div class="input-row">
<input type="text" id="subject" name="subject"/>
</div>
</div>
<div>
<div class="form-label">
<label for="message">{{#t}}Description of Issue{{/t}}</label>
</div>
<div class="input-row">
<textarea id="message" name="message" rows="5" cols="70"></textarea>
</div>
</div>
<div class="button-row">
<button class="settings-button secondary-button cancel">{{#t}}Cancel{{/t}}</button>
<button type="submit" class="settings-button primary-button" disabled>{{#t}}Send{{/t}}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<footer id="legal-footer"><a class="terms" href="/legal/terms">{{#t}}Terms of Service{{/t}}</a><a class="privacy" href="/legal/privacy">{{#t}}Privacy Notice{{/t}}</a></footer>

Просмотреть файл

@ -0,0 +1,34 @@
/* 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/. */
import Session from '../../lib/session';
export default {
/**
* Set the user's signed in account with the relier's uid.
* Used in settings and support views.
*/
getUidAndSetSignedInAccount() {
const uid = this.relier.get('uid');
this.notifier.trigger('set-uid', uid);
// A uid param is set by RPs linking directly to the settings
// page for a particular account.
//
// We set the current account to the one with `uid` if
// it exists in our list of cached accounts. If the account is
// not in the list of cached accounts, clear the current account.
//
if (!this.user.getAccountByUid(uid).isDefault()) {
// The account with uid exists; set it to our current account.
this.user.setSignedInAccountByUid(uid);
} else if (uid) {
// session is expired or user does not exist. Force the user
// to sign in.
Session.clear();
this.user.clearSignedInAccount();
this.logViewEvent('signout.forced');
}
},
};

Просмотреть файл

@ -4,6 +4,7 @@
import $ from 'jquery';
import allowOnlyOneSubmit from './decorators/allow_only_one_submit';
import AccountByUidMixin from './mixins/account-by-uid-mixin';
import AccountRecoveryView from './settings/account_recovery/account_recovery';
import AccountRecoveryConfirmPasswordView from './settings/account_recovery/confirm_password';
import AccountRecoveryConfirmRevokeView from './settings/account_recovery/confirm_revoke';
@ -27,7 +28,6 @@ import EmailsView from './settings/emails';
import LoadingMixin from './mixins/loading-mixin';
import 'modal';
import preventDefaultThen from './decorators/prevent_default_then';
import Session from '../lib/session';
import SettingsHeaderTemplate from 'templates/partial/settings-header.mustache';
import SignedOutNotificationMixin from './mixins/signed-out-notification-mixin';
import SubPanels from './sub_panels';
@ -77,27 +77,7 @@ const View = BaseView.extend({
this._subscriptionsManagementEnabled =
options.subscriptionsManagementEnabled !== false;
const uid = this.relier.get('uid');
this.notifier.trigger('set-uid', uid);
// A uid param is set by RPs linking directly to the settings
// page for a particular account.
//
// We set the current account to the one with `uid` if
// it exists in our list of cached accounts. If the account is
// not in the list of cached accounts, clear the current account.
//
// The `mustVerify` flag will ensure that the account is valid.
if (!this.user.getAccountByUid(uid).isDefault()) {
// The account with uid exists; set it to our current account.
this.user.setSignedInAccountByUid(uid);
} else if (uid) {
// session is expired or user does not exist. Force the user
// to sign in.
Session.clear();
this.user.clearSignedInAccount();
this.logViewEvent('signout.forced');
}
this.getUidAndSetSignedInAccount();
},
notifications: {
@ -316,6 +296,7 @@ const View = BaseView.extend({
Cocktail.mixin(
View,
AccountByUidMixin,
AvatarMixin,
LoadingMixin,
SignedOutNotificationMixin,

Просмотреть файл

@ -0,0 +1,157 @@
/* 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/. */
import $ from 'jquery';
import allowOnlyOneSubmit from './decorators/allow_only_one_submit';
import AccountByUidMixin from './mixins/account-by-uid-mixin';
import AvatarMixin from './mixins/avatar-mixin';
import BaseView from './base';
import 'chosen-js';
import Cocktail from 'cocktail';
import LoadingMixin from './mixins/loading-mixin';
import 'modal';
import PaymentServer from '../lib/payment-server';
import preventDefaultThen from './decorators/prevent_default_then';
import SettingsHeaderTemplate from 'templates/partial/settings-header.mustache';
import SupportFormErrorTemplate from 'templates/partial/support-form-error.mustache';
import Template from 'templates/support.mustache';
const t = msg => msg;
const proto = BaseView.prototype;
const SupportView = BaseView.extend({
template: Template,
className: 'settings',
layoutClassName: 'settings',
viewName: 'support',
// The `mustVerify` flag will ensure that the account is valid.
mustVerify: true,
initialize(options = {}) {
this.getUidAndSetSignedInAccount();
this._subscriptionsConfig = {};
if (options && options.config && options.config.subscriptions) {
this._subscriptionsConfig = options.config.subscriptions;
}
},
setInitialContext(context) {
const account = this.getSignedInAccount();
context.set({
unsafeHeaderHTML: this._getHeaderHTML(account),
topicPlaceHolder: t('Please Select One'),
});
},
events: {
'change #topic': 'onFormChange',
'keyup #message': 'onFormChange',
'click button[type=submit]': 'submitSupportForm',
'click button.cancel': 'navigateToSubscriptionsManagement',
},
beforeRender() {
const account = this.getSignedInAccount();
return account.fetchActiveSubscriptions().then(subscriptions => {
if (subscriptions.length > 0) {
return account.fetchProfile().then(() => this.user.setAccount(account));
} else {
// Note that if a user landed here, it is because:
// a) they accessed the page directly, as the button for this page is
// not displayed when a user does not have any active subscriptions
// b) the edge case where the (last) subscription expired between
// clicking of the button and here
this.navigateToSubscriptionsManagement();
return false;
}
});
},
_getHeaderHTML(account) {
return SettingsHeaderTemplate(account.pick('displayName', 'email'));
},
afterVisible() {
this.topicEl = this.$('#topic');
this.submitBtn = this.$('button[type="submit"]');
this.subjectEl = this.$('#subject');
this.messageEl = this.$('#message');
this.topicEl.chosen({ disable_search: true, width: '100%' });
return proto.afterVisible.call(this).then(this._showAvatar.bind(this));
},
_showAvatar() {
var account = this.getSignedInAccount();
return this.displayAccountProfileImage(account);
},
onFormChange(e) {
e.stopPropagation();
if (this.messageEl.val().trim() !== '' && this.topicEl.val() !== '') {
this.submitBtn.attr('disabled', false);
} else {
this.submitBtn.attr('disabled', true);
}
},
submitSupportForm: preventDefaultThen(
allowOnlyOneSubmit(function() {
const account = this.getSignedInAccount();
const supportTicket = this.buildSupportTicket();
return account
.createSupportTicket(supportTicket)
.then(this.handleFormResponse.bind(this))
.catch(this.displayErrorMessage.bind(this));
})
),
handleFormResponse(resp) {
if (resp.success === true) {
this.navigateToSubscriptionsManagement({
successfulSupportTicketSubmission: true,
});
} else {
this.displayErrorMessage();
}
},
displayErrorMessage() {
// Inject the error modal if it's not already there.
if (!$('.modal').length) {
const errorModal = this.renderTemplate(SupportFormErrorTemplate);
$('body').append(errorModal);
}
$('.modal').modal({
closeText: '✕',
closeClass: 'icon-remove',
});
},
navigateToSubscriptionsManagement(queryParams = {}) {
PaymentServer.navigateToPaymentServer(
this,
this._subscriptionsConfig,
'subscriptions',
queryParams
);
},
buildSupportTicket() {
return {
topic: this.topicEl.val(),
subject: this.subjectEl.val().trim(),
message: this.messageEl.val().trim(),
};
},
});
Cocktail.mixin(SupportView, AccountByUidMixin, AvatarMixin, LoadingMixin);
export default SupportView;

Просмотреть файл

@ -17,3 +17,5 @@
@import 'modules/graphic';
@import 'modules/marketing';
@import 'modules/marketing-ios';
@import 'modules/support';
@import 'modules/chosen';

Просмотреть файл

@ -48,11 +48,11 @@ button {
background: $button-background-color-primary;
color: $button-text-color-primary;
&:hover:not:disabled {
&:hover:enabled {
background: $button-background-color-primary-hover;
}
&:active {
&:active:enabled {
background: $button-background-color-primary-active;
}
}

Просмотреть файл

@ -0,0 +1,177 @@
/*
* Styles for Chosen: https://github.com/harvesthq/chosen
*/
.chosen-container {
user-select: none;
* {
box-sizing: border-box;
}
.chosen-drop {
background: #fff;
border: 1px solid #aaa;
border-top: 0;
box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15);
clip: rect(0, 0, 0, 0);
clip-path: inset(100% 100%);
position: absolute;
top: 100%;
width: 100%;
z-index: 10;
}
&.chosen-with-drop {
.chosen-drop {
clip: auto;
clip-path: none;
}
.chosen-single {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.chosen-container-single {
.chosen-single {
border: 1px solid #aaa;
border-radius: $input-border-radius;
color: #20123b;
display: block;
font-size: $input-text-font-size-default;
overflow: hidden;
position: relative;
text-decoration: none;
@include respond-to('big') {
height: $input-height;
line-height: $input-height;
}
@include respond-to('small') {
height: $input-height-small;
line-height: $input-height-small;
}
html[dir='ltr'] & {
padding-left: 16px;
}
html[dir='rtl'] & {
padding-right: 16px;
}
}
.chosen-default {
color: #999;
}
.chosen-single {
div {
border-color: #686869 transparent transparent transparent;
border-style: solid;
border-width: 7px 8px 0 8px;
display: block;
height: 0;
position: absolute;
width: 0;
@include respond-to('big') {
top: 20px;
html[dir='ltr'] & {
right: 20px;
}
html[dir='rtl'] & {
left: 20px;
}
}
@include respond-to('small') {
top: 16px;
html[dir='ltr'] & {
right: 16px;
}
html[dir='rtl'] & {
left: 16px;
}
}
@at-root .chosen-with-drop#{&} {
border-color: transparent transparent #686869 transparent;
border-width: 0 8px 7px 8px;
}
b {
display: none;
}
}
}
.chosen-drop {
background-clip: padding-box;
border-radius: 0 0 $input-border-radius $input-border-radius;
}
&.chosen-container-single-nosearch .chosen-search {
clip-path: inset(100% 100%);
clip: rect(0, 0, 0, 0);
position: absolute;
}
}
.chosen-container {
.chosen-results {
color: #000;
font-size: $input-text-font-size-default;
margin: 8px 0;
overflow-x: hidden;
overflow-y: auto;
padding: 0;
position: relative;
li {
display: none;
line-height: $input-height;
list-style: none;
margin: 0;
padding-left: $input-left-right-padding;
padding-right: $input-left-right-padding;
&.active-result {
cursor: pointer;
display: list-item;
}
&.highlighted {
background-color: #d8d8d8;
font-weight: bold;
}
}
}
}
.chosen-container-active {
.chosen-single {
border: 1px solid #5897fb;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
}
&.chosen-with-drop .chosen-single {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border: 1px solid #aaa;
box-shadow: 0 1px 0 #fff inset;
}
.chosen-choices {
border: 1px solid #5897fb;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
}
}

Просмотреть файл

@ -0,0 +1,76 @@
.support {
.settings-unit:first-child {
@include respond-to('small') {
display: none;
}
@include respond-to('big') {
display: block;
}
.settings-unit-stub {
.settings-unit-title {
font-size: 20px;
font-weight: bold;
}
}
}
}
.support-form {
margin: 25px 32px;
h3 {
font-size: 16px;
}
textarea {
@include input-element();
display: inline-block;
height: 101px;
padding: $input-left-right-padding;
html[dir='rtl'] & {
direction: ltr;
text-align: right;
}
@include respond-to('small') {
height: 101px;
}
}
.button-row {
.settings-button {
height: 40x;
width: 128px;
}
}
.form-label {
margin-bottom: 5px;
label {
font-size: 13px;
font-weight: 600;
}
}
}
.modal.dialog-error {
display: inline-block;
padding: 25px 27px;
.close-modal {
background: transparent;
color: #0c0c0d;
font-size: 16px;
font-weight: 600;
right:2px;
text-indent: 0;
top: 13px;
&:hover {
text-decoration: none;
}
}
}

Просмотреть файл

@ -62,4 +62,20 @@ describe('lib/payment-server-redirect', () => {
);
});
});
it('redirects as expected with query string', () => {
const REDIRECT_PATH = 'example/path';
PaymentServer.navigateToPaymentServer(
view,
config.subscriptions,
REDIRECT_PATH,
{ foo: 'bar', fizz: '', quuz: '&buzz', buzz: null }
).then(() => {
assert.deepEqual(
view.navigateAway.args[0][0],
`${config.subscriptions.managementUrl}/${REDIRECT_PATH}?foo=bar&quzz=%26buzz#accessToken=MOCK_TOKEN`,
'should make the correct call to navigateAway'
);
});
});
});

Просмотреть файл

@ -0,0 +1,98 @@
/* 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/. */
import AccountByUidMixin from 'views/mixins/account-by-uid-mixin';
import { assert } from 'chai';
import BaseView from 'views/base';
import Cocktail from 'cocktail';
import Notifier from 'lib/channels/notifier';
import NullChannel from 'lib/channels/null';
import Relier from 'models/reliers/relier';
import Session from 'lib/session';
import sinon from 'sinon';
import TestHelpers from '../../../lib/helpers';
import User from 'models/user';
const UID = TestHelpers.createUid();
const View = BaseView.extend({});
Cocktail.mixin(View, AccountByUidMixin);
describe('views/mixins/account-by-uid-mixin', function() {
let notifier;
let relier;
let tabChannelMock;
let user;
let view;
beforeEach(function() {
relier = new Relier();
tabChannelMock = new NullChannel();
user = new User();
notifier = new Notifier({
tabChannel: tabChannelMock,
});
sinon
.stub(relier, 'get')
.withArgs('uid')
.returns(UID);
view = new View({
notifier: notifier,
relier: relier,
user: user,
});
});
afterEach(function() {
relier.get.restore();
view.remove();
view.destroy();
view = null;
});
describe('getUidAndSetSignedInAccount', function() {
it('gets the uid from the relier', function() {
sinon.spy(notifier, 'trigger');
sinon.stub(user, 'clearSignedInAccount');
view.getUidAndSetSignedInAccount();
assert.isTrue(relier.get.calledWith('uid'));
assert.isTrue(notifier.trigger.calledWith('set-uid', UID));
notifier.trigger.restore();
user.clearSignedInAccount.restore();
});
it('sets signed in account with uid when account exists', function() {
sinon.stub(user, 'getAccountByUid').returns({ isDefault: () => false });
sinon.stub(user, 'setSignedInAccountByUid');
view.getUidAndSetSignedInAccount();
assert.isTrue(user.getAccountByUid.calledWith(UID));
assert.isTrue(user.setSignedInAccountByUid.calledWith(UID));
user.setSignedInAccountByUid.restore();
user.getAccountByUid.restore();
});
it('forces the user to sign in when account does not exist', function() {
sinon.stub(user, 'getAccountByUid').returns({ isDefault: () => true });
sinon.stub(user, 'clearSignedInAccount');
sinon.stub(Session, 'clear');
sinon.stub(view, 'logViewEvent');
view.getUidAndSetSignedInAccount();
assert.isTrue(Session.clear.calledOnce);
assert.isTrue(user.clearSignedInAccount.calledOnce);
assert.isTrue(view.logViewEvent.calledWith('signout.forced'));
view.logViewEvent.restore();
Session.clear.restore();
user.clearSignedInAccount.restore();
user.getAccountByUid.restore();
});
});
});

Просмотреть файл

@ -0,0 +1,186 @@
/* 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/. */
import $ from 'jquery';
import AccountByUidMixin from 'views/mixins/account-by-uid-mixin';
import { assert } from 'chai';
import Notifier from 'lib/channels/notifier';
import ProfileClient from 'lib/profile-client';
import Relier from 'models/reliers/relier';
import sinon from 'sinon';
import TestHelpers from '../../lib/helpers';
import User from 'models/user';
import SupportView from 'views/support';
sinon.spy(AccountByUidMixin.getUidAndSetSignedInAccount);
describe('views/support', function() {
let account;
let notifier;
let profileClient;
let relier;
let user;
let view;
const email = 'a@a.com';
const UID = TestHelpers.createUid();
const subscriptionsConfig = { managementClientId: 'OVER9000' };
const supportTicket = {
topic: 'General inquiries',
subject: '',
message: 'inquiries from generals',
};
function createSupportView() {
view = new SupportView({
config: { subscriptions: subscriptionsConfig },
notifier,
relier,
user,
});
sinon.stub(view, 'checkAuthorization').returns(Promise.resolve(true));
}
beforeEach(function() {
notifier = new Notifier();
profileClient = new ProfileClient();
relier = new Relier();
relier.set('uid', 'wibble');
user = new User({
notifier: notifier,
profileClient: profileClient,
});
account = user.initAccount({
email,
sessionToken: 'abc123',
uid: UID,
verified: true,
});
sinon.stub(account, 'fetchProfile').returns(Promise.resolve());
sinon
.stub(account, 'fetchActiveSubscriptions')
.returns(Promise.resolve([{ id: '123done' }]));
sinon.stub(user, 'getAccountByUid').returns(account);
sinon.stub(user, 'setSignedInAccountByUid').returns(Promise.resolve());
sinon.stub(user, 'getSignedInAccount').returns(account);
createSupportView();
});
afterEach(function() {
$(view.el).remove();
view.destroy();
view = null;
});
it('should have a header', function() {
return view
.render()
.then(function() {
$('#container').append(view.el);
})
.then(function() {
assert.ok(view.$('#fxa-settings-header').length);
assert.ok(view.$('#fxa-settings-profile-header-wrapper').length);
assert.equal(
view.$('#fxa-settings-profile-header-wrapper h1').text(),
email
);
});
});
describe('submit button', function() {
it('should be disabled by default', function() {
return view
.render()
.then(function() {
$('#container').append(view.el);
})
.then(function() {
assert.ok(view.$('form button[type=submit]').attr('disabled'));
});
});
it('should be enabled once a topic is selected and a message is entered', function() {
return view
.render()
.then(function() {
view.afterVisible();
$('#container').append(view.el);
})
.then(function() {
view
.$('#topic option:eq(1)')
.prop('selected', true)
.trigger('change');
assert.ok(view.$('form button[type=submit]').attr('disabled'));
view
.$('#message')
.val(supportTicket.message)
.trigger('keyup');
assert.isUndefined(
view.$('form button[type=submit]').attr('disabled')
);
});
});
});
describe('successful form submission', function() {
it('should take user to subscriptions management page', function() {
sinon.stub(view, 'navigateToSubscriptionsManagement');
sinon
.stub(account, 'createSupportTicket')
.returns(Promise.resolve({ success: true }));
return view
.render()
.then(function() {
view.afterVisible();
$('#container').append(view.el);
})
.then(function() {
view.$('#topic option:eq(1)').prop('selected', true);
view.$('#message').val(supportTicket.message);
// calling this directly instead of clicking submit so we can have
// a promise to await
return view.submitSupportForm();
})
.then(function() {
assert.isTrue(account.createSupportTicket.calledOnce);
assert.deepEqual(
account.createSupportTicket.firstCall.args[0],
supportTicket
);
assert.isTrue(view.navigateToSubscriptionsManagement.calledOnce);
});
});
});
describe('failed form submission', function() {
it('should display an error modal', function() {
sinon
.stub(account, 'createSupportTicket')
.returns(Promise.resolve({ success: false }));
return view
.render()
.then(function() {
view.afterVisible();
$('#container').append(view.el);
})
.then(function() {
view.$('#topic option:eq(1)').prop('selected', true);
view.$('#message').val(supportTicket.message);
// calling this directly instead of clicking submit so we can have
// a promise to await
return view.submitSupportForm();
})
.then(function() {
assert.ok($('.dialog-error').length);
});
});
});
});

Просмотреть файл

@ -157,6 +157,7 @@ require('./spec/views/force_auth');
require('./spec/views/form');
require('./spec/views/index');
require('./spec/views/marketing_snippet');
require('./spec/views/mixins/account-by-uid-mixin');
require('./spec/views/mixins/account-reset-mixin');
require('./spec/views/mixins/avatar-mixin');
require('./spec/views/mixins/back-mixin');
@ -258,6 +259,7 @@ require('./spec/views/sms_sent');
require('./spec/views/sub_panels');
require('./spec/views/subscriptions_management_redirect');
require('./spec/views/subscriptions_product_redirect');
require('./spec/views/support');
require('./spec/views/tooltip');
require('./spec/views/tos');
require('./spec/views/why_connect_another_device');

5
packages/fxa-content-server/npm-shrinkwrap.json сгенерированный
Просмотреть файл

@ -2949,6 +2949,11 @@
"upath": "^1.1.1"
}
},
"chosen-js": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/chosen-js/-/chosen-js-1.8.7.tgz",
"integrity": "sha512-eVdrZJ2U5ISdObkgsi0od5vIJdLwq1P1Xa/Vj/mgxkMZf14DlgobfB6nrlFi3kW4kkvKLsKk4NDqZj1MU1DCpw=="
},
"chownr": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz",

Просмотреть файл

@ -49,6 +49,7 @@
"body-parser": "1.18.2",
"cache-loader": "^3.0.1",
"celebrate": "7.0.3",
"chosen-js": "1.8.7",
"connect-cachify": "0.0.17",
"consolidate": "0.14.5",
"convict": "1.5.0",

Просмотреть файл

@ -75,6 +75,7 @@ module.exports = function() {
'sms/why',
'subscriptions',
'subscriptions/products/[\\w_]+',
'support',
'verify_email',
'verify_primary_email',
'verify_secondary_email',

Просмотреть файл

@ -67,6 +67,7 @@ module.exports = [
'tests/functional/sign_in_token_code.js',
'tests/functional/sign_in_totp.js',
'tests/functional/sign_up.js',
'tests/functional/support.js',
'tests/functional/sync_v1.js',
'tests/functional/sync_v2.js',
'tests/functional/sync_v3_email_first.js',

Просмотреть файл

@ -31,6 +31,7 @@ const SIGNIN_URL = config.fxaContentRoot + 'signin';
const SIGNUP_URL = config.fxaContentRoot + 'signup';
const ENABLE_TOTP_URL = `${SETTINGS_URL}/two_step_authentication`;
const UNTRUSTED_OAUTH_APP = config.fxaUntrustedOauthApp;
const TEST_PRODUCT_URL = `${config.fxaContentRoot}subscriptions/products/${config.testProductId}`;
/**
* Convert a function to a form that can be used as a `then` callback.
@ -2342,6 +2343,34 @@ const destroySessionForEmail = thenify(function(email) {
});
});
/**
* Subscribe to the test product. The user should be signed in at this point.
*
* @returns {promise} resolves when complete
*/
const subscribeToTestProduct = thenify(function() {
const nextYear = (new Date().getFullYear() + 1).toString().substr(2);
return this.parent
.then(openPage(TEST_PRODUCT_URL, 'div.product-payment'))
.then(type('input[name=name]', 'Testo McTestson'))
.switchToFrame(2)
.then(type('input[name=cardnumber]', '4242 4242 4242 4242'))
.switchToParentFrame()
.end(Infinity)
.switchToFrame(3)
.then(type('input[name=exp-date]', `12${nextYear}`))
.switchToParentFrame()
.end(Infinity)
.switchToFrame(4)
.then(type('.InputElement', '123'))
.switchToParentFrame()
.end(Infinity)
.then(type('input[name=zip]', '12345'))
.then(click('input[type=checkbox]'))
.then(click('button[name=submit]'))
.then(testElementExists('.subscription-ready'));
});
module.exports = {
cleanMemory,
clearBrowserNotifications,
@ -2412,6 +2441,7 @@ module.exports = {
reOpenWithAdditionalQueryParams,
respondToWebChannelMessage,
storeWebChannelMessageData,
subscribeToTestProduct,
switchToWindow,
takeScreenshot,
testAreEventsLogged,

Просмотреть файл

@ -72,6 +72,7 @@ var pages = [
'sms/sent',
'sms/sent/why',
'sms/why',
'support',
'verify_email',
'v1/complete_reset_password',
'v1/reset_password',

Просмотреть файл

@ -0,0 +1,71 @@
/* 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/. */
'use strict';
const { registerSuite } = intern.getInterface('object');
const TestHelpers = require('../lib/helpers');
const FunctionalHelpers = require('./lib/helpers');
const config = intern._config;
const SIGNIN_URL = config.fxaContentRoot + 'signin';
const SUPPORT_URL = config.fxaContentRoot + 'support';
const PASSWORD = 'amazingpassword';
const email = TestHelpers.createEmail();
const {
clearBrowserState,
click,
createUser,
fillOutSignIn,
openPage,
subscribeToTestProduct,
testElementExists,
type,
} = FunctionalHelpers;
registerSuite('support form without valid session', {
tests: {
'go to support form, redirects to signin': function() {
return this.remote.then(openPage(SUPPORT_URL, '#fxa-signin-header'));
},
},
});
registerSuite('support form without active subscriptions', {
before: function() {
return this.remote
.then(createUser(email, PASSWORD, { preVerified: true }))
.then(clearBrowserState())
.then(openPage(SIGNIN_URL, '#fxa-signin-header'))
.then(fillOutSignIn(email, PASSWORD))
.then(testElementExists('#fxa-settings-header'));
},
tests: {
'go to support form, redirects to subscription management': function() {
return this.remote.then(
openPage(SUPPORT_URL, '.subscription-management')
);
},
},
});
registerSuite('support form', {
before: function() {
return this.remote.then(subscribeToTestProduct());
},
tests: {
'go to support form, successfully submits the form': function() {
return this.remote
.then(openPage(SUPPORT_URL, 'div.support'))
.then(click('a.chosen-single'))
.then(click('ul.chosen-results li[data-option-array-index="1"]'))
.then(type('textarea[name=message]', 'please send halp'))
.then(click('button[type=submit]'));
// Since we don't have proper Zendesk config in CircleCI, the form
// cannot be successfully submitted.
// .then(testElementExists('.subscription-management'));
},
},
});

Просмотреть файл

@ -23,6 +23,7 @@ const fxaEmailRoot = args.fxaEmailRoot || 'http://127.0.0.1:9001';
const fxaOAuthApp = args.fxaOAuthApp || 'http://127.0.0.1:8080/';
const fxaUntrustedOauthApp =
args.fxaUntrustedOauthApp || 'http://127.0.0.1:10139/';
const fxaPaymentsRoot = args.fxaPaymentsRoot || 'http://127.0.0.1:3031/';
// "fxaProduction" is a little overloaded in how it is used in the tests.
// Sometimes it means real "stage" or real production configuration, but
@ -39,6 +40,8 @@ const asyncTimeout = parseInt(args.asyncTimeout || 5000, 10);
// args.bailAfterFirstFailure comes in as a string.
const bailAfterFirstFailure = args.bailAfterFirstFailure === 'true';
const testProductId = '123doneProProduct';
// Intern specific options are here: https://theintern.io/docs.html#Intern/4/docs/docs%2Fconfiguration.md/properties
const config = {
asyncTimeout: asyncTimeout,
@ -61,6 +64,7 @@ const config = {
fxaToken: fxaToken,
fxaTokenRoot: fxaTokenRoot,
fxaUntrustedOauthApp: fxaUntrustedOauthApp,
fxaPaymentsRoot,
pageLoadTimeout: 20000,
reporters: 'runner',
@ -70,6 +74,8 @@ const config = {
tunnelOptions: {
drivers: ['firefox'],
},
testProductId,
};
if (args.grep) {

Просмотреть файл

@ -81,6 +81,7 @@ var routes = {
'/sms/sent': { statusCode: 200 },
'/sms/sent/why': { statusCode: 200 },
'/sms/why': { statusCode: 200 },
'/support': { statusCode: 200 },
// the following have a version prefix
'/v1/complete_reset_password': { statusCode: 200 },
'/v1/reset_password': { statusCode: 200 },

Просмотреть файл

@ -18,6 +18,7 @@ const webpackConfig = {
'lib/jquery',
'backbone',
'canvasToBlob',
'chosen-js',
'cocktail-lib',
'duration',
'es6-promise',
@ -59,6 +60,10 @@ const webpackConfig = {
__dirname,
'node_modules/blueimp-canvas-to-blob/js/canvas-to-blob'
),
'chosen-js': path.resolve(
__dirname,
'node_modules/chosen-js/chosen.jquery'
),
'cocktail-lib': path.resolve(
__dirname,
'node_modules/backbone.cocktail/Cocktail'

Просмотреть файл

@ -2,10 +2,10 @@ import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { useClickOutsideEffect } from '../lib/hooks';
import './DialogMessage.scss';
import Portal from './Portal';
import './DialogMessage.scss';
type DialogMessageProps = {
className?: string,
onDismiss: Function,
@ -30,4 +30,4 @@ export const DialogMessage = ({
);
};
export default DialogMessage;
export default DialogMessage;

Просмотреть файл

@ -6,6 +6,7 @@ export interface Config {
export interface QueryParams {
plan?: string,
activated?: string
successfulSupportTicketSubmission?: string
};
export interface GenericObject {

Просмотреть файл

@ -0,0 +1,90 @@
import React from 'react';
import { render, cleanup } from '@testing-library/react';
import 'jest-dom/extend-expect';
import { Subscriptions, SubscriptionsProps } from './index';
import { AppContext, AppContextType, defaultAppContext } from '../../lib/AppContext';
import { Customer, Profile, Subscription } from '../../store/types';
beforeEach(() => {
});
afterEach(cleanup);
const mockProfile: Profile = {
amrValues: [],
avatar: 'avatar',
avatarDefault: true,
displayName: null,
email: 'email',
locale: 'locale',
twoFactorAuthentication: false,
uid: 'uid',
};
const mockCustomer: Customer = {
payment_type: 'cc',
last4: '4444',
exp_month: '12',
exp_year: '22',
subscriptions: [],
};
const mockSubscription: Subscription = {
'subscriptionId': 'abc',
'cancelledAt': null,
'createdAt': Date.now(),
'productName': 'pro jest',
};
const mockPlan = {
plan_id: 'abc',
plan_name: 'abc',
product_id: 'abc',
product_name: 'abc',
currency: 'abc',
amount: 100,
interval: 'abc',
};
const mockCustomerSubscription = {
cancel_at_period_end: false,
current_period_end: 99,
current_period_start: 9,
end_at: null,
nickname: 'abc',
plan_id: 'abc',
status: 'abc',
subscription_id: 'abc',
}
const mockedSubscriptionsProps = {
profile: {error: null, loading: false, result: mockProfile},
plans: {error: null, loading: false, result: [mockPlan]},
customer: {error: null, loading: false, result: mockCustomer},
subscriptions: {error: null, loading: false, result: [mockSubscription]},
customerSubscriptions: [mockCustomerSubscription],
fetchSubscriptionsRouteResources: jest.fn(),
cancelSubscription: jest.fn(),
cancelSubscriptionStatus: {error: null, loading: false, result: mockSubscription},
resetCancelSubscription: jest.fn(),
reactivateSubscription: jest.fn(),
reactivateSubscriptionStatus: {error: null, loading: false, result: null},
resetReactivateSubscription: jest.fn(),
updatePayment: jest.fn(),
updatePaymentStatus: {error: null, loading: false, result: null},
resetUpdatePayment: jest.fn(),
};
const Subject = (props: SubscriptionsProps) => {
const appContextValue = {
...defaultAppContext,
config: {servers: {content: {url: 'http://127.0.0.1:3030'}}},
queryParams: { successfulSupportTicketSubmission: 'quux'},
};
return (
<AppContext.Provider value={appContextValue}>
<Subscriptions {...props} />
</AppContext.Provider>
)
}
it('renders successful support ticket submission messsage when query param exists', () => {
const { getByTestId } = render(<Subject {...mockedSubscriptionsProps} />);
expect(getByTestId('supportFormSuccess')).toBeInTheDocument();
});

Просмотреть файл

@ -29,9 +29,6 @@ import DialogMessage from '../../components/DialogMessage';
import SubscriptionItem from './SubscriptionItem';
import { LoadingOverlay } from '../../components/LoadingOverlay';
// TODO: From where does this URL come - "Contact Support" button destination.
const SUPPORT_PANEL_URL = 'https://support.accounts.firefox.com';
export type SubscriptionsProps = {
profile: ProfileFetchState,
plans: PlansFetchState,
@ -68,13 +65,16 @@ export const Subscriptions = ({
}: SubscriptionsProps) => {
const {
accessToken,
config,
locationReload,
navigateToUrl,
queryParams
} = useContext(AppContext);
const [showPaymentSuccessAlert, setShowPaymentSuccessAlert] = useState(true);
const clearSuccessAlert = useCallback(() => setShowPaymentSuccessAlert(false),
[setShowPaymentSuccessAlert]);
const SUPPORT_FORM_URL = `${config.servers.content.url}/support`;
// Fetch subscriptions and customer on initial render or auth change.
useEffect(() => {
@ -84,8 +84,8 @@ export const Subscriptions = ({
}, [ fetchSubscriptionsRouteResources, accessToken ]);
const onSupportClick = useCallback(
() => navigateToUrl(SUPPORT_PANEL_URL),
[ navigateToUrl ]
() => navigateToUrl(SUPPORT_FORM_URL),
[ navigateToUrl, SUPPORT_FORM_URL ]
);
if (customer.loading || subscriptions.loading || profile.loading || plans.loading) {
@ -140,7 +140,8 @@ export const Subscriptions = ({
subscription: cancelSubscriptionStatus.result,
customerSubscriptions,
plans,
resetCancelSubscription
resetCancelSubscription,
supportFormUrl: SUPPORT_FORM_URL
}} />
)}
@ -175,6 +176,15 @@ export const Subscriptions = ({
</DialogMessage>
)}
{queryParams.successfulSupportTicketSubmission && (
<AlertBar className="alert alertSuccess">
<span data-testid="supportFormSuccess">
Your support question was sent! We'll reach out to you via email as soon as possible.
</span>
</AlertBar>
)}
{profile.result && ( <ProfileBanner profile={profile.result} /> )}
<div className="child-views">
@ -268,6 +278,7 @@ type CancellationDialogMessageProps = {
customerSubscriptions: Array<CustomerSubscription>,
plans: PlansFetchState,
resetCancelSubscription: Function,
supportFormUrl: string
};
const CancellationDialogMessage = ({
@ -275,6 +286,7 @@ const CancellationDialogMessage = ({
customerSubscriptions,
plans,
resetCancelSubscription,
supportFormUrl,
}: CancellationDialogMessageProps) => {
const customerSubscription = customerSubscriptionForId(
subscription.subscriptionId,
@ -298,7 +310,7 @@ const CancellationDialogMessage = ({
You will still have until access to {plan.plan_name} until {periodEndDate}.
</p>
<p className="small">
Have questions? Visit <a href={SUPPORT_PANEL_URL}>Mozilla Support</a>.
Have questions? Visit <a href={supportFormUrl}>Mozilla Support</a>.
</p>
</DialogMessage>
);