diff --git a/ghost/admin/app/adapters/base.js b/ghost/admin/app/adapters/base.js index ac7dbec947..3225efbb59 100644 --- a/ghost/admin/app/adapters/base.js +++ b/ghost/admin/app/adapters/base.js @@ -46,7 +46,6 @@ export default RESTAdapter.extend(DataAdapterMixin, { if (status === 401) { if (this.get('session.isAuthenticated')) { this.get('session').invalidate(); - return; // prevent error from bubbling because invalidate is async } } diff --git a/ghost/admin/app/components/modals/delete-post.js b/ghost/admin/app/components/modals/delete-post.js index 8ce18b7351..903d3fddb3 100644 --- a/ghost/admin/app/components/modals/delete-post.js +++ b/ghost/admin/app/components/modals/delete-post.js @@ -29,8 +29,8 @@ export default ModalComponent.extend({ this.get('routing').transitionTo('posts'); }, - _failure() { - this.get('notifications').showAlert('Your post could not be deleted. Please try again.', {type: 'error', key: 'post.delete.failed'}); + _failure(error) { + this.get('notifications').showAPIError(error, {key: 'post.delete.failed'}); }, actions: { @@ -39,8 +39,8 @@ export default ModalComponent.extend({ this._deletePost().then(() => { this._success(); - }, () => { - this._failure(); + }, (error) => { + this._failure(error); }).finally(() => { this.send('closeModal'); }); diff --git a/ghost/admin/app/components/modals/invite-new-user.js b/ghost/admin/app/components/modals/invite-new-user.js index 61c6dc8e5f..06984c2c67 100644 --- a/ghost/admin/app/components/modals/invite-new-user.js +++ b/ghost/admin/app/components/modals/invite-new-user.js @@ -1,6 +1,6 @@ import RSVP from 'rsvp'; import injectService from 'ember-service/inject'; -import {A as emberA, isEmberArray} from 'ember-array/utils'; +import {A as emberA} from 'ember-array/utils'; import run from 'ember-runloop'; import ModalComponent from 'ghost-admin/components/modals/base'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; @@ -111,13 +111,9 @@ export default ModalComponent.extend(ValidationEngine, { } else { notifications.showNotification(notificationText, {key: 'invite.send.success'}); } - }).catch((errors) => { + }).catch((error) => { newUser.deleteRecord(); - if (isEmberArray(errors)) { - notifications.showErrors(errors, {key: 'invite.send'}); - } else { - notifications.showAPIError(errors, {key: 'invite.send'}); - } + notifications.showAPIError(error, {key: 'invite.send'}); }).finally(() => { this.send('closeModal'); }); diff --git a/ghost/admin/app/controllers/post-settings-menu.js b/ghost/admin/app/controllers/post-settings-menu.js index 8ebad097ca..9aed6a6619 100644 --- a/ghost/admin/app/controllers/post-settings-menu.js +++ b/ghost/admin/app/controllers/post-settings-menu.js @@ -185,11 +185,8 @@ export default Controller.extend(SettingsMenuMixin, { }), showErrors(errors) { - if (isVersionMismatchError(errors)) { - return this.get('notifications').showAPIError(errors); - } errors = isEmberArray(errors) ? errors : [errors]; - this.get('notifications').showErrors(errors); + this.get('notifications').showAPIError(errors); }, actions: { diff --git a/ghost/admin/app/controllers/settings/apps/slack.js b/ghost/admin/app/controllers/settings/apps/slack.js index 1ee3e18ea1..3a4c65324b 100644 --- a/ghost/admin/app/controllers/settings/apps/slack.js +++ b/ghost/admin/app/controllers/settings/apps/slack.js @@ -61,9 +61,7 @@ export default Controller.extend({ this.set('isSaving', true); return settings.save().catch((err) => { - if (err && err.isAdapterError) { - this.get('notifications').showAPIError(err); - } + this.get('notifications').showAPIError(err); throw err; }).finally(() => { this.set('isSaving', false); diff --git a/ghost/admin/app/controllers/team/user.js b/ghost/admin/app/controllers/team/user.js index aa343c95dd..24787f7b8f 100644 --- a/ghost/admin/app/controllers/team/user.js +++ b/ghost/admin/app/controllers/team/user.js @@ -12,6 +12,7 @@ import { invoke } from 'ember-invoke-action'; export default Controller.extend({ submitting: false, + updatingPassword: false, lastPromise: null, showDeleteUserModal: false, showTransferOwnerModal: false, @@ -140,13 +141,12 @@ export default Controller.extend({ this.get('notifications').closeAlerts('user.update'); return model; - }).catch((errors) => { - if (isEmberArray(errors)) { - this.get('notifications').showErrors(errors, {key: 'user.update'}); - } else { - this.get('notifications').showAPIError(errors); + }).catch((error) => { + // validation engine returns undefined so we have to check + // before treating the failure as an API error + if (error) { + this.get('notifications').showAPIError(error, {key: 'user.update'}); } - this.toggleProperty('submitting'); }); @@ -168,11 +168,13 @@ export default Controller.extend({ } }, - password() { + changePassword() { let user = this.get('user'); - if (user.get('isPasswordValid')) { - user.saveNewPassword().then((model) => { + if (!this.get('updatingPassword')) { + this.set('updatingPassword', true); + + return user.saveNewPassword().then((model) => { // Clear properties from view user.setProperties({ password: '', @@ -180,15 +182,22 @@ export default Controller.extend({ ne2Password: '' }); - this.get('notifications').showAlert('Password updated.', {type: 'success', key: 'user.change-password.success'}); + this.get('notifications').showNotification('Password updated.', {type: 'success', key: 'user.change-password.success'}); + + // clear errors manually for ne2password because validation + // engine only clears the "validated proeprty" + // TODO: clean up once we have a better validations library + user.get('errors').remove('ne2Password'); return model; - }).catch((errors) => { - this.get('notifications').showAPIError(errors, {key: 'user.change-password'}); + }).catch((error) => { + // error will be undefined if we have a validation error + if (error) { + this.get('notifications').showAPIError(error, {key: 'user.change-password'}); + } + }).finally(() => { + this.set('updatingPassword', false); }); - } else { - // TODO: switch to in-line validation - this.get('notifications').showErrors(user.get('passwordValidationErrors'), {key: 'user.change-password'}); } }, @@ -419,6 +428,26 @@ export default Controller.extend({ toggleUploadImageModal() { this.toggleProperty('showUploadImageModal'); + }, + + // TODO: remove those mutation actions once we have better + // inline validations that auto-clear errors on input + updatePassword(password) { + this.set('user.password', password); + this.get('user.hasValidated').removeObject('password'); + this.get('user.errors').remove('password'); + }, + + updateNewPassword(password) { + this.set('user.newPassword', password); + this.get('user.hasValidated').removeObject('newPassword'); + this.get('user.errors').remove('newPassword'); + }, + + updateNe2Password(password) { + this.set('user.ne2Password', password); + this.get('user.hasValidated').removeObject('ne2Password'); + this.get('user.errors').remove('ne2Password'); } } }); diff --git a/ghost/admin/app/mirage/config.js b/ghost/admin/app/mirage/config.js index 3b504a0cce..e79ff78bf8 100644 --- a/ghost/admin/app/mirage/config.js +++ b/ghost/admin/app/mirage/config.js @@ -472,12 +472,19 @@ export function testConfig() { this.put('/users/:id/', function (db, request) { let {id} = request.params; - let [attrs] = JSON.parse(request.requestBody).users; - let record = db.users.update(id, attrs); - return { - user: record - }; + if (id === 'password') { + return { + password: [{message: 'Password changed successfully.'}] + }; + } else { + let [attrs] = JSON.parse(request.requestBody).users; + let record = db.users.update(id, attrs); + + return { + user: record + }; + } }); /* External sites ------------------------------------------------------- */ diff --git a/ghost/admin/app/models/user.js b/ghost/admin/app/models/user.js index 8643abe85f..ded203b62c 100644 --- a/ghost/admin/app/models/user.js +++ b/ghost/admin/app/models/user.js @@ -1,10 +1,9 @@ /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ -import computed, {equal, empty} from 'ember-computed'; -import injectService from 'ember-service/inject'; - import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -import { hasMany } from 'ember-data/relationships'; +import {hasMany} from 'ember-data/relationships'; +import computed, {equal} from 'ember-computed'; +import injectService from 'ember-service/inject'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; export default Model.extend(ValidationEngine, { @@ -39,6 +38,7 @@ export default Model.extend(ValidationEngine, { ghostPaths: injectService(), ajax: injectService(), + session: injectService(), // TODO: Once client-side permissions are in place, // remove the hard role check. @@ -47,7 +47,9 @@ export default Model.extend(ValidationEngine, { isAdmin: equal('role.name', 'Administrator'), isOwner: equal('role.name', 'Owner'), - isPasswordValid: empty('passwordValidationErrors.[]'), + isLoggedIn: computed('id', 'session.user.id', function () { + return this.get('id') === this.get('session.user.id'); + }), active: computed('status', function () { return ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked'].indexOf(this.get('status')) > -1; @@ -59,20 +61,6 @@ export default Model.extend(ValidationEngine, { pending: equal('status', 'invited-pending'), - passwordValidationErrors: computed('password', 'newPassword', 'ne2Password', function () { - let validationErrors = []; - - if (!validator.equals(this.get('newPassword'), this.get('ne2Password'))) { - validationErrors.push({message: 'Your new passwords do not match'}); - } - - if (!validator.isLength(this.get('newPassword'), 8)) { - validationErrors.push({message: 'Your password is not long enough. It must be at least 8 characters long.'}); - } - - return validationErrors; - }), - role: computed('roles', { get() { return this.get('roles.firstObject'); @@ -88,16 +76,19 @@ export default Model.extend(ValidationEngine, { saveNewPassword() { let url = this.get('ghostPaths.url').api('users', 'password'); + let validation = this.get('isLoggedIn') ? 'ownPasswordChange' : 'passwordChange'; - return this.get('ajax').put(url, { - data: { - password: [{ - user_id: this.get('id'), - oldPassword: this.get('password'), - newPassword: this.get('newPassword'), - ne2Password: this.get('ne2Password') - }] - } + return this.validate({property: validation}).then(() => { + return this.get('ajax').put(url, { + data: { + password: [{ + user_id: this.get('id'), + oldPassword: this.get('password'), + newPassword: this.get('newPassword'), + ne2Password: this.get('ne2Password') + }] + } + }); }); }, diff --git a/ghost/admin/app/services/ajax.js b/ghost/admin/app/services/ajax.js index ccc93f819f..c5e38e2d3d 100644 --- a/ghost/admin/app/services/ajax.js +++ b/ghost/admin/app/services/ajax.js @@ -26,8 +26,22 @@ export function isVersionMismatchError(errorOrStatus, payload) { /* Request entity too large error */ +export function ServerUnreachableError(errors) { + AjaxError.call(this, errors, 'Server was unreachable'); +} + +ServerUnreachableError.prototype = Object.create(AjaxError.prototype); + +export function isServerUnreachableError(error) { + if (isAjaxError(error)) { + return error instanceof ServerUnreachableError; + } else { + return error === 0 || error === '0'; + } +} + export function RequestEntityTooLargeError(errors) { - AjaxError.call(this, errors, 'Request was rejected because it\'s larger than the maximum file size the server allows'); + AjaxError.call(this, errors, 'Request is larger than the maximum file size the server allows'); } RequestEntityTooLargeError.prototype = Object.create(AjaxError.prototype); @@ -43,7 +57,7 @@ export function isRequestEntityTooLargeError(errorOrStatus) { /* Unsupported media type error */ export function UnsupportedMediaTypeError(errors) { - AjaxError.call(this, errors, 'Request was rejected because it contains an unknown or unsupported file type.'); + AjaxError.call(this, errors, 'Request contains an unknown or unsupported file type.'); } UnsupportedMediaTypeError.prototype = Object.create(AjaxError.prototype); @@ -79,6 +93,8 @@ export default AjaxService.extend({ handleResponse(status, headers, payload) { if (this.isVersionMismatchError(status, headers, payload)) { return new VersionMismatchError(payload.errors); + } else if (this.isServerUnreachableError(status, headers, payload)) { + return new ServerUnreachableError(payload.errors); } else if (this.isRequestEntityTooLargeError(status, headers, payload)) { return new RequestEntityTooLargeError(payload.errors); } else if (this.isUnsupportedMediaTypeError(status, headers, payload)) { @@ -110,6 +126,10 @@ export default AjaxService.extend({ return isVersionMismatchError(status, payload); }, + isServerUnreachableError(status/*, headers, payload */) { + return isServerUnreachableError(status); + }, + isRequestEntityTooLargeError(status/*, headers, payload */) { return isRequestEntityTooLargeError(status); }, diff --git a/ghost/admin/app/services/notifications.js b/ghost/admin/app/services/notifications.js index 399137b875..e7078719d3 100644 --- a/ghost/admin/app/services/notifications.js +++ b/ghost/admin/app/services/notifications.js @@ -5,6 +5,8 @@ import get from 'ember-metal/get'; import set from 'ember-metal/set'; import injectService from 'ember-service/inject'; import {isVersionMismatchError} from 'ghost-admin/services/ajax'; +import {isBlank} from 'ember-utils'; +import {dasherize} from 'ember-string'; // Notification keys take the form of "noun.verb.message", eg: // @@ -82,25 +84,6 @@ export default Service.extend({ }, options.delayed); }, - // TODO: review whether this can be removed once no longer used by validations - showErrors(errors, options) { - options = options || {}; - options.type = options.type || 'error'; - // TODO: getting keys from the server would be useful here (necessary for i18n) - options.key = (options.key && `${options.key}.api-error`) || 'api-error'; - - if (!options.doNotCloseNotifications) { - this.closeNotifications(); - } - - // ensure all errors that are passed in get shown - options.doNotCloseNotifications = true; - - for (let i = 0; i < errors.length; i += 1) { - this.showNotification(errors[i].message || errors[i], options); - } - }, - showAPIError(resp, options) { // handle "global" errors if (isVersionMismatchError(resp)) { @@ -120,24 +103,26 @@ export default Service.extend({ _showAPIError(resp, options) { options = options || {}; options.type = options.type || 'error'; - // TODO: getting keys from the server would be useful here (necessary for i18n) - options.key = (options.key && `${options.key}.api-error`) || 'api-error'; - if (!options.doNotCloseNotifications) { - this.closeNotifications(); + // if possible use the title to get a unique key + // - we only show one alert for each key so if we get multiple errors + // only the last one will be shown + if (!options.key && !isBlank(get(resp, 'title'))) { + options.key = dasherize(get(resp, 'title')); + } + options.key = ['api-error', options.key].compact().join('.'); + + let msg = options.defaultErrorText || 'There was a problem on the server, please try again.'; + + if (resp instanceof String) { + msg = resp; + } else if (!isBlank(get(resp, 'detail'))) { + msg = resp.detail; + } else if (!isBlank(get(resp, 'message'))) { + msg = resp.message; } - options.defaultErrorText = options.defaultErrorText || 'There was a problem on the server, please try again.'; - - if (resp && isEmberArray(resp) && resp.length) { // Array of errors - this.showErrors(resp, options); - } else if (resp && resp.message) { - this.showAlert(resp.message, options); - } else if (resp && resp.detail) { // ember-ajax provided error message - this.showAlert(resp.detail, options); - } else { // text error or no error - this.showAlert(resp || options.defaultErrorText, options); - } + this.showAlert(msg, options); }, displayDelayed() { diff --git a/ghost/admin/app/templates/team/user.hbs b/ghost/admin/app/templates/team/user.hbs index d256a21ad4..2a4db7f341 100644 --- a/ghost/admin/app/templates/team/user.hbs +++ b/ghost/admin/app/templates/team/user.hbs @@ -171,24 +171,27 @@ {{#unless isAdminUserOnOwnerProfile}}
{{#unless isNotOwnProfile}} -
- - {{gh-input user.password type="password" id="user-password-old" update=(action (mut user.password))}} -
+ {{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="password"}} + + {{gh-input value=user.password type="password" id="user-password-old" update=(action 'updatePassword') onenter=(action "changePassword")}} + {{gh-error-message errors=user.errors property="password"}} + {{/gh-form-group}} {{/unless}} -
+ {{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="newPassword"}} - {{gh-input user.newPassword type="password" id="user-password-new" update=(action (mut user.newPassword))}} -
+ {{gh-input user.newPassword type="password" id="user-password-new" update=(action 'updateNewPassword') onenter=(action "changePassword")}} + {{gh-error-message errors=user.errors property="newPassword"}} + {{/gh-form-group}} -
+ {{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="ne2Password"}} - {{gh-input user.ne2Password type="password" id="user-new-password-verification" update=(action (mut user.ne2Password))}} -
+ {{gh-input user.ne2Password type="password" id="user-new-password-verification" update=(action 'updateNe2Password') onenter=(action "changePassword")}} + {{gh-error-message errors=user.errors property="ne2Password"}} + {{/gh-form-group}}
- + {{#gh-spin-button class="btn btn-red button-change-password" action=(action "changePassword") submitting=updatingPassword}}Change Password{{/gh-spin-button}}
{{/unless}} diff --git a/ghost/admin/app/utils/ajax.js b/ghost/admin/app/utils/ajax.js deleted file mode 100644 index bb01e4527f..0000000000 --- a/ghost/admin/app/utils/ajax.js +++ /dev/null @@ -1,47 +0,0 @@ -import {isEmberArray} from 'ember-array/utils'; - -// TODO: this should be removed and instead have our app serializer properly -// process the response so that errors can be tied to the model - -// Used in API request fail handlers to parse a standard api error -// response json for the message to display -export default function getRequestErrorMessage(request, performConcat) { - let message, - msgDetail; - - // Can't really continue without a request - if (!request) { - return null; - } - - // Seems like a sensible default - message = request.statusText; - - // If a non 200 response - if (request.status !== 200) { - try { - // Try to parse out the error, or default to 'Unknown' - if (request.errors && isEmberArray(request.errors)) { - message = request.errors.map((errorItem) => { - return errorItem.message; - }); - } else { - message = request.error || 'Unknown Error'; - } - } catch (e) { - msgDetail = request.status ? `${request.status} - ${request.statusText}` : 'Server was not available'; - message = `The server returned an error (${msgDetail}).`; - } - } - - if (performConcat && isEmberArray(message)) { - message = message.join('
'); - } - - // return an array of errors by default - if (!performConcat && typeof message === 'string') { - message = [message]; - } - - return message; -} diff --git a/ghost/admin/app/validators/user.js b/ghost/admin/app/validators/user.js index 0d89a9ec65..cc74c32297 100644 --- a/ghost/admin/app/validators/user.js +++ b/ghost/admin/app/validators/user.js @@ -1,4 +1,5 @@ import BaseValidator from './base'; +import {isBlank} from 'ember-utils'; export default BaseValidator.create({ properties: ['name', 'bio', 'email', 'location', 'website', 'roles'], @@ -77,5 +78,45 @@ export default BaseValidator.create({ this.invalidate(); } } + }, + + passwordChange(model) { + let newPassword = model.get('newPassword'); + let ne2Password = model.get('ne2Password'); + + // validation only marks the requested property as validated so we + // have to add properties manually + model.get('hasValidated').addObject('newPassword'); + model.get('hasValidated').addObject('ne2Password'); + + if (isBlank(newPassword) && isBlank(ne2Password)) { + model.get('errors').add('newPassword', 'Sorry, passwords can\'t be blank'); + this.invalidate(); + } else { + if (!validator.equals(newPassword, ne2Password)) { + model.get('errors').add('ne2Password', 'Your new passwords do not match'); + this.invalidate(); + } + + if (!validator.isLength(newPassword, 8)) { + model.get('errors').add('newPassword', 'Your password must be at least 8 characters long.'); + this.invalidate(); + } + } + }, + + ownPasswordChange(model) { + let oldPassword = model.get('password'); + + this.passwordChange(model); + + // validation only marks the requested property as validated so we + // have to add properties manually + model.get('hasValidated').addObject('password'); + + if (isBlank(oldPassword)) { + model.get('errors').add('password', 'Your current password is required to set a new one'); + this.invalidate(); + } } }); diff --git a/ghost/admin/tests/acceptance/team-test.js b/ghost/admin/tests/acceptance/team-test.js index 0946c805f2..be3df5d5b3 100644 --- a/ghost/admin/tests/acceptance/team-test.js +++ b/ghost/admin/tests/acceptance/team-test.js @@ -11,6 +11,7 @@ import destroyApp from '../helpers/destroy-app'; import { invalidateSession, authenticateSession } from 'ghost-admin/tests/helpers/ember-simple-auth'; import { errorOverride, errorReset } from 'ghost-admin/tests/helpers/adapter-error'; import Mirage from 'ember-cli-mirage'; +import $ from 'jquery'; describe('Acceptance: Team', function () { let application; @@ -61,9 +62,11 @@ describe('Acceptance: Team', function () { }); describe('when logged in', function () { + let admin; + beforeEach(function () { let role = server.create('role', {name: 'Admininstrator'}); - let user = server.create('user', {roles: [role]}); + admin = server.create('user', {roles: [role]}); server.loadFixtures(); @@ -238,7 +241,7 @@ describe('Acceptance: Team', function () { let user; beforeEach(function () { - server.create('user', { + user = server.create('user', { slug: 'test-1', name: 'Test User', facebook: 'test', @@ -456,6 +459,133 @@ describe('Acceptance: Team', function () { andThen(() => { expect(find('.user-details-bottom .form-group:nth-of-type(7)').hasClass('error'), 'bio input should be in error state').to.be.true; }); + + // password reset ------ + + // button triggers validation + click('.button-change-password'); + + andThen(() => { + expect( + find('#user-password-new').closest('.form-group').hasClass('error'), + 'new password has error class when blank' + ).to.be.true; + + expect( + find('#user-password-new').siblings('.response').text(), + 'new password error when blank' + ).to.match(/can't be blank/); + }); + + // typing in inputs clears validation + fillIn('#user-password-new', 'password'); + triggerEvent('#user-password-new', 'input'); + + andThen(() => { + expect( + find('#user-password-new').closest('.form-group').hasClass('error'), + 'password validation is visible after typing' + ).to.be.false; + }); + + // enter key triggers action + keyEvent('#user-password-new', 'keyup', 13); + + andThen(() => { + expect( + find('#user-new-password-verification').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(), + 'confirm password error when it doesn\'t match' + ).to.match(/do not match/); + }); + + // submits with correct details + fillIn('#user-new-password-verification', 'password'); + click('.button-change-password'); + + andThen(() => { + // hits the endpoint + let [lastRequest] = server.pretender.handledRequests.slice(-1); + let params = $.deparam(lastRequest.requestBody); + + expect(lastRequest.url, 'password request URL') + .to.match(/\/users\/password/); + + /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ + expect(params.password[0].user_id).to.equal(user.id.toString()); + expect(params.password[0].newPassword).to.equal('password'); + expect(params.password[0].ne2Password).to.equal('password'); + /* jscs:enable requireCamelCaseOrUpperCaseIdentifiers */ + + // clears the fields + expect( + find('#user-password-new').val(), + 'password field after submit' + ).to.be.blank; + + expect( + find('#user-new-password-verification').val(), + 'password verification field after submit' + ).to.be.blank; + + // displays a notification + expect( + find('.gh-notifications .gh-notification').length, + 'password saved notification is displayed' + ).to.equal(1); + }); + }); + }); + + describe('own user', function () { + beforeEach(function () { + server.loadFixtures(); + }); + + it('requires current password when changing password', function () { + visit(`/team/${admin.slug}`); + + // test the "old password" field is validated + click('.button-change-password'); + + andThen(() => { + // old password has error + expect( + find('#user-password-old').closest('.form-group').hasClass('error'), + 'old password has error class when blank' + ).to.be.true; + + expect( + find('#user-password-old').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'), + 'new password has error class when blank' + ).to.be.true; + + expect( + find('#user-password-new').siblings('.response').text(), + 'new password error when blank' + ).to.match(/can't be blank/); + }); + + // validation is cleared when typing + fillIn('#user-password-old', 'password'); + triggerEvent('#user-password-old', 'input'); + + andThen(() => { + expect( + find('#user-password-old').closest('.form-group').hasClass('error'), + 'old password validation is in error state after typing' + ).to.be.false; + }); }); }); diff --git a/ghost/admin/tests/unit/services/notifications-test.js b/ghost/admin/tests/unit/services/notifications-test.js index 73bad6459e..a2a5dda049 100644 --- a/ghost/admin/tests/unit/services/notifications-test.js +++ b/ghost/admin/tests/unit/services/notifications-test.js @@ -10,6 +10,7 @@ import { it } from 'ember-mocha'; import {AjaxError, InvalidError} from 'ember-ajax/errors'; +import {ServerUnreachableError} from 'ghost-admin/services/ajax'; describeModule( 'service:notifications', @@ -149,23 +150,6 @@ describeModule( expect(notifications.get('notifications.length')).to.equal(2); }); - // TODO: review whether this can be removed once it's no longer used by validations - it('#showErrors adds multiple notifications', function () { - let notifications = this.subject(); - - run(() => { - notifications.showErrors([ - {message: 'First'}, - {message: 'Second'} - ]); - }); - - expect(notifications.get('notifications')).to.deep.equal([ - {message: 'First', status: 'notification', type: 'error', key: undefined}, - {message: 'Second', status: 'notification', type: 'error', key: undefined} - ]); - }); - it('#showAPIError handles single json response error', function () { let notifications = this.subject(); let error = new AjaxError([{message: 'Single error'}]); @@ -181,24 +165,21 @@ describeModule( expect(get(alert, 'key')).to.equal('api-error'); }); - // TODO: update once we have unique api key handling it('#showAPIError handles multiple json response errors', function () { let notifications = this.subject(); let error = new AjaxError([ - {message: 'First error'}, - {message: 'Second error'} + {title: 'First error', message: 'First error message'}, + {title: 'Second error', message: 'Second error message'} ]); run(() => { notifications.showAPIError(error); }); - // First error is removed due to duplicate api-key - let alert = notifications.get('alerts.firstObject'); - expect(get(alert, 'message')).to.equal('Second error'); - expect(get(alert, 'status')).to.equal('alert'); - expect(get(alert, 'type')).to.equal('error'); - expect(get(alert, 'key')).to.equal('api-error'); + expect(notifications.get('alerts.length')).to.equal(2); + let [alert1, alert2] = notifications.get('alerts'); + expect(alert1).to.deep.equal({message: 'First error message', status: 'alert', type: 'error', key: 'api-error.first-error'}); + expect(alert2).to.deep.equal({message: 'Second error message', status: 'alert', type: 'error', key: 'api-error.second-error'}); }); it('#showAPIError displays default error text if response has no error/message', function () { @@ -228,7 +209,7 @@ describeModule( notifications.showAPIError('Test', {key: 'test.alert'}); }); - expect(notifications.get('alerts.firstObject.key')).to.equal('test.alert.api-error'); + expect(notifications.get('alerts.firstObject.key')).to.equal('api-error.test.alert'); }); it('#showAPIError sets correct key when not passed a key', function () { @@ -241,19 +222,34 @@ describeModule( expect(notifications.get('alerts.firstObject.key')).to.equal('api-error'); }); - it('#showAPIError parses errors from ember-ajax correctly', function () { + it('#showAPIError parses default ember-ajax errors correctly', function () { let notifications = this.subject(); - let error = new InvalidError([{message: 'Test Error'}]); + let error = new InvalidError(); run(() => { notifications.showAPIError(error); }); let notification = notifications.get('alerts.firstObject'); - expect(get(notification, 'message')).to.equal('Test Error'); + expect(get(notification, 'message')).to.equal('Request was rejected because it was invalid'); expect(get(notification, 'status')).to.equal('alert'); expect(get(notification, 'type')).to.equal('error'); - expect(get(notification, 'key')).to.equal('api-error'); + expect(get(notification, 'key')).to.equal('api-error.ajax-error'); + }); + + it('#showAPIError parses custom ember-ajax errors correctly', function () { + let notifications = this.subject(); + let error = new ServerUnreachableError(); + + run(() => { + notifications.showAPIError(error); + }); + + let notification = notifications.get('alerts.firstObject'); + expect(get(notification, 'message')).to.equal('Server was unreachable'); + expect(get(notification, 'status')).to.equal('alert'); + expect(get(notification, 'type')).to.equal('error'); + expect(get(notification, 'key')).to.equal('api-error.ajax-error'); }); it('#displayDelayed moves delayed notifications into content', function () {