lib/amd/src/datafilter.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. * Data filter management.
  17. *
  18. * @module core/datafilter
  19. * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import CourseFilter from 'core/datafilter/filtertypes/courseid';
  23. import GenericFilter from 'core/datafilter/filtertype';
  24. import {getStrings} from 'core/str';
  25. import Notification from 'core/notification';
  26. import Pending from 'core/pending';
  27. import Selectors from 'core/datafilter/selectors';
  28. import Templates from 'core/templates';
  29. import CustomEvents from 'core/custom_interaction_events';
  30. import jQuery from 'jquery';
  31. export default class {
  32. /**
  33. * Initialise the filter on the element with the given filterSet and callback.
  34. *
  35. * @param {HTMLElement} filterSet The filter element.
  36. * @param {Function} applyCallback Callback function when updateTableFromFilter
  37. */
  38. constructor(filterSet, applyCallback) {
  39. this.filterSet = filterSet;
  40. this.applyCallback = applyCallback;
  41. // Keep a reference to all of the active filters.
  42. this.activeFilters = {
  43. courseid: new CourseFilter('courseid', filterSet),
  44. };
  45. }
  46. /**
  47. * Initialise event listeners to the filter.
  48. */
  49. init() {
  50. // Add listeners for the main actions.
  51. this.filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
  52. if (e.target.closest(Selectors.filterset.actions.addRow)) {
  53. e.preventDefault();
  54. this.addFilterRow();
  55. }
  56. if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
  57. e.preventDefault();
  58. this.updateTableFromFilter();
  59. }
  60. if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
  61. e.preventDefault();
  62. this.removeAllFilters();
  63. }
  64. });
  65. // Add the listener to remove a single filter.
  66. this.filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
  67. if (e.target.closest(Selectors.filter.actions.remove)) {
  68. e.preventDefault();
  69. this.removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
  70. }
  71. });
  72. // Add listeners for the filter type selection.
  73. let filterRegion = jQuery(this.getFilterRegion());
  74. CustomEvents.define(filterRegion, [CustomEvents.events.accessibleChange]);
  75. filterRegion.on(CustomEvents.events.accessibleChange, e => {
  76. const typeField = e.target.closest(Selectors.filter.fields.type);
  77. if (typeField && typeField.value) {
  78. const filter = e.target.closest(Selectors.filter.region);
  79. this.addFilter(filter, typeField.value);
  80. }
  81. });
  82. this.filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
  83. this.filterSet.dataset.filterverb = e.target.value;
  84. });
  85. }
  86. /**
  87. * Get the filter list region.
  88. *
  89. * @return {HTMLElement}
  90. */
  91. getFilterRegion() {
  92. return this.filterSet.querySelector(Selectors.filterset.regions.filterlist);
  93. }
  94. /**
  95. * Add a filter row.
  96. *
  97. * @param {Object} filterdata Optional, data for adding for row with an existing filter.
  98. * @return {Promise}
  99. */
  100. addFilterRow(filterdata = {}) {
  101. const pendingPromise = new Pending('core/datafilter:addFilterRow');
  102. const rownum = filterdata.rownum ?? 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
  103. return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rownum})
  104. .then(({html, js}) => {
  105. const newContentNodes = Templates.appendNodeContents(this.getFilterRegion(), html, js);
  106. return newContentNodes;
  107. })
  108. .then(filterRow => {
  109. // Note: This is a nasty hack.
  110. // We should try to find a better way of doing this.
  111. // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
  112. // it in place.
  113. const typeList = this.filterSet.querySelector(Selectors.data.typeList);
  114. filterRow.forEach(contentNode => {
  115. const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
  116. if (contentTypeList) {
  117. contentTypeList.innerHTML = typeList.innerHTML;
  118. }
  119. });
  120. return filterRow;
  121. })
  122. .then(filterRow => {
  123. this.updateFiltersOptions();
  124. return filterRow;
  125. })
  126. .then(result => {
  127. pendingPromise.resolve();
  128. // If an existing filter is passed in, add it. Otherwise, leave the row empty.
  129. if (filterdata.filtertype) {
  130. result.forEach(filter => {
  131. this.addFilter(filter, filterdata.filtertype, filterdata.values,
  132. filterdata.jointype, filterdata.filteroptions);
  133. });
  134. }
  135. return result;
  136. })
  137. .catch(Notification.exception);
  138. }
  139. /**
  140. * Get the filter data source node fro the specified filter type.
  141. *
  142. * @param {String} filterType
  143. * @return {HTMLElement}
  144. */
  145. getFilterDataSource(filterType) {
  146. const filterDataNode = this.filterSet.querySelector(Selectors.filterset.regions.datasource);
  147. return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
  148. }
  149. /**
  150. * Add a filter to the list of active filters, performing any necessary setup.
  151. *
  152. * @param {HTMLElement} filterRow
  153. * @param {String} filterType
  154. * @param {Array} initialFilterValues The initially selected values for the filter
  155. * @param {String} filterJoin
  156. * @param {Object} filterOptions
  157. * @returns {Filter}
  158. */
  159. async addFilter(filterRow, filterType, initialFilterValues, filterJoin, filterOptions) {
  160. // Name the filter on the filter row.
  161. filterRow.dataset.filterType = filterType;
  162. const filterDataNode = this.getFilterDataSource(filterType);
  163. // Instantiate the Filter class.
  164. let Filter = GenericFilter;
  165. if (filterDataNode.dataset.filterTypeClass) {
  166. // Ensure the filter class passed through exists, otherwise the filtering will break.
  167. try {
  168. Filter = await import(filterDataNode.dataset.filterTypeClass);
  169. } catch (error) {
  170. Notification.exception(error);
  171. }
  172. }
  173. this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues, filterOptions);
  174. // Disable the select.
  175. const typeField = filterRow.querySelector(Selectors.filter.fields.type);
  176. typeField.value = filterType;
  177. typeField.disabled = 'disabled';
  178. // Update the join list.
  179. this.updateJoinList(JSON.parse(filterDataNode.dataset.joinList), filterRow);
  180. const joinField = filterRow.querySelector(Selectors.filter.fields.join);
  181. if (!isNaN(filterJoin)) {
  182. joinField.value = filterJoin;
  183. }
  184. // Update the list of available filter types.
  185. this.updateFiltersOptions();
  186. return this.activeFilters[filterType];
  187. }
  188. /**
  189. * Get the registered filter class for the named filter.
  190. *
  191. * @param {String} name
  192. * @return {Object} See the Filter class.
  193. */
  194. getFilterObject(name) {
  195. return this.activeFilters[name];
  196. }
  197. /**
  198. * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
  199. * that it is replaced instead of being removed.
  200. *
  201. * @param {HTMLElement} filterRow
  202. * @param {Bool} refreshContent Whether to refresh the table content when removing
  203. */
  204. removeOrReplaceFilterRow(filterRow, refreshContent) {
  205. const filterCount = this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
  206. if (filterCount === 1) {
  207. this.replaceFilterRow(filterRow, refreshContent);
  208. } else {
  209. this.removeFilterRow(filterRow, refreshContent);
  210. }
  211. }
  212. /**
  213. * Remove the specified filter row and associated class.
  214. *
  215. * @param {HTMLElement} filterRow
  216. * @param {Bool} refreshContent Whether to refresh the table content when removing
  217. */
  218. async removeFilterRow(filterRow, refreshContent = true) {
  219. if (filterRow.querySelector(Selectors.data.required)) {
  220. return;
  221. }
  222. const filterType = filterRow.querySelector(Selectors.filter.fields.type);
  223. const hasFilterValue = !!filterType.value;
  224. // Remove the filter object.
  225. this.removeFilterObject(filterRow.dataset.filterType);
  226. // Remove the actual filter HTML.
  227. filterRow.remove();
  228. // Update the list of available filter types.
  229. this.updateFiltersOptions();
  230. if (hasFilterValue && refreshContent) {
  231. // Refresh the table if there was any content in this row.
  232. this.updateTableFromFilter();
  233. }
  234. // Update filter fieldset legends.
  235. const filterLegends = await this.getAvailableFilterLegends();
  236. this.getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
  237. filterRow.querySelector('legend').innerText = filterLegends[index];
  238. });
  239. }
  240. /**
  241. * Replace the specified filter row with a new one.
  242. *
  243. * @param {HTMLElement} filterRow
  244. * @param {Bool} refreshContent Whether to refresh the table content when removing
  245. * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
  246. * @return {Promise}
  247. */
  248. replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) {
  249. if (filterRow.querySelector(Selectors.data.required)) {
  250. return;
  251. }
  252. // Remove the filter object.
  253. this.removeFilterObject(filterRow.dataset.filterType);
  254. return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rowNum})
  255. .then(({html, js}) => {
  256. const newContentNodes = Templates.replaceNode(filterRow, html, js);
  257. return newContentNodes;
  258. })
  259. .then(filterRow => {
  260. // Note: This is a nasty hack.
  261. // We should try to find a better way of doing this.
  262. // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
  263. // it in place.
  264. const typeList = this.filterSet.querySelector(Selectors.data.typeList);
  265. filterRow.forEach(contentNode => {
  266. const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
  267. if (contentTypeList) {
  268. contentTypeList.innerHTML = typeList.innerHTML;
  269. }
  270. });
  271. return filterRow;
  272. })
  273. .then(filterRow => {
  274. this.updateFiltersOptions();
  275. return filterRow;
  276. })
  277. .then(filterRow => {
  278. // Refresh the table.
  279. if (refreshContent) {
  280. return this.updateTableFromFilter();
  281. } else {
  282. return filterRow;
  283. }
  284. })
  285. .catch(Notification.exception);
  286. }
  287. /**
  288. * Remove the Filter Object from the register.
  289. *
  290. * @param {string} filterName The name of the filter to be removed
  291. */
  292. removeFilterObject(filterName) {
  293. if (filterName) {
  294. const filter = this.getFilterObject(filterName);
  295. if (filter) {
  296. filter.tearDown();
  297. // Remove from the list of active filters.
  298. delete this.activeFilters[filterName];
  299. }
  300. }
  301. }
  302. /**
  303. * Remove all filters.
  304. *
  305. * @returns {Promise}
  306. */
  307. removeAllFilters() {
  308. const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
  309. filters.forEach(filterRow => this.removeOrReplaceFilterRow(filterRow, false));
  310. // Refresh the table.
  311. return this.updateTableFromFilter();
  312. }
  313. /**
  314. * Remove any empty filters.
  315. */
  316. removeEmptyFilters() {
  317. const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
  318. filters.forEach(filterRow => {
  319. const filterType = filterRow.querySelector(Selectors.filter.fields.type);
  320. if (!filterType.value) {
  321. this.removeOrReplaceFilterRow(filterRow, false);
  322. }
  323. });
  324. }
  325. /**
  326. * Update the list of filter types to filter out those already selected.
  327. */
  328. updateFiltersOptions() {
  329. const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
  330. filters.forEach(filterRow => {
  331. const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
  332. options.forEach(option => {
  333. if (option.value === filterRow.dataset.filterType) {
  334. option.classList.remove('hidden');
  335. option.disabled = false;
  336. } else if (this.activeFilters[option.value]) {
  337. option.classList.add('hidden');
  338. option.disabled = true;
  339. } else {
  340. option.classList.remove('hidden');
  341. option.disabled = false;
  342. }
  343. });
  344. });
  345. // Configure the state of the "Add row" button.
  346. // This button is disabled when there is a filter row available for each condition.
  347. const addRowButton = this.filterSet.querySelector(Selectors.filterset.actions.addRow);
  348. const filterDataNode = this.filterSet.querySelectorAll(Selectors.data.fields.all);
  349. if (filterDataNode.length <= filters.length) {
  350. addRowButton.setAttribute('disabled', 'disabled');
  351. } else {
  352. addRowButton.removeAttribute('disabled');
  353. }
  354. if (filters.length === 1) {
  355. this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
  356. this.filterSet.querySelector(Selectors.filterset.fields.join).value = 2;
  357. this.filterSet.dataset.filterverb = 2;
  358. } else {
  359. this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
  360. }
  361. }
  362. /**
  363. * Update the Dynamic table based upon the current filter.
  364. *
  365. * @param {bool} validate Should we validate the filters? We might want to skip this if the filters won't have changed,
  366. * for example for pagination/sorting.
  367. */
  368. updateTableFromFilter(validate = true) {
  369. const pendingPromise = new Pending('core/datafilter:updateTableFromFilter');
  370. const filters = {};
  371. let valid = true;
  372. Object.values(this.activeFilters).forEach(filter => {
  373. if (validate) {
  374. valid = valid && filter.validate();
  375. }
  376. filters[filter.filterValue.name] = filter.filterValue;
  377. });
  378. if (validate) {
  379. valid = valid && document.querySelector(Selectors.filter.region).closest('form').reportValidity();
  380. }
  381. if (this.applyCallback && valid) {
  382. this.applyCallback(filters, pendingPromise);
  383. } else {
  384. pendingPromise.resolve();
  385. }
  386. }
  387. /**
  388. * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
  389. *
  390. * @return {array}
  391. */
  392. async getAvailableFilterLegends() {
  393. const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
  394. let requests = [];
  395. [...Array(maxFilters)].forEach((_, rowIndex) => {
  396. requests.push({
  397. "key": "filterrowlegend",
  398. "component": "core",
  399. // Add 1 since rows begin at 1 (index begins at zero).
  400. "param": rowIndex + 1
  401. });
  402. });
  403. const legendStrings = await getStrings(requests)
  404. .then(fetchedStrings => {
  405. return fetchedStrings;
  406. })
  407. .catch(Notification.exception);
  408. return legendStrings;
  409. }
  410. /**
  411. * Update the list of join types for a filter.
  412. *
  413. * This will update the list of join types based on the allowed types defined for a filter.
  414. * If only one type is allowed, the list will be hidden.
  415. *
  416. * @param {Array} filterJoinList Array of join types, a subset of the regularJoinList array in this function.
  417. * @param {Element} filterRow The row being updated.
  418. */
  419. updateJoinList(filterJoinList, filterRow) {
  420. const regularJoinList = [0, 1, 2];
  421. // If a join list was specified for this filter, find the default join list and disable the options that are not allowed
  422. // for this filter.
  423. if (filterJoinList.length !== 0) {
  424. const joinField = filterRow.querySelector(Selectors.filter.fields.join);
  425. // Check each option from the default list, and disable the option in this filter row if it is not allowed
  426. // for this filter.
  427. regularJoinList.forEach((join) => {
  428. if (!filterJoinList.includes(join)) {
  429. joinField.options[join].classList.add('hidden');
  430. joinField.options[join].disabled = true;
  431. }
  432. });
  433. // Now remove the disabled options, and hide the select list of there is only one option left.
  434. joinField.options.forEach((element, index) => {
  435. if (element.disabled) {
  436. joinField.options[index] = null;
  437. }
  438. });
  439. if (joinField.options.length === 1) {
  440. joinField.hidden = true;
  441. }
  442. }
  443. }
  444. }