MDL-62658 message_popup: fixed issue with malformed URL being generated
[moodle.git] / message / output / popup / 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_popup/notification_popover
20  *
21  * @module     message_popup/notification_popover_controller
22  * @class      notification_popover_controller
23  * @package    message_popup
24  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
25  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  */
27 define(['jquery', 'core/ajax', 'core/templates', 'core/str', 'core/url',
28             'core/notification', 'core/custom_interaction_events', 'core/popover_region_controller',
29             'message_popup/notification_repository', 'message_popup/notification_area_events'],
30         function($, Ajax, Templates, Str, URL, DebugNotification, CustomEvents,
31             PopoverController, NotificationRepo, NotificationAreaEvents) {
33     var SELECTORS = {
34         MARK_ALL_READ_BUTTON: '[data-action="mark-all-read"]',
35         ALL_NOTIFICATIONS_CONTAINER: '[data-region="all-notifications"]',
36         NOTIFICATION: '[data-region="notification-content-item-container"]',
37         UNREAD_NOTIFICATION: '[data-region="notification-content-item-container"].unread',
38         NOTIFICATION_LINK: '[data-action="content-item-link"]',
39         EMPTY_MESSAGE: '[data-region="empty-message"]',
40         COUNT_CONTAINER: '[data-region="count-container"]',
41     };
43     /**
44      * Constructor for the NotificationPopoverController.
45      * Extends PopoverRegionController.
46      *
47      * @param {object} element jQuery object root element of the popover
48      */
49     var NotificationPopoverController = function(element) {
50         // Initialise base class.
51         PopoverController.call(this, element);
53         this.markAllReadButton = this.root.find(SELECTORS.MARK_ALL_READ_BUTTON);
54         this.unreadCount = 0;
55         this.userId = this.root.attr('data-userid');
56         this.container = this.root.find(SELECTORS.ALL_NOTIFICATIONS_CONTAINER);
57         this.limit = 20;
58         this.offset = 0;
59         this.loadedAll = false;
60         this.initialLoad = false;
62         // Let's find out how many unread notifications there are.
63         this.unreadCount = this.root.find(SELECTORS.COUNT_CONTAINER).html();
64     };
66     /**
67      * Clone the parent prototype.
68      */
69     NotificationPopoverController.prototype = Object.create(PopoverController.prototype);
71     /**
72      * Make sure the constructor is set correctly.
73      */
74     NotificationPopoverController.prototype.constructor = NotificationPopoverController;
76     /**
77      * Set the correct aria label on the menu toggle button to be read out by screen
78      * readers. The message will indicate the state of the unread notifications.
79      *
80      * @method updateButtonAriaLabel
81      */
82     NotificationPopoverController.prototype.updateButtonAriaLabel = function() {
83         if (this.isMenuOpen()) {
84             Str.get_string('hidenotificationwindow', 'message').done(function(string) {
85                 this.menuToggle.attr('aria-label', string);
86             }.bind(this));
87         } else {
88             if (this.unreadCount) {
89                 Str.get_string('shownotificationwindowwithcount', 'message', this.unreadCount).done(function(string) {
90                     this.menuToggle.attr('aria-label', string);
91                 }.bind(this));
92             } else {
93                 Str.get_string('shownotificationwindownonew', 'message').done(function(string) {
94                     this.menuToggle.attr('aria-label', string);
95                 }.bind(this));
96             }
97         }
98     };
100     /**
101      * Return the jQuery element with the content. This will return either
102      * the unread notification container or the all notification container
103      * depending on which is currently visible.
104      *
105      * @method getContent
106      * @return {object} jQuery object currently visible content contianer
107      */
108     NotificationPopoverController.prototype.getContent = function() {
109         return this.container;
110     };
112     /**
113      * Get the offset value for the current state of the popover in order
114      * to sent to the backend to correctly paginate the notifications.
115      *
116      * @method getOffset
117      * @return {int} current offset
118      */
119     NotificationPopoverController.prototype.getOffset = function() {
120         return this.offset;
121     };
123     /**
124      * Increment the offset for the current state, if required.
125      *
126      * @method incrementOffset
127      */
128     NotificationPopoverController.prototype.incrementOffset = function() {
129         this.offset += this.limit;
130     };
132     /**
133      * Check if the first load of notification has been triggered for the current
134      * state of the popover.
135      *
136      * @method hasDoneInitialLoad
137      * @return {bool} true if first notification loaded, false otherwise
138      */
139     NotificationPopoverController.prototype.hasDoneInitialLoad = function() {
140         return this.initialLoad;
141     };
143     /**
144      * Check if we've loaded all of the notifications for the current popover
145      * state.
146      *
147      * @method hasLoadedAllContent
148      * @return {bool} true if all notifications loaded, false otherwise
149      */
150     NotificationPopoverController.prototype.hasLoadedAllContent = function() {
151         return this.loadedAll;
152     };
154     /**
155      * Set the state of the loaded all content property for the current state
156      * of the popover.
157      *
158      * @method setLoadedAllContent
159      * @param {bool} val True if all content is loaded, false otherwise
160      */
161     NotificationPopoverController.prototype.setLoadedAllContent = function(val) {
162         this.loadedAll = val;
163     };
165     /**
166      * Show the unread notification count badge on the menu toggle if there
167      * are unread notifications, otherwise hide it.
168      *
169      * @method renderUnreadCount
170      */
171     NotificationPopoverController.prototype.renderUnreadCount = function() {
172         var element = this.root.find(SELECTORS.COUNT_CONTAINER);
174         if (this.unreadCount) {
175             element.text(this.unreadCount);
176             element.removeClass('hidden');
177         } else {
178             element.addClass('hidden');
179         }
180     };
182     /**
183      * Hide the unread notification count badge on the menu toggle.
184      *
185      * @method hideUnreadCount
186      */
187     NotificationPopoverController.prototype.hideUnreadCount = function() {
188         this.root.find(SELECTORS.COUNT_CONTAINER).addClass('hidden');
189     };
191     /**
192      * Find the notification element for the given id.
193      *
194      * @param {int} id
195      * @method getNotificationElement
196      * @return {object|null} The notification element
197      */
198     NotificationPopoverController.prototype.getNotificationElement = function(id) {
199         var element = this.root.find(SELECTORS.NOTIFICATION + '[data-id="' + id + '"]');
200         return element.length == 1 ? element : null;
201     };
203     /**
204      * Render the notification data with the appropriate template and add it to the DOM.
205      *
206      * @method renderNotifications
207      * @param {array} notifications Notification data
208      * @param {object} container jQuery object the container to append the rendered notifications
209      * @return {object} jQuery promise that is resolved when all notifications have been
210      *                  rendered and added to the DOM
211      */
212     NotificationPopoverController.prototype.renderNotifications = function(notifications, container) {
213         var promises = [];
215         $.each(notifications, function(index, notification) {
216             // Determine what the offset was when loading this notification.
217             var offset = this.getOffset() - this.limit;
218             // Update the view more url to contain the offset to allow the notifications
219             // page to load to the correct position in the list of notifications.
220             notification.viewmoreurl = URL.relativeUrl('/message/output/popup/notifications.php', {
221                 notificationid: notification.id,
222                 offset: offset,
223             });
225             // Link to mark read page before loading the actual link.
226             notification.contexturl = URL.relativeUrl('message/output/popup/mark_notification_read.php', {
227                 notificationid: notification.id,
228                 redirecturl: notification.contexturl
229             });
231             var promise = Templates.render('message_popup/notification_content_item', notification)
232             .then(function(html, js) {
233                 return {html: html, js: js};
234             });
235             promises.push(promise);
236         }.bind(this));
238         return $.when.apply($, promises).then(function() {
239             // Each of the promises in the when will pass its results as an argument to the function.
240             // The order of the arguments will be the order that the promises are passed to when()
241             // i.e. the first promise's results will be in the first argument.
242             $.each(arguments, function(index, argument) {
243                 container.append(argument.html);
244                 Templates.runTemplateJS(argument.js);
245             });
246             return;
247         });
248     };
250     /**
251      * Send a request for more notifications from the server, if we aren't already
252      * loading some and haven't already loaded all of them.
253      *
254      * Takes into account the current mode of the popover and will request only
255      * unread notifications if required.
256      *
257      * All notifications are marked as read by the server when they are returned.
258      *
259      * @method loadMoreNotifications
260      * @return {object} jQuery promise that is resolved when notifications have been
261      *                        retrieved and added to the DOM
262      */
263     NotificationPopoverController.prototype.loadMoreNotifications = function() {
264         if (this.isLoading || this.hasLoadedAllContent()) {
265             return $.Deferred().resolve();
266         }
268         this.startLoading();
269         var request = {
270             limit: this.limit,
271             offset: this.getOffset(),
272             useridto: this.userId,
273         };
275         var container = this.getContent();
276         return NotificationRepo.query(request).then(function(result) {
277             var notifications = result.notifications;
278             this.unreadCount = result.unreadcount;
279             this.setLoadedAllContent(!notifications.length || notifications.length < this.limit);
280             this.initialLoad = true;
281             this.updateButtonAriaLabel();
283             if (notifications.length) {
284                 this.incrementOffset();
285                 return this.renderNotifications(notifications, container);
286             }
288             return false;
289         }.bind(this))
290         .always(function() {
291             this.stopLoading();
292         }.bind(this));
293     };
295     /**
296      * Send a request to the server to mark all unread notifications as read and update
297      * the unread count and unread notification elements appropriately.
298      *
299      * @return {Promise}
300      * @method markAllAsRead
301      */
302     NotificationPopoverController.prototype.markAllAsRead = function() {
303         this.markAllReadButton.addClass('loading');
305         return NotificationRepo.markAllAsRead({useridto: this.userId})
306             .then(function() {
307                 this.unreadCount = 0;
308                 this.root.find(SELECTORS.UNREAD_NOTIFICATION).removeClass('unread');
309             }.bind(this))
310             .always(function() {
311                 this.markAllReadButton.removeClass('loading');
312             }.bind(this));
313     };
315     /**
316      * Add all of the required event listeners for this notification popover.
317      *
318      * @method registerEventListeners
319      */
320     NotificationPopoverController.prototype.registerEventListeners = function() {
321         CustomEvents.define(this.root, [
322             CustomEvents.events.activate,
323         ]);
325         // Mark all notifications read if the user activates the mark all as read button.
326         this.root.on(CustomEvents.events.activate, SELECTORS.MARK_ALL_READ_BUTTON, function(e, data) {
327             this.markAllAsRead();
328             e.stopPropagation();
329             data.originalEvent.preventDefault();
330         }.bind(this));
332         // Mark individual notification read if the user activates it.
333         this.root.on(CustomEvents.events.activate, SELECTORS.NOTIFICATION_LINK, function(e) {
334             var element = $(e.target).closest(SELECTORS.NOTIFICATION);
336             if (element.hasClass('unread')) {
337                 this.unreadCount--;
338                 element.removeClass('unread');
339             }
341             e.stopPropagation();
342         }.bind(this));
344         // Update the notification information when the menu is opened.
345         this.root.on(this.events().menuOpened, function() {
346             this.hideUnreadCount();
347             this.updateButtonAriaLabel();
349             if (!this.hasDoneInitialLoad()) {
350                 this.loadMoreNotifications();
351             }
352         }.bind(this));
354         // Update the unread notification count when the menu is closed.
355         this.root.on(this.events().menuClosed, function() {
356             this.renderUnreadCount();
357             this.updateButtonAriaLabel();
358         }.bind(this));
360         // Set aria attributes when popover is loading.
361         this.root.on(this.events().startLoading, function() {
362             this.getContent().attr('aria-busy', 'true');
363         }.bind(this));
365         // Set aria attributes when popover is finished loading.
366         this.root.on(this.events().stopLoading, function() {
367             this.getContent().attr('aria-busy', 'false');
368         }.bind(this));
370         // Load more notifications if the user has scrolled to the end of content
371         // item list.
372         this.getContentContainer().on(CustomEvents.events.scrollBottom, function() {
373             if (!this.isLoading && !this.hasLoadedAllContent()) {
374                 this.loadMoreNotifications();
375             }
376         }.bind(this));
378         // Stop mouse scroll from propagating to the window element and
379         // scrolling the page.
380         CustomEvents.define(this.getContentContainer(), [
381             CustomEvents.events.scrollLock
382         ]);
384         // Listen for when a notification is shown in the notifications page and mark
385         // it as read, if it's unread.
386         $(document).on(NotificationAreaEvents.notificationShown, function(e, notification) {
387             if (!notification.read) {
388                 var element = this.getNotificationElement(notification.id);
390                 if (element) {
391                     element.removeClass('unread');
392                 }
394                 this.unreadCount--;
395                 this.renderUnreadCount();
396             }
397         }.bind(this));
398     };
400     return NotificationPopoverController;
401 });