Merge lp:~frankban/juju-gui/bug-1076404-growl into lp:juju-gui/experimental

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 248
Proposed branch: lp:~frankban/juju-gui/bug-1076404-growl
Merge into: lp:juju-gui/experimental
Diff against target: 543 lines (+406/-4)
12 files modified
app/app.js (+2/-1)
app/index.html (+1/-0)
app/models/models.js (+2/-0)
app/modules-debug.js (+4/-0)
app/store/notifications.js (+1/-1)
app/templates/notifier.handlebars (+3/-0)
app/views/notifications.js (+30/-2)
app/widgets/notifier.js (+148/-0)
lib/views/stylesheet.less (+50/-0)
test/index.html (+1/-0)
test/test_notifications.js (+61/-0)
test/test_notifier_widget.js (+103/-0)
To merge this branch: bzr merge lp:~frankban/juju-gui/bug-1076404-growl
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+134660@code.launchpad.net

Description of the change

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.

https://codereview.appspot.com/6851058/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+134660_code.launchpad.net,

Message:
Please take a look.

Description:
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://code.launchpad.net/~frankban/juju-gui/bug-1076404-growl/+merge/134660

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/6851058/

Affected files:
   A [revision details]
   M app/app.js
   A app/assets/images/notifier-error.png
   M app/index.html
   M app/models/models.js
   M app/modules-debug.js
   M app/store/notifications.js
   A app/templates/notifier.handlebars
   M app/views/notifications.js
   A app/widgets/notifier.js
   M lib/views/stylesheet.less
   M test/index.html
   M test/test_notifications.js
   A test/test_notifier_widget.js

Revision history for this message
Thiago Veronezi (tveronezi) wrote :

Thanks, Francesco.
I like it! I have just one really minor comment.

[]s,
Thiago.

https://codereview.appspot.com/6851058/diff/1/app/views/notifications.js
File app/views/notifications.js (right):

