message/amd/src/message_drawer_router.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/>.
  15. /**
  16. * A simple router for the message drawer that allows navigating between
  17. * the "pages" in the drawer.
  18. *
  19. * This module will maintain a linear history of the unique pages access
  20. * to allow navigating back.
  21. *
  22. * @module core_message/message_drawer_router
  23. * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
  24. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25. */
  26. define(
  27. [
  28. 'jquery',
  29. 'core/pubsub',
  30. 'core/str',
  31. 'core_message/message_drawer_events',
  32. 'core/aria',
  33. 'core/pending',
  34. ],
  35. function(
  36. $,
  37. PubSub,
  38. Str,
  39. MessageDrawerEvents,
  40. Aria,
  41. PendingPromise,
  42. ) {
  43. /* @var {object} routes Message drawer route elements and callbacks. */
  44. var routes = {};
  45. /* @var {object} history Store for route objects history. */
  46. var history = {};
  47. var SELECTORS = {
  48. CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
  49. ROUTES_BACK: '[data-route-back]'
  50. };
  51. /**
  52. * Add a route.
  53. *
  54. * @param {String} namespace Unique identifier for the Routes
  55. * @param {string} route Route config name.
  56. * @param {array} parameters Route parameters.
  57. * @param {callback} onGo Route initialization function.
  58. * @param {callback} getDescription Route initialization function.
  59. */
  60. var add = function(namespace, route, parameters, onGo, getDescription) {
  61. if (!routes[namespace]) {
  62. routes[namespace] = [];
  63. }
  64. routes[namespace][route] =
  65. {
  66. parameters: parameters,
  67. onGo: onGo,
  68. getDescription: getDescription
  69. };
  70. };
  71. /**
  72. * Go to a defined route and run the route callbacks.
  73. *
  74. * @param {String} namespace Unique identifier for the Routes
  75. * @param {string} newRoute Route config name.
  76. * @return {object} record Current route record with route config name and parameters.
  77. */
  78. var changeRoute = function(namespace, newRoute) {
  79. var newConfig;
  80. var pendingPromise = new PendingPromise(`message-drawer-router-${namespace}-${newRoute}`);
  81. // Check if the Route change call is made from an element in the app panel.
  82. var fromPanel = [].slice.call(arguments).some(function(arg) {
  83. return arg == 'frompanel';
  84. });
  85. // Get the rest of the arguments, if any.
  86. var args = [].slice.call(arguments, 2);
  87. var renderPromise = $.Deferred().resolve().promise();
  88. Object.keys(routes[namespace]).forEach(function(route) {
  89. var config = routes[namespace][route];
  90. var isMatch = route === newRoute;
  91. if (isMatch) {
  92. newConfig = config;
  93. }
  94. config.parameters.forEach(function(element) {
  95. // Some parameters may be null, or not an element.
  96. if (typeof element !== 'object' || element === null) {
  97. return;
  98. }
  99. element.removeClass('previous');
  100. element.attr('data-from-panel', false);
  101. if (isMatch) {
  102. if (fromPanel) {
  103. // Set this attribute to let the conversation renderer know not to show a back button.
  104. element.attr('data-from-panel', true);
  105. }
  106. element.removeClass('hidden');
  107. Aria.unhide(element.get());
  108. } else {
  109. // For the message index page elements in the left panel should not be hidden.
  110. if (!element.attr('data-in-panel')) {
  111. element.addClass('hidden');
  112. Aria.hide(element.get());
  113. } else if (newRoute == 'view-search' || newRoute == 'view-overview') {
  114. element.addClass('hidden');
  115. Aria.hide(element.get());
  116. }
  117. }
  118. });
  119. });
  120. if (newConfig) {
  121. if (newConfig.onGo) {
  122. renderPromise = newConfig.onGo.apply(undefined, newConfig.parameters.concat(args));
  123. var currentFocusElement = $(document.activeElement);
  124. var hasFocus = false;
  125. var firstFocusable = null;
  126. // No need to start at 0 as we know that is the namespace.
  127. for (var i = 1; i < newConfig.parameters.length; i++) {
  128. var element = newConfig.parameters[i];
  129. // Some parameters may be null, or not an element.
  130. if (typeof element !== 'object' || element === null) {
  131. continue;
  132. }
  133. if (!firstFocusable) {
  134. firstFocusable = element;
  135. }
  136. if (element.has(currentFocusElement).length) {
  137. hasFocus = true;
  138. break;
  139. }
  140. }
  141. if (!hasFocus) {
  142. // This page doesn't have focus yet so focus the first focusable
  143. // element in the new view.
  144. firstFocusable.find(SELECTORS.CAN_RECEIVE_FOCUS).filter(':visible').first().focus();
  145. }
  146. }
  147. }
  148. var record = {
  149. route: newRoute,
  150. params: args,
  151. renderPromise: renderPromise
  152. };
  153. PubSub.publish(MessageDrawerEvents.ROUTE_CHANGED, record);
  154. renderPromise.then(() => pendingPromise.resolve());
  155. return record;
  156. };
  157. /**
  158. * Go to a defined route and store the route history.
  159. *
  160. * @param {String} namespace Unique identifier for the Routes
  161. * @return {object} record Current route record with route config name and parameters.
  162. */
  163. var go = function(namespace) {
  164. var currentFocusElement = $(document.activeElement);
  165. var record = changeRoute.apply(namespace, arguments);
  166. var inHistory = false;
  167. if (!history[namespace]) {
  168. history[namespace] = [];
  169. }
  170. // History stores a unique list of routes. Check to see if the new route
  171. // is already in the history, if it is then forget all history after it.
  172. // This ensures there are no duplicate routes in history and that it represents
  173. // a linear path of routes (it never stores something like [foo, bar, foo])).
  174. history[namespace] = history[namespace].reduce(function(carry, previous) {
  175. if (previous.route === record.route) {
  176. inHistory = true;
  177. }
  178. if (!inHistory) {
  179. carry.push(previous);
  180. }
  181. return carry;
  182. }, []);
  183. var historylength = history[namespace].length;
  184. var previousRecord = historylength ? history[namespace][historylength - 1] : null;
  185. if (previousRecord) {
  186. var prevConfig = routes[namespace][previousRecord.route];
  187. var elements = prevConfig.parameters;
  188. // The first one will be the namespace, skip it.
  189. for (var i = 1; i < elements.length; i++) {
  190. // Some parameters may be null, or not an element.
  191. if (typeof elements[i] !== 'object' || elements[i] === null) {
  192. continue;
  193. }
  194. elements[i].addClass('previous');
  195. }
  196. previousRecord.focusElement = currentFocusElement;
  197. if (prevConfig.getDescription) {
  198. // If the route has a description then set it on the back button for
  199. // the new page we're displaying.
  200. prevConfig.getDescription.apply(null, prevConfig.parameters.concat(previousRecord.params))
  201. .then(function(description) {
  202. return Str.get_string('backto', 'core_message', description);
  203. })
  204. .then(function(label) {
  205. // Wait for the new page to finish rendering so that we know
  206. // that the back button is visible.
  207. return record.renderPromise.then(function() {
  208. // Find the elements for the new route we displayed.
  209. routes[namespace][record.route].parameters.forEach(function(element) {
  210. // Some parameters may be null, or not an element.
  211. if (typeof element !== 'object' || !element) {
  212. return;
  213. }
  214. // Update the aria label for the back button.
  215. element.find(SELECTORS.ROUTES_BACK).attr('aria-label', label);
  216. });
  217. });
  218. })
  219. .catch(function() {
  220. // Silently ignore.
  221. });
  222. }
  223. }
  224. history[namespace].push(record);
  225. return record;
  226. };
  227. /**
  228. * Go back to the previous route record stored in history.
  229. *
  230. * @param {String} namespace Unique identifier for the Routes
  231. */
  232. var back = function(namespace) {
  233. if (history[namespace].length) {
  234. // Remove the current route.
  235. history[namespace].pop();
  236. var previous = history[namespace].pop();
  237. if (previous) {
  238. // If we have a previous route then show it.
  239. go.apply(undefined, [namespace, previous.route].concat(previous.params));
  240. // Delay the focus 50 milliseconds otherwise it doesn't correctly
  241. // focus the element for some reason...
  242. window.setTimeout(function() {
  243. previous.focusElement.focus();
  244. }, 50);
  245. }
  246. }
  247. };
  248. return {
  249. add: add,
  250. go: go,
  251. back: back
  252. };
  253. });