🎨 Added confirmation dialogs when leaving screens with unsaved changes (#891)

closes TryGhost/Ghost#9119, refs TryGhost/Ghost#8483

- Apps - AMP
   - Added `leave-settings-modal` component to Settings - Apps - AMP
- Apps - Slack
   - Added `leave-settings-modal` component to Settings - Apps - Slack
   - Added a `triggerDirtyState` action that will uses a new Array with the input data to trigger the dirty state on the parent settings model
- Apps - Unsplash
   - Added `leave-settings-modal` component to Settings - Apps - Unsplash
   - Used manual tracking of changes with using a custom `dirtyAttributes` property and a `rollbackValue` to manually rollback the `isActive` attribute on the model
- Code injection
   - Added `leave-settings-modal` component to Settings - Code injection
- Design
   - Added `leave-settings-modal` component to Settings - Design (only for navigation model)
   - Used manual tracking of changes with using a custom `dirtyAttributes`
   - Added an additional `updateLabel` action to underlying `gh-navitem` component which gets fired on the `focusOut` event, to detect changes on the label
- Team - User
   - Added `leave-settings-modal` component to Team - User
   - Used manual tracking of changes with using a custom `dirtyAttributes` to track changes in slug and role properties
This commit is contained in:
Aileen Nowak 2017-10-31 22:27:25 +07:00 committed by Kevin Ansfield
parent 1e73e5930b
commit 6ef4c622ad
27 changed files with 718 additions and 178 deletions

View File

@ -42,6 +42,10 @@ export default Component.extend(ValidationState, {
this.sendAction('updateUrl', value, this.get('navItem'));
},
updateLabel(value) {
this.sendAction('updateLabel', value, this.get('navItem'));
},
clearLabelErrors() {
this.get('navItem.errors').remove('label');
},

View File

@ -9,6 +9,8 @@ export default Controller.extend({
model: alias('settings.amp'),
leaveSettingsTransition: null,
save: task(function* () {
let amp = this.get('model');
let settings = this.get('settings');
@ -17,7 +19,6 @@ export default Controller.extend({
try {
return yield settings.save();
} catch (error) {
this.get('notifications').showAPIError(error);
throw error;
@ -31,6 +32,45 @@ export default Controller.extend({
save() {
this.get('save').perform();
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('showLeaveSettingsModal')) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.get('save.isRunning')) {
return this.get('save.last').then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.get('leaveSettingsTransition');
let settings = this.get('settings');
if (!transition) {
this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
settings.rollbackAttributes();
return transition.retry();
}
}
});

View File

@ -1,5 +1,5 @@
import Controller from '@ember/controller';
import {alias} from '@ember/object/computed';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import {empty} from '@ember/object/computed';
import {isInvalidError} from 'ember-ajax/errors';
import {inject as service} from '@ember/service';
@ -11,18 +11,23 @@ export default Controller.extend({
notifications: service(),
settings: service(),
model: alias('settings.slack.firstObject'),
model: boundOneWay('settings.slack.firstObject'),
testNotificationDisabled: empty('model.url'),
leaveSettingsTransition: null,
slackArray: [],
save: task(function* () {
let slack = this.get('model');
let settings = this.get('settings');
let slackArray = this.get('slackArray');
try {
yield slack.validate();
settings.get('slack').clear().pushObject(slack);
// clear existing objects in slackArray to make sure we only push the validated one
slackArray.clear().pushObject(slack);
yield settings.set('slack', slackArray);
return yield settings.save();
} catch (error) {
if (error) {
this.get('notifications').showAPIError(error);
@ -40,7 +45,6 @@ export default Controller.extend({
yield this.get('ajax').post(slackApi);
notifications.showNotification('Check your Slack channel for the test message!', {type: 'info', key: 'slack-test.send.success'});
return true;
} catch (error) {
notifications.showAPIError(error, {key: 'slack-test:send'});
@ -58,6 +62,59 @@ export default Controller.extend({
updateURL(value) {
this.set('model.url', value);
this.get('model.errors').clear();
},
triggerDirtyState() {
let slack = this.get('model');
let slackArray = this.get('slackArray');
let settings = this.get('settings');
// Hack to trigger the `isDirty` state on the settings model by setting a new Array
// for slack rather that replacing the existing one which would still point to the
// same reference and therfore not setting the model into a dirty state
slackArray.clear().pushObject(slack);
settings.set('slack', slackArray);
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('showLeaveSettingsModal')) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.get('save.isRunning')) {
return this.get('save.last').then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.get('leaveSettingsTransition');
let settings = this.get('settings');
let slackArray = this.get('slackArray');
if (!transition) {
this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
settings.rollbackAttributes();
slackArray.clear();
return transition.retry();
}
}
});

View File

@ -8,6 +8,10 @@ export default Controller.extend({
settings: service(),
model: alias('settings.unsplash'),
dirtyAttributes: null,
rollbackValue: null,
leaveSettingsTransition: null,
save: task(function* () {
let unsplash = this.get('model');
@ -15,6 +19,8 @@ export default Controller.extend({
try {
settings.set('unsplash', unsplash);
this.set('dirtyAttributes', false);
this.set('rollbackValue', null);
return yield settings.save();
} catch (error) {
if (error) {
@ -30,7 +36,51 @@ export default Controller.extend({
},
update(value) {
if (!this.get('dirtyAttributes')) {
this.set('rollbackValue', this.get('model.isActive'));
}
this.set('model.isActive', value);
this.set('dirtyAttributes', true);
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('showLeaveSettingsModal')) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.get('save.isRunning')) {
return this.get('save.last').then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.get('leaveSettingsTransition');
if (!transition) {
this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
this.set('model.isActive', this.get('rollbackValue'));
this.set('dirtyAttributes', false);
this.set('rollbackValue', null);
return transition.retry();
}
}
});

View File

@ -19,6 +19,46 @@ export default Controller.extend({
actions: {
save() {
this.get('save').perform();
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('showLeaveSettingsModal')) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.get('save.isRunning')) {
return this.get('save.last').then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.get('leaveSettingsTransition');
let settings = this.get('model');
if (!transition) {
this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
settings.rollbackAttributes();
return transition.retry();
}
}
});

View File

@ -17,6 +17,8 @@ export default Controller.extend({
newNavItem: null,
dirtyAttributes: false,
themes: null,
themeToDelete: null,
showDeleteThemeModal: notEmpty('themeToDelete'),
@ -48,6 +50,7 @@ export default Controller.extend({
try {
yield RSVP.all(validationPromises);
this.set('dirtyAttributes', false);
return yield this.get('model').save();
} catch (error) {
if (error) {
@ -63,6 +66,7 @@ export default Controller.extend({
newNavItem.set('isNew', false);
navItems.pushObject(newNavItem);
this.set('dirtyAttributes', true);
this.set('newNavItem', NavigationItem.create({isNew: true}));
$('.gh-blognav-line:last input:first').focus();
},
@ -110,6 +114,16 @@ export default Controller.extend({
let navItems = this.get('model.navigation');
navItems.removeObject(item);
this.set('dirtyAttributes', true);
},
updateLabel(label, navItem) {
if (!navItem) {
return;
}
navItem.set('label', label);
this.set('dirtyAttributes', true);
},
updateUrl(url, navItem) {
@ -118,6 +132,47 @@ export default Controller.extend({
}
navItem.set('url', url);
this.set('dirtyAttributes', true);
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('showLeaveSettingsModal')) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.get('save.isRunning')) {
return this.get('save.last').then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.get('leaveSettingsTransition');
let model = this.get('model');
if (!transition) {
this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
model.rollbackAttributes();
this.set('dirtyAttributes', false);
return transition.retry();
},
activateTheme(theme) {

View File

@ -14,6 +14,8 @@ import {task, taskGroup} from 'ember-concurrency';
const {Handlebars} = Ember;
export default Controller.extend({
leaveSettingsTransition: null,
dirtyAttributes: false,
showDeleteUserModal: false,
showSuspendUserModal: false,
showTransferOwnerModal: false,
@ -110,7 +112,7 @@ export default Controller.extend({
saveHandlers: taskGroup().enqueue(),
updateSlug: task(function* (newSlug) {
let slug = this.get('model.slug');
let slug = this.get('user.slug');
newSlug = newSlug || slug;
newSlug = newSlug.trim();
@ -151,6 +153,8 @@ export default Controller.extend({
}
this.set('slugValue', serverSlug);
this.set('dirtyAttributes', true);
return true;
}).group('saveHandlers'),
@ -181,6 +185,7 @@ export default Controller.extend({
window.history.replaceState({path: newPath}, '', newPath);
}
this.set('dirtyAttributes', false);
this.get('notifications').closeAlerts('user.update');
return model;
@ -195,7 +200,8 @@ export default Controller.extend({
actions: {
changeRole(newRole) {
this.set('model.role', newRole);
this.get('user').set('role', newRole);
this.set('dirtyAttributes', true);
},
deleteUser() {
@ -213,7 +219,7 @@ export default Controller.extend({
},
suspendUser() {
this.get('model').set('status', 'inactive');
this.get('user').set('status', 'inactive');
return this.get('save').perform();
},
@ -224,7 +230,7 @@ export default Controller.extend({
},
unsuspendUser() {
this.get('model').set('status', 'active');
this.get('user').set('status', 'active');
return this.get('save').perform();
},
@ -286,13 +292,10 @@ export default Controller.extend({
this.get('user.errors').remove('facebook');
this.get('user.hasValidated').pushObject('facebook');
// User input is validated
this.get('save').perform().then(() => {
// necessary to update the value in the input field
this.set('user.facebook', '');
run.schedule('afterRender', this, function () {
this.set('user.facebook', newUrl);
});
// necessary to update the value in the input field
this.set('user.facebook', '');
run.schedule('afterRender', this, function () {
this.set('user.facebook', newUrl);
});
} else {
errMessage = 'The URL must be in a format like '
@ -351,13 +354,10 @@ export default Controller.extend({
this.get('user.errors').remove('twitter');
this.get('user.hasValidated').pushObject('twitter');
// User input is validated
this.get('save').perform().then(() => {
// necessary to update the value in the input field
this.set('user.twitter', '');
run.schedule('afterRender', this, function () {
this.set('user.twitter', newUrl);
});
// necessary to update the value in the input field
this.set('user.twitter', '');
run.schedule('afterRender', this, function () {
this.set('user.twitter', newUrl);
});
} else {
errMessage = 'The URL must be in a format like '
@ -398,6 +398,50 @@ export default Controller.extend({
});
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('showLeaveSettingsModal')) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.get('saveHandlers.isRunning')) {
return this.get('saveHandlers.last').then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.get('leaveSettingsTransition');
let user = this.get('user');
if (!transition) {
this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
user.rollbackAttributes();
// roll back the slugValue property
if (this.get('dirtyAttributes')) {
this.set('slugValue', user.get('slug'));
this.set('dirtyAttributes', false);
}
return transition.retry();
},
toggleTransferOwnerModal() {
if (this.get('canMakeOwner')) {
this.toggleProperty('showTransferOwnerModal');

View File

@ -9,6 +9,18 @@ export default AuthenticatedRoute.extend(styleBody, {
actions: {
save() {
this.get('controller').send('save');
},
willTransition(transition) {
let controller = this.get('controller');
let settings = controller.get('settings');
let modelIsDirty = settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
}

View File

@ -1,14 +1,33 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import styleBody from 'ghost-admin/mixins/style-body';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(styleBody, {
titleToken: 'Settings - Apps - Slack',
classNames: ['settings-view-apps-slack'],
settings: service(),
afterModel() {
return this.get('settings').reload();
},
actions: {
save() {
this.get('controller').send('save');
},
willTransition(transition) {
let controller = this.get('controller');
let settings = this.get('settings');
let modelIsDirty = settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
}
});

View File

@ -33,6 +33,17 @@ export default AuthenticatedRoute.extend(styleBody, {
actions: {
save() {
this.get('controller').send('save');
},
willTransition(transition) {
let controller = this.get('controller');
let modelIsDirty = controller.get('dirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
}
});

View File

@ -23,6 +23,18 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
actions: {
save() {
this.get('controller').send('save');
},
willTransition(transition) {
let controller = this.get('controller');
let settings = this.get('settings');
let modelIsDirty = settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
}
});

View File

@ -25,6 +25,8 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
},
setupController(controller, models) {
// reset the leave setting transition
controller.set('leaveSettingsTransition', null);
controller.set('model', models.settings);
controller.set('themes', this.get('store').peekAll('theme'));
this.get('controller').send('reset');
@ -39,11 +41,15 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
this.get('controller').send('save');
},
willTransition() {
// reset the model so that our CPs re-calc and unsaved changes aren't
// persisted across transitions
this.set('controller.model', null);
return this._super(...arguments);
willTransition(transition) {
let controller = this.get('controller');
let modelIsDirty = controller.get('dirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
},
activateTheme(theme) {

View File

@ -32,19 +32,6 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
});
},
deactivate() {
let model = this.modelFor('team.user');
// we want to revert any unsaved changes on exit
if (model && model.get('hasDirtyAttributes')) {
model.rollbackAttributes();
}
model.get('errors').clear();
this._super(...arguments);
},
actions: {
didTransition() {
this.modelFor('team.user').get('errors').clear();
@ -52,6 +39,19 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
save() {
this.get('controller.save').perform();
},
willTransition(transition) {
let controller = this.get('controller');
let user = controller.get('user');
let dirtyAttributes = controller.get('dirtyAttributes');
let modelIsDirty = user.get('hasDirtyAttributes');
if (modelIsDirty || dirtyAttributes) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
}
});

View File

@ -7,7 +7,7 @@
<div class="gh-blognav-line">
{{#gh-validation-status-container tagName="span" class="gh-blognav-label" errors=navItem.errors property="label" hasValidated=navItem.hasValidated}}
{{gh-trim-focus-input navItem.label shouldFocus=navItem.last placeholder="Label" keyPress=(action "clearLabelErrors") update=(action (mut navItem.label))}}
{{gh-trim-focus-input navItem.label shouldFocus=navItem.last placeholder="Label" keyPress=(action "clearLabelErrors") focusOut=(action "updateLabel" navItem.label) update=(action (mut navItem.label))}}
{{gh-error-message errors=navItem.errors property="label"}}
{{/gh-validation-status-container}}
{{#gh-validation-status-container tagName="span" class="gh-blognav-url" errors=navItem.errors property="url" hasValidated=navItem.hasValidated}}

View File

@ -10,6 +10,13 @@
</section>
</header>
{{#if showLeaveSettingsModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveSettings")
close=(action "toggleLeaveSettingsModal")
modifier="action wide"}}
{{/if}}
<section class="view-container">
<br>
<section class="app-grid">

View File

@ -10,6 +10,13 @@
</section>
</header>
{{#if showLeaveSettingsModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveSettings")
close=(action "toggleLeaveSettingsModal")
modifier="action wide"}}
{{/if}}
<section class="view-container">
<br>
<section class="app-grid">
@ -30,7 +37,7 @@
<div class="gh-setting-desc">Automatically send newly published posts to a channel in Slack</div>
<div class="gh-setting-content-extended">
{{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="url"}}
{{gh-input model.url name="slack[url]" update=(action "updateURL") onenter=(action "save") placeholder="https://hooks.slack.com/services/..." data-test-slack-url-input=true}}
{{gh-input model.url name="slack[url]" update=(action "updateURL") onenter=(action "save") focusOut=(action "triggerDirtyState") placeholder="https://hooks.slack.com/services/..." data-test-slack-url-input=true}}
{{#unless model.errors.url}}
<p>Set up a new incoming webhook <a href="https://my.slack.com/apps/new/A0F7XDUAZ-incoming-webhooks" target="_blank">here</a>, and grab the URL.</p>
{{else}}

View File

@ -10,6 +10,13 @@
</section>
</header>
{{#if showLeaveSettingsModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveSettings")
close=(action "toggleLeaveSettingsModal")
modifier="action wide"}}
{{/if}}
<section class="view-container">
<br>
<section class="app-grid">

View File

@ -6,6 +6,13 @@
</section>
</header>
{{#if showLeaveSettingsModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveSettings")
close=(action "toggleLeaveSettingsModal")
modifier="action wide"}}
{{/if}}
<section class="view-continer">
<form id="settings-code" novalidate="novalidate">
<fieldset>

View File

@ -6,6 +6,13 @@
</section>
</header>
{{#if showLeaveSettingsModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveSettings")
close=(action "toggleLeaveSettingsModal")
modifier="action wide"}}
{{/if}}
<section class="view-container">
<div class="gh-setting-header">Navigation</div>
<div class="gh-blognav-container">
@ -13,7 +20,7 @@
{{#sortable-objects sortableObjectList=model.navigation useSwap=false}}
{{#each model.navigation as |navItem|}}
{{#draggable-object content=navItem dragHandle=".gh-blognav-grab" isSortable=true}}
{{gh-navitem navItem=navItem baseUrl=blogUrl addItem="addNavItem" deleteItem="deleteNavItem" updateUrl="updateUrl"}}
{{gh-navitem navItem=navItem baseUrl=blogUrl addItem="addNavItem" deleteItem="deleteNavItem" updateUrl="updateUrl" updateLabel="updateLabel"}}
{{/draggable-object}}
{{/each}}
{{/sortable-objects}}

View File

@ -10,6 +10,13 @@
{{/if}}
</h2>
{{#if showLeaveSettingsModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveSettings")
close=(action "toggleLeaveSettingsModal")
modifier="action wide"}}
{{/if}}
<section class="view-actions">
{{#if userActionsAreVisible}}
<span class="dropdown">
@ -118,7 +125,7 @@
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="name" class="first-form-group"}}
<label for="user-name">Full Name</label>
{{gh-input user.name id="user-name" class="user-name" placeholder="Full Name" autocorrect="off" focusOut=(action "validate" "name" target=user) update=(action (mut user.name))}}
{{gh-input user.name id="user-name" class="user-name" placeholder="Full Name" autocorrect="off" focusOut=(action "validate" "name" target=user) update=(action (mut user.name)) data-test-name-input=true}}
{{#if user.errors.name}}
{{gh-error-message errors=user.errors property="name"}}
{{else}}
@ -128,7 +135,7 @@
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="slug"}}
<label for="user-slug">Slug</label>
{{gh-input slugValue class="user-name" id="user-slug" name="user" focusOut=(action (perform updateSlug slugValue)) placeholder="Slug" selectOnClick="true" autocorrect="off" update=(action (mut slugValue))}}
{{gh-input slugValue class="user-name" id="user-slug" name="user" focusOut=(action (perform updateSlug slugValue)) placeholder="Slug" selectOnClick="true" autocorrect="off" update=(action (mut slugValue)) data-test-slug-input=true}}
<p>{{gh-blog-url}}/author/{{slugValue}}</p>
{{gh-error-message errors=user.errors property="slug"}}
{{/gh-form-group}}
@ -137,7 +144,7 @@
<label for="user-email">Email</label>
{{!-- Administrators only see text of Owner's email address but not input --}}
{{#if canChangeEmail}}
{{gh-input user.email type="email" id="user-email" name="email" placeholder="Email Address" autocapitalize="off" autocorrect="off" autocomplete="off" focusOut=(action "validate" "email" target=user) update=(action (mut user.email))}}
{{gh-input user.email type="email" id="user-email" name="email" placeholder="Email Address" autocapitalize="off" autocorrect="off" autocomplete="off" focusOut=(action "validate" "email" target=user) update=(action (mut user.email)) data-test-email-input=true}}
{{gh-error-message errors=user.errors property="email"}}
{{else}}
<span>{{user.email}}</span>
@ -154,7 +161,7 @@
options=roles
optionValuePath="id"
optionLabelPath="name"
value=model.role
value=user.role
update=(action "changeRole")
}}
</span>
@ -164,35 +171,35 @@
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="location"}}
<label for="user-location">Location</label>
{{gh-input user.location type="text" id="user-location" focusOut=(action "validate" "location" target=user) update=(action (mut user.location))}}
{{gh-input user.location type="text" id="user-location" focusOut=(action "validate" "location" target=user) update=(action (mut user.location)) data-test-location-input=true}}
{{gh-error-message errors=user.errors property="location"}}
<p>Where in the world do you live?</p>
{{/gh-form-group}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="website"}}
<label for="user-website">Website</label>
{{gh-input user.website type="url" id="user-website" autocapitalize="off" autocorrect="off" autocomplete="off" focusOut=(action "validate" "website" target=user) update=(action (mut user.website))}}
{{gh-input user.website type="url" id="user-website" autocapitalize="off" autocorrect="off" autocomplete="off" focusOut=(action "validate" "website" target=user) update=(action (mut user.website)) data-test-website-input=true}}
{{gh-error-message errors=user.errors property="website"}}
<p>Have a website or blog other than this one? Link it!</p>
{{/gh-form-group}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="facebook"}}
<label for="user-facebook">Facebook Profile</label>
<input value={{user.facebook}} oninput={{action (mut _scratchFacebook) value="target.value"}} {{action "validateFacebookUrl" on="focusOut"}} type="url" class="gh-input" id="user-facebook" name="user[facebook]" placeholder="https://www.facebook.com/username" autocorrect="off" />
<input value={{user.facebook}} oninput={{action (mut _scratchFacebook) value="target.value"}} {{action "validateFacebookUrl" on="focusOut"}} type="url" class="gh-input" id="user-facebook" name="user[facebook]" placeholder="https://www.facebook.com/username" autocorrect="off" data-test-facebook-input=true/>
{{gh-error-message errors=user.errors property="facebook"}}
<p>URL of your personal Facebook Profile</p>
{{/gh-form-group}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="twitter"}}
<label for="user-twitter">Twitter Profile</label>
<input value={{user.twitter}} oninput={{action (mut _scratchTwitter) value="target.value"}} {{action "validateTwitterUrl" on="focusOut"}} type="url" class="gh-input" id="user-twitter" name="user[twitter]" placeholder="https://twitter.com/username" autocorrect="off" />
<input value={{user.twitter}} oninput={{action (mut _scratchTwitter) value="target.value"}} {{action "validateTwitterUrl" on="focusOut"}} type="url" class="gh-input" id="user-twitter" name="user[twitter]" placeholder="https://twitter.com/username" autocorrect="off" data-test-twitter-input=true/>
{{gh-error-message errors=user.errors property="twitter"}}
<p>URL of your personal Twitter profile</p>
{{/gh-form-group}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="bio" class="bio-container"}}
<label for="user-bio">Bio</label>
{{gh-textarea user.bio id="user-bio" focusOut=(action "validate" "bio" target=user) update=(action (mut user.bio))}}
{{gh-textarea user.bio id="user-bio" focusOut=(action "validate" "bio" target=user) update=(action (mut user.bio)) data-test-bio-input=true}}
{{gh-error-message errors=user.errors property="bio"}}
<p>
Write about you, in 200 characters or less.
@ -213,25 +220,25 @@
{{#unless isNotOwnProfile}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="password"}}
<label for="user-password-old">Old Password</label>
{{gh-input value=user.password type="password" id="user-password-old" update=(action 'updatePassword') onenter=(action (perform user.saveNewPassword))}}
{{gh-input value=user.password type="password" id="user-password-old" update=(action 'updatePassword') onenter=(action (perform user.saveNewPassword)) data-test-old-pass-input=true}}
{{gh-error-message errors=user.errors property="password"}}
{{/gh-form-group}}
{{/unless}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="newPassword"}}
<label for="user-password-new">New Password</label>
{{gh-input user.newPassword type="password" id="user-password-new" update=(action 'updateNewPassword') onenter=(action (perform user.saveNewPassword))}}
{{gh-input user.newPassword type="password" id="user-password-new" update=(action 'updateNewPassword') onenter=(action (perform user.saveNewPassword)) data-test-new-pass-input=true}}
{{gh-error-message errors=user.errors property="newPassword"}}
{{/gh-form-group}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="ne2Password"}}
<label for="user-new-password-verification">Verify Password</label>
{{gh-input user.ne2Password type="password" id="user-new-password-verification" update=(action 'updateNe2Password') onenter=(action (perform user.saveNewPassword))}}
{{gh-input user.ne2Password type="password" id="user-new-password-verification" update=(action 'updateNe2Password') onenter=(action (perform user.saveNewPassword)) data-test-ne2-pass-input=true}}
{{gh-error-message errors=user.errors property="ne2Password"}}
{{/gh-form-group}}
<div class="form-group">
{{gh-task-button "Change Password" class="gh-btn gh-btn-red gh-btn-icon button-change-password" task=user.saveNewPassword}}
{{gh-task-button "Change Password" class="gh-btn gh-btn-red gh-btn-icon button-change-password" task=user.saveNewPassword data-test-save-pw-button=true}}
</div>
</fieldset>
</form> {{! change password form }}

View File

@ -8,7 +8,7 @@ Route.reopen({
this.get('upgradeStatus').requireUpgrade();
return false;
} else {
this._super(...arguments);
return true;
}
}
}

View File

@ -93,5 +93,35 @@ describe('Acceptance: Settings - Apps - AMP', function () {
expect(find('[data-test-amp-checkbox]').prop('checked'), 'AMP checkbox').to.be.true;
expect(params.settings.findBy('key', 'amp').value).to.equal(true);
});
it('warns when leaving without saving', async function () {
await visit('/settings/apps/amp');
// has correct url
expect(currentURL(), 'currentURL').to.equal('/settings/apps/amp');
// AMP is enabled by default
expect(find('[data-test-amp-checkbox]').prop('checked'), 'AMP checkbox').to.be.true;
await click('[data-test-amp-checkbox]');
expect(find('[data-test-amp-checkbox]').prop('checked'), 'AMP checkbox').to.be.false;
await visit('/team');
expect(find('.fullscreen-modal').length, 'modal exists').to.equal(1);
// Leave without saving
await(click('.fullscreen-modal [data-test-leave-button]'), 'leave without saving');
expect(currentURL(), 'currentURL').to.equal('/team');
await visit('/settings/apps/amp');
expect(currentURL(), 'currentURL').to.equal('/settings/apps/amp');
// settings were not saved
expect(find('[data-test-amp-checkbox]').prop('checked'), 'AMP checkbox').to.be.true;
});
});
});

View File

@ -106,14 +106,24 @@ describe('Acceptance: Settings - Design', function () {
).to.equal(1);
});
it('clears unsaved settings when navigating away', async function () {
it('clears unsaved settings when navigating away but warns with a confirmation dialog', async function () {
await visit('/settings/design');
await fillIn('.gh-blognav-label:first input', 'Test');
await triggerEvent('.gh-blognav-label:first input', 'blur');
expect(find('.gh-blognav-label:first input').val()).to.equal('Test');
// this.timeout(0);
// return pauseTest();
await visit('/settings/code-injection');
expect(find('.fullscreen-modal').length, 'modal exists').to.equal(1);
// Leave without saving
await(click('.fullscreen-modal [data-test-leave-button]'), 'leave without saving');
expect(currentURL(), 'currentURL').to.equal('/settings/code-injection');
await visit('/settings/design');
expect(find('.gh-blognav-label:first input').val()).to.equal('Home');

View File

@ -59,7 +59,7 @@ describe('Acceptance: Settings - Apps - Slack', function () {
// has correct url
expect(currentURL(), 'currentURL').to.equal('/settings/apps/slack');
await fillIn('#slack-settings input[name="slack[url]"]', 'notacorrecturl');
await fillIn('[data-test-slack-url-input]', 'notacorrecturl');
await click('[data-test-save-button]');
expect(find('#slack-settings .error .response').text().trim(), 'inline validation response')
@ -81,7 +81,7 @@ describe('Acceptance: Settings - Apps - Slack', function () {
expect(find('#slack-settings .error .response').text().trim(), 'inline validation response')
.to.equal('');
await fillIn('#slack-settings input[name="slack[url]"]', 'https://hooks.slack.com/services/1275958430');
await fillIn('[data-test-slack-url-input]', 'https://hooks.slack.com/services/1275958430');
await click('[data-test-send-notification-button]');
expect(find('.gh-notification').length, 'number of notifications').to.equal(1);
@ -107,5 +107,34 @@ describe('Acceptance: Settings - Apps - Slack', function () {
expect(lastRequest.url).to.not.match(/\/slack\/test/);
expect(find('.gh-notification').length, 'check slack notification after api validation error').to.equal(0);
});
it('warns when leaving without saving', async function () {
await visit('/settings/apps/slack');
// has correct url
expect(currentURL(), 'currentURL').to.equal('/settings/apps/slack');
await fillIn('[data-test-slack-url-input]', 'https://hooks.slack.com/services/1275958430');
await triggerEvent('[data-test-slack-url-input]', 'blur');
await visit('/settings/design');
expect(find('.fullscreen-modal').length, 'modal exists').to.equal(1);
// Leave without saving
await(click('.fullscreen-modal [data-test-leave-button]'), 'leave without saving');
expect(currentURL(), 'currentURL').to.equal('/settings/design');
await visit('/settings/apps/slack');
expect(currentURL(), 'currentURL').to.equal('/settings/apps/slack');
// settings were not saved
expect(
find('[data-test-slack-url-input]').text().trim(),
'Slack Webhook URL'
).to.equal('');
});
});
});

View File

@ -92,5 +92,37 @@ describe('Acceptance: Settings - Apps - Unsplash', function () {
[setting] = server.db.settings.where({key: 'unsplash'});
expect(setting.value).to.equal('{"isActive":false}');
});
it('warns when leaving without saving', async function () {
await visit('/settings/apps/unsplash');
// has correct url
expect(currentURL(), 'currentURL').to.equal('/settings/apps/unsplash');
expect(
find('[data-test-checkbox="unsplash"]').prop('checked'),
'checked by default'
).to.be.true;
await click('[data-test-checkbox="unsplash"]');
expect(find('[data-test-checkbox="unsplash"]').prop('checked'), 'Unsplash checkbox').to.be.false;
await visit('/settings/labs');
expect(find('.fullscreen-modal').length, 'modal exists').to.equal(1);
// Leave without saving
await(click('.fullscreen-modal [data-test-leave-button]'), 'leave without saving');
expect(currentURL(), 'currentURL').to.equal('/settings/labs');
await visit('/settings/apps/unsplash');
expect(currentURL(), 'currentURL').to.equal('/settings/apps/unsplash');
// settings were not saved
expect(find('[data-test-checkbox="unsplash"]').prop('checked'), 'Unsplash checkbox').to.be.true;
});
});
});

View File

@ -465,31 +465,31 @@ describe('Acceptance: Team', function () {
await visit('/team/test-1');
expect(currentURL(), 'currentURL').to.equal('/team/test-1');
expect(find('.user-details-bottom .first-form-group input.user-name').val(), 'current user name').to.equal('Test User');
expect(find('[data-test-name-input]').val(), 'current user name').to.equal('Test User');
expect(find('[data-test-save-button]').text().trim(), 'save button text').to.equal('Save');
// test empty user name
await fillIn('.user-details-bottom .first-form-group input.user-name', '');
await triggerEvent('.user-details-bottom .first-form-group input.user-name', 'blur');
await fillIn('[data-test-name-input]', '');
await triggerEvent('[data-test-name-input]', 'blur');
expect(find('.user-details-bottom .first-form-group').hasClass('error'), 'username input is in error state with blank input').to.be.true;
// test too long user name
await fillIn('.user-details-bottom .first-form-group input.user-name', new Array(160).join('a'));
await triggerEvent('.user-details-bottom .first-form-group input.user-name', 'blur');
await fillIn('[data-test-name-input]', new Array(160).join('a'));
await triggerEvent('[data-test-name-input]', 'blur');
expect(find('.user-details-bottom .first-form-group').hasClass('error'), 'username input is in error state with too long input').to.be.true;
// reset name field
await fillIn('.user-details-bottom .first-form-group input.user-name', 'Test User');
await fillIn('[data-test-name-input]', 'Test User');
expect(find('.user-details-bottom input[name="user"]').val(), 'slug value is default').to.equal('test-1');
expect(find('[data-test-slug-input]').val(), 'slug value is default').to.equal('test-1');
await fillIn('.user-details-bottom input[name="user"]', '');
await triggerEvent('.user-details-bottom input[name="user"]', 'blur');
await fillIn('[data-test-slug-input]', '');
await triggerEvent('[data-test-slug-input]', 'blur');
expect(find('.user-details-bottom input[name="user"]').val(), 'slug value is reset to original upon empty string').to.equal('test-1');
expect(find('[data-test-slug-input]').val(), 'slug value is reset to original upon empty string').to.equal('test-1');
// Save changes
await click('[data-test-save-button]');
@ -497,7 +497,7 @@ describe('Acceptance: Team', function () {
expect(find('[data-test-save-button]').text().trim(), 'save button text').to.equal('Saved');
// CMD-S shortcut works
await fillIn('.user-details-bottom input[name="user"]', 'Test User');
await fillIn('[data-test-slug-input]', 'Test User');
await triggerEvent('.gh-app', 'keydown', {
keyCode: 83, // s
metaKey: ctrlOrCmd === 'command',
@ -511,166 +511,166 @@ describe('Acceptance: Team', function () {
expect(params.users[0].name).to.equal('Test User');
await fillIn('.user-details-bottom input[name="user"]', 'white space');
await triggerEvent('.user-details-bottom input[name="user"]', 'blur');
await fillIn('[data-test-slug-input]', 'white space');
await triggerEvent('[data-test-slug-input]', 'blur');
expect(find('.user-details-bottom input[name="user"]').val(), 'slug value is correctly dasherized').to.equal('white-space');
expect(find('[data-test-slug-input]').val(), 'slug value is correctly dasherized').to.equal('white-space');
await fillIn('.user-details-bottom input[name="email"]', 'thisisnotanemail');
await triggerEvent('.user-details-bottom input[name="email"]', 'blur');
await fillIn('[data-test-email-input]', 'thisisnotanemail');
await triggerEvent('[data-test-email-input]', 'blur');
expect(find('.user-details-bottom .form-group:nth-of-type(3)').hasClass('error'), 'email input should be in error state with invalid email').to.be.true;
await fillIn('.user-details-bottom input[name="email"]', 'test@example.com');
await fillIn('#user-location', new Array(160).join('a'));
await triggerEvent('#user-location', 'blur');
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-location-input]', new Array(160).join('a'));
await triggerEvent('[data-test-location-input]', 'blur');
expect(find('#user-location').closest('.form-group').hasClass('error'), 'location input should be in error state').to.be.true;
expect(find('[data-test-location-input]').closest('.form-group').hasClass('error'), 'location input should be in error state').to.be.true;
await fillIn('#user-location', '');
await fillIn('#user-website', 'thisisntawebsite');
await triggerEvent('#user-website', 'blur');
await fillIn('[data-test-location-input]', '');
await fillIn('[data-test-website-input]', 'thisisntawebsite');
await triggerEvent('[data-test-website-input]', 'blur');
expect(find('#user-website').closest('.form-group').hasClass('error'), 'website input should be in error state').to.be.true;
expect(find('[data-test-website-input]').closest('.form-group').hasClass('error'), 'website input should be in error state').to.be.true;
// Testing Facebook input
// displays initial value
expect(find('#user-facebook').val(), 'initial facebook value')
expect(find('[data-test-facebook-input]').val(), 'initial facebook value')
.to.equal('https://www.facebook.com/test');
await triggerEvent('#user-facebook', 'focus');
await triggerEvent('#user-facebook', 'blur');
await triggerEvent('[data-test-facebook-input]', 'focus');
await triggerEvent('[data-test-facebook-input]', 'blur');
// regression test: we still have a value after the input is
// focused and then blurred without any changes
expect(find('#user-facebook').val(), 'facebook value after blur with no change')
expect(find('[data-test-facebook-input]').val(), 'facebook value after blur with no change')
.to.equal('https://www.facebook.com/test');
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', ')(*&%^%)');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', ')(*&%^%)');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.true;
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.true;
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', 'pages/)(*&%^%)');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', 'pages/)(*&%^%)');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').val()).to.be.equal('https://www.facebook.com/pages/)(*&%^%)');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/pages/)(*&%^%)');
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', 'testing');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', 'testing');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').val()).to.be.equal('https://www.facebook.com/testing');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/testing');
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', 'somewebsite.com/pages/some-facebook-page/857469375913?ref=ts');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', 'somewebsite.com/pages/some-facebook-page/857469375913?ref=ts');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').val()).to.be.equal('https://www.facebook.com/pages/some-facebook-page/857469375913?ref=ts');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/pages/some-facebook-page/857469375913?ref=ts');
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', 'test');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', 'test');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').val()).to.be.equal('https://www.facebook.com/test');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/test');
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', 'http://twitter.com/testuser');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', 'http://twitter.com/testuser');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').val()).to.be.equal('https://www.facebook.com/testuser');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/testuser');
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
await fillIn('#user-facebook', '');
await fillIn('#user-facebook', 'facebook.com/testing');
await triggerEvent('#user-facebook', 'blur');
await fillIn('[data-test-facebook-input]', '');
await fillIn('[data-test-facebook-input]', 'facebook.com/testing');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('#user-facebook').val()).to.be.equal('https://www.facebook.com/testing');
expect(find('#user-facebook').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/testing');
expect(find('[data-test-facebook-input]').closest('.form-group').hasClass('error'), 'facebook input should be in error state').to.be.false;
// Testing Twitter input
// loads fixtures and performs transform
expect(find('#user-twitter').val(), 'initial twitter value')
expect(find('[data-test-twitter-input]').val(), 'initial twitter value')
.to.equal('https://twitter.com/test');
await triggerEvent('#user-twitter', 'focus');
await triggerEvent('#user-twitter', 'blur');
await triggerEvent('[data-test-twitter-input]', 'focus');
await triggerEvent('[data-test-twitter-input]', 'blur');
// regression test: we still have a value after the input is
// focused and then blurred without any changes
expect(find('#user-twitter').val(), 'twitter value after blur with no change')
expect(find('[data-test-twitter-input]').val(), 'twitter value after blur with no change')
.to.equal('https://twitter.com/test');
await fillIn('#user-twitter', '');
await fillIn('#user-twitter', ')(*&%^%)');
await triggerEvent('#user-twitter', 'blur');
await fillIn('[data-test-twitter-input]', '');
await fillIn('[data-test-twitter-input]', ')(*&%^%)');
await triggerEvent('[data-test-twitter-input]', 'blur');
expect(find('#user-twitter').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.true;
expect(find('[data-test-twitter-input]').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.true;
await fillIn('#user-twitter', '');
await fillIn('#user-twitter', 'name');
await triggerEvent('#user-twitter', 'blur');
await fillIn('[data-test-twitter-input]', '');
await fillIn('[data-test-twitter-input]', 'name');
await triggerEvent('[data-test-twitter-input]', 'blur');
expect(find('#user-twitter').val()).to.be.equal('https://twitter.com/name');
expect(find('#user-twitter').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.false;
expect(find('[data-test-twitter-input]').val()).to.be.equal('https://twitter.com/name');
expect(find('[data-test-twitter-input]').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.false;
await fillIn('#user-twitter', '');
await fillIn('#user-twitter', 'http://github.com/user');
await triggerEvent('#user-twitter', 'blur');
await fillIn('[data-test-twitter-input]', '');
await fillIn('[data-test-twitter-input]', 'http://github.com/user');
await triggerEvent('[data-test-twitter-input]', 'blur');
expect(find('#user-twitter').val()).to.be.equal('https://twitter.com/user');
expect(find('#user-twitter').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.false;
expect(find('[data-test-twitter-input]').val()).to.be.equal('https://twitter.com/user');
expect(find('[data-test-twitter-input]').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.false;
await fillIn('#user-twitter', '');
await fillIn('#user-twitter', 'twitter.com/user');
await triggerEvent('#user-twitter', 'blur');
await fillIn('[data-test-twitter-input]', '');
await fillIn('[data-test-twitter-input]', 'twitter.com/user');
await triggerEvent('[data-test-twitter-input]', 'blur');
expect(find('#user-twitter').val()).to.be.equal('https://twitter.com/user');
expect(find('#user-twitter').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.false;
expect(find('[data-test-twitter-input]').val()).to.be.equal('https://twitter.com/user');
expect(find('[data-test-twitter-input]').closest('.form-group').hasClass('error'), 'twitter input should be in error state').to.be.false;
await fillIn('#user-website', '');
await fillIn('#user-bio', new Array(210).join('a'));
await triggerEvent('#user-bio', 'blur');
await fillIn('[data-test-website-input]', '');
await fillIn('[data-test-bio-input]', new Array(210).join('a'));
await triggerEvent('[data-test-bio-input]', 'blur');
expect(find('#user-bio').closest('.form-group').hasClass('error'), 'bio input should be in error state').to.be.true;
expect(find('[data-test-bio-input]').closest('.form-group').hasClass('error'), 'bio input should be in error state').to.be.true;
// password reset ------
// button triggers validation
await click('.button-change-password');
await click('[data-test-save-pw-button]');
expect(
find('#user-password-new').closest('.form-group').hasClass('error'),
find('[data-test-new-pass-input]').closest('.form-group').hasClass('error'),
'new password has error class when blank'
).to.be.true;
expect(
find('#user-password-new').siblings('.response').text(),
find('[data-test-new-pass-input]').siblings('.response').text(),
'new password error when blank'
).to.match(/can't be blank/);
// validates too short password (< 10 characters)
await fillIn('#user-password-new', 'notlong');
await fillIn('#user-new-password-verification', 'notlong');
await fillIn('[data-test-new-pass-input]', 'notlong');
await fillIn('[data-test-ne2-pass-input]', 'notlong');
// enter key triggers action
await keyEvent('#user-password-new', 'keyup', 13);
await keyEvent('[data-test-new-pass-input]', 'keyup', 13);
expect(
find('#user-password-new').closest('.form-group').hasClass('error'),
find('[data-test-new-pass-input]').closest('.form-group').hasClass('error'),
'new password has error class when password too short'
).to.be.true;
expect(
find('#user-password-new').siblings('.response').text(),
find('[data-test-new-pass-input]').siblings('.response').text(),
'confirm password error when it\'s too short'
).to.match(/at least 10 characters long/);
@ -692,30 +692,30 @@ describe('Acceptance: Team', function () {
).to.match(/you cannot use an insecure password/);
// typing in inputs clears validation
await fillIn('#user-password-new', 'thisissupersafe');
await triggerEvent('#user-password-new', 'input');
await fillIn('[data-test-new-pass-input]', 'thisissupersafe');
await triggerEvent('[data-test-new-pass-input]', 'input');
expect(
find('#user-password-new').closest('.form-group').hasClass('error'),
find('[data-test-new-pass-input]').closest('.form-group').hasClass('error'),
'password validation is visible after typing'
).to.be.false;
// enter key triggers action
await keyEvent('#user-password-new', 'keyup', 13);
await keyEvent('[data-test-new-pass-input]', 'keyup', 13);
expect(
find('#user-new-password-verification').closest('.form-group').hasClass('error'),
find('[data-test-ne2-pass-input]').closest('.form-group').hasClass('error'),
'confirm password has error class when it doesn\'t match'
).to.be.true;
expect(
find('#user-new-password-verification').siblings('.response').text(),
find('[data-test-ne2-pass-input]').siblings('.response').text(),
'confirm password error when it doesn\'t match'
).to.match(/do not match/);
// submits with correct details
await fillIn('#user-new-password-verification', 'thisissupersafe');
await click('.button-change-password');
await fillIn('[data-test-ne2-pass-input]', 'thisissupersafe');
await click('[data-test-save-pw-button]');
// hits the endpoint
let [newRequest] = server.pretender.handledRequests.slice(-1);
@ -731,12 +731,12 @@ describe('Acceptance: Team', function () {
// clears the fields
expect(
find('#user-password-new').val(),
find('[data-test-new-pass-input]').val(),
'password field after submit'
).to.be.blank;
expect(
find('#user-new-password-verification').val(),
find('[data-test-ne2-pass-input]').val(),
'password verification field after submit'
).to.be.blank;
@ -746,6 +746,39 @@ describe('Acceptance: Team', function () {
'password saved notification is displayed'
).to.equal(1);
});
it('warns when leaving without saving', async function () {
await visit('/team/test-1');
expect(currentURL(), 'currentURL').to.equal('/team/test-1');
await fillIn('[data-test-slug-input]', 'another slug');
await triggerEvent('[data-test-slug-input]', 'blur');
expect(find('[data-test-slug-input]').val()).to.be.equal('another-slug');
await fillIn('[data-test-facebook-input]', 'testuser');
await triggerEvent('[data-test-facebook-input]', 'blur');
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/testuser');
await visit('/settings/team');
expect(find('.fullscreen-modal').length, 'modal exists').to.equal(1);
// Leave without saving
await(click('.fullscreen-modal [data-test-leave-button]'), 'leave without saving');
expect(currentURL(), 'currentURL').to.equal('/settings/team');
await visit('/team/test-1');
expect(currentURL(), 'currentURL').to.equal('/team/test-1');
// settings were not saved
expect(find('[data-test-slug-input]').val()).to.be.equal('test-1');
expect(find('[data-test-facebook-input]').val()).to.be.equal('https://www.facebook.com/test');
});
});
describe('own user', function () {
@ -753,36 +786,36 @@ describe('Acceptance: Team', function () {
await visit(`/team/${admin.slug}`);
// test the "old password" field is validated
await click('.button-change-password');
await click('[data-test-save-pw-button]');
// old password has error
expect(
find('#user-password-old').closest('.form-group').hasClass('error'),
find('[data-test-old-pass-input]').closest('.form-group').hasClass('error'),
'old password has error class when blank'
).to.be.true;
expect(
find('#user-password-old').siblings('.response').text(),
find('[data-test-old-pass-input]').siblings('.response').text(),
'old password error when blank'
).to.match(/is required/);
// new password has error
expect(
find('#user-password-new').closest('.form-group').hasClass('error'),
find('[data-test-new-pass-input]').closest('.form-group').hasClass('error'),
'new password has error class when blank'
).to.be.true;
expect(
find('#user-password-new').siblings('.response').text(),
find('[data-test-new-pass-input]').siblings('.response').text(),
'new password error when blank'
).to.match(/can't be blank/);
// validation is cleared when typing
await fillIn('#user-password-old', 'password');
await triggerEvent('#user-password-old', 'input');
await fillIn('[data-test-old-pass-input]', 'password');
await triggerEvent('[data-test-old-pass-input]', 'input');
expect(
find('#user-password-old').closest('.form-group').hasClass('error'),
find('[data-test-old-pass-input]').closest('.form-group').hasClass('error'),
'old password validation is in error state after typing'
).to.be.false;
});

View File

@ -80,7 +80,7 @@ describe('Integration: Component: gh-navitem', function () {
expect(addActionCallCount).to.equal(1);
});
it('triggers update action', function () {
it('triggers update url action', function () {
this.set('navItem', NavItem.create({label: 'Test', url: '/url'}));
let updateActionCallCount = 0;
@ -94,6 +94,20 @@ describe('Integration: Component: gh-navitem', function () {
expect(updateActionCallCount).to.equal(1);
});
it('triggers update label action', function () {
this.set('navItem', NavItem.create({label: 'Test', url: '/url'}));
let updateActionCallCount = 0;
this.on('update', () => {
updateActionCallCount++;
});
this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl updateLabel="update"}}`);
this.$('.gh-blognav-label input').trigger('blur');
expect(updateActionCallCount).to.equal(1);
});
it('displays inline errors', function () {
this.set('navItem', NavItem.create({label: '', url: ''}));
this.get('navItem').validate();