https://codereview.appspot.com/6851058/diff/1/app/views/notifications.js#newcode50
app/views/notifications.js:50: if (notifierBox &&
I think will you always have the 'notifier-box' element. It is
hard-coded in index.html. So probably you can remove this check.

https://codereview.appspot.com/6851058/

Revision history for this message
Francesco Banconi (frankban) wrote :

Thanks for the review Thiago! A reply to your comment follows.

https://codereview.appspot.com/6851058/diff/1/app/views/notifications.js
File app/views/notifications.js (right):

https://codereview.appspot.com/6851058/diff/1/app/views/notifications.js#newcode50
app/views/notifications.js:50: if (notifierBox &&
On 2012/11/16 14:39:29, thiago wrote:
> I think will you always have the 'notifier-box' element. It is
hard-coded in
> index.html. So probably you can remove this check.

The check is here to make tests work. The element is not present in the
test page, and it's created by tests' setUp and tearDown only when
needed, so that notifications are not created as side effects by
non-relevant test cases.

https://codereview.appspot.com/6851058/

Revision history for this message
Madison Scott-Clary (makyo) wrote :

This all looks pretty good to me, Francesco! Thanks for the branch.
Works well, tests pass.

https://codereview.appspot.com/6851058/diff/1/lib/views/stylesheet.less
File lib/views/stylesheet.less (right):

https://codereview.appspot.com/6851058/diff/1/lib/views/stylesheet.less#newcode592
lib/views/stylesheet.less:592: opacity: @opacity - 0.1;
Less is so neat, sometimes :)

https://codereview.appspot.com/6851058/

Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

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.

R=thiago, matthew.scott
CC=
https://codereview.appspot.com/6851058

https://codereview.appspot.com/6851058/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'app/app.js'
2--- app/app.js 2012-11-07 12:35:06 +0000
3+++ app/app.js 2012-11-16 13:56:21 +0000
4@@ -280,7 +280,8 @@
5 'unit',
6 // The querystring is used to handle highlighting relation rows in
7 // links from notifications about errors.
8- { unit: unit, db: this.db, env: this.env,
9+ { getModelURL: Y.bind(this.getModelURL, this),
10+ unit: unit, db: this.db, env: this.env,
11 querystring: req.query });
12 },
13
14
15=== added file 'app/assets/images/notifier-error.png'
16Binary 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
17=== modified file 'app/index.html'
18--- app/index.html 2012-11-13 18:18:47 +0000
19+++ app/index.html 2012-11-16 13:56:21 +0000
20@@ -18,6 +18,7 @@
21
22 <body>
23
24+ <div id="notifier-box"></div>
25 <div id="viewport-wrapper">
26 <div id="vp-left-border"></div>
27 <div id="viewport">
28
29=== modified file 'app/models/models.js'
30--- app/models/models.js 2012-10-19 01:44:45 +0000
31+++ app/models/models.js 2012-11-16 13:56:21 +0000
32@@ -309,6 +309,8 @@
33 [model.name,
34 (model instanceof Y.Model) ? model.get('id') : model.id]);
35 }},
36+ // Whether or not the notification is related to the delta stream.
37+ isDelta: {value: false},
38 link: {},
39 link_title: {
40 value: 'View Details'
41
42=== modified file 'app/modules-debug.js'
43--- app/modules-debug.js 2012-11-13 16:46:36 +0000
44+++ app/modules-debug.js 2012-11-16 13:56:21 +0000
45@@ -24,6 +24,10 @@
46 modules: {
47 // Primitives
48
49+ 'notifier': {
50+ fullpath: '/juju-ui/widgets/notifier.js'
51+ },
52+
53 'svg-layouts': {
54 fullpath: '/juju-ui/assets/javascripts/svg-layouts.js'
55 },
56
57=== modified file 'app/store/notifications.js'
58--- app/store/notifications.js 2012-10-15 10:07:49 +0000
59+++ app/store/notifications.js 2012-11-16 13:56:21 +0000
60@@ -156,7 +156,7 @@
61 var change_type = change[0],
62 change_op = change[1],
63 change_data = change[2],
64- notify_data = {},
65+ notify_data = {isDelta: true},
66 rule = rules[change_type],
67 model;
68
69
70=== added file 'app/templates/notifier.handlebars'
71--- app/templates/notifier.handlebars 1970-01-01 00:00:00 +0000
72+++ app/templates/notifier.handlebars 2012-11-16 13:56:21 +0000
73@@ -0,0 +1,3 @@
74+<i class="sprite notifier-error"></i>
75+<h3>{{title}}</h3>
76+<div>{{message}}</div>
77
78=== modified file 'app/views/notifications.js'
79--- app/views/notifications.js 2012-10-05 13:08:43 +0000
80+++ app/views/notifications.js 2012-11-16 13:56:21 +0000
81@@ -3,10 +3,11 @@
82 YUI.add('juju-notifications', function(Y) {
83
84 var views = Y.namespace('juju.views'),
85+ widgets = Y.namespace('juju.widgets'),
86 Templates = views.Templates;
87
88 /*
89- * Abstract Base class used to view a ModelList of notifications
90+ * Abstract Base class used to view a ModelList of notifications
91 */
92 var NotificationsBaseView = Y.Base.create('NotificationsBaseView',
93 Y.View, [views.JujuBaseView], {
94@@ -24,10 +25,36 @@
95 notifications.after('create', this.slowRender, this);
96 notifications.after('remove', this.slowRender, this);
97 notifications.after('reset', this.slowRender, this);
98+ // Bind new notifications to the notifier widget.
99+ notifications.after('add', this.addNotifier, this);
100
101 // Env connection state watcher
102 env.on('connectedChange', this.slowRender, this);
103+ },
104
105+ /**
106+ * Create and display a notifier widget when a notification is added.
107+ * The notifier is created only if:
108+ * - the notifier box exists in the DOM;
109+ * - the notification is a local one (not related to the delta stream);
110+ * - the notification is an error.
111+ *
112+ * @method addNotifier
113+ * @param {Object} ev An event object (with a "model" attribute).
114+ * @return {undefined} Mutates only.
115+ */
116+ addNotifier: function(ev) {
117+ var notification = ev.model,
118+ notifierBox = Y.one('#notifier-box');
119+ // Show error notifications only if the DOM contain the notifier box.
120+ if (notifierBox &&
121+ !notification.get('isDelta') &&
122+ notification.get('level') === 'error') {
123+ new widgets.Notifier({
124+ title: notification.get('title'),
125+ message: notification.get('message')
126+ }).render(notifierBox);
127+ }
128 },
129
130 /*
131@@ -251,6 +278,7 @@
132 'view',
133 'juju-view-utils',
134 'node',
135- 'handlebars'
136+ 'handlebars',
137+ 'notifier'
138 ]
139 });
140
141=== added directory 'app/widgets'
142=== added file 'app/widgets/notifier.js'
143--- app/widgets/notifier.js 1970-01-01 00:00:00 +0000
144+++ app/widgets/notifier.js 2012-11-16 13:56:21 +0000
145@@ -0,0 +1,148 @@
146+'use strict';
147+
148+YUI.add('notifier', function(Y) {
149+
150+ var widgets = Y.namespace('juju.widgets');
151+
152+ /**
153+ * Display a notification.
154+ * This is the constructor for the notifier widget.
155+ *
156+ * @class Notifier
157+ * @namespace widgets
158+ */
159+ function Notifier(config) {
160+ Notifier.superclass.constructor.apply(this, arguments);
161+ }
162+
163+ Notifier.NAME = 'Notifier';
164+ Notifier.ATTRS = {
165+ title: {value: ''},
166+ message: {value: ''},
167+ timeout: {value: 8000}
168+ };
169+
170+ /**
171+ * Define the widget class extending Y.Widget.
172+ *
173+ * @class Notifier
174+ * @namespace widgets
175+ */
176+ Y.extend(Notifier, Y.Widget, {
177+
178+ CONTENT_TEMPLATE: null,
179+ TEMPLATE: Y.namespace('juju.views').Templates.notifier,
180+
181+ /**
182+ * Attach the widget bounding box to the DOM.
183+ * Override to insert new instances before existing ones.
184+ * This way new notification are displayed on top of the page.
185+ * The resulting rendering process is also very simplified.
186+ *
187+ * @method _renderBox
188+ * @protected
189+ * @param {Y.Node object} parentNode The node containing this widget.
190+ * @return {undefined} Mutates only.
191+ */
192+ _renderBox: function(parentNode) {
193+ parentNode.prepend(this.get('boundingBox'));
194+ },
195+
196+ /**
197+ * Create the nodes required by this widget and attach them to the DOM.
198+ *
199+ * @method renderUI
200+ * @return {undefined} Mutates only.
201+ */
202+ renderUI: function() {
203+ var content = this.TEMPLATE({
204+ title: this.get('title'),
205+ message: this.get('message')
206+ });
207+ this.get('contentBox').append(content);
208+ },
209+
210+ /**
211+ * Attach event listeners which bind the UI to the widget state.
212+ * The mouse enter event on a notification node pauses the timer.
213+ * The mouse click event on a notification destroys the widget.
214+ *
215+ * @method bindUI
216+ * @return {undefined} Mutates only.
217+ */
218+ bindUI: function() {
219+ var contentBox = this.get('contentBox');
220+ contentBox.on(
221+ 'hover',
222+ function() {
223+ if (this.timer) {
224+ this.timer.pause();
225+ }
226+ },
227+ function() {
228+ if (this.timer) {
229+ this.timer.resume();
230+ }
231+ },
232+ this
233+ );
234+ contentBox.on('click', function(ev) {
235+ this.hideAndDestroy();
236+ ev.halt();
237+ }, this);
238+ },
239+
240+ /**
241+ * Create and start the timer that will destroy the widget after N seconds.
242+ *
243+ * @method syncUI
244+ * @return {undefined} Mutates only.
245+ */
246+ syncUI: function() {
247+ this.timer = new Y.Timer({
248+ length: this.get('timeout'),
249+ repeatCount: 1,
250+ callback: Y.bind(this.hideAndDestroy, this)
251+ });
252+ this.timer.start();
253+ },
254+
255+ /**
256+ * Hide this widget using an animation and destroy the widget at the end.
257+ *
258+ * @method hideAndDestroy
259+ * @return {undefined} Mutates only.
260+ */
261+ hideAndDestroy: function() {
262+ this.timer.stop();
263+ this.timer = null;
264+ if (this.get('boundingBox').getDOMNode()) {
265+ // Animate and destroy the notification if it still exists in the DOM.
266+ var anim = new Y.Anim({
267+ node: this.get('boundingBox'),
268+ to: {opacity: 0},
269+ easing: 'easeIn',
270+ duration: 0.2
271+ });
272+ anim.on('end', this.destroy, this);
273+ anim.run();
274+ } else {
275+ // Otherwise, just destroy the notification.
276+ this.destroy();
277+ }
278+ }
279+
280+ });
281+
282+ widgets.Notifier = Notifier;
283+
284+}, '0.1.0', {requires: [
285+ 'anim',
286+ 'event',
287+ 'event-hover',
288+ 'handlebars',
289+ 'gallery-timer',
290+ 'juju-templates',
291+ 'node',
292+ 'widget'
293+]});
294
295=== modified file 'lib/views/stylesheet.less'
296--- lib/views/stylesheet.less 2012-11-15 14:33:54 +0000
297+++ lib/views/stylesheet.less 2012-11-16 13:56:21 +0000
298@@ -565,6 +565,56 @@
299 }
300
301 /*
302+ * Notifier widget.
303+ */
304+#notifier-box {
305+ position: absolute;
306+ right: 0px;
307+ top: 0px;
308+ z-index: 9999;
309+ .yui3-notifier-content {
310+ @background-color: black;
311+ @margin-left: 41px;
312+ @margin-top: 13px;
313+ @opacity: 0.8;
314+ .create-border-radius(8px);
315+ // Using a variable here because LESS strips commas in mixin args.
316+ @box-shadow: 0 2px 4px lighten(@background-color, 10%),
317+ 0 1px 2px lighten(@background-color, 20%) inset;
318+ .create-box-shadow(@box-shadow);
319+ background-color: @background-color;
320+ margin: 6px;
321+ opacity: @opacity;
322+ overflow: hidden;
323+ width: 277px;
324+ &:hover {
325+ cursor: pointer;
326+ opacity: @opacity - 0.1;
327+ }
328+ h3 {
329+ color: #FDF6E3;
330+ font-size: 16px;
331+ font-weight: normal;
332+ margin-top: @margin-top;
333+ margin-left: @margin-left;
334+ }
335+ div {
336+ color: #DDD7C6;
337+ font-size: 12px;
338+ line-height: 16px;
339+ margin-left: @margin-left;
340+ margin-bottom: 12px;
341+ }
342+ .sprite {
343+ float: left;
344+ margin-top: @margin-top + 5px;
345+ margin-left: 12px;
346+ opacity: 1;
347+ }
348+ }
349+}
350+
351+/*
352 * Overview Module
353 */
354 #overview {
355
356=== modified file 'test/index.html'
357--- test/index.html 2012-11-13 13:55:16 +0000
358+++ test/index.html 2012-11-16 13:56:21 +0000
359@@ -33,6 +33,7 @@
360 <script src="test_endpoints.js"></script>
361 <script src="test_application_notifications.js"></script>
362 <script src="test_charm_store.js"></script>
363+ <script src="test_notifier_widget.js"></script>
364
365 <script>
366 YUI().use('node', 'event', function(Y) {
367
368=== modified file 'test/test_notifications.js'
369--- test/test_notifications.js 2012-10-18 15:12:38 +0000
370+++ test/test_notifications.js 2012-11-16 13:56:21 +0000
371@@ -414,3 +414,64 @@
372 'Relation with endpoint1 (relation type "relation1") was created');
373 });
374 });
375+
376+describe('notification visual feedback', function() {
377+ var env, models, notifications, notificationsView, notifierBox, views, Y;
378+
379+ before(function(done) {
380+ Y = YUI(GlobalConfig).use('juju-env', 'juju-models', 'juju-views',
381+ function(Y) {
382+ var juju = Y.namespace('juju');
383+ env = new juju.Environment();
384+ models = Y.namespace('juju.models');
385+ views = Y.namespace('juju.views');
386+ done();
387+ });
388+ });
389+
390+ // Instantiate the notifications model list and view.
391+ // Also create the notifier box and attach it as first element of the body.
392+ beforeEach(function() {
393+ notifications = new models.NotificationList();
394+ notificationsView = new views.NotificationsView({
395+ env: env,
396+ notifications: notifications
397+ });
398+ notifierBox = Y.Node.create('<div id="notifier-box"></div>');
399+ notifierBox.setStyle('display', 'none');
400+ Y.one('body').prepend(notifierBox);
401+ });
402+
403+ // Destroy the notifier box created in beforeEach.
404+ afterEach(function() {
405+ notifierBox.remove();
406+ notifierBox.destroy(true);
407+ });
408+
409+ // Assert the notifier box contains the expectedNumber of notifiers.
410+ var assertNumNotifiers = function(expectedNumber) {
411+ assert.equal(expectedNumber, notifierBox.get('children').size());
412+ };
413+
414+ it('should appear when a new error is notified', function() {
415+ notifications.add({title: 'mytitle', level: 'error'});
416+ assertNumNotifiers(1);
417+ });
418+
419+ it('should only appear when the DOM contains the notifier box', function() {
420+ notifierBox.remove();
421+ notifications.add({title: 'mytitle', level: 'error'});
422+ assertNumNotifiers(0);
423+ });
424+
425+ it('should not appear when the notification is not an error', function() {
426+ notifications.add({title: 'mytitle', level: 'info'});
427+ assertNumNotifiers(0);
428+ });
429+
430+ it('should not appear when the notification comes form delta', function() {
431+ notifications.add({title: 'mytitle', level: 'error', isDelta: true});
432+ assertNumNotifiers(0);
433+ });
434+
435+});
436
437=== added file 'test/test_notifier_widget.js'
438--- test/test_notifier_widget.js 1970-01-01 00:00:00 +0000
439+++ test/test_notifier_widget.js 2012-11-16 13:56:21 +0000
440@@ -0,0 +1,103 @@
441+'use strict';
442+
443+describe('notifier widget', function() {
444+ var Notifier, notifierBox, Y;
445+
446+ before(function(done) {
447+ Y = YUI(GlobalConfig).use('notifier', 'node-event-simulate',
448+ function(Y) {
449+ Notifier = Y.namespace('juju.widgets').Notifier;
450+ done();
451+ });
452+ });
453+
454+ // Create the notifier box and attach it as first element of the body.
455+ beforeEach(function() {
456+ notifierBox = Y.Node.create('<div id="notifier-box"></div>');
457+ notifierBox.setStyle('display', 'none');
458+ Y.one('body').prepend(notifierBox);
459+ });
460+
461+ // Destroy the notifier box created in beforeEach.
462+ afterEach(function() {
463+ notifierBox.remove();
464+ notifierBox.destroy(true);
465+ });
466+
467+ // Factory rendering and returning a notifier instance.
468+ var makeNotifier = function(title, message, timeout) {
469+ var notifier = new Notifier({
470+ title: title || 'mytitle',
471+ message: message || 'mymessage',
472+ timeout: timeout || 10000
473+ });
474+ notifier.render(notifierBox);
475+ return notifier;
476+ };
477+
478+ // Assert the notifier box contains the expectedNumber of notifiers.
479+ var assertNumNotifiers = function(expectedNumber) {
480+ assert.equal(expectedNumber, notifierBox.get('children').size());
481+ };
482+
483+ it('should be able to display a notification', function() {
484+ makeNotifier();
485+ assertNumNotifiers(1);
486+ });
487+
488+ it('should display the given title and message', function() {
489+ makeNotifier('mytitle', 'mymessage');
490+ var notifierNode = notifierBox.one('*');
491+ assert.equal('mytitle', notifierNode.one('h3').getContent());
492+ assert.equal('mymessage', notifierNode.one('div').getContent());
493+ });
494+
495+ it('should be able to display multiple notifications', function() {
496+ var number = 10;
497+ for (var i = 0; i < number; i += 1) {
498+ makeNotifier();
499+ }
500+ assertNumNotifiers(number);
501+ });
502+
503+ it('should display new notifications on top', function() {
504+ makeNotifier('mytitle1', 'mymessage1');
505+ makeNotifier('mytitle2', 'mymessage2');
506+ var notifierNode = notifierBox.one('*');
507+ assert.equal('mytitle2', notifierNode.one('h3').getContent());
508+ assert.equal('mymessage2', notifierNode.one('div').getContent());
509+ });
510+
511+ it('should destroy notifications after N milliseconds', function(done) {
512+ makeNotifier('mytitle', 'mymessage', 1);
513+ // A timeout of 250 milliseconds is used so that we ensure the destroying
514+ // animation can be completed.
515+ setTimeout(function() {
516+ assertNumNotifiers(0);
517+ done();
518+ }, 250);
519+ });
520+
521+ it('should destroy notifications on click', function(done) {
522+ makeNotifier();
523+ notifierBox.one('*').simulate('click');
524+ // A timeout of 250 milliseconds is used so that we ensure the destroying
525+ // animation can be completed.
526+ setTimeout(function() {
527+ assertNumNotifiers(0);
528+ done();
529+ }, 250);
530+ });
531+
532+ it('should prevent notification removal on mouse enter', function(done) {
533+ makeNotifier('mytitle', 'mymessage', 1);
534+ notifierBox.one('*').simulate('mouseover');
535+ // A timeout of 250 milliseconds is used so that we ensure the node is not
536+ // preserved by the destroying animation.
537+ setTimeout(function() {
538+ assertNumNotifiers(1);
539+ done();
540+ }, 250);
541+ });
542+
543+});

Subscribers

People subscribed via source and target branches