"400 Version Mismatch" error handling

refs https://github.com/TryGhost/Ghost/issues/6949

Handle version mismatch errors by:
- displaying an alert asking the user to copy any data and refresh
- disabling navigation so that unsaved data is not accidentally lost

Detailed changes:
- add `error` action to application route for global route-based error handling
- remove 404-handler mixin, move logic into app route error handler
- update `.catch` in validation-engine so that promises are rejected with the
  original error objects
- add `VersionMismatchError` and `isVersionMismatchError` to ajax service
- add `upgrade-status` service
  - has a method to trigger the alert and toggle the "upgrade required" mode
  - is injected into all routes by default so that it can be checked before
    transitioning
- add `Route` override
  - updates the `willTransition` hook to check the `upgrade-status` service
    and abort the transition if we're in "upgrade required" mode
- update notifications `showAPIError` method to handle version mismatch errors
- update any areas where we were catching ajax errors manually so that the
  version mismatch error handling is obeyed
- fix redirect tests in editor acceptance test
- fix mirage's handling of 404s for unknown posts in get post requests
- adjust alert z-index to to appear above modal backgrounds
This commit is contained in:
Kevin Ansfield 2016-06-30 15:45:02 +01:00
parent bfe542b27d
commit b4cdc85a59
46 changed files with 663 additions and 162 deletions

View File

@ -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';

View File

@ -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)) {

View File

@ -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);
}
}
});

View File

@ -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'});
});
},

View File

@ -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');
});

View File

@ -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);

View File

@ -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);
});

View File

@ -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);
},

View File

@ -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);

View File

@ -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

View File

@ -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(() => {

View File

@ -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(() => {

View File

@ -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');

View File

@ -0,0 +1,8 @@
export function initialize(application) {
application.inject('route', 'upgradeStatus', 'service:upgrade-status');
}
export default {
name: 'upgrade-status',
initialize
};

View File

@ -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) {

View File

@ -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);
}
}
});

View File

@ -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);

View File

@ -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) {

View File

@ -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);
});

View File

@ -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);

View File

@ -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;
}
}
});

View File

@ -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) {

View File

@ -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,

View File

@ -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');
});

View File

@ -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});

View File

@ -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);
});
}
});

View File

@ -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'],

View File

@ -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);
},

View File

@ -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}`);
});

View File

@ -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

View File

@ -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'}
);
}
});

View File

@ -130,7 +130,7 @@
/* Base alert style */
.gh-alert {
z-index: 1000;
z-index: 9999;
flex-grow: 1;
display: flex;
justify-content: space-between;

View File

@ -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}}
</div>

View File

@ -1,3 +1,4 @@
{{gh-fullscreen-modal "import-subscribers"
confirm=(route-action "reset")
close=(route-action "cancel")}}
close=(route-action "cancel")
modifier="action wide"}}

View File

@ -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);
}
}
}
});

View File

@ -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 () {

View File

@ -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/);

View File

@ -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/);
});
});
});
});

View File

@ -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}}`);

View File

@ -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)}}`);

View File

@ -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}}`);

View File

@ -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);

View File

@ -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();
});

View File

@ -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 () {

View File

@ -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);

View File

@ -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;
});
}
);