diff --git a/ghost/admin/app/app.js b/ghost/admin/app/app.js index 0818b24930..942d76a33a 100755 --- a/ghost/admin/app/app.js +++ b/ghost/admin/app/app.js @@ -2,6 +2,7 @@ import Ember from 'ember'; import Application from 'ember-application'; import Resolver from './resolver'; import loadInitializers from 'ember-load-initializers'; +import 'ghost-admin/utils/route'; import 'ghost-admin/utils/link-component'; import 'ghost-admin/utils/text-field'; import config from './config/environment'; diff --git a/ghost/admin/app/components/gh-file-uploader.js b/ghost/admin/app/components/gh-file-uploader.js index 45bd3f3306..0df6cf3227 100644 --- a/ghost/admin/app/components/gh-file-uploader.js +++ b/ghost/admin/app/components/gh-file-uploader.js @@ -7,6 +7,7 @@ import run from 'ember-runloop'; import { invoke, invokeAction } from 'ember-invoke-action'; import { + isVersionMismatchError, isRequestEntityTooLargeError, isUnsupportedMediaTypeError } from 'ghost-admin/services/ajax'; @@ -28,6 +29,7 @@ export default Component.extend({ uploadPercentage: 0, ajax: injectService(), + notifications: injectService(), formData: computed('file', function () { let paramName = this.get('paramName'); @@ -128,6 +130,10 @@ export default Component.extend({ _uploadFailed(error) { let message; + if (isVersionMismatchError(error)) { + this.get('notifications').showAPIError(error); + } + if (isUnsupportedMediaTypeError(error)) { message = 'The file type you uploaded is not supported.'; } else if (isRequestEntityTooLargeError(error)) { diff --git a/ghost/admin/app/components/gh-image-uploader.js b/ghost/admin/app/components/gh-image-uploader.js index 51c322d867..86b3b27161 100644 --- a/ghost/admin/app/components/gh-image-uploader.js +++ b/ghost/admin/app/components/gh-image-uploader.js @@ -5,10 +5,12 @@ import {htmlSafe} from 'ember-string'; import {isBlank} from 'ember-utils'; import run from 'ember-runloop'; +import {invokeAction} from 'ember-invoke-action'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; import { isRequestEntityTooLargeError, - isUnsupportedMediaTypeError + isUnsupportedMediaTypeError, + isVersionMismatchError } from 'ghost-admin/services/ajax'; export default Component.extend({ @@ -29,6 +31,7 @@ export default Component.extend({ ajax: injectService(), config: injectService(), + notifications: injectService(), // TODO: this wouldn't be necessary if the server could accept direct // file uploads @@ -114,13 +117,11 @@ export default Component.extend({ } }, - uploadStarted() { - if (typeof this.attrs.uploadStarted === 'function') { - this.attrs.uploadStarted(); - } + _uploadStarted() { + invokeAction(this, 'uploadStarted'); }, - uploadProgress(event) { + _uploadProgress(event) { if (event.lengthComputable) { run(() => { let percentage = Math.round((event.loaded / event.total) * 100); @@ -129,21 +130,24 @@ export default Component.extend({ } }, - uploadFinished() { - if (typeof this.attrs.uploadFinished === 'function') { - this.attrs.uploadFinished(); - } + _uploadFinished() { + invokeAction(this, 'uploadFinished'); }, - uploadSuccess(response) { + _uploadSuccess(response) { this.set('url', response); this.send('saveUrl'); this.send('reset'); + invokeAction(this, 'uploadSuccess', response); }, - uploadFailed(error) { + _uploadFailed(error) { let message; + if (isVersionMismatchError(error)) { + this.get('notifications').showAPIError(error); + } + if (isUnsupportedMediaTypeError(error)) { message = 'The image type you uploaded is not supported. Please use .PNG, .JPG, .GIF, .SVG.'; } else if (isRequestEntityTooLargeError(error)) { @@ -155,6 +159,7 @@ export default Component.extend({ } this.set('failureMessage', message); + invokeAction(this, 'uploadFailed', error); }, generateRequest() { @@ -162,7 +167,7 @@ export default Component.extend({ let formData = this.get('formData'); let url = `${ghostPaths().apiRoot}/uploads/`; - this.uploadStarted(); + this._uploadStarted(); ajax.post(url, { data: formData, @@ -173,18 +178,18 @@ export default Component.extend({ let xhr = new window.XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { - this.uploadProgress(event); + this._uploadProgress(event); }, false); return xhr; } }).then((response) => { let url = JSON.parse(response); - this.uploadSuccess(url); + this._uploadSuccess(url); }).catch((error) => { - this.uploadFailed(error); + this._uploadFailed(error); }).finally(() => { - this.uploadFinished(); + this._uploadFinished(); }); }, @@ -198,10 +203,7 @@ export default Component.extend({ onInput(url) { this.set('url', url); - - if (typeof this.attrs.onInput === 'function') { - this.attrs.onInput(url); - } + invokeAction(this, 'onInput', url); }, reset() { @@ -212,16 +214,14 @@ export default Component.extend({ switchForm(formType) { this.set('formType', formType); - if (typeof this.attrs.formChanged === 'function') { - run.scheduleOnce('afterRender', this, function () { - this.attrs.formChanged(formType); - }); - } + run.scheduleOnce('afterRender', this, function () { + invokeAction(this, 'formChanged', formType); + }); }, saveUrl() { let url = this.get('url'); - this.attrs.update(url); + invokeAction(this, 'update', url); } } }); diff --git a/ghost/admin/app/components/gh-search-input.js b/ghost/admin/app/components/gh-search-input.js index 7a4164d6c1..a634294f67 100644 --- a/ghost/admin/app/components/gh-search-input.js +++ b/ghost/admin/app/components/gh-search-input.js @@ -39,6 +39,7 @@ export default Component.extend({ _store: injectService('store'), _routing: injectService('-routing'), ajax: injectService(), + notifications: injectService(), refreshContent() { let promises = []; @@ -91,7 +92,6 @@ export default Component.extend({ let content = this.get('content'); return this.get('ajax').request(postsUrl, {data: postsQuery}).then((posts) => { - content.pushObjects(posts.posts.map((post) => { return { id: `post.${post.id}`, @@ -99,6 +99,8 @@ export default Component.extend({ category: post.page ? 'Pages' : 'Posts' }; })); + }).catch((error) => { + this.get('notifications').showAPIError(error, {key: 'search.loadPosts.error'}); }); }, @@ -116,6 +118,8 @@ export default Component.extend({ category: 'Users' }; })); + }).catch((error) => { + this.get('notifications').showAPIError(error, {key: 'search.loadUsers.error'}); }); }, @@ -133,6 +137,8 @@ export default Component.extend({ category: 'Tags' }; })); + }).catch((error) => { + this.get('notifications').showAPIError(error, {key: 'search.loadTags.error'}); }); }, diff --git a/ghost/admin/app/components/modals/invite-new-user.js b/ghost/admin/app/components/modals/invite-new-user.js index a32f413f7b..61c6dc8e5f 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} from 'ember-array/utils'; +import {A as emberA, isEmberArray} 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'; @@ -113,7 +113,11 @@ export default ModalComponent.extend(ValidationEngine, { } }).catch((errors) => { newUser.deleteRecord(); - notifications.showErrors(errors, {key: 'invite.send'}); + if (isEmberArray(errors)) { + notifications.showErrors(errors, {key: 'invite.send'}); + } else { + notifications.showAPIError(errors, {key: 'invite.send'}); + } }).finally(() => { this.send('closeModal'); }); diff --git a/ghost/admin/app/components/modals/new-subscriber.js b/ghost/admin/app/components/modals/new-subscriber.js index f4870c2ad5..a371c0968c 100644 --- a/ghost/admin/app/components/modals/new-subscriber.js +++ b/ghost/admin/app/components/modals/new-subscriber.js @@ -16,12 +16,26 @@ export default ModalComponent.extend({ confirmAction().then(() => { this.send('closeModal'); - }).catch((errors) => { - let [error] = errors; - if (error && error.match(/email/i)) { - this.get('model.errors').add('email', error); - this.get('model.hasValidated').pushObject('email'); + }).catch((error) => { + // TODO: server-side validation errors should be serialized + // properly so that errors are added to the model's errors + // property + if (error && error.isAdapterError) { + let [firstError] = error.errors; + let {message, errorType} = firstError; + + if (errorType === 'ValidationError') { + if (message && message.match(/email/i)) { + this.get('model.errors').add('email', message); + this.get('model.hasValidated').pushObject('email'); + return; + } + } } + + // this is a route action so it should bubble up to the global + // error handler + throw error; }).finally(() => { if (!this.get('isDestroying') && !this.get('isDestroyed')) { this.set('submitting', false); diff --git a/ghost/admin/app/components/modals/re-authenticate.js b/ghost/admin/app/components/modals/re-authenticate.js index 2124278177..51d91ddb24 100644 --- a/ghost/admin/app/components/modals/re-authenticate.js +++ b/ghost/admin/app/components/modals/re-authenticate.js @@ -4,6 +4,7 @@ import injectService from 'ember-service/inject'; import {htmlSafe} from 'ember-string'; import ModalComponent from 'ghost-admin/components/modals/base'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; +import {isVersionMismatchError} from 'ghost-admin/services/ajax'; export default ModalComponent.extend(ValidationEngine, { validationType: 'signin', @@ -49,6 +50,9 @@ export default ModalComponent.extend(ValidationEngine, { }).catch((error) => { if (error && error.errors) { error.errors.forEach((err) => { + if (isVersionMismatchError(err)) { + return this.get('notifications').showAPIError(error); + } err.message = htmlSafe(err.message); }); diff --git a/ghost/admin/app/controllers/post-settings-menu.js b/ghost/admin/app/controllers/post-settings-menu.js index 08eb33f9d1..8ebad097ca 100644 --- a/ghost/admin/app/controllers/post-settings-menu.js +++ b/ghost/admin/app/controllers/post-settings-menu.js @@ -16,6 +16,7 @@ import {parseDateString} from 'ghost-admin/utils/date-formatting'; import SettingsMenuMixin from 'ghost-admin/mixins/settings-menu-controller'; import boundOneWay from 'ghost-admin/utils/bound-one-way'; import isNumber from 'ghost-admin/utils/isNumber'; +import {isVersionMismatchError} from 'ghost-admin/services/ajax'; const {ArrayProxy, Handlebars, PromiseProxyMixin} = Ember; @@ -74,10 +75,13 @@ export default Controller.extend(SettingsMenuMixin, { if (!isBlank(slug)) { this.set(destination, slug); } - }).catch(() => { + }).catch((error) => { // Nothing to do (would be nice to log this somewhere though), // but a rejected promise needs to be handled here so that a resolved // promise is returned. + if (isVersionMismatchError(error)) { + this.get('notifications').showAPIError(error); + } }); }); @@ -181,6 +185,9 @@ 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); }, diff --git a/ghost/admin/app/controllers/settings/apps/slack.js b/ghost/admin/app/controllers/settings/apps/slack.js index fe8d678189..1ee3e18ea1 100644 --- a/ghost/admin/app/controllers/settings/apps/slack.js +++ b/ghost/admin/app/controllers/settings/apps/slack.js @@ -33,6 +33,7 @@ export default Controller.extend({ notifications.showAlert('Check your slack channel test message.', {type: 'info', key: 'slack-test.send.success'}); }).catch((error) => { notifications.showAPIError(error, {key: 'slack-test:send'}); + throw error; }); }).catch(() => { // noop - error already handled in .save @@ -60,7 +61,9 @@ export default Controller.extend({ this.set('isSaving', true); return settings.save().catch((err) => { - this.get('notifications').showErrors(err); + if (err && err.isAdapterError) { + this.get('notifications').showAPIError(err); + } throw err; }).finally(() => { this.set('isSaving', false); diff --git a/ghost/admin/app/controllers/settings/navigation.js b/ghost/admin/app/controllers/settings/navigation.js index 24064abf4b..d1df5d3337 100644 --- a/ghost/admin/app/controllers/settings/navigation.js +++ b/ghost/admin/app/controllers/settings/navigation.js @@ -38,7 +38,7 @@ export default Controller.extend(SettingsSaveMixin, { return RSVP.all(validationPromises).then(() => { return this.get('model').save().catch((err) => { - notifications.showErrors(err); + notifications.showAPIError(err); }); }).catch(() => { // TODO: noop - needed to satisfy spinner button diff --git a/ghost/admin/app/controllers/signin.js b/ghost/admin/app/controllers/signin.js index cd9d3b73ca..b2932d95b4 100644 --- a/ghost/admin/app/controllers/signin.js +++ b/ghost/admin/app/controllers/signin.js @@ -4,6 +4,10 @@ import injectService from 'ember-service/inject'; import injectController from 'ember-controller/inject'; import {isEmberArray} from 'ember-array/utils'; +import { + VersionMismatchError, + isVersionMismatchError +} from 'ghost-admin/services/ajax'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; export default Controller.extend(ValidationEngine, { @@ -31,6 +35,14 @@ export default Controller.extend(ValidationEngine, { this.toggleProperty('loggingIn'); if (error && error.errors) { + // we don't get back an ember-data/ember-ajax error object + // back so we need to pass in a null status in order to + // test against the payload + if (isVersionMismatchError(null, error)) { + let versionMismatchError = new VersionMismatchError(error); + return this.get('notifications').showAPIError(versionMismatchError); + } + error.errors.forEach((err) => { err.message = err.message.htmlSafe(); }); @@ -62,12 +74,8 @@ export default Controller.extend(ValidationEngine, { this.validate({property: 'signin'}).then(() => { this.toggleProperty('loggingIn'); this.send('authenticate'); - }).catch((error) => { - if (error) { - this.get('notifications').showAPIError(error, {key: 'signin.authenticate'}); - } else { - this.set('flowErrors', 'Please fill out the form to sign in.'); - } + }).catch(() => { + this.set('flowErrors', 'Please fill out the form to sign in.'); }); }, @@ -89,11 +97,15 @@ export default Controller.extend(ValidationEngine, { }).then(() => { this.toggleProperty('submitting'); notifications.showAlert('Please check your email for instructions.', {type: 'info', key: 'forgot-password.send.success'}); - }).catch((resp) => { + }).catch((error) => { this.toggleProperty('submitting'); - if (resp && resp.errors && isEmberArray(resp.errors)) { - let [{message}] = resp.errors; + if (isVersionMismatchError(error)) { + return notifications.showAPIError(error); + } + + if (error && error.errors && isEmberArray(error.errors)) { + let [{message}] = error.errors; this.set('flowErrors', message); @@ -101,7 +113,7 @@ export default Controller.extend(ValidationEngine, { this.get('model.errors').add('identification', ''); } } else { - notifications.showAPIError(resp, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'}); + notifications.showAPIError(error, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'}); } }); }).catch(() => { diff --git a/ghost/admin/app/controllers/signup.js b/ghost/admin/app/controllers/signup.js index c275690dde..ad59d4392e 100644 --- a/ghost/admin/app/controllers/signup.js +++ b/ghost/admin/app/controllers/signup.js @@ -4,6 +4,7 @@ import injectService from 'ember-service/inject'; import {isEmberArray} from 'ember-array/utils'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; +import {isVersionMismatchError} from 'ghost-admin/services/ajax'; const {Promise} = RSVP; @@ -74,13 +75,16 @@ export default Controller.extend(ValidationEngine, { }).catch((resp) => { notifications.showAPIError(resp, {key: 'signup.complete'}); }); - }).catch((resp) => { + }).catch((error) => { this.toggleProperty('submitting'); - if (resp && resp.errors && isEmberArray(resp.errors)) { - this.set('flowErrors', resp.errors[0].message); + if (error && error.errors && isEmberArray(error.errors)) { + if (isVersionMismatchError(error)) { + notifications.showAPIError(error); + } + this.set('flowErrors', error.errors[0].message); } else { - notifications.showAPIError(resp, {key: 'signup.complete'}); + notifications.showAPIError(error, {key: 'signup.complete'}); } }); }).catch(() => { diff --git a/ghost/admin/app/controllers/team/user.js b/ghost/admin/app/controllers/team/user.js index 4054406680..aa343c95dd 100644 --- a/ghost/admin/app/controllers/team/user.js +++ b/ghost/admin/app/controllers/team/user.js @@ -141,8 +141,10 @@ export default Controller.extend({ return model; }).catch((errors) => { - if (errors) { + if (isEmberArray(errors)) { this.get('notifications').showErrors(errors, {key: 'user.update'}); + } else { + this.get('notifications').showAPIError(errors); } this.toggleProperty('submitting'); diff --git a/ghost/admin/app/initializers/upgrade-status.js b/ghost/admin/app/initializers/upgrade-status.js new file mode 100644 index 0000000000..1baa0ca92b --- /dev/null +++ b/ghost/admin/app/initializers/upgrade-status.js @@ -0,0 +1,8 @@ +export function initialize(application) { + application.inject('route', 'upgradeStatus', 'service:upgrade-status'); +} + +export default { + name: 'upgrade-status', + initialize +}; diff --git a/ghost/admin/app/mirage/config.js b/ghost/admin/app/mirage/config.js index 27045d871c..3b504a0cce 100644 --- a/ghost/admin/app/mirage/config.js +++ b/ghost/admin/app/mirage/config.js @@ -8,6 +8,16 @@ const { String: {dasherize} } = Ember; +/* jshint unused:false */ +function versionMismatchResponse() { + return new Mirage.Response(400, {}, { + errors: [{ + errorType: 'VersionMismatchError' + }] + }); +} +/* jshint unused:true */ + function paginatedResponse(modelName, allModels, request) { let page = +request.queryParams.page || 1; let limit = request.queryParams.limit || 15; @@ -122,7 +132,7 @@ export default function () { this.timing = 400; // delay for each request, automatically set to 0 during testing // Mock endpoints here to override real API requests during development - mockSubscribers(this); + // this.put('/posts/:id/', versionMismatchResponse); // keep this line, it allows all other API requests to hit the real server this.passthrough(); @@ -211,13 +221,20 @@ export function testConfig() { return response; }); - this.get('/posts/:id', function (db, request) { + this.get('/posts/:id/', function (db, request) { let {id} = request.params; let post = db.posts.find(id); - return { - posts: [post] - }; + if (!post) { + return new Mirage.Response(404, {}, { + errors: [{ + errorType: 'NotFoundError', + message: 'Post not found.' + }] + }); + } else { + return {posts: [post]}; + } }); this.put('/posts/:id/', function (db, request) { diff --git a/ghost/admin/app/mixins/404-handler.js b/ghost/admin/app/mixins/404-handler.js deleted file mode 100644 index a0977a1c80..0000000000 --- a/ghost/admin/app/mixins/404-handler.js +++ /dev/null @@ -1,23 +0,0 @@ -import Mixin from 'ember-metal/mixin'; - -export default Mixin.create({ - actions: { - error(error, transition) { - if (error.errors && error.errors[0].errorType === 'NotFoundError') { - transition.abort(); - - let routeInfo = transition.handlerInfos[transition.handlerInfos.length - 1]; - let router = this.get('router'); - let params = []; - - for (let key of Object.keys(routeInfo.params)) { - params.push(routeInfo.params[key]); - } - - return this.transitionTo('error404', router.generate(routeInfo.name, ...params).replace('/ghost/', '').replace(/^\//g, '')); - } - - return this._super(...arguments); - } - } -}); diff --git a/ghost/admin/app/mixins/editor-base-controller.js b/ghost/admin/app/mixins/editor-base-controller.js index e169663f30..cce49fc644 100644 --- a/ghost/admin/app/mixins/editor-base-controller.js +++ b/ghost/admin/app/mixins/editor-base-controller.js @@ -403,10 +403,19 @@ export default Mixin.create({ } return model; }); - }).catch((errors) => { + }).catch((error) => { + // re-throw if we have a general server error + // TODO: use isValidationError(error) once we have + // ember-ajax/ember-data integration + if (error && error.errors && error.errors[0].errorType !== 'ValidationError') { + this.toggleProperty('submitting'); + this.send('error', error); + return; + } + if (!options.silent) { - errors = errors || this.get('model.errors.messages'); - this.showErrorAlert(prevStatus, this.get('model.status'), errors); + error = error || this.get('model.errors.messages'); + this.showErrorAlert(prevStatus, this.get('model.status'), error); } this.set('model.status', prevStatus); diff --git a/ghost/admin/app/mixins/editor-base-route.js b/ghost/admin/app/mixins/editor-base-route.js index c514f158d9..94865e6536 100644 --- a/ghost/admin/app/mixins/editor-base-route.js +++ b/ghost/admin/app/mixins/editor-base-route.js @@ -49,6 +49,10 @@ export default Mixin.create(styleBody, ShortcutsRoute, { let deletedWithoutChanges, fromNewToEdit; + if (this.get('upgradeStatus.isRequired')) { + return this._super(...arguments); + } + // if a save is in-flight we don't know whether or not it's safe to leave // so we abort the transition and retry after the save has completed. if (state.isSaving) { diff --git a/ghost/admin/app/mixins/pagination.js b/ghost/admin/app/mixins/pagination.js index df412efc61..f801d03b68 100644 --- a/ghost/admin/app/mixins/pagination.js +++ b/ghost/admin/app/mixins/pagination.js @@ -4,8 +4,6 @@ import computed from 'ember-computed'; import RSVP from 'rsvp'; import injectService from 'ember-service/inject'; -import getRequestErrorMessage from 'ghost-admin/utils/ajax'; - let defaultPaginationSettings = { page: 1, limit: 15 @@ -40,25 +38,11 @@ export default Mixin.create({ this.set('paginationMeta', {}); }, - /** - * Takes an ajax response, concatenates any error messages, then generates an error notification. - * @param {jqXHR} response The jQuery ajax reponse object. - * @return - */ - reportLoadError(response) { - let message = 'A problem was encountered while loading more records'; - - if (response) { - // Get message from response - message += `: ${getRequestErrorMessage(response, true)}`; - } else { - message += '.'; - } - - this.get('notifications').showAlert(message, {type: 'error', key: 'pagination.load.failed'}); + reportLoadError(error) { + this.get('notifications').showAPIError(error, {key: 'pagination.load.failed'}); }, - loadFirstPage() { + loadFirstPage(transition) { let paginationSettings = this.get('paginationSettings'); let modelName = this.get('paginationModel'); @@ -69,8 +53,14 @@ export default Mixin.create({ return this.get('store').query(modelName, paginationSettings).then((results) => { this.set('paginationMeta', results.meta); return results; - }).catch((response) => { - this.reportLoadError(response); + }).catch((error) => { + // if we have a transition we're executing in a route hook so we + // want to throw in order to trigger the global error handler + if (transition) { + throw error; + } else { + this.reportLoadError(error); + } }).finally(() => { this.set('isLoading', false); }); @@ -99,8 +89,8 @@ export default Mixin.create({ return store.query(modelName, paginationSettings).then((results) => { this.set('paginationMeta', results.meta); return results; - }).catch((response) => { - this.reportLoadError(response); + }).catch((error) => { + this.reportLoadError(error); }).finally(() => { this.set('isLoading', false); }); diff --git a/ghost/admin/app/mixins/validation-engine.js b/ghost/admin/app/mixins/validation-engine.js index 4f4b01405b..8dee7f9c85 100644 --- a/ghost/admin/app/mixins/validation-engine.js +++ b/ghost/admin/app/mixins/validation-engine.js @@ -3,7 +3,6 @@ import RSVP from 'rsvp'; import {A as emberA, isEmberArray} from 'ember-array/utils'; import DS from 'ember-data'; import Model from 'ember-data/model'; -import getRequestErrorMessage from 'ghost-admin/utils/ajax'; import InviteUserValidator from 'ghost-admin/validators/invite-user'; import NavItemValidator from 'ghost-admin/validators/nav-item'; @@ -146,8 +145,7 @@ export default Mixin.create({ }).catch((result) => { // server save failed or validator type doesn't exist if (result && !isEmberArray(result)) { - // return the array of errors from the server - result = getRequestErrorMessage(result); + throw result; } return RSVP.reject(result); diff --git a/ghost/admin/app/routes/application.js b/ghost/admin/app/routes/application.js index f261d1197e..f433143137 100644 --- a/ghost/admin/app/routes/application.js +++ b/ghost/admin/app/routes/application.js @@ -2,6 +2,7 @@ import Route from 'ember-route'; import {htmlSafe} from 'ember-string'; import injectService from 'ember-service/inject'; import run from 'ember-runloop'; +import {isEmberArray} from 'ember-array/utils'; import AuthConfiguration from 'ember-simple-auth/configuration'; import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin'; @@ -146,6 +147,46 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { }, // noop default for unhandled save (used from shortcuts) - save: K + save: K, + + error(error, transition) { + if (error && isEmberArray(error.errors)) { + switch (error.errors[0].errorType) { + + case 'NotFoundError': + if (transition) { + transition.abort(); + } + + let routeInfo = transition.handlerInfos[transition.handlerInfos.length - 1]; + let router = this.get('router'); + let params = []; + + for (let key of Object.keys(routeInfo.params)) { + params.push(routeInfo.params[key]); + } + + return this.transitionTo('error404', router.generate(routeInfo.name, ...params).replace('/ghost/', '').replace(/^\//g, '')); + + case 'VersionMismatchError': + if (transition) { + transition.abort(); + } + + this.get('upgradeStatus').requireUpgrade(); + return false; + + default: + this.get('notifications').showAPIError(error); + // don't show the 500 page if we weren't navigating + if (!transition) { + return false; + } + } + } + + // fallback to 500 error page + return true; + } } }); diff --git a/ghost/admin/app/routes/editor/edit.js b/ghost/admin/app/routes/editor/edit.js index fb89ab41c0..a3a1108ea6 100644 --- a/ghost/admin/app/routes/editor/edit.js +++ b/ghost/admin/app/routes/editor/edit.js @@ -1,11 +1,10 @@ /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import base from 'ghost-admin/mixins/editor-base-route'; -import NotFoundHandler from 'ghost-admin/mixins/404-handler'; import isNumber from 'ghost-admin/utils/isNumber'; import isFinite from 'ghost-admin/utils/isFinite'; -export default AuthenticatedRoute.extend(base, NotFoundHandler, { +export default AuthenticatedRoute.extend(base, { titleToken: 'Editor', beforeModel(transition) { diff --git a/ghost/admin/app/routes/posts/post.js b/ghost/admin/app/routes/posts/post.js index b39a643d3d..33646c8883 100644 --- a/ghost/admin/app/routes/posts/post.js +++ b/ghost/admin/app/routes/posts/post.js @@ -1,10 +1,9 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; -import NotFoundHandler from 'ghost-admin/mixins/404-handler'; import isNumber from 'ghost-admin/utils/isNumber'; import isFinite from 'ghost-admin/utils/isFinite'; -export default AuthenticatedRoute.extend(ShortcutsRoute, NotFoundHandler, { +export default AuthenticatedRoute.extend(ShortcutsRoute, { model(params) { let post, postId, diff --git a/ghost/admin/app/routes/settings/tags.js b/ghost/admin/app/routes/settings/tags.js index 6c7944ef65..9c241aab4c 100644 --- a/ghost/admin/app/routes/settings/tags.js +++ b/ghost/admin/app/routes/settings/tags.js @@ -29,8 +29,8 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, PaginationMixin, S .then(this.transitionAuthor()); }, - model() { - return this.loadFirstPage().then(() => { + model(params, transition) { + return this.loadFirstPage(transition).then(() => { return this.store.filter('tag', (tag) => { return !tag.get('isNew'); }); diff --git a/ghost/admin/app/routes/settings/tags/tag.js b/ghost/admin/app/routes/settings/tags/tag.js index 628be8d08a..08097f83f7 100644 --- a/ghost/admin/app/routes/settings/tags/tag.js +++ b/ghost/admin/app/routes/settings/tags/tag.js @@ -1,8 +1,7 @@ /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import NotFoundHandler from 'ghost-admin/mixins/404-handler'; -export default AuthenticatedRoute.extend(NotFoundHandler, { +export default AuthenticatedRoute.extend({ model(params) { return this.store.queryRecord('tag', {slug: params.tag_slug}); diff --git a/ghost/admin/app/routes/setup/one.js b/ghost/admin/app/routes/setup/one.js index fe19ab92f6..8f6a1a5d30 100644 --- a/ghost/admin/app/routes/setup/one.js +++ b/ghost/admin/app/routes/setup/one.js @@ -15,6 +15,7 @@ let DownloadCountPoller = EmberObject.extend({ runId: null, ajax: AjaxService.create(), + notifications: injectService(), init() { this._super(...arguments); @@ -44,8 +45,9 @@ let DownloadCountPoller = EmberObject.extend({ } this.set('count', count); - }).catch(() => { + }).catch((error) => { this.set('count', ''); + this.get('notifications').showAPIError(error); }); } }); diff --git a/ghost/admin/app/routes/team/user.js b/ghost/admin/app/routes/team/user.js index 12fddeadeb..5e68d4b3e8 100644 --- a/ghost/admin/app/routes/team/user.js +++ b/ghost/admin/app/routes/team/user.js @@ -2,9 +2,8 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; import styleBody from 'ghost-admin/mixins/style-body'; -import NotFoundHandler from 'ghost-admin/mixins/404-handler'; -export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, NotFoundHandler, { +export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, { titleToken: 'Team - User', classNames: ['team-view-user'], diff --git a/ghost/admin/app/services/ajax.js b/ghost/admin/app/services/ajax.js index 6b392e224f..ccc93f819f 100644 --- a/ghost/admin/app/services/ajax.js +++ b/ghost/admin/app/services/ajax.js @@ -1,3 +1,4 @@ +import get from 'ember-metal/get'; import computed from 'ember-computed'; import injectService from 'ember-service/inject'; import {isEmberArray} from 'ember-array/utils'; @@ -5,34 +6,58 @@ import AjaxService from 'ember-ajax/services/ajax'; import {AjaxError, isAjaxError} from 'ember-ajax/errors'; import config from 'ghost-admin/config/environment'; +/* Version mismatch error */ + +export function VersionMismatchError(errors) { + AjaxError.call(this, errors, 'API server is running a newer version of Ghost, please upgrade.'); +} + +VersionMismatchError.prototype = Object.create(AjaxError.prototype); + +export function isVersionMismatchError(errorOrStatus, payload) { + if (isAjaxError(errorOrStatus)) { + return errorOrStatus instanceof VersionMismatchError; + } else if (errorOrStatus && get(errorOrStatus, 'isAdapterError')) { + return get(errorOrStatus, 'errors.firstObject.errorType') === 'VersionMismatchError'; + } else { + return get(payload || {}, 'errors.firstObject.errorType') === 'VersionMismatchError'; + } +} + +/* Request entity too large error */ + export function RequestEntityTooLargeError(errors) { AjaxError.call(this, errors, 'Request was rejected because it\'s larger than the maximum file size the server allows'); } RequestEntityTooLargeError.prototype = Object.create(AjaxError.prototype); -export function isRequestEntityTooLargeError(error) { - if (isAjaxError(error)) { - return error instanceof RequestEntityTooLargeError; +export function isRequestEntityTooLargeError(errorOrStatus) { + if (isAjaxError(errorOrStatus)) { + return errorOrStatus instanceof RequestEntityTooLargeError; } else { - return error === 413; + return errorOrStatus === 413; } } +/* Unsupported media type error */ + export function UnsupportedMediaTypeError(errors) { AjaxError.call(this, errors, 'Request was rejected because it contains an unknown or unsupported file type.'); } UnsupportedMediaTypeError.prototype = Object.create(AjaxError.prototype); -export function isUnsupportedMediaTypeError(error) { - if (isAjaxError(error)) { - return error instanceof UnsupportedMediaTypeError; +export function isUnsupportedMediaTypeError(errorOrStatus) { + if (isAjaxError(errorOrStatus)) { + return errorOrStatus instanceof UnsupportedMediaTypeError; } else { - return error === 415; + return errorOrStatus === 415; } } +/* end: custom error types */ + export default AjaxService.extend({ session: injectService(), @@ -52,7 +77,9 @@ export default AjaxService.extend({ }).volatile(), handleResponse(status, headers, payload) { - if (this.isRequestEntityTooLargeError(status, headers, payload)) { + if (this.isVersionMismatchError(status, headers, payload)) { + return new VersionMismatchError(payload.errors); + } else if (this.isRequestEntityTooLargeError(status, headers, payload)) { return new RequestEntityTooLargeError(payload.errors); } else if (this.isUnsupportedMediaTypeError(status, headers, payload)) { return new UnsupportedMediaTypeError(payload.errors); @@ -79,6 +106,10 @@ export default AjaxService.extend({ return this._super(status, headers, payload); }, + isVersionMismatchError(status, headers, payload) { + return isVersionMismatchError(status, payload); + }, + isRequestEntityTooLargeError(status/*, headers, payload */) { return isRequestEntityTooLargeError(status); }, diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 5546b4271d..7e1f0eda1d 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -65,17 +65,17 @@ export default Service.extend({ this.notifyPropertyChange('labs'); return this.get(`labs.${key}`); - }).catch((errors) => { + }).catch((error) => { settings.rollbackAttributes(); this.notifyPropertyChange('labs'); // we'll always have an errors object unless we hit a // validation error - if (!errors) { + if (!error) { throw new EmberError(`Validation of the feature service settings model failed when updating labs.`); } - this.get('notifications').showErrors(errors); + this.get('notifications').showAPIError(error); return this.get(`labs.${key}`); }); diff --git a/ghost/admin/app/services/notifications.js b/ghost/admin/app/services/notifications.js index 10b071e9db..399137b875 100644 --- a/ghost/admin/app/services/notifications.js +++ b/ghost/admin/app/services/notifications.js @@ -3,7 +3,8 @@ import {filter} from 'ember-computed'; import {A as emberA, isEmberArray} from 'ember-array/utils'; import get from 'ember-metal/get'; import set from 'ember-metal/set'; -import {isAjaxError} from 'ember-ajax/errors'; +import injectService from 'ember-service/inject'; +import {isVersionMismatchError} from 'ghost-admin/services/ajax'; // Notification keys take the form of "noun.verb.message", eg: // @@ -18,6 +19,8 @@ export default Service.extend({ delayedNotifications: emberA(), content: emberA(), + upgradeStatus: injectService(), + alerts: filter('content', function (notification) { let status = get(notification, 'status'); return status === 'alert'; @@ -99,6 +102,22 @@ export default Service.extend({ }, showAPIError(resp, options) { + // handle "global" errors + if (isVersionMismatchError(resp)) { + return this.get('upgradeStatus').requireUpgrade(); + } + + // loop over Ember Data / ember-ajax errors object + if (resp && isEmberArray(resp.errors)) { + return resp.errors.forEach((error) => { + this._showAPIError(error, options); + }); + } + + this._showAPIError(resp, options); + }, + + _showAPIError(resp, options) { options = options || {}; options.type = options.type || 'error'; // TODO: getting keys from the server would be useful here (necessary for i18n) @@ -110,12 +129,10 @@ export default Service.extend({ options.defaultErrorText = options.defaultErrorText || 'There was a problem on the server, please try again.'; - if (isAjaxError(resp)) { - resp = resp.errors; - } - 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 diff --git a/ghost/admin/app/services/upgrade-status.js b/ghost/admin/app/services/upgrade-status.js new file mode 100644 index 0000000000..76a7822c6c --- /dev/null +++ b/ghost/admin/app/services/upgrade-status.js @@ -0,0 +1,16 @@ +import Service from 'ember-service'; +import injectService from 'ember-service/inject'; + +export default Service.extend({ + isRequired: false, + + notifications: injectService(), + + requireUpgrade() { + this.set('isRequired', true); + this.get('notifications').showAlert( + 'Ghost has been upgraded, please copy any unsaved data and refresh the page to continue.', + {type: 'error', key: 'api-error.upgrade-required'} + ); + } +}); diff --git a/ghost/admin/app/styles/components/notifications.css b/ghost/admin/app/styles/components/notifications.css index 1131e51106..932a65f11d 100644 --- a/ghost/admin/app/styles/components/notifications.css +++ b/ghost/admin/app/styles/components/notifications.css @@ -130,7 +130,7 @@ /* Base alert style */ .gh-alert { - z-index: 1000; + z-index: 9999; flex-grow: 1; display: flex; justify-content: space-between; diff --git a/ghost/admin/app/templates/components/modals/import-subscribers.hbs b/ghost/admin/app/templates/components/modals/import-subscribers.hbs index e04b2aa9c7..ae0085095d 100644 --- a/ghost/admin/app/templates/components/modals/import-subscribers.hbs +++ b/ghost/admin/app/templates/components/modals/import-subscribers.hbs @@ -34,9 +34,9 @@ url=uploadUrl paramName="subscribersfile" labelText="Select or drag-and-drop a CSV file." - uploadStarted=(action 'uploadStarted') - uploadFinished=(action 'uploadFinished') - uploadSuccess=(action 'uploadSuccess')}} + uploadStarted=(action "uploadStarted") + uploadFinished=(action "uploadFinished") + uploadSuccess=(action "uploadSuccess")}} {{/liquid-if}} diff --git a/ghost/admin/app/templates/subscribers/import.hbs b/ghost/admin/app/templates/subscribers/import.hbs index 35ede97005..a8c10927d7 100644 --- a/ghost/admin/app/templates/subscribers/import.hbs +++ b/ghost/admin/app/templates/subscribers/import.hbs @@ -1,3 +1,4 @@ {{gh-fullscreen-modal "import-subscribers" confirm=(route-action "reset") - close=(route-action "cancel")}} + close=(route-action "cancel") + modifier="action wide"}} diff --git a/ghost/admin/app/utils/route.js b/ghost/admin/app/utils/route.js new file mode 100644 index 0000000000..69369dd12c --- /dev/null +++ b/ghost/admin/app/utils/route.js @@ -0,0 +1,15 @@ +import Route from 'ember-route'; + +Route.reopen({ + actions: { + willTransition(transition) { + if (this.get('upgradeStatus.isRequired')) { + transition.abort(); + this.get('upgradeStatus').requireUpgrade(); + return false; + } else { + this._super(...arguments); + } + } + } +}); diff --git a/ghost/admin/tests/acceptance/editor-test.js b/ghost/admin/tests/acceptance/editor-test.js index 557d8405a8..0b4aa394e4 100644 --- a/ghost/admin/tests/acceptance/editor-test.js +++ b/ghost/admin/tests/acceptance/editor-test.js @@ -24,6 +24,8 @@ describe('Acceptance: Editor', function() { }); it('redirects to signin when not authenticated', function () { + server.create('post'); + invalidateSession(application); visit('/editor/1'); @@ -35,6 +37,7 @@ describe('Acceptance: Editor', function() { it('does not redirect to team page when authenticated as author', function () { let role = server.create('role', {name: 'Author'}); let user = server.create('user', {roles: [role], slug: 'test-user'}); + server.create('post'); authenticateSession(application); visit('/editor/1'); @@ -47,6 +50,7 @@ describe('Acceptance: Editor', function() { it('does not redirect to team page when authenticated as editor', function () { let role = server.create('role', {name: 'Editor'}); let user = server.create('user', {roles: [role], slug: 'test-user'}); + server.create('post'); authenticateSession(application); visit('/editor/1'); @@ -56,6 +60,19 @@ describe('Acceptance: Editor', function() { }); }); + it('displays 404 when post does not exist', function () { + let role = server.create('role', {name: 'Editor'}); + let user = server.create('user', {roles: [role], slug: 'test-user'}); + + authenticateSession(application); + visit('/editor/1'); + + andThen(() => { + expect(currentPath()).to.equal('error404'); + expect(currentURL()).to.equal('/editor/1'); + }); + }); + describe('when logged in', function () { beforeEach(function () { diff --git a/ghost/admin/tests/acceptance/settings/slack-test.js b/ghost/admin/tests/acceptance/settings/slack-test.js index 0f6b986995..c578268cc3 100644 --- a/ghost/admin/tests/acceptance/settings/slack-test.js +++ b/ghost/admin/tests/acceptance/settings/slack-test.js @@ -108,6 +108,7 @@ describe('Acceptance: Settings - Apps - Slack', function () { click('.gh-alert-blue .gh-alert-close'); click('#sendTestNotification'); + // we shouldn't try to send the test request if the save fails andThen(() => { let [lastRequest] = server.pretender.handledRequests.slice(-1); expect(lastRequest.url).to.not.match(/\/slack\/test/); diff --git a/ghost/admin/tests/acceptance/version-mismatch-test.js b/ghost/admin/tests/acceptance/version-mismatch-test.js new file mode 100644 index 0000000000..16f1013097 --- /dev/null +++ b/ghost/admin/tests/acceptance/version-mismatch-test.js @@ -0,0 +1,133 @@ +/* jshint expr:true */ +import { + describe, + it, + beforeEach, + afterEach +} from 'mocha'; +import { expect } from 'chai'; +import startApp from '../helpers/start-app'; +import destroyApp from '../helpers/destroy-app'; +import { invalidateSession, authenticateSession } from 'ghost-admin/tests/helpers/ember-simple-auth'; +import Mirage from 'ember-cli-mirage'; + +let versionMismatchResponse = function () { + return new Mirage.Response(400, {}, { + errors: [{ + errorType: 'VersionMismatchError', + statusCode: 400 + }] + }); +}; + +describe('Acceptance: Version Mismatch', function() { + let application; + + beforeEach(function() { + application = startApp(); + }); + + afterEach(function() { + destroyApp(application); + }); + + describe('logged in', function () { + beforeEach(function () { + let role = server.create('role', {name: 'Administrator'}); + let user = server.create('user', {roles: [role]}); + + server.loadFixtures(); + + return authenticateSession(application); + }); + + it('displays an alert and disables navigation when saving', function () { + server.createList('post', 3); + + // mock the post save endpoint to return version mismatch + server.put('/posts/:id', versionMismatchResponse); + + visit('/'); + click('.posts-list li:nth-of-type(2) a'); // select second post + click('.post-edit'); // preview edit button + click('.js-publish-button'); // "Save post" + + andThen(() => { + // has the refresh to update alert + expect(find('.gh-alert').length).to.equal(1); + expect(find('.gh-alert').text()).to.match(/refresh/); + }); + + // try navigating back to the content list + click('.gh-nav-main-content'); + + andThen(() => { + expect(currentPath()).to.equal('editor.edit'); + }); + }); + + it('displays alert and aborts the transition when navigating', function () { + // mock the tags endpoint to return version mismatch + server.get('/tags/', versionMismatchResponse); + + visit('/'); + click('.gh-nav-settings-tags'); + + andThen(() => { + // navigation is blocked + expect(currentPath()).to.equal('posts.index'); + + // has the refresh to update alert + expect(find('.gh-alert').length).to.equal(1); + expect(find('.gh-alert').text()).to.match(/refresh/); + }); + }); + + it('displays alert and aborts the transition when an ember-ajax error is thrown whilst navigating', function () { + server.get('/configuration/timezones/', versionMismatchResponse); + + visit('/settings/tags'); + click('.gh-nav-settings-general'); + + andThen(() => { + // navigation is blocked + expect(currentPath()).to.equal('settings.tags.index'); + + // has the refresh to update alert + expect(find('.gh-alert').length).to.equal(1); + expect(find('.gh-alert').text()).to.match(/refresh/); + }); + }); + + it('can be triggered when passed in to a component', function () { + server.post('/subscribers/csv/', versionMismatchResponse); + + visit('/subscribers'); + click('.btn:contains("Import CSV")'); + fileUpload('.fullscreen-modal input[type="file"]'); + + andThen(() => { + // alert is shown + expect(find('.gh-alert').length).to.equal(1); + expect(find('.gh-alert').text()).to.match(/refresh/); + }); + }); + }); + + describe('logged out', function () { + it('displays alert', function () { + server.post('/authentication/token', versionMismatchResponse); + + visit('/signin'); + fillIn('[name="identification"]', 'test@example.com'); + fillIn('[name="password"]', 'password'); + click('.btn-blue'); + + andThen(() => { + // has the refresh to update alert + expect(find('.gh-alert').length).to.equal(1); + expect(find('.gh-alert').text()).to.match(/refresh/); + }); + }); + }); +}); diff --git a/ghost/admin/tests/integration/components/gh-file-uploader-test.js b/ghost/admin/tests/integration/components/gh-file-uploader-test.js index 738347c229..e17a143b36 100644 --- a/ghost/admin/tests/integration/components/gh-file-uploader-test.js +++ b/ghost/admin/tests/integration/components/gh-file-uploader-test.js @@ -11,6 +11,13 @@ import Pretender from 'pretender'; import wait from 'ember-test-helpers/wait'; import sinon from 'sinon'; import {createFile, fileUpload} from '../../helpers/file-upload'; +import Service from 'ember-service'; + +const notificationsStub = Service.extend({ + showAPIError(error, options) { + // noop - to be stubbed + } +}); const stubSuccessfulUpload = function (server, delay = 0) { server.post('/ghost/api/v0.1/uploads/', function () { @@ -41,6 +48,9 @@ describeComponent( beforeEach(function () { server = new Pretender(); this.set('uploadUrl', '/ghost/api/v0.1/uploads/'); + + this.register('service:notifications', notificationsStub); + this.inject.service('notifications', {as: 'notifications'}); }); afterEach(function () { @@ -217,6 +227,35 @@ describeComponent( }); }); + it('triggers notifications.showAPIError for VersionMismatchError', function (done) { + let showAPIError = sinon.spy(); + this.set('notifications.showAPIError', showAPIError); + + stubFailedUpload(server, 400, 'VersionMismatchError'); + + this.render(hbs`{{gh-file-uploader url=uploadUrl}}`); + fileUpload(this.$('input[type="file"]')); + + wait().then(() => { + expect(showAPIError.calledOnce).to.be.true; + done(); + }); + }); + + it('doesn\'t trigger notifications.showAPIError for other errors', function (done) { + let showAPIError = sinon.spy(); + this.set('notifications.showAPIError', showAPIError); + + stubFailedUpload(server, 400, 'UnknownError'); + this.render(hbs`{{gh-file-uploader url=uploadUrl}}`); + fileUpload(this.$('input[type="file"]')); + + wait().then(() => { + expect(showAPIError.called).to.be.false; + done(); + }); + }); + it('can be reset after a failed upload', function (done) { stubFailedUpload(server, 400, 'UnknownError'); this.render(hbs`{{gh-file-uploader url=uploadUrl}}`); diff --git a/ghost/admin/tests/integration/components/gh-image-uploader-test.js b/ghost/admin/tests/integration/components/gh-image-uploader-test.js index c951956276..90d5a2e220 100644 --- a/ghost/admin/tests/integration/components/gh-image-uploader-test.js +++ b/ghost/admin/tests/integration/components/gh-image-uploader-test.js @@ -21,6 +21,12 @@ const configStub = Service.extend({ fileStorage: true }); +const notificationsStub = Service.extend({ + showAPIError(error, options) { + // noop - to be stubbed + } +}); + const sessionStub = Service.extend({ isAuthenticated: false, authorize(authorizer, block) { @@ -59,8 +65,10 @@ describeComponent( beforeEach(function () { this.register('service:config', configStub); this.register('service:session', sessionStub); + this.register('service:notifications', notificationsStub); this.inject.service('config', {as: 'configService'}); this.inject.service('session', {as: 'sessionService'}); + this.inject.service('notifications', {as: 'notifications'}); this.set('update', function () {}); server = new Pretender(); }); @@ -298,6 +306,35 @@ describeComponent( }); }); + it('triggers notifications.showAPIError for VersionMismatchError', function (done) { + let showAPIError = sinon.spy(); + this.set('notifications.showAPIError', showAPIError); + + stubFailedUpload(server, 400, 'VersionMismatchError'); + + this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`); + fileUpload(this.$('input[type="file"]')); + + wait().then(() => { + expect(showAPIError.calledOnce).to.be.true; + done(); + }); + }); + + it('doesn\'t trigger notifications.showAPIError for other errors', function (done) { + let showAPIError = sinon.spy(); + this.set('notifications.showAPIError', showAPIError); + + stubFailedUpload(server, 400, 'UnknownError'); + this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`); + fileUpload(this.$('input[type="file"]')); + + wait().then(() => { + expect(showAPIError.called).to.be.false; + done(); + }); + }); + it('can be reset after a failed upload', function (done) { stubFailedUpload(server, 400, 'UnknownError'); this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`); diff --git a/ghost/admin/tests/integration/components/gh-search-input-test.js b/ghost/admin/tests/integration/components/gh-search-input-test.js index 82c9139daa..8074f75de6 100644 --- a/ghost/admin/tests/integration/components/gh-search-input-test.js +++ b/ghost/admin/tests/integration/components/gh-search-input-test.js @@ -6,7 +6,18 @@ import { } from 'ember-mocha'; import hbs from 'htmlbars-inline-precompile'; import run from 'ember-runloop'; +import Pretender from 'pretender'; import wait from 'ember-test-helpers/wait'; +import sinon from 'sinon'; + +let versionMismatchResponse = function () { + return [400, {'Content-Type': 'application/json'}, JSON.stringify({ + errors: [{ + errorType: 'VersionMismatchError', + statusCode: 400 + }] + })]; +}; describeComponent( 'gh-search-input', @@ -15,6 +26,16 @@ describeComponent( integration: true }, function () { + let server; + + beforeEach(function () { + server = new Pretender(); + }); + + afterEach(function () { + server.shutdown(); + }); + it('renders', function () { // renders the component on the page this.render(hbs`{{gh-search-input}}`); diff --git a/ghost/admin/tests/integration/services/ajax-test.js b/ghost/admin/tests/integration/services/ajax-test.js index 1c9abdfd03..acd58d2973 100644 --- a/ghost/admin/tests/integration/services/ajax-test.js +++ b/ghost/admin/tests/integration/services/ajax-test.js @@ -9,6 +9,7 @@ import { isUnauthorizedError } from 'ember-ajax/errors'; import { + isVersionMismatchError, isRequestEntityTooLargeError, isUnsupportedMediaTypeError } from 'ghost-admin/services/ajax'; @@ -125,6 +126,30 @@ describeModule( }); }); + it('handles error checking for VersionMismatchError', function (done) { + server.get('/test/', function () { + return [ + 400, + {'Content-Type': 'application/json'}, + JSON.stringify({ + errors: [{ + errorType: 'VersionMismatchError', + statusCode: 400 + }] + }) + ]; + }); + + let ajax = this.subject(); + + ajax.request('/test/').then(() => { + expect(false).to.be.true; + }).catch((error) => { + expect(isVersionMismatchError(error)).to.be.true; + done(); + }); + }); + it('handles error checking for RequestEntityTooLargeError on 413 errors', function (done) { stubAjaxEndpoint(server, {}, 413); diff --git a/ghost/admin/tests/integration/services/feature-test.js b/ghost/admin/tests/integration/services/feature-test.js index 01265a00eb..1828acd7d6 100644 --- a/ghost/admin/tests/integration/services/feature-test.js +++ b/ghost/admin/tests/integration/services/feature-test.js @@ -174,8 +174,16 @@ describeModule( }); return wait().then(() => { - expect(server.handlers[1].numberOfCalls).to.equal(1); - expect(service.get('notifications.notifications').length).to.equal(1); + expect( + server.handlers[1].numberOfCalls, + 'PUT call is made' + ).to.equal(1); + + expect( + service.get('notifications.alerts').length, + 'number of alerts shown' + ).to.equal(1); + expect(service.get('testFlag')).to.be.false; done(); }); diff --git a/ghost/admin/tests/unit/controllers/settings/navigation-test.js b/ghost/admin/tests/unit/controllers/settings/navigation-test.js index 318784f7a6..418c185e4d 100644 --- a/ghost/admin/tests/unit/controllers/settings/navigation-test.js +++ b/ghost/admin/tests/unit/controllers/settings/navigation-test.js @@ -25,7 +25,14 @@ describeModule( 'Unit: Controller: settings/navigation', { // Specify the other units that are required for this test. - needs: ['service:config', 'service:notifications', 'model:navigation-item', 'service:ajax', 'service:ghostPaths'] + needs: [ + 'service:config', + 'service:notifications', + 'model:navigation-item', + 'service:ajax', + 'service:ghostPaths', + 'service:upgrade-status' + ] }, function () { it('blogUrl: captures config and ensures trailing slash', function () { diff --git a/ghost/admin/tests/unit/services/notifications-test.js b/ghost/admin/tests/unit/services/notifications-test.js index c1fed1f87d..73bad6459e 100644 --- a/ghost/admin/tests/unit/services/notifications-test.js +++ b/ghost/admin/tests/unit/services/notifications-test.js @@ -166,34 +166,39 @@ describeModule( ]); }); - it('#showAPIError adds single json response error', function () { + it('#showAPIError handles single json response error', function () { let notifications = this.subject(); - let error = new AjaxError('Single error'); + let error = new AjaxError([{message: 'Single error'}]); run(() => { notifications.showAPIError(error); }); - let notification = notifications.get('alerts.firstObject'); - expect(get(notification, 'message')).to.equal('Single error'); - expect(get(notification, 'status')).to.equal('alert'); - expect(get(notification, 'type')).to.equal('error'); - expect(get(notification, 'key')).to.equal('api-error'); + let alert = notifications.get('alerts.firstObject'); + expect(get(alert, 'message')).to.equal('Single error'); + expect(get(alert, 'status')).to.equal('alert'); + expect(get(alert, 'type')).to.equal('error'); + expect(get(alert, 'key')).to.equal('api-error'); }); - // used to display validation errors returned from the server - it('#showAPIError adds multiple json response errors', function () { + // 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(['First error', 'Second error']); + let error = new AjaxError([ + {message: 'First error'}, + {message: 'Second error'} + ]); run(() => { notifications.showAPIError(error); }); - expect(notifications.get('notifications')).to.deep.equal([ - {message: 'First error', status: 'notification', type: 'error', key: undefined}, - {message: 'Second error', status: 'notification', type: 'error', key: undefined} - ]); + // 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'); }); it('#showAPIError displays default error text if response has no error/message', function () { @@ -238,7 +243,7 @@ describeModule( it('#showAPIError parses errors from ember-ajax correctly', function () { let notifications = this.subject(); - let error = new InvalidError('Test Error'); + let error = new InvalidError([{message: 'Test Error'}]); run(() => { notifications.showAPIError(error); diff --git a/ghost/admin/tests/unit/services/upgrade-status-test.js b/ghost/admin/tests/unit/services/upgrade-status-test.js new file mode 100644 index 0000000000..cc7d35c7ba --- /dev/null +++ b/ghost/admin/tests/unit/services/upgrade-status-test.js @@ -0,0 +1,23 @@ +/* jshint expr:true */ +import { expect } from 'chai'; +import { + describeModule, + it +} from 'ember-mocha'; + +describeModule( + 'service:upgrade-status', + 'UpgradeStatusService', + { + // Specify the other units that are required for this test. + // needs: ['service:foo'] + needs: [] + }, + function() { + // Replace this with your real tests. + it('exists', function() { + let service = this.subject(); + expect(service).to.be.ok; + }); + } +);