yellow team mailing list archive
-
yellow team
-
Mailing list archive
-
Message #01621
[Merge] lp:~frankban/juju-gui/bug-1076404-growl into lp:juju-gui
Francesco Banconi has proposed merging lp:~frankban/juju-gui/bug-1076404-growl into lp:juju-gui.
Requested reviews:
Juju GUI Hackers (juju-gui)
Related bugs:
Bug #1076404 in juju-ui: "User notifications need better immediate feedback"
https://bugs.launchpad.net/juju-gui/+bug/1076404
For more details, see:
https://code.launchpad.net/~frankban/juju-gui/bug-1076404-growl/+merge/134660
Growl-style notifications.
This branch introduces growl-style notifications
in the top-right corner of the window.
Implemented a notifier widget: it is a reusable
piece of code that just need a title, a message
and a timeout to display a notification. It is used
to render notification outside view containers, so
that notifications are preserved when the user changes
the current view.
A notifier is created when a notification is added,
following these rules:
- the notification is an error;
- the notification is local (it's not related to the
delta stream).
The last point involves adding a new field "isDelta"
to the notification model, defaulting to false.
Notifications created when the delta stream arrives are
marked as "isDelta: true".
UI: new notifications appear on top, and disappear after
8 seconds. Mouse over prevents a notification to hide,
mouse click destroys a notification.
Also fixed a bug in the unit view: a callable was undefined
when trying to "retry" or "resolve" a unit.
This branch is intended to be a first implementation proposal
of the growl notifications. It doesn't solve open issues like:
- how to handle/aggregate multiple notifications;
- how to correctly generate and format titles and messages;
- where notifications should appear in the window.
https://codereview.appspot.com/6851058/
--
https://code.launchpad.net/~frankban/juju-gui/bug-1076404-growl/+merge/134660
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~frankban/juju-gui/bug-1076404-growl into lp:juju-gui.
=== modified file 'app/app.js'
--- app/app.js 2012-11-07 12:35:06 +0000
+++ app/app.js 2012-11-16 13:56:21 +0000
@@ -280,7 +280,8 @@
'unit',
// The querystring is used to handle highlighting relation rows in
// links from notifications about errors.
- { unit: unit, db: this.db, env: this.env,
+ { getModelURL: Y.bind(this.getModelURL, this),
+ unit: unit, db: this.db, env: this.env,
querystring: req.query });
},
=== added file 'app/assets/images/notifier-error.png'
Binary files app/assets/images/notifier-error.png 1970-01-01 00:00:00 +0000 and app/assets/images/notifier-error.png 2012-11-16 13:56:21 +0000 differ
=== modified file 'app/index.html'
--- app/index.html 2012-11-13 18:18:47 +0000
+++ app/index.html 2012-11-16 13:56:21 +0000
@@ -18,6 +18,7 @@
<body>
+ <div id="notifier-box"></div>
<div id="viewport-wrapper">
<div id="vp-left-border"></div>
<div id="viewport">
=== modified file 'app/models/models.js'
--- app/models/models.js 2012-10-19 01:44:45 +0000
+++ app/models/models.js 2012-11-16 13:56:21 +0000
@@ -309,6 +309,8 @@
[model.name,
(model instanceof Y.Model) ? model.get('id') : model.id]);
}},
+ // Whether or not the notification is related to the delta stream.
+ isDelta: {value: false},
link: {},
link_title: {
value: 'View Details'
=== modified file 'app/modules-debug.js'
--- app/modules-debug.js 2012-11-13 16:46:36 +0000
+++ app/modules-debug.js 2012-11-16 13:56:21 +0000
@@ -24,6 +24,10 @@
modules: {
// Primitives
+ 'notifier': {
+ fullpath: '/juju-ui/widgets/notifier.js'
+ },
+
'svg-layouts': {
fullpath: '/juju-ui/assets/javascripts/svg-layouts.js'
},
=== modified file 'app/store/notifications.js'
--- app/store/notifications.js 2012-10-15 10:07:49 +0000
+++ app/store/notifications.js 2012-11-16 13:56:21 +0000
@@ -156,7 +156,7 @@
var change_type = change[0],
change_op = change[1],
change_data = change[2],
- notify_data = {},
+ notify_data = {isDelta: true},
rule = rules[change_type],
model;
=== added file 'app/templates/notifier.handlebars'
--- app/templates/notifier.handlebars 1970-01-01 00:00:00 +0000
+++ app/templates/notifier.handlebars 2012-11-16 13:56:21 +0000
@@ -0,0 +1,3 @@
+<i class="sprite notifier-error"></i>
+<h3>{{title}}</h3>
+<div>{{message}}</div>
=== modified file 'app/views/notifications.js'
--- app/views/notifications.js 2012-10-05 13:08:43 +0000
+++ app/views/notifications.js 2012-11-16 13:56:21 +0000
@@ -3,10 +3,11 @@
YUI.add('juju-notifications', function(Y) {
var views = Y.namespace('juju.views'),
+ widgets = Y.namespace('juju.widgets'),
Templates = views.Templates;
/*
- * Abstract Base class used to view a ModelList of notifications
+ * Abstract Base class used to view a ModelList of notifications
*/
var NotificationsBaseView = Y.Base.create('NotificationsBaseView',
Y.View, [views.JujuBaseView], {
@@ -24,10 +25,36 @@
notifications.after('create', this.slowRender, this);
notifications.after('remove', this.slowRender, this);
notifications.after('reset', this.slowRender, this);
+ // Bind new notifications to the notifier widget.
+ notifications.after('add', this.addNotifier, this);
// Env connection state watcher
env.on('connectedChange', this.slowRender, this);
+ },
+ /**
+ * Create and display a notifier widget when a notification is added.
+ * The notifier is created only if:
+ * - the notifier box exists in the DOM;
+ * - the notification is a local one (not related to the delta stream);
+ * - the notification is an error.
+ *
+ * @method addNotifier
+ * @param {Object} ev An event object (with a "model" attribute).
+ * @return {undefined} Mutates only.
+ */
+ addNotifier: function(ev) {
+ var notification = ev.model,
+ notifierBox = Y.one('#notifier-box');
+ // Show error notifications only if the DOM contain the notifier box.
+ if (notifierBox &&
+ !notification.get('isDelta') &&
+ notification.get('level') === 'error') {
+ new widgets.Notifier({
+ title: notification.get('title'),
+ message: notification.get('message')
+ }).render(notifierBox);
+ }
},
/*
@@ -251,6 +278,7 @@
'view',
'juju-view-utils',
'node',
- 'handlebars'
+ 'handlebars',
+ 'notifier'
]
});
=== added directory 'app/widgets'
=== added file 'app/widgets/notifier.js'
--- app/widgets/notifier.js 1970-01-01 00:00:00 +0000
+++ app/widgets/notifier.js 2012-11-16 13:56:21 +0000
@@ -0,0 +1,148 @@
+'use strict';
+
+YUI.add('notifier', function(Y) {
+
+ var widgets = Y.namespace('juju.widgets');
+
+ /**
+ * Display a notification.
+ * This is the constructor for the notifier widget.
+ *
+ * @class Notifier
+ * @namespace widgets
+ */
+ function Notifier(config) {
+ Notifier.superclass.constructor.apply(this, arguments);
+ }
+
+ Notifier.NAME = 'Notifier';
+ Notifier.ATTRS = {
+ title: {value: ''},
+ message: {value: ''},
+ timeout: {value: 8000}
+ };
+
+ /**
+ * Define the widget class extending Y.Widget.
+ *
+ * @class Notifier
+ * @namespace widgets
+ */
+ Y.extend(Notifier, Y.Widget, {
+
+ CONTENT_TEMPLATE: null,
+ TEMPLATE: Y.namespace('juju.views').Templates.notifier,
+
+ /**
+ * Attach the widget bounding box to the DOM.
+ * Override to insert new instances before existing ones.
+ * This way new notification are displayed on top of the page.
+ * The resulting rendering process is also very simplified.
+ *
+ * @method _renderBox
+ * @protected
+ * @param {Y.Node object} parentNode The node containing this widget.
+ * @return {undefined} Mutates only.
+ */
+ _renderBox: function(parentNode) {
+ parentNode.prepend(this.get('boundingBox'));
+ },
+
+ /**
+ * Create the nodes required by this widget and attach them to the DOM.
+ *
+ * @method renderUI
+ * @return {undefined} Mutates only.
+ */
+ renderUI: function() {
+ var content = this.TEMPLATE({
+ title: this.get('title'),
+ message: this.get('message')
+ });
+ this.get('contentBox').append(content);
+ },
+
+ /**
+ * Attach event listeners which bind the UI to the widget state.
+ * The mouse enter event on a notification node pauses the timer.
+ * The mouse click event on a notification destroys the widget.
+ *
+ * @method bindUI
+ * @return {undefined} Mutates only.
+ */
+ bindUI: function() {
+ var contentBox = this.get('contentBox');
+ contentBox.on(
+ 'hover',
+ function() {
+ if (this.timer) {
+ this.timer.pause();
+ }
+ },
+ function() {
+ if (this.timer) {
+ this.timer.resume();
+ }
+ },
+ this
+ );
+ contentBox.on('click', function(ev) {
+ this.hideAndDestroy();
+ ev.halt();
+ }, this);
+ },
+
+ /**
+ * Create and start the timer that will destroy the widget after N seconds.
+ *
+ * @method syncUI
+ * @return {undefined} Mutates only.
+ */
+ syncUI: function() {
+ this.timer = new Y.Timer({
+ length: this.get('timeout'),
+ repeatCount: 1,
+ callback: Y.bind(this.hideAndDestroy, this)
+ });
+ this.timer.start();
+ },
+
+ /**
+ * Hide this widget using an animation and destroy the widget at the end.
+ *
+ * @method hideAndDestroy
+ * @return {undefined} Mutates only.
+ */
+ hideAndDestroy: function() {
+ this.timer.stop();
+ this.timer = null;
+ if (this.get('boundingBox').getDOMNode()) {
+ // Animate and destroy the notification if it still exists in the DOM.
+ var anim = new Y.Anim({
+ node: this.get('boundingBox'),
+ to: {opacity: 0},
+ easing: 'easeIn',
+ duration: 0.2
+ });
+ anim.on('end', this.destroy, this);
+ anim.run();
+ } else {
+ // Otherwise, just destroy the notification.
+ this.destroy();
+ }
+ }
+
+ });
+
+ widgets.Notifier = Notifier;
+
+}, '0.1.0', {requires: [
+ 'anim',
+ 'event',
+ 'event-hover',
+ 'handlebars',
+ 'gallery-timer',
+ 'juju-templates',
+ 'node',
+ 'widget'
+]});
=== modified file 'lib/views/stylesheet.less'
--- lib/views/stylesheet.less 2012-11-15 14:33:54 +0000
+++ lib/views/stylesheet.less 2012-11-16 13:56:21 +0000
@@ -565,6 +565,56 @@
}
/*
+ * Notifier widget.
+ */
+#notifier-box {
+ position: absolute;
+ right: 0px;
+ top: 0px;
+ z-index: 9999;
+ .yui3-notifier-content {
+ @background-color: black;
+ @margin-left: 41px;
+ @margin-top: 13px;
+ @opacity: 0.8;
+ .create-border-radius(8px);
+ // Using a variable here because LESS strips commas in mixin args.
+ @box-shadow: 0 2px 4px lighten(@background-color, 10%),
+ 0 1px 2px lighten(@background-color, 20%) inset;
+ .create-box-shadow(@box-shadow);
+ background-color: @background-color;
+ margin: 6px;
+ opacity: @opacity;
+ overflow: hidden;
+ width: 277px;
+ &:hover {
+ cursor: pointer;
+ opacity: @opacity - 0.1;
+ }
+ h3 {
+ color: #FDF6E3;
+ font-size: 16px;
+ font-weight: normal;
+ margin-top: @margin-top;
+ margin-left: @margin-left;
+ }
+ div {
+ color: #DDD7C6;
+ font-size: 12px;
+ line-height: 16px;
+ margin-left: @margin-left;
+ margin-bottom: 12px;
+ }
+ .sprite {
+ float: left;
+ margin-top: @margin-top + 5px;
+ margin-left: 12px;
+ opacity: 1;
+ }
+ }
+}
+
+/*
* Overview Module
*/
#overview {
=== modified file 'test/index.html'
--- test/index.html 2012-11-13 13:55:16 +0000
+++ test/index.html 2012-11-16 13:56:21 +0000
@@ -33,6 +33,7 @@
<script src="test_endpoints.js"></script>
<script src="test_application_notifications.js"></script>
<script src="test_charm_store.js"></script>
+ <script src="test_notifier_widget.js"></script>
<script>
YUI().use('node', 'event', function(Y) {
=== modified file 'test/test_notifications.js'
--- test/test_notifications.js 2012-10-18 15:12:38 +0000
+++ test/test_notifications.js 2012-11-16 13:56:21 +0000
@@ -414,3 +414,64 @@
'Relation with endpoint1 (relation type "relation1") was created');
});
});
+
+describe('notification visual feedback', function() {
+ var env, models, notifications, notificationsView, notifierBox, views, Y;
+
+ before(function(done) {
+ Y = YUI(GlobalConfig).use('juju-env', 'juju-models', 'juju-views',
+ function(Y) {
+ var juju = Y.namespace('juju');
+ env = new juju.Environment();
+ models = Y.namespace('juju.models');
+ views = Y.namespace('juju.views');
+ done();
+ });
+ });
+
+ // Instantiate the notifications model list and view.
+ // Also create the notifier box and attach it as first element of the body.
+ beforeEach(function() {
+ notifications = new models.NotificationList();
+ notificationsView = new views.NotificationsView({
+ env: env,
+ notifications: notifications
+ });
+ notifierBox = Y.Node.create('<div id="notifier-box"></div>');
+ notifierBox.setStyle('display', 'none');
+ Y.one('body').prepend(notifierBox);
+ });
+
+ // Destroy the notifier box created in beforeEach.
+ afterEach(function() {
+ notifierBox.remove();
+ notifierBox.destroy(true);
+ });
+
+ // Assert the notifier box contains the expectedNumber of notifiers.
+ var assertNumNotifiers = function(expectedNumber) {
+ assert.equal(expectedNumber, notifierBox.get('children').size());
+ };
+
+ it('should appear when a new error is notified', function() {
+ notifications.add({title: 'mytitle', level: 'error'});
+ assertNumNotifiers(1);
+ });
+
+ it('should only appear when the DOM contains the notifier box', function() {
+ notifierBox.remove();
+ notifications.add({title: 'mytitle', level: 'error'});
+ assertNumNotifiers(0);
+ });
+
+ it('should not appear when the notification is not an error', function() {
+ notifications.add({title: 'mytitle', level: 'info'});
+ assertNumNotifiers(0);
+ });
+
+ it('should not appear when the notification comes form delta', function() {
+ notifications.add({title: 'mytitle', level: 'error', isDelta: true});
+ assertNumNotifiers(0);
+ });
+
+});
=== added file 'test/test_notifier_widget.js'
--- test/test_notifier_widget.js 1970-01-01 00:00:00 +0000
+++ test/test_notifier_widget.js 2012-11-16 13:56:21 +0000
@@ -0,0 +1,103 @@
+'use strict';
+
+describe('notifier widget', function() {
+ var Notifier, notifierBox, Y;
+
+ before(function(done) {
+ Y = YUI(GlobalConfig).use('notifier', 'node-event-simulate',
+ function(Y) {
+ Notifier = Y.namespace('juju.widgets').Notifier;
+ done();
+ });
+ });
+
+ // Create the notifier box and attach it as first element of the body.
+ beforeEach(function() {
+ notifierBox = Y.Node.create('<div id="notifier-box"></div>');
+ notifierBox.setStyle('display', 'none');
+ Y.one('body').prepend(notifierBox);
+ });
+
+ // Destroy the notifier box created in beforeEach.
+ afterEach(function() {
+ notifierBox.remove();
+ notifierBox.destroy(true);
+ });
+
+ // Factory rendering and returning a notifier instance.
+ var makeNotifier = function(title, message, timeout) {
+ var notifier = new Notifier({
+ title: title || 'mytitle',
+ message: message || 'mymessage',
+ timeout: timeout || 10000
+ });
+ notifier.render(notifierBox);
+ return notifier;
+ };
+
+ // Assert the notifier box contains the expectedNumber of notifiers.
+ var assertNumNotifiers = function(expectedNumber) {
+ assert.equal(expectedNumber, notifierBox.get('children').size());
+ };
+
+ it('should be able to display a notification', function() {
+ makeNotifier();
+ assertNumNotifiers(1);
+ });
+
+ it('should display the given title and message', function() {
+ makeNotifier('mytitle', 'mymessage');
+ var notifierNode = notifierBox.one('*');
+ assert.equal('mytitle', notifierNode.one('h3').getContent());
+ assert.equal('mymessage', notifierNode.one('div').getContent());
+ });
+
+ it('should be able to display multiple notifications', function() {
+ var number = 10;
+ for (var i = 0; i < number; i += 1) {
+ makeNotifier();
+ }
+ assertNumNotifiers(number);
+ });
+
+ it('should display new notifications on top', function() {
+ makeNotifier('mytitle1', 'mymessage1');
+ makeNotifier('mytitle2', 'mymessage2');
+ var notifierNode = notifierBox.one('*');
+ assert.equal('mytitle2', notifierNode.one('h3').getContent());
+ assert.equal('mymessage2', notifierNode.one('div').getContent());
+ });
+
+ it('should destroy notifications after N milliseconds', function(done) {
+ makeNotifier('mytitle', 'mymessage', 1);
+ // A timeout of 250 milliseconds is used so that we ensure the destroying
+ // animation can be completed.
+ setTimeout(function() {
+ assertNumNotifiers(0);
+ done();
+ }, 250);
+ });
+
+ it('should destroy notifications on click', function(done) {
+ makeNotifier();
+ notifierBox.one('*').simulate('click');
+ // A timeout of 250 milliseconds is used so that we ensure the destroying
+ // animation can be completed.
+ setTimeout(function() {
+ assertNumNotifiers(0);
+ done();
+ }, 250);
+ });
+
+ it('should prevent notification removal on mouse enter', function(done) {
+ makeNotifier('mytitle', 'mymessage', 1);
+ notifierBox.one('*').simulate('mouseover');
+ // A timeout of 250 milliseconds is used so that we ensure the node is not
+ // preserved by the destroying animation.
+ setTimeout(function() {
+ assertNumNotifiers(1);
+ done();
+ }, 250);
+ });
+
+});
Follow ups