grade/amd/src/searchwidget/basewidget.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 widget to search users or grade items within the gradebook.
  17. *
  18. * @module core_grades/searchwidget/basewidget
  19. * @copyright 2022 Mathew May <mathew.solutions>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import {debounce} from 'core/utils';
  23. import * as Templates from 'core/templates';
  24. import * as Selectors from 'core_grades/searchwidget/selectors';
  25. import Notification from 'core/notification';
  26. import Log from 'core/log';
  27. /**
  28. * Build the base searching widget.
  29. *
  30. * @method init
  31. * @param {HTMLElement} widgetContentContainer The selector for the widget container element.
  32. * @param {Promise} bodyPromise The promise from the callee of the contents to place in the widget container.
  33. * @param {Array} data An array of all the data generated by the callee.
  34. * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
  35. * @param {string|null} unsearchableContent The content rendered in a non-searchable area.
  36. * @param {Function|null} afterSelect Callback executed after an item is selected.
  37. */
  38. export const init = async(
  39. widgetContentContainer,
  40. bodyPromise,
  41. data,
  42. searchFunc,
  43. unsearchableContent = null,
  44. afterSelect = null,
  45. ) => {
  46. Log.debug('The core_grades/searchwidget/basewidget component is deprecated. Please refer to core/search_combobox() instead.');
  47. bodyPromise.then(async(bodyContent) => {
  48. // Render the body content.
  49. widgetContentContainer.innerHTML = bodyContent;
  50. // Render the unsearchable content if defined.
  51. if (unsearchableContent) {
  52. const unsearchableContentContainer = widgetContentContainer.querySelector(Selectors.regions.unsearchableContent);
  53. unsearchableContentContainer.innerHTML += unsearchableContent;
  54. }
  55. const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
  56. // Display a loader until the search results are rendered.
  57. await showLoader(searchResultsContainer);
  58. // Render the search results.
  59. await renderSearchResults(searchResultsContainer, data);
  60. registerListenerEvents(widgetContentContainer, data, searchFunc, afterSelect);
  61. }).catch(Notification.exception);
  62. };
  63. /**
  64. * Register the event listeners for the search widget.
  65. *
  66. * @method registerListenerEvents
  67. * @param {HTMLElement} widgetContentContainer The selector for the widget container element.
  68. * @param {Array} data An array of all the data generated by the callee.
  69. * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
  70. * @param {Function|null} afterSelect Callback executed after an item is selected.
  71. */
  72. export const registerListenerEvents = (widgetContentContainer, data, searchFunc, afterSelect = null) => {
  73. const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
  74. const searchInput = widgetContentContainer.querySelector(Selectors.actions.search);
  75. if (!searchInput) {
  76. // Too late. The widget is already closed and its content is empty.
  77. return;
  78. }
  79. // We want to focus on the first known user interable element within the dropdown.
  80. searchInput.focus();
  81. const clearSearchButton = widgetContentContainer.querySelector(Selectors.actions.clearSearch);
  82. // The search input is triggered.
  83. searchInput.addEventListener('input', debounce(async() => {
  84. // If search query is present display the 'clear search' button, otherwise hide it.
  85. if (searchInput.value.length > 0) {
  86. clearSearchButton.classList.remove('d-none');
  87. } else {
  88. clearSearchButton.classList.add('d-none');
  89. }
  90. // Remove aria-activedescendant when the available options change.
  91. searchInput.removeAttribute('aria-activedescendant');
  92. // Display the search results.
  93. await renderSearchResults(
  94. searchResultsContainer,
  95. debounceCallee(
  96. searchInput.value,
  97. data,
  98. searchFunc()
  99. )
  100. );
  101. }, 300));
  102. // Clear search is triggered.
  103. clearSearchButton.addEventListener('click', async(e) => {
  104. e.stopPropagation();
  105. // Clear the entered search query in the search bar.
  106. searchInput.value = "";
  107. searchInput.focus();
  108. clearSearchButton.classList.add('d-none');
  109. // Remove aria-activedescendant when the available options change.
  110. searchInput.removeAttribute('aria-activedescendant');
  111. // Display all results.
  112. await renderSearchResults(
  113. searchResultsContainer,
  114. debounceCallee(
  115. searchInput.value,
  116. data,
  117. searchFunc()
  118. )
  119. );
  120. });
  121. const inputElement = document.getElementById(searchInput.dataset.inputElement);
  122. if (inputElement && afterSelect) {
  123. inputElement.addEventListener('change', e => {
  124. const selectedOption = widgetContentContainer.querySelector(
  125. Selectors.elements.getSearchWidgetSelectOption(searchInput),
  126. );
  127. if (selectedOption) {
  128. afterSelect(e.target.value);
  129. }
  130. });
  131. }
  132. // Backward compatibility. Handle the click event for the following cases:
  133. // - When we have <li> tags without an afterSelect callback function being provided (old js).
  134. // - When we have <a> tags without href (old template).
  135. widgetContentContainer.addEventListener('click', e => {
  136. const deprecatedOption = e.target.closest(
  137. 'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
  138. );
  139. if (deprecatedOption) {
  140. // We are in one of these situations:
  141. // - We have <li> tags without an afterSelect callback function being provided.
  142. // - We have <a> tags without href.
  143. if (inputElement && afterSelect) {
  144. afterSelect(deprecatedOption.dataset.value);
  145. } else {
  146. const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
  147. location.href = url;
  148. }
  149. }
  150. });
  151. // Backward compatibility. Handle the keydown event for the following cases:
  152. // - When we have <li> tags without an afterSelect callback function being provided (old js).
  153. // - When we have <a> tags without href (old template).
  154. widgetContentContainer.addEventListener('keydown', e => {
  155. const deprecatedOption = e.target.closest(
  156. 'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
  157. );
  158. if (deprecatedOption && (e.key === ' ' || e.key === 'Enter')) {
  159. // We are in one of these situations:
  160. // - We have <li> tags without an afterSelect callback function being provided.
  161. // - We have <a> tags without href.
  162. e.preventDefault();
  163. if (inputElement && afterSelect) {
  164. afterSelect(deprecatedOption.dataset.value);
  165. } else {
  166. const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
  167. location.href = url;
  168. }
  169. }
  170. });
  171. };
  172. /**
  173. * Renders the loading placeholder for the search widget.
  174. *
  175. * @method showLoader
  176. * @param {HTMLElement} container The DOM node where we'll render the loading placeholder.
  177. */
  178. export const showLoader = async(container) => {
  179. container.innerHTML = '';
  180. const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/loading', {});
  181. Templates.replaceNodeContents(container, html, js);
  182. };
  183. /**
  184. * We have a small helper that'll call the curried search function allowing callers to filter
  185. * the data set however we want rather than defining how data must be filtered.
  186. *
  187. * @method debounceCallee
  188. * @param {String} searchValue The input from the user that we'll search against.
  189. * @param {Array} data An array of all the data generated by the callee.
  190. * @param {Function} searchFunction Partially applied function we need to manage search the passed dataset.
  191. * @return {Array} The filtered subset of the provided data that we'll then render into the results.
  192. */
  193. const debounceCallee = (searchValue, data, searchFunction) => {
  194. if (searchValue.length > 0) { // Search query is present.
  195. return searchFunction(data, searchValue);
  196. }
  197. return data;
  198. };
  199. /**
  200. * Given the output of the callers' search function, render out the results into the search results container.
  201. *
  202. * @method renderSearchResults
  203. * @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results.
  204. * @param {Array} searchResultsData The filtered subset of the provided data that we'll then render into the results.
  205. */
  206. const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
  207. const templateData = {
  208. 'searchresults': searchResultsData,
  209. };
  210. // Build up the html & js ready to place into the help section.
  211. const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/searchresults', templateData);
  212. await Templates.replaceNodeContents(searchResultsContainer, html, js);
  213. // Backward compatibility.
  214. if (searchResultsContainer.getAttribute('role') !== 'listbox') {
  215. const deprecatedOptions = searchResultsContainer.querySelectorAll(
  216. 'a.dropdown-item[role="menuitem"][href=""], .dropdown-item[role="option"]:not([href])'
  217. );
  218. for (const option of deprecatedOptions) {
  219. option.tabIndex = 0;
  220. option.removeAttribute('href');
  221. }
  222. }
  223. };
  224. /**
  225. * We want to create the basic promises and hooks that the caller will implement, so we can build the search widget
  226. * ahead of time and allow the caller to resolve their promises once complete.
  227. *
  228. * @method promisesAndResolvers
  229. * @returns {{bodyPromise: Promise, bodyPromiseResolver}}
  230. */
  231. export const promisesAndResolvers = () => {
  232. // We want to show the widget instantly but loading whilst waiting for our data.
  233. let bodyPromiseResolver;
  234. const bodyPromise = new Promise(resolve => {
  235. bodyPromiseResolver = resolve;
  236. });
  237. return {bodyPromiseResolver, bodyPromise};
  238. };