Refactor notifications service & components

issue #5409

- change persistent/passive notification status to alert/notification
- replace showSuccess/Info/Warn/Error with showNotification/showAlert
- fix and clean up notification/alert components
This commit is contained in:
Kevin Ansfield 2015-06-18 22:56:18 +01:00
parent 5503fb39cc
commit 1ba6bcc131
41 changed files with 689 additions and 216 deletions

View File

@ -2,7 +2,7 @@ import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'article',
classNames: ['gh-alert', 'gh-alert-blue'],
classNames: ['gh-alert'],
classNameBindings: ['typeClass'],
notifications: Ember.inject.service(),
@ -10,22 +10,18 @@ export default Ember.Component.extend({
typeClass: Ember.computed(function () {
var classes = '',
message = this.get('message'),
type,
dismissible;
type = Ember.get(message, 'type'),
typeMapping;
// Check to see if we're working with a DS.Model or a plain JS object
if (typeof message.toJSON === 'function') {
type = message.get('type');
dismissible = message.get('dismissible');
} else {
type = message.type;
dismissible = message.dismissible;
}
typeMapping = {
success: 'green',
error: 'red',
warn: 'yellow',
info: 'blue'
};
classes += 'notification-' + type;
if (type === 'success' && dismissible !== false) {
classes += ' notification-passive';
if (typeMapping[type] !== undefined) {
classes += 'gh-alert-' + typeMapping[type];
}
return classes;

View File

@ -1,18 +1,14 @@
import Ember from 'ember';
var AlertsComponent = Ember.Component.extend({
export default Ember.Component.extend({
tagName: 'aside',
classNames: 'gh-alerts',
messages: Ember.computed.filter('notifications', function (notification) {
var displayStatus = (typeof notification.toJSON === 'function') ?
notification.get('status') : notification.status;
notifications: Ember.inject.service(),
return displayStatus === 'persistent';
}),
messages: Ember.computed.alias('notifications.alerts'),
messageCountObserver: Ember.observer('messages.[]', function () {
this.sendAction('notify', this.get('messages').length);
})
});
export default AlertsComponent;

View File

@ -2,7 +2,7 @@ import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'article',
classNames: ['gh-notification', 'gh-notification-green'],
classNames: ['gh-notification', 'gh-notification-passive'],
classNameBindings: ['typeClass'],
message: null,
@ -12,22 +12,17 @@ export default Ember.Component.extend({
typeClass: Ember.computed(function () {
var classes = '',
message = this.get('message'),
type,
dismissible;
type = Ember.get(message, 'type'),
typeMapping;
// Check to see if we're working with a DS.Model or a plain JS object
if (typeof message.toJSON === 'function') {
type = message.get('type');
dismissible = message.get('dismissible');
} else {
type = message.type;
dismissible = message.dismissible;
}
typeMapping = {
success: 'green',
error: 'red',
warn: 'yellow'
};
classes += 'notification-' + type;
if (type === 'success' && dismissible !== false) {
classes += ' notification-passive';
if (typeMapping[type] !== undefined) {
classes += 'gh-notification-' + typeMapping[type];
}
return classes;
@ -38,11 +33,15 @@ export default Ember.Component.extend({
self.$().on('animationend webkitAnimationEnd oanimationend MSAnimationEnd', function (event) {
if (event.originalEvent.animationName === 'fade-out') {
self.get('notifications').removeObject(self.get('message'));
self.get('notifications').closeNotification(self.get('message'));
}
});
},
willDestroyElement: function () {
this.$().off('animationend webkitAnimationEnd oanimationend MSAnimationEnd');
},
actions: {
closeNotification: function () {
this.get('notifications').closeNotification(this.get('message'));

View File

@ -6,10 +6,5 @@ export default Ember.Component.extend({
notifications: Ember.inject.service(),
messages: Ember.computed.filter('notifications.content', function (notification) {
var displayStatus = (typeof notification.toJSON === 'function') ?
notification.get('status') : notification.status;
return displayStatus === 'passive';
})
messages: Ember.computed.alias('notifications.notifications')
});

View File

@ -24,10 +24,10 @@ export default Ember.Component.extend({
// If sending the invitation email fails, the API will still return a status of 201
// but the user's status in the response object will be 'invited-pending'.
if (result.users[0].status === 'invited-pending') {
notifications.showWarn('Invitation email was not sent. Please try resending.');
notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error'});
} else {
user.set('status', result.users[0].status);
notifications.showSuccess(notificationText);
notifications.showNotification(notificationText);
}
}).catch(function (error) {
notifications.showAPIError(error);
@ -46,14 +46,14 @@ export default Ember.Component.extend({
user.destroyRecord().then(function () {
var notificationText = 'Invitation revoked. (' + email + ')';
notifications.showSuccess(notificationText, false);
notifications.showNotification(notificationText);
}).catch(function (error) {
notifications.showAPIError(error);
});
} else {
// if the user is no longer marked as "invited", then show a warning and reload the route
self.sendAction('reload');
notifications.showError('This user has already accepted the invitation.', {delayed: 500});
notifications.showAlert('This user has already accepted the invitation.', {type: 'error', delayed: true});
}
});
}

View File

@ -12,7 +12,7 @@ export default Ember.Controller.extend({
ajax(this.get('ghostPaths.url').api('db'), {
type: 'DELETE'
}).then(function () {
self.get('notifications').showSuccess('All content deleted from database.');
self.get('notifications').showAlert('All content deleted from database.', {type: 'success'});
self.store.unloadAll('post');
self.store.unloadAll('tag');
}).catch(function (response) {

View File

@ -15,9 +15,8 @@ export default Ember.Controller.extend({
model.destroyRecord().then(function () {
self.get('dropdown').closeDropdowns();
self.transitionToRoute('posts.index');
self.get('notifications').showSuccess('Your post has been deleted.', {delayed: true});
}, function () {
self.get('notifications').showError('Your post could not be deleted. Please try again.');
self.get('notifications').showAlert('Your post could not be deleted. Please try again.', {type: 'error'});
});
},

View File

@ -10,14 +10,11 @@ export default Ember.Controller.extend({
actions: {
confirmAccept: function () {
var tag = this.get('model'),
name = tag.get('name'),
self = this;
this.send('closeMenus');
tag.destroyRecord().then(function () {
self.get('notifications').showSuccess('Deleted ' + name);
}).catch(function (error) {
tag.destroyRecord().catch(function (error) {
self.get('notifications').showAPIError(error);
});
},

View File

@ -31,9 +31,8 @@ export default Ember.Controller.extend({
user.destroyRecord().then(function () {
self.store.unloadAll('post');
self.transitionToRoute('team');
self.get('notifications').showSuccess('The user has been deleted.', {delayed: true});
}, function () {
self.get('notifications').showError('The user could not be deleted. Please try again.');
self.get('notifications').showAlert('The user could not be deleted. Please try again.', {type: 'error'});
});
},

View File

@ -55,9 +55,9 @@ export default Ember.Controller.extend({
if (invitedUser) {
if (invitedUser.get('status') === 'invited' || invitedUser.get('status') === 'invited-pending') {
self.get('notifications').showWarn('A user with that email address was already invited.');
self.get('notifications').showAlert('A user with that email address was already invited.', {type: 'warn'});
} else {
self.get('notifications').showWarn('A user with that email address already exists.');
self.get('notifications').showAlert('A user with that email address already exists.', {type: 'warn'});
}
} else {
newUser = self.store.createRecord('user', {
@ -72,9 +72,9 @@ export default Ember.Controller.extend({
// If sending the invitation email fails, the API will still return a status of 201
// but the user's status in the response object will be 'invited-pending'.
if (newUser.get('status') === 'invited-pending') {
self.get('notifications').showWarn('Invitation email was not sent. Please try resending.');
self.get('notifications').showAlert('Invitation email was not sent. Please try resending.', {type: 'error'});
} else {
self.get('notifications').showSuccess(notificationText);
self.get('notifications').showAlert(notificationText, {type: 'success'});
}
}).catch(function (errors) {
newUser.deleteRecord();

View File

@ -19,7 +19,7 @@ export default Ember.Controller.extend({
}
if (!transition || !editorController) {
this.get('notifications').showError('Sorry, there was an error in the application. Please let the Ghost team know what happened.');
this.get('notifications').showNotification('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return true;
}

View File

@ -22,7 +22,6 @@ export default Ember.Controller.extend(ValidationEngine, {
this.get('session').authenticate(authStrategy, data).then(function () {
self.send('closeModal');
self.get('notifications').showSuccess('Login successful.');
self.set('password', '');
}).catch(function () {
// if authentication fails a rejected promise will be returned.
@ -41,7 +40,7 @@ export default Ember.Controller.extend(ValidationEngine, {
$('#login').find('input').trigger('change');
this.validate({format: false}).then(function () {
self.get('notifications').closePassive();
self.get('notifications').closeNotifications();
self.send('authenticate');
}).catch(function (errors) {
self.get('notifications').showErrors(errors);

View File

@ -33,7 +33,7 @@ export default Ember.Controller.extend({
});
}
self.get('notifications').showSuccess('Ownership successfully transferred to ' + user.get('name'));
self.get('notifications').showAlert('Ownership successfully transferred to ' + user.get('name'), {type: 'success'});
}).catch(function (error) {
self.get('notifications').showAPIError(error);
});

View File

@ -10,8 +10,6 @@ export default Ember.Controller.extend({
var notifications = this.get('notifications');
this.get('model').save().then(function (model) {
notifications.showSuccess('Saved');
return model;
}).catch(function (err) {
notifications.showErrors(err);

View File

@ -193,10 +193,6 @@ export default Ember.Controller.extend(SettingsMenuMixin, {
this.get('notifications').showErrors(errors);
},
showSuccess: function (message) {
this.get('notifications').showSuccess(message);
},
actions: {
togglePage: function () {
var self = this;

View File

@ -43,7 +43,7 @@ export default Ember.Controller.extend(ValidationEngine, {
}
}).then(function (resp) {
self.toggleProperty('submitting');
self.get('notifications').showSuccess(resp.passwordreset[0].message, true);
self.get('notifications').showAlert(resp.passwordreset[0].message, {type: 'warn', delayed: true});
self.get('session').authenticate('simple-auth-authenticator:oauth2-password-grant', {
identification: self.get('email'),
password: credentials.newPassword

View File

@ -8,12 +8,11 @@ export default Ember.Controller.extend({
var notifications = this.get('notifications');
return this.get('model').save().then(function (model) {
notifications.closePassive();
notifications.showSuccess('Settings successfully saved.');
notifications.closeNotifications();
return model;
}).catch(function (errors) {
notifications.closePassive();
notifications.closeNotifications();
notifications.showErrors(errors);
});
}

View File

@ -69,7 +69,6 @@ export default Ember.Controller.extend({
return this.get('model').save().then(function (model) {
config.set('blogTitle', model.get('title'));
notifications.showSuccess('Settings successfully saved.');
return model;
}).catch(function (errors) {

View File

@ -36,7 +36,7 @@ export default Ember.Controller.extend({
this.set('uploadButtonText', 'Importing');
this.set('importErrors', '');
notifications.closePassive();
notifications.closeNotifications();
formData.append('importfile', file);
@ -52,13 +52,14 @@ export default Ember.Controller.extend({
self.store.unloadAll();
// Reload currentUser and set session
self.set('session.user', self.store.find('user', currentUserId));
notifications.showSuccess('Import successful.');
// TODO: keep as notification, add link to view content
notifications.showNotification('Import successful.');
}).catch(function (response) {
if (response && response.jqXHR && response.jqXHR.responseJSON && response.jqXHR.responseJSON.errors) {
self.set('importErrors', response.jqXHR.responseJSON.errors);
}
notifications.showError('Import Failed');
notifications.showAlert('Import Failed', {type: 'error'});
}).finally(function () {
self.set('uploadButtonText', 'Import');
});
@ -82,7 +83,7 @@ export default Ember.Controller.extend({
ajax(this.get('ghostPaths.url').api('mail', 'test'), {
type: 'POST'
}).then(function () {
notifications.showSuccess('Check your email for the test message.');
notifications.showAlert('Check your email for the test message.', {type: 'info'});
}).catch(function (error) {
if (typeof error.jqXHR !== 'undefined') {
notifications.showAPIError(error);

View File

@ -108,7 +108,7 @@ export default Ember.Controller.extend({
// Don't save if there's a blank label.
if (navItems.find(function (item) {return !item.get('isComplete') && !item.get('last');})) {
notifications.showErrors([message.htmlSafe()]);
notifications.showAlert(message.htmlSafe(), {type: 'error'});
return;
}
@ -148,11 +148,9 @@ export default Ember.Controller.extend({
// we need to have navigationItems recomputed.
this.get('model').notifyPropertyChange('navigation');
notifications.closePassive();
notifications.closeNotifications();
this.get('model').save().then(function () {
notifications.showSuccess('Navigation items saved.');
}).catch(function (err) {
this.get('model').save().catch(function (err) {
notifications.showErrors(err);
});
}

View File

@ -59,7 +59,7 @@ export default Ember.Controller.extend(PaginationMixin, SettingsMenuMixin, {
activeTag.set(propKey, newValue);
this.get('notifications').closePassive();
this.get('notifications').closeNotifications();
activeTag.save().catch(function (errors) {
self.showErrors(errors);

View File

@ -104,20 +104,21 @@ export default Ember.Controller.extend({
if (erroredEmails.length > 0) {
message = 'Failed to send ' + erroredEmails.length + ' invitations: ';
message += erroredEmails.join(', ');
notifications.showError(message, {delayed: successCount > 0});
notifications.showAlert(message, {type: 'error', delayed: successCount > 0});
}
if (successCount > 0) {
// pluralize
invitationsString = successCount > 1 ? 'invitations' : 'invitation';
notifications.showSuccess(successCount + ' ' + invitationsString + ' sent!', {delayed: true});
notifications.showAlert(successCount + ' ' + invitationsString + ' sent!', {type: 'success', delayed: true});
self.transitionTo('posts.index');
}
});
});
} else if (users.length === 0) {
notifications.showError('No users to invite.');
// TODO: switch to inline-validation
notifications.showAlert('No users to invite.', {type: 'error'});
} else {
errorMessages = validationErrors.map(function (error) {
// Only one error type here so far, but one day the errors might be more detailed
@ -127,6 +128,7 @@ export default Ember.Controller.extend({
}
});
// TODO: switch to inline-validation
notifications.showErrors(errorMessages);
}
}

View File

@ -31,7 +31,7 @@ export default Ember.Controller.extend(ValidationEngine, {
$('#login').find('input').trigger('change');
this.validate({format: false}).then(function () {
self.get('notifications').closePassive();
self.get('notifications').closeNotifications();
self.send('authenticate');
}).catch(function (errors) {
if (errors) {
@ -46,7 +46,8 @@ export default Ember.Controller.extend(ValidationEngine, {
self = this;
if (!email) {
return notifications.showError('Enter email address to reset password.');
// TODO: Switch to in-line validation
return notifications.showNotification('Enter email address to reset password.', {type: 'error'});
}
self.set('submitting', true);
@ -61,7 +62,7 @@ export default Ember.Controller.extend(ValidationEngine, {
}
}).then(function () {
self.set('submitting', false);
notifications.showSuccess('Please check your email for instructions.');
notifications.showAlert('Please check your email for instructions.', {type: 'info'});
}).catch(function (resp) {
self.set('submitting', false);
notifications.showAPIError(resp, {defaultErrorText: 'There was a problem with the reset, please try again.'});

View File

@ -18,7 +18,7 @@ export default Ember.Controller.extend(ValidationEngine, {
data = model.getProperties('name', 'email', 'password', 'token'),
notifications = this.get('notifications');
notifications.closePassive();
notifications.closeNotifications();
this.toggleProperty('submitting');
this.validate({format: false}).then(function () {

View File

@ -105,8 +105,6 @@ export default Ember.Controller.extend({
var currentPath,
newPath;
self.get('notifications').showSuccess('Settings successfully saved.');
// If the user's slug has changed, change the URL and replace
// the history so refresh and back button still work
if (slugChanged) {
@ -142,13 +140,14 @@ export default Ember.Controller.extend({
ne2Password: ''
});
self.get('notifications').showSuccess('Password updated.');
self.get('notifications').showAlert('Password updated.', {type: 'success'});
return model;
}).catch(function (errors) {
self.get('notifications').showAPIError(errors);
});
} else {
// TODO: switch to in-line validation
self.get('notifications').showErrors(user.get('passwordValidationErrors'));
}
},

View File

@ -209,6 +209,7 @@ export default Ember.Mixin.create({
}
},
// TODO: Update for new notification click-action API
showSaveNotification: function (prevStatus, status, delay) {
var message = this.messageMap.success.post[prevStatus][status],
path = this.get('model.absoluteUrl'),
@ -219,7 +220,7 @@ export default Ember.Mixin.create({
message += `&nbsp;<a href="${path}">View ${type}</a>`;
}
notifications.showSuccess(message.htmlSafe(), {delayed: delay});
notifications.showNotification(message.htmlSafe(), {delayed: delay});
},
showErrorNotification: function (prevStatus, status, errors, delay) {
@ -229,7 +230,7 @@ export default Ember.Mixin.create({
message += '<br />' + error;
notifications.showError(message.htmlSafe(), {delayed: delay});
notifications.showAlert(message.htmlSafe(), {type: 'error', delayed: delay});
},
actions: {
@ -263,7 +264,7 @@ export default Ember.Mixin.create({
this.set('timedSaveId', null);
}
notifications.closePassive();
notifications.closeNotifications();
// Set the properties that are indirected
// set markdown equal to what's in the editor, minus the image markers.

View File

@ -25,7 +25,7 @@ export default Ember.Mixin.create({
message += '.';
}
this.get('notifications').showError(message);
this.get('notifications').showAlert(message, {type: 'error'});
},
actions: {

View File

@ -1,7 +1,6 @@
import DS from 'ember-data';
var Notification = DS.Model.extend({
dismissible: DS.attr('boolean'),
location: DS.attr('string'),
status: DS.attr('string'),
type: DS.attr('string'),
message: DS.attr('string')

View File

@ -11,7 +11,7 @@ var Router = Ember.Router.extend({
clearNotifications: Ember.on('didTransition', function () {
var notifications = this.get('notifications');
notifications.closePassive();
notifications.closeNotifications();
notifications.displayDelayed();
})
});

View File

@ -66,7 +66,7 @@ export default Ember.Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
this.get('notifications').showErrors(error.errors);
} else {
// connection errors don't return proper status message, only req.body
this.get('notifications').showError('There was a problem on the server.');
this.get('notifications').showAlert('There was a problem on the server.', {type: 'error'});
}
},
@ -91,7 +91,7 @@ export default Ember.Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
},
sessionInvalidationFailed: function (error) {
this.get('notifications').showError(error.message);
this.get('notifications').showAlert(error.message, {type: 'error'});
},
openModal: function (modalName, model, type) {
@ -152,19 +152,6 @@ export default Ember.Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
}
},
handleErrors: function (errors) {
var notifications = this.get('notifications');
notifications.clear();
errors.forEach(function (errorObj) {
notifications.showError(errorObj.message || errorObj);
if (errorObj.hasOwnProperty('el')) {
errorObj.el.addClass('input-error');
}
});
},
// noop default for unhandled save (used from shortcuts)
save: Ember.K
}

View File

@ -9,7 +9,7 @@ export default Ember.Route.extend(styleBody, {
beforeModel: function () {
if (this.get('session').isAuthenticated) {
this.get('notifications').showWarn('You can\'t reset your password while you\'re signed in.', {delayed: true});
this.get('notifications').showAlert('You can\'t reset your password while you\'re signed in.', {type: 'warn', delayed: true});
this.transitionTo(Configuration.routeAfterAuthentication);
}
},

View File

@ -12,7 +12,7 @@ export default Ember.Route.extend(styleBody, {
beforeModel: function () {
if (this.get('session').isAuthenticated) {
this.get('notifications').showWarn('You need to sign out to register as a new user.', {delayed: true});
this.get('notifications').showAlert('You need to sign out to register as a new user.', {type: 'warn', delayed: true});
this.transitionTo(Configuration.routeAfterAuthentication);
}
},
@ -26,7 +26,7 @@ export default Ember.Route.extend(styleBody, {
return new Ember.RSVP.Promise(function (resolve) {
if (!re.test(params.token)) {
self.get('notifications').showError('Invalid token.', {delayed: true});
self.get('notifications').showAlert('Invalid token.', {type: 'error', delayed: true});
return resolve(self.transitionTo('signin'));
}
@ -47,7 +47,7 @@ export default Ember.Route.extend(styleBody, {
}
}).then(function (response) {
if (response && response.invitation && response.invitation[0].valid === false) {
self.get('notifications').showError('The invitation does not exist or is no longer valid.', {delayed: true});
self.get('notifications').showAlert('The invitation does not exist or is no longer valid.', {type: 'warn', delayed: true});
return resolve(self.transitionTo('signin'));
}

View File

@ -1,138 +1,94 @@
import Ember from 'ember';
import Notification from 'ghost/models/notification';
export default Ember.Service.extend({
delayedNotifications: Ember.A(),
content: Ember.A(),
timeout: 3000,
pushObject: function (object) {
// object can be either a DS.Model or a plain JS object, so when working with
// it, we need to handle both cases.
alerts: Ember.computed.filter('content', function (notification) {
var status = Ember.get(notification, 'status');
return status === 'alert';
}),
// make sure notifications have all the necessary properties set.
if (typeof object.toJSON === 'function') {
// working with a DS.Model
if (object.get('location') === '') {
object.set('location', 'bottom');
}
} else {
if (!object.location) {
object.location = 'bottom';
}
}
this._super(object);
},
notifications: Ember.computed.filter('content', function (notification) {
var status = Ember.get(notification, 'status');
return status === 'notification';
}),
handleNotification: function (message, delayed) {
if (typeof message.toJSON === 'function') {
// If this is a persistent message from the server, treat it as html safe
if (message.get('status') === 'persistent') {
message.set('message', message.get('message').htmlSafe());
}
// If this is an alert message from the server, treat it as html safe
if (typeof message.toJSON === 'function' && message.get('status') === 'alert') {
message.set('message', message.get('message').htmlSafe());
}
if (!message.get('status')) {
message.set('status', 'passive');
}
} else {
if (!message.status) {
message.status = 'passive';
}
if (!Ember.get(message, 'status')) {
Ember.set(message, 'status', 'notification');
}
if (!delayed) {
this.get('content').pushObject(message);
} else {
this.delayedNotifications.pushObject(message);
this.get('delayedNotifications').pushObject(message);
}
},
showError: function (message, options) {
showAlert: function (message, options) {
options = options || {};
if (!options.doNotClosePassive) {
this.closePassive();
}
this.handleNotification({
type: 'error',
message: message
message: message,
status: 'alert',
type: options.type
}, options.delayed);
},
showNotification: function (message, options) {
options = options || {};
if (!options.doNotCloseNotifications) {
this.closeNotifications();
}
this.handleNotification({
message: message,
status: 'notification',
type: options.type
}, options.delayed);
},
// TODO: review whether this can be removed once no longer used by validations
showErrors: function (errors, options) {
options = options || {};
if (!options.doNotClosePassive) {
this.closePassive();
if (!options.doNotCloseNotifications) {
this.closeNotifications();
}
for (var i = 0; i < errors.length; i += 1) {
this.showError(errors[i].message || errors[i], {doNotClosePassive: true});
this.showNotification(errors[i].message || errors[i], {type: 'error', doNotCloseNotifications: true});
}
},
showAPIError: function (resp, options) {
options = options || {};
options.type = options.type || 'error';
if (!options.doNotClosePassive) {
this.closePassive();
if (!options.doNotCloseNotifications) {
this.closeNotifications();
}
options.defaultErrorText = options.defaultErrorText || 'There was a problem on the server, please try again.';
if (resp && resp.jqXHR && resp.jqXHR.responseJSON && resp.jqXHR.responseJSON.error) {
this.showError(resp.jqXHR.responseJSON.error, options);
this.showAlert(resp.jqXHR.responseJSON.error, options);
} else if (resp && resp.jqXHR && resp.jqXHR.responseJSON && resp.jqXHR.responseJSON.errors) {
this.showErrors(resp.jqXHR.responseJSON.errors, options);
} else if (resp && resp.jqXHR && resp.jqXHR.responseJSON && resp.jqXHR.responseJSON.message) {
this.showError(resp.jqXHR.responseJSON.message, options);
this.showAlert(resp.jqXHR.responseJSON.message, options);
} else {
this.showError(options.defaultErrorText, {doNotClosePassive: true});
this.showAlert(options.defaultErrorText, {type: options.type, doNotCloseNotifications: true});
}
},
showInfo: function (message, options) {
options = options || {};
if (!options.doNotClosePassive) {
this.closePassive();
}
this.handleNotification({
type: 'info',
message: message
}, options.delayed);
},
showSuccess: function (message, options) {
options = options || {};
if (!options.doNotClosePassive) {
this.closePassive();
}
this.handleNotification({
type: 'success',
message: message
}, options.delayed);
},
showWarn: function (message, options) {
options = options || {};
if (!options.doNotClosePassive) {
this.closePassive();
}
this.handleNotification({
type: 'warn',
message: message
}, options.delayed);
},
displayDelayed: function () {
var self = this;
@ -145,7 +101,7 @@ export default Ember.Service.extend({
closeNotification: function (notification) {
var content = this.get('content');
if (notification instanceof Notification) {
if (typeof notification.toJSON === 'function') {
notification.deleteRecord();
notification.save().finally(function () {
content.removeObject(notification);
@ -155,12 +111,8 @@ export default Ember.Service.extend({
}
},
closePassive: function () {
this.set('content', this.get('content').rejectBy('status', 'passive'));
},
closePersistent: function () {
this.set('content', this.get('content').rejectBy('status', 'persistent'));
closeNotifications: function () {
this.set('content', this.get('content').rejectBy('status', 'notification'));
},
closeAll: function () {

View File

@ -63,6 +63,16 @@
color: var(--red);
}
.gh-notification-passive {
animation: fade-out;
animation-delay: 5s;
animation-iteration-count: 1;
}
.gh-notification-passive:hover {
animation: fade-in;
}
/* Red notification
/* ---------------------------------------------------------- */

View File

@ -26,6 +26,7 @@
"password-generator": "git://github.com/bermi/password-generator#49accd7",
"rangyinputs": "1.2.0",
"showdown-ghost": "0.3.6",
"sinonjs": "1.14.1",
"validator-js": "3.39.0",
"xregexp": "2.0.0"
}

View File

@ -37,6 +37,7 @@
"ember-data": "1.0.0-beta.18",
"ember-export-application-global": "^1.0.2",
"ember-myth": "0.1.0",
"ember-sinon": "0.2.1",
"fs-extra": "0.16.3",
"glob": "^4.0.5"
},

View File

@ -0,0 +1,71 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
}
from 'ember-mocha';
import sinon from 'sinon';
describeComponent(
'gh-alert',
'GhAlertComponent', {
// specify the other units that are required for this test
// needs: ['component:foo', 'helper:bar']
},
function () {
it('renders', function () {
// creates the component instance
var component = this.subject();
expect(component._state).to.equal('preRender');
component.set('message', {message: 'Test message', type: 'success'});
// renders the component on the page
this.render();
expect(component._state).to.equal('inDOM');
expect(this.$().prop('tagName')).to.equal('ARTICLE');
expect(this.$().hasClass('gh-alert')).to.be.true;
expect(this.$().text()).to.match(/Test message/);
});
it('maps success alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'success'});
expect(this.$().hasClass('gh-alert-green')).to.be.true;
});
it('maps error alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'error'});
expect(this.$().hasClass('gh-alert-red')).to.be.true;
});
it('maps warn alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'warn'});
expect(this.$().hasClass('gh-alert-yellow')).to.be.true;
});
it('maps info alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'info'});
expect(this.$().hasClass('gh-alert-blue')).to.be.true;
});
it('closes notification through notifications service', function () {
var component = this.subject(),
notifications = {},
notification = {message: 'Test close', type: 'success'};
notifications.closeNotification = sinon.spy();
component.set('notifications', notifications);
component.set('message', notification);
this.$().find('button').click();
expect(notifications.closeNotification.calledWith(notification)).to.be.true;
});
}
);

View File

@ -0,0 +1,63 @@
/* jshint expr:true */
import Ember from 'ember';
import { expect } from 'chai';
import {
describeComponent,
it
}
from 'ember-mocha';
import sinon from 'sinon';
describeComponent(
'gh-alerts',
'GhAlertsComponent', {
// specify the other units that are required for this test
needs: ['component:gh-alert']
},
function () {
beforeEach(function () {
// Stub the notifications service
var notifications = Ember.Object.create();
notifications.alerts = Ember.A();
notifications.alerts.pushObject({message: 'First', type: 'error'});
notifications.alerts.pushObject({message: 'Second', type: 'warn'});
this.subject().set('notifications', notifications);
});
it('renders', function () {
// creates the component instance
var component = this.subject();
expect(component._state).to.equal('preRender');
// renders the component on the page
this.render();
expect(component._state).to.equal('inDOM');
expect(this.$().prop('tagName')).to.equal('ASIDE');
expect(this.$().hasClass('gh-alerts')).to.be.true;
expect(this.$().children().length).to.equal(2);
Ember.run(function () {
component.set('notifications.alerts', Ember.A());
});
expect(this.$().children().length).to.equal(0);
});
it('triggers "notify" action when message count changes', function () {
var component = this.subject();
component.sendAction = sinon.spy();
component.get('notifications.alerts')
.pushObject({message: 'New alert', type: 'info'});
expect(component.sendAction.calledWith('notify', 3)).to.be.true;
component.set('notifications.alerts', Ember.A());
expect(component.sendAction.calledWith('notify', 0)).to.be.true;
});
}
);

View File

@ -0,0 +1,82 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
}
from 'ember-mocha';
import sinon from 'sinon';
describeComponent(
'gh-notification',
'GhNotificationComponent', {
// specify the other units that are required for this test
// needs: ['component:foo', 'helper:bar']
},
function () {
it('renders', function () {
// creates the component instance
var component = this.subject();
expect(component._state).to.equal('preRender');
component.set('message', {message: 'Test message', type: 'success'});
// renders the component on the page
this.render();
expect(component._state).to.equal('inDOM');
expect(this.$().prop('tagName')).to.equal('ARTICLE');
expect(this.$().is('.gh-notification, .gh-notification-passive')).to.be.true;
expect(this.$().text()).to.match(/Test message/);
});
it('maps success alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'success'});
expect(this.$().hasClass('gh-notification-green')).to.be.true;
});
it('maps error alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'error'});
expect(this.$().hasClass('gh-notification-red')).to.be.true;
});
it('maps warn alert type to correct class', function () {
var component = this.subject();
component.set('message', {message: 'Test message', type: 'warn'});
expect(this.$().hasClass('gh-notification-yellow')).to.be.true;
});
it('closes notification through notifications service', function () {
var component = this.subject(),
notifications = {},
notification = {message: 'Test close', type: 'success'};
notifications.closeNotification = sinon.spy();
component.set('notifications', notifications);
component.set('message', notification);
this.$().find('button').click();
expect(notifications.closeNotification.calledWith(notification)).to.be.true;
});
it('closes notification when animationend event is triggered', function (done) {
var component = this.subject(),
notifications = {},
notification = {message: 'Test close', type: 'success'};
notifications.closeNotification = sinon.spy();
component.set('notifications', notifications);
component.set('message', notification);
// shorten the animation delay to speed up test
this.$().css('animation-delay', '0.1s');
setTimeout(function () {
expect(notifications.closeNotification.calledWith(notification)).to.be.true;
done();
}, 150);
});
}
);

View File

@ -0,0 +1,47 @@
/* jshint expr:true */
import Ember from 'ember';
import { expect } from 'chai';
import {
describeComponent,
it
}
from 'ember-mocha';
describeComponent(
'gh-notifications',
'GhNotificationsComponent', {
// specify the other units that are required for this test
needs: ['component:gh-notification']
},
function () {
beforeEach(function () {
// Stub the notifications service
var notifications = Ember.Object.create();
notifications.notifications = Ember.A();
notifications.notifications.pushObject({message: 'First', type: 'error'});
notifications.notifications.pushObject({message: 'Second', type: 'warn'});
this.subject().set('notifications', notifications);
});
it('renders', function () {
// creates the component instance
var component = this.subject();
expect(component._state).to.equal('preRender');
// renders the component on the page
this.render();
expect(component._state).to.equal('inDOM');
expect(this.$().prop('tagName')).to.equal('ASIDE');
expect(this.$().hasClass('gh-notifications')).to.be.true;
expect(this.$().children().length).to.equal(2);
Ember.run(function () {
component.set('notifications.notifications', Ember.A());
});
expect(this.$().children().length).to.equal(0);
});
}
);

View File

@ -0,0 +1,286 @@
/* jshint expr:true */
import Ember from 'ember';
import sinon from 'sinon';
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'service:notifications',
'NotificationsService',
{
// Specify the other units that are required for this test.
// needs: ['model:notification']
},
function () {
beforeEach(function () {
this.subject().set('content', Ember.A());
this.subject().set('delayedNotifications', Ember.A());
});
it('filters alerts/notifications', function () {
var notifications = this.subject();
notifications.set('content', [
{message: 'Alert', status: 'alert'},
{message: 'Notification', status: 'notification'}
]);
expect(notifications.get('alerts'))
.to.deep.equal([{message: 'Alert', status: 'alert'}]);
expect(notifications.get('notifications'))
.to.deep.equal([{message: 'Notification', status: 'notification'}]);
});
it('#handleNotification deals with DS.Notification notifications', function () {
var notifications = this.subject(),
notification = Ember.Object.create({message: '<h1>Test</h1>', status: 'alert'});
notification.toJSON = function () {};
notifications.handleNotification(notification);
notification = notifications.get('alerts')[0];
// alerts received from the server should be marked html safe
expect(notification.get('message')).to.have.property('toHTML');
});
it('#handleNotification defaults to notification if no status supplied', function () {
var notifications = this.subject();
notifications.handleNotification({message: 'Test'}, false);
expect(notifications.get('content'))
.to.deep.include({message: 'Test', status: 'notification'});
});
it('#showAlert adds POJO alerts', function () {
var notifications = this.subject();
notifications.showAlert('Test Alert', {type: 'error'});
expect(notifications.get('alerts'))
.to.deep.include({message: 'Test Alert', status: 'alert', type: 'error'});
});
it('#showAlert adds delayed notifications', function () {
var notifications = this.subject();
notifications.showNotification('Test Alert', {type: 'error', delayed: true});
expect(notifications.get('delayedNotifications'))
.to.deep.include({message: 'Test Alert', status: 'notification', type: 'error'});
});
it('#showNotification adds POJO notifications', function () {
var notifications = this.subject();
notifications.showNotification('Test Notification', {type: 'success'});
expect(notifications.get('notifications'))
.to.deep.include({message: 'Test Notification', status: 'notification', type: 'success'});
});
it('#showNotification adds delayed notifications', function () {
var notifications = this.subject();
notifications.showNotification('Test Notification', {delayed: true});
expect(notifications.get('delayedNotifications'))
.to.deep.include({message: 'Test Notification', status: 'notification', type: undefined});
});
it('#showNotification clears existing notifications', function () {
var notifications = this.subject();
notifications.showNotification('First');
notifications.showNotification('Second');
expect(notifications.get('content.length')).to.equal(1);
expect(notifications.get('content'))
.to.deep.equal([{message: 'Second', status: 'notification', type: undefined}]);
});
it('#showNotification keeps existing notifications if doNotCloseNotifications option passed', function () {
var notifications = this.subject();
notifications.showNotification('First');
notifications.showNotification('Second', {doNotCloseNotifications: true});
expect(notifications.get('content.length')).to.equal(2);
});
// TODO: review whether this can be removed once it's no longer used by validations
it('#showErrors adds multiple notifications', function () {
var notifications = this.subject();
notifications.showErrors([
{message: 'First'},
{message: 'Second'}
]);
expect(notifications.get('content')).to.deep.equal([
{message: 'First', status: 'notification', type: 'error'},
{message: 'Second', status: 'notification', type: 'error'}
]);
});
it('#showAPIError adds single json response error', function () {
var notifications = this.subject(),
resp = {jqXHR: {responseJSON: {error: 'Single error'}}};
notifications.showAPIError(resp);
expect(notifications.get('content')).to.deep.equal([
{message: 'Single error', status: 'alert', type: 'error'}
]);
});
// used to display validation errors returned from the server
it('#showAPIError adds multiple json response errors', function () {
var notifications = this.subject(),
resp = {jqXHR: {responseJSON: {errors: ['First error', 'Second error']}}};
notifications.showAPIError(resp);
expect(notifications.get('content')).to.deep.equal([
{message: 'First error', status: 'notification', type: 'error'},
{message: 'Second error', status: 'notification', type: 'error'}
]);
});
it('#showAPIError adds single json response message', function () {
var notifications = this.subject(),
resp = {jqXHR: {responseJSON: {message: 'Single message'}}};
notifications.showAPIError(resp);
expect(notifications.get('content')).to.deep.equal([
{message: 'Single message', status: 'alert', type: 'error'}
]);
});
it('#showAPIError displays default error text if response has no error/message', function () {
var notifications = this.subject(),
resp = {};
notifications.showAPIError(resp);
expect(notifications.get('content')).to.deep.equal([
{message: 'There was a problem on the server, please try again.', status: 'alert', type: 'error'}
]);
notifications.set('content', Ember.A());
notifications.showAPIError(resp, {defaultErrorText: 'Overridden default'});
expect(notifications.get('content')).to.deep.equal([
{message: 'Overridden default', status: 'alert', type: 'error'}
]);
});
it('#displayDelayed moves delayed notifications into content', function () {
var notifications = this.subject();
notifications.showNotification('First', {delayed: true});
notifications.showNotification('Second', {delayed: true});
notifications.showNotification('Third', {delayed: false});
notifications.displayDelayed();
expect(notifications.get('content')).to.deep.equal([
{message: 'Third', status: 'notification', type: undefined},
{message: 'First', status: 'notification', type: undefined},
{message: 'Second', status: 'notification', type: undefined}
]);
});
it('#closeNotification removes POJO notifications', function () {
var notification = {message: 'Close test', status: 'notification'},
notifications = this.subject();
notifications.handleNotification(notification);
expect(notifications.get('notifications'))
.to.include(notification);
notifications.closeNotification(notification);
expect(notifications.get('notifications'))
.to.not.include(notification);
});
it('#closeNotification removes and deletes DS.Notification records', function () {
var notification = Ember.Object.create({message: 'Close test', status: 'alert'}),
notifications = this.subject();
notification.toJSON = function () {};
notification.deleteRecord = function () {};
sinon.spy(notification, 'deleteRecord');
notification.save = function () {
return {
finally: function (callback) { return callback(notification); }
};
};
sinon.spy(notification, 'save');
notifications.handleNotification(notification);
expect(notifications.get('alerts')).to.include(notification);
notifications.closeNotification(notification);
expect(notification.deleteRecord.calledOnce).to.be.true;
expect(notification.save.calledOnce).to.be.true;
// wrap in runloop so filter updates
Ember.run.next(function () {
expect(notifications.get('alerts')).to.not.include(notification);
});
});
it('#closeNotifications only removes notifications', function () {
var notifications = this.subject();
notifications.showAlert('First alert');
notifications.showNotification('First notification');
notifications.showNotification('Second notification', {doNotCloseNotifications: true});
expect(notifications.get('alerts.length')).to.equal(1);
expect(notifications.get('notifications.length')).to.equal(2);
notifications.closeNotifications();
// wrap in runloop so filter updates
Ember.run.next(function () {
expect(notifications.get('alerts.length')).to.equal(1);
expect(notifications.get('notifications.length')).to.equal(1);
});
});
it('#closeAll removes everything without deletion', function () {
var notifications = this.subject(),
notificationModel = Ember.Object.create({message: 'model'});
notificationModel.toJSON = function () {};
notificationModel.deleteRecord = function () {};
sinon.spy(notificationModel, 'deleteRecord');
notificationModel.save = function () {
return {
finally: function (callback) { return callback(notificationModel); }
};
};
sinon.spy(notificationModel, 'save');
notifications.handleNotification(notificationModel);
notifications.handleNotification({message: 'pojo'});
notifications.closeAll();
expect(notifications.get('content')).to.be.empty;
expect(notificationModel.deleteRecord.called).to.be.false;
expect(notificationModel.save.called).to.be.false;
});
}
);