MDL-54708 message: add notification popover to nav bar
[moodle.git] / message / amd / src / notification_popover_controller.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Controls the notification popover in the nav bar.
18  *
19  * See template: message/notification_menu
20  *
21  * @module     message/notification_popover_controller
22  * @class      notification_popover_controller
23  * @package    message
24  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
25  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  * @since      3.2
27  */
28 define(['jquery', 'theme_bootstrapbase/bootstrap', 'core/ajax', 'core/templates', 'core/str',
29             'core/notification', 'core/custom_interaction_events', 'core/mdl_popover_controller',
30             'message/notification_repository'],
31         function($, bootstrap, ajax, templates, str, debugNotification, customEvents,
32             popoverController, notificationRepo) {
34     var SELECTORS = {
35         MARK_ALL_READ_BUTTON: '#mark-all-read-button',
36         USER_ID: 'data-userid',
37         MODE_TOGGLE: '.mdl-popover-header-actions .fancy-toggle',
38         UNREAD_NOTIFICATIONS_CONTAINER: '.unread-notifications',
39         ALL_NOTIFICATIONS_CONTAINER: '.all-notifications',
40         SHOW_BUTTON: '.show-button',
41         HIDE_BUTTON: '.hide-button',
42         CONTENT_ITEM_CONTAINER: '.content-item-container',
43         EMPTY_MESSAGE: '.empty-message',
44         CONTENT_BODY_SHORT: '.content-body-short',
45         CONTENT_BODY_FULL: '.content-body-full',
46     };
48     /**
49      * Constructor for the NotificationPopoverController.
50      * Extends MdlPopoverController.
51      *
52      * @param element jQuery object root element of the popover
53      * @return object NotificationPopoverController
54      */
55     var NotificationPopoverController = function(element) {
56         // Initialise base class.
57         popoverController.call(this, element);
59         this.markAllReadButton = this.root.find(SELECTORS.MARK_ALL_READ_BUTTON);
60         this.unreadCount = 0;
61         this.userId = this.root.attr(SELECTORS.USER_ID);
62         this.modeToggle = this.root.find(SELECTORS.MODE_TOGGLE);
63         this.state = {
64             unread: {
65                 container: this.root.find(SELECTORS.UNREAD_NOTIFICATIONS_CONTAINER),
66                 limit: 6,
67                 offset: 0,
68                 loadedAll: false,
69                 initialLoad: false,
70             },
71             all: {
72                 container: this.root.find(SELECTORS.ALL_NOTIFICATIONS_CONTAINER),
73                 limit: 20,
74                 offset: 0,
75                 loadedAll: false,
76                 initialLoad: false,
77             }
78         };
80         // Let's find out how many unread notifications there are.
81         this.loadUnreadNotificationCount();
82         this.root.find('[data-toggle="tooltip"]').tooltip();
83     };
85     /**
86      * Clone the parent prototype.
87      */
88     NotificationPopoverController.prototype = Object.create(popoverController.prototype);
90     /**
91      * Set the correct aria label on the menu toggle button to be read out by screen
92      * readers. The message will indicate the state of the unread notifications.
93      *
94      * @method updateButtonAriaLabel
95      */
96     NotificationPopoverController.prototype.updateButtonAriaLabel = function() {
97         if (this.isMenuOpen()) {
98             str.get_string('hidenotificationwindow', 'message').done(function(string) {
99                 this.menuToggle.attr('aria-label', string);
100             }.bind(this));
101         } else {
102             if (this.unreadCount) {
103                 str.get_string('shownotificationwindowwithcount', 'message', this.unreadCount).done(function(string) {
104                     this.menuToggle.attr('aria-label', string);
105                 }.bind(this));
106             } else {
107                 str.get_string('shownotificationwindownonew', 'message').done(function(string) {
108                     this.menuToggle.attr('aria-label', string);
109                 }.bind(this));
110             }
111         }
112     };
114     /**
115      * Return the jQuery element with the content. This will return either
116      * the unread notification container or the all notification container
117      * depending on which is currently visible.
118      *
119      * @method getContent
120      * @return jQuery object currently visible content contianer
121      */
122     NotificationPopoverController.prototype.getContent = function() {
123         return this.getState().container;
124     };
126     /**
127      * Check whether the notification menu is showing unread notification or
128      * all notifications.
129      *
130      * @method unreadOnlyMode
131      * @return bool true if only showing unread notifications, false otherwise
132      */
133     NotificationPopoverController.prototype.unreadOnlyMode = function() {
134         return this.modeToggle.hasClass('on');
135     };
137     /**
138      * Get the current state of the notification menu. Checks whether
139      * the popover is in unread only mode.
140      *
141      * The internal state tracks various properties required for loading
142      * notifications.
143      *
144      * @method getState
145      * @return object unread state or all state
146      */
147     NotificationPopoverController.prototype.getState = function() {
148         if (this.unreadOnlyMode()) {
149             return this.state.unread;
150         } else {
151             return this.state.all;
152         }
153     };
155     /**
156      * Get the offset value for the current state of the popover in order
157      * to sent to the backend to correctly paginate the notifications.
158      *
159      * @method getOffset
160      * @return int current offset
161      */
162     NotificationPopoverController.prototype.getOffset = function() {
163         return this.getState().offset;
164     };
166     /**
167      * Increment the offset for the current state, if required.
168      *
169      * @method incrementOffset
170      */
171     NotificationPopoverController.prototype.incrementOffset = function() {
172         // Only need to increment offset if we're combining read and unread
173         // because all unread messages are marked as read when we retrieve them
174         // which acts as the result set increment for us.
175         if (!this.unreadOnlyMode()) {
176             this.getState().offset += this.getState().limit;
177         }
178     };
180     /**
181      * Reset the offset to zero for the current state.
182      *
183      * @method resetOffset
184      */
185     NotificationPopoverController.prototype.resetOffset = function() {
186         this.getState().offset = 0;
187     };
189     /**
190      * Check if the first load of notification has been triggered for the current
191      * state of the popover.
192      *
193      * @method hasDoneInitialLoad
194      * @return bool true if first notification loaded, false otherwise
195      */
196     NotificationPopoverController.prototype.hasDoneInitialLoad = function() {
197         return this.getState().initialLoad;
198     };
200     /**
201      * Check if we've loaded all of the notifications for the current popover
202      * state.
203      *
204      * @method hasLoadedAllContent
205      * @return bool true if all notifications loaded, false otherwise
206      */
207     NotificationPopoverController.prototype.hasLoadedAllContent = function() {
208         return this.getState().loadedAll;
209     };
211     /**
212      * Set the state of the loaded all content property for the current state
213      * of the popover.
214      *
215      * @method setLoadedAllContent
216      * @param bool true if all content is loaded, false otherwise
217      */
218     NotificationPopoverController.prototype.setLoadedAllContent = function(val) {
219         this.getState().loadedAll = val;
220     };
222     /**
223      * Reset the unread notification state and empty the unread notification content
224      * element.
225      *
226      * @method clearUnreadNotifications
227      */
228     NotificationPopoverController.prototype.clearUnreadNotifications = function() {
229         this.state.unread.offset = 0;
230         this.state.unread.loadedAll = false;
231         this.state.unread.initialLoad = false;
232         this.state.unread.container.empty();
233     };
235     /**
236      * Show the unread notification count badge on the menu toggle if there
237      * are unread notifications, otherwise hide it.
238      *
239      * @method renderUnreadCount
240      */
241     NotificationPopoverController.prototype.renderUnreadCount = function() {
242         var element = this.root.find('.count-container');
244         if (this.unreadCount) {
245             element.text(this.unreadCount);
246             element.removeClass('hidden');
247         } else {
248             element.addClass('hidden');
249         }
250     };
252     /**
253      * Hide the unread notification count badge on the menu toggle.
254      *
255      * @method hideUnreadCount
256      */
257     NotificationPopoverController.prototype.hideUnreadCount = function() {
258         this.root.find('.count-container').addClass('hidden');
259     };
261     /**
262      * Ask the server how many unread notifications are left, render the value
263      * as a badge on the menu toggle and update the aria labels on the menu
264      * toggle.
265      *
266      * @method loadUnreadNotificationCount
267      */
268     NotificationPopoverController.prototype.loadUnreadNotificationCount = function() {
269         notificationRepo.countUnread({useridto: this.userId}).then(function(count) {
270             this.unreadCount = count;
271             this.renderUnreadCount();
272             this.updateButtonAriaLabel();
273         }.bind(this));
274     };
276     /**
277      * Render the notification data with the appropriate template and add it to the DOM.
278      *
279      * @method renderNotifications
280      * @param notifications array notification data
281      * @param container jQuery object the container to append the rendered notifications
282      * @return jQuery promise that is resolved when all notifications have been
283      *                rendered and added to the DOM
284      */
285     NotificationPopoverController.prototype.renderNotifications = function(notifications, container) {
286         var promises = [];
288         if (notifications.length) {
289             $.each(notifications, function(index, notification) {
290                 var promise = templates.render('message/notification_content_item', notification);
291                 promise.then(function(html, js) {
292                     container.append(html);
293                     templates.runTemplateJS(js);
294                 }.bind(this));
296                 promises.push(promise);
297             }.bind(this));
298         }
300         return $.when.apply($.when, promises);
301     };
303     /**
304      * Send a request for more notifications from the server, if we aren't already
305      * loading some and haven't already loaded all of them.
306      *
307      * Takes into account the current mode of the popover and will request only
308      * unread notifications if required.
309      *
310      * All notifications are marked as read by the server when they are returned.
311      *
312      * @method loadMoreNotifications
313      * @return jQuery promise that is resolved when notifications have been
314      *                        retrieved and added to the DOM
315      */
316     NotificationPopoverController.prototype.loadMoreNotifications = function() {
317         if (this.isLoading || this.hasLoadedAllContent()) {
318             return $.Deferred().resolve();
319         }
321         this.startLoading();
322         var request = {
323             limit: this.limit,
324             offset: this.getOffset(),
325             useridto: this.userId,
326             markasread: true,
327             embeduserto: false,
328             embeduserfrom: true,
329         };
331         if (this.unreadOnlyMode()) {
332             request.status = 'unread';
333         }
335         var container = this.getContent();
336         var promise = notificationRepo.query(request).then(function(result) {
337             var notifications = result.notifications;
338             this.unreadCount = result.unreadcount;
339             this.setLoadedAllContent(!notifications.length || notifications.length < this.limit);
340             this.getState().initialLoad = true;
341             this.updateButtonAriaLabel();
343             if (notifications.length) {
344                 this.incrementOffset();
345                 return this.renderNotifications(notifications, container);
346             }
347         }.bind(this))
348         .always(function() { this.stopLoading(); }.bind(this));
350         return promise;
351     };
353     /**
354      * Send a request to the server to mark all unread notifications as read and update
355      * the unread count and unread notification elements appropriately.
356      *
357      * @method markAllAsRead
358      */
359     NotificationPopoverController.prototype.markAllAsRead = function() {
360         this.markAllReadButton.addClass('loading');
362         return notificationRepo.markAllAsRead({useridto: this.userId})
363             .then(function() {
364                 this.unreadCount = 0;
365                 this.clearUnreadNotifications();
366             }.bind(this))
367             .always(function() { this.markAllReadButton.removeClass('loading'); }.bind(this));
368     };
370     /**
371      * Shift focus to the next content item in the list if the content item
372      * list current contains focus, otherwise the first item in the list is
373      * given focus.
374      *
375      * Overrides MdlPopoverController.focusNextContentItem
376      * @method focusNextContentItem
377      */
378     NotificationPopoverController.prototype.focusNextContentItem = function() {
379         var currentFocus = $(document.activeElement);
380         var container = this.getContent();
382         if (container.has(currentFocus).length) {
383             var currentNotification = currentFocus.closest(SELECTORS.CONTENT_ITEM_CONTAINER);
384             currentNotification.next().focus();
385         } else {
386             this.focusFirstContentItem();
387         }
388     };
390     /**
391      * Shift focus to the previous content item in the content item list, if the
392      * content item list contains focus.
393      *
394      * Overrides MdlPopoverController.focusPreviousContentItem
395      * @method focusPreviousContentItem
396      */
397     NotificationPopoverController.prototype.focusPreviousContentItem = function() {
398         var currentFocus = $(document.activeElement);
399         var container = this.getContent();
401         if (container.has(currentFocus).length) {
402             var currentNotification = currentFocus.closest(SELECTORS.CONTENT_ITEM_CONTAINER);
403             currentNotification.prev().focus();
404         }
405     };
407     /**
408      * Give focus to the first item in the list of content items.
409      *
410      * Overrides MdlPopoverController.focusFirstContentItem
411      * @method focusFirstContentItem
412      */
413     NotificationPopoverController.prototype.focusFirstContentItem = function() {
414         var container = this.getContent();
415         var notification = container.children().first();
417         if (!notification.length) {
418             // If we don't have any notifications then we should focus the empty
419             // empty message for the user.
420             notification = container.next(SELECTORS.EMPTY_MESSAGE);
421         }
423         notification.focus();
424     };
426     /**
427      * Give focus to the last item in the list of content items, that is the list
428      * of notifications that have already been loaded.
429      *
430      * Overrides MdlPopoverController.focusLastContentItem
431      * @method focusLastContentItem
432      */
433     NotificationPopoverController.prototype.focusLastContentItem = function() {
434         var container = this.getContent();
435         var notification = container.children().last();
437         if (!notification.length) {
438             // If we don't have any notifications then we should focus the empty
439             // empty message for the user.
440             notification = container.next(SELECTORS.EMPTY_MESSAGE);
441         }
443         notification.focus();
444     };
446     /**
447      * Expand all the currently rendered notificaitons in the current state
448      * of the popover (unread or all).
449      *
450      * @method expandAllContentItems
451      */
452     NotificationPopoverController.prototype.expandAllContentItems = function() {
453         this.getContent()
454             .find(SELECTORS.CONTENT_ITEM_CONTAINER)
455             .addClass('expanded')
456             .attr('aria-expanded', 'true');
457     };
459     /**
460      * Expand a single content item.
461      *
462      * @method expandContentItem
463      * @param item jQuery object the content item to be expanded
464      */
465     NotificationPopoverController.prototype.expandContentItem = function(item) {
466         item.addClass('expanded');
467         item.attr('aria-expanded', 'true');
468         item.find(SELECTORS.SHOW_BUTTON).attr('aria-hidden', 'true');
469         item.find(SELECTORS.CONTENT_BODY_SHORT).attr('aria-hidden', 'true');
470         item.find(SELECTORS.CONTENT_BODY_FULL).attr('aria-hidden', 'false');
471         item.find(SELECTORS.HIDE_BUTTON).attr('aria-hidden', 'false').focus();
472     };
474     /**
475      * Collapse a single content item.
476      *
477      * @method collapseContentItem
478      * @param item jQuery object the content item to be collapsed.
479      */
480     NotificationPopoverController.prototype.collapseContentItem = function(item) {
481         item.removeClass('expanded');
482         item.attr('aria-expanded', 'false');
483         item.find(SELECTORS.HIDE_BUTTON).attr('aria-hidden', 'true');
484         item.find(SELECTORS.CONTENT_BODY_FULL).attr('aria-hidden', 'true');
485         item.find(SELECTORS.CONTENT_BODY_SHORT).attr('aria-hidden', 'false');
486         item.find(SELECTORS.SHOW_BUTTON).attr('aria-hidden', 'false').focus();
487     };
489     /**
490      * Navigate the browser to the content URL for the content item, if it has one.
491      *
492      * @method navigateToContextURL
493      * @param item jQuery object representing the content item
494      */
495     NotificationPopoverController.prototype.navigateToContextURL = function(item) {
496         var url = item.attr('data-context-url');
498         if (url) {
499             window.location.assign(url);
500         }
501     };
503     /**
504      * Add all of the required event listeners for this notification popover.
505      *
506      * @method registerEventListeners
507      */
508     NotificationPopoverController.prototype.registerEventListeners = function() {
509         customEvents.define(this.root, [
510             customEvents.events.activate,
511             customEvents.events.next,
512             customEvents.events.previous,
513             customEvents.events.asterix,
514         ]);
516         // Expand the content item if the user activates (click/enter/space) the show
517         // button.
518         this.root.on(customEvents.events.activate, SELECTORS.SHOW_BUTTON, function(e, data) {
519             var container = $(e.target).closest(SELECTORS.CONTENT_ITEM_CONTAINER);
520             this.expandContentItem(container);
522             e.stopPropagation();
523             data.originalEvent.preventDefault();
524         }.bind(this));
526         // Expand the content item if the user triggers the next event (right arrow in LTR).
527         this.root.on(customEvents.events.next, SELECTORS.CONTENT_ITEM_CONTAINER, function(e) {
528             var contentItem = $(e.target).closest(SELECTORS.CONTENT_ITEM_CONTAINER);
529             this.expandContentItem(contentItem);
530         }.bind(this));
532         // Collapse the content item if the user activates the hide button.
533         this.root.on(customEvents.events.activate, SELECTORS.HIDE_BUTTON, function(e, data) {
534             var container = $(e.target).closest(SELECTORS.CONTENT_ITEM_CONTAINER);
535             this.collapseContentItem(container);
537             e.stopPropagation();
538             data.originalEvent.preventDefault();
539         }.bind(this));
541         // Collapse the content item if the user triggers the previous event (left arrow in LTR).
542         this.root.on(customEvents.events.previous, SELECTORS.CONTENT_ITEM_CONTAINER, function(e) {
543             var contentItem = $(e.target).closest(SELECTORS.CONTENT_ITEM_CONTAINER);
544             this.collapseContentItem(contentItem);
545         }.bind(this));
547         // Switch between popover states (read/unread) if the user activates the toggle.
548         this.root.on(customEvents.events.activate, SELECTORS.MODE_TOGGLE, function(e) {
549             if (this.modeToggle.hasClass('on')) {
550                 this.clearUnreadNotifications();
551                 this.modeToggle.removeClass('on');
552                 this.modeToggle.addClass('off');
553                 this.root.removeClass('unread-only');
555                 str.get_string('shownewnotifications', 'message').done(function(string) {
556                     this.modeToggle.attr('aria-label', string);
557                 }.bind(this));
558             } else {
559                 this.modeToggle.removeClass('off');
560                 this.modeToggle.addClass('on');
561                 this.root.addClass('unread-only');
563                 str.get_string('showallnotifications', 'message').done(function(string) {
564                     this.modeToggle.attr('aria-label', string);
565                 }.bind(this));
566             }
568             if (!this.hasDoneInitialLoad()) {
569                 this.loadMoreNotifications();
570             }
572             e.stopPropagation();
573         }.bind(this));
575         // Follow the context URL if the user activates the content item.
576         this.root.on(customEvents.events.activate, SELECTORS.CONTENT_ITEM_CONTAINER, function(e) {
577             var contentItem = $(e.target).closest(SELECTORS.CONTENT_ITEM_CONTAINER);
578             this.navigateToContextURL(contentItem);
579             e.stopPropagation();
580         }.bind(this));
582         // Mark all notifications read if the user activates the mark all as read button.
583         this.root.on(customEvents.events.activate, SELECTORS.MARK_ALL_READ_BUTTON, function(e) {
584             this.markAllAsRead();
585             e.stopPropagation();
586         }.bind(this));
588         // Expand all the currently visible content items if the user hits the
589         // asterix key.
590         this.root.on(customEvents.events.asterix, function() {
591             this.expandAllContentItems();
592         }.bind(this));
594         // Update the notification information when the menu is opened.
595         this.root.on(this.events().menuOpened, function() {
596             this.hideUnreadCount();
597             this.updateButtonAriaLabel();
599             if (!this.hasDoneInitialLoad()) {
600                 this.loadMoreNotifications();
601             }
602         }.bind(this));
604         // Update the unread notification count when the menu is closed.
605         this.root.on(this.events().menuClosed, function() {
606             this.renderUnreadCount();
607             this.clearUnreadNotifications();
608             this.updateButtonAriaLabel();
609         }.bind(this));
611         // Set aria attributes when popover is loading.
612         this.root.on(this.events().startLoading, function() {
613             this.getContent().attr('aria-busy', 'true');
614         }.bind(this));
616         // Set aria attributes when popover is finished loading.
617         this.root.on(this.events().stopLoading, function() {
618             this.getContent().attr('aria-busy', 'false');
619         }.bind(this));
621         // Load more notifications if the user has scrolled to the end of content
622         // item list.
623         this.getContentContainer().on(customEvents.events.scrollBottom, function() {
624             if (!this.isLoading && !this.hasLoadedAllContent()) {
625                 this.loadMoreNotifications();
626             }
627         }.bind(this));
628     };
630     return NotificationPopoverController;
631 });