MDL-54708 message: add notification popover to nav bar
[moodle.git] / message / amd / src / notification_popover_controller.js
CommitLineData
a0e358a6
RW
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/>.
15
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 */
28define(['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) {
33
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 };
47
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);
58
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 };
79
80 // Let's find out how many unread notifications there are.
81 this.loadUnreadNotificationCount();
82 this.root.find('[data-toggle="tooltip"]').tooltip();
83 };
84
85 /**
86 * Clone the parent prototype.
87 */
88 NotificationPopoverController.prototype = Object.create(popoverController.prototype);
89
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 };
113
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 };
125
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 };
136
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 };
154
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 };
165
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 };
179
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 };
188
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 };
199
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 };
210
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 };
221
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 };
234
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');
243
244 if (this.unreadCount) {
245 element.text(this.unreadCount);
246 element.removeClass('hidden');
247 } else {
248 element.addClass('hidden');
249 }
250 };
251
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 };
260
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 };
275
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 = [];
287
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));
295
296 promises.push(promise);
297 }.bind(this));
298 }
299
300 return $.when.apply($.when, promises);
301 };
302
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 }
320
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 };
330
331 if (this.unreadOnlyMode()) {
332 request.status = 'unread';
333 }
334
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();
342
343 if (notifications.length) {
344 this.incrementOffset();
345 return this.renderNotifications(notifications, container);
346 }
347 }.bind(this))
348 .always(function() { this.stopLoading(); }.bind(this));
349
350 return promise;
351 };
352
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');
361
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 };
369
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();
381
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 };
389
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();
400
401 if (container.has(currentFocus).length) {
402 var currentNotification = currentFocus.closest(SELECTORS.CONTENT_ITEM_CONTAINER);
403 currentNotification.prev().focus();
404 }
405 };
406
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();
416
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 }
422
423 notification.focus();
424 };
425
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();
436
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 }
442
443 notification.focus();
444 };
445
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 };
458
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 };
473
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 };
488
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');
497
498 if (url) {
499 window.location.assign(url);
500 }
501 };
502
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 ]);
515
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);
521
522 e.stopPropagation();
523 data.originalEvent.preventDefault();
524 }.bind(this));
525
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));
531
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);
536
537 e.stopPropagation();
538 data.originalEvent.preventDefault();
539 }.bind(this));
540
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));
546
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');
554
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');
562
563 str.get_string('showallnotifications', 'message').done(function(string) {
564 this.modeToggle.attr('aria-label', string);
565 }.bind(this));
566 }
567
568 if (!this.hasDoneInitialLoad()) {
569 this.loadMoreNotifications();
570 }
571
572 e.stopPropagation();
573 }.bind(this));
574
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));
581
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));
587
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));
593
594 // Update the notification information when the menu is opened.
595 this.root.on(this.events().menuOpened, function() {
596 this.hideUnreadCount();
597 this.updateButtonAriaLabel();
598
599 if (!this.hasDoneInitialLoad()) {
600 this.loadMoreNotifications();
601 }
602 }.bind(this));
603
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));
610
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));
615
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));
620
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 };
629
630 return NotificationPopoverController;
631});