diff --git a/lang/en/user.php b/lang/en/user.php
index e010e0948e1..0c768b1c8cd 100644
--- a/lang/en/user.php
+++ b/lang/en/user.php
@@ -22,7 +22,12 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+$string['addcondition'] = 'Add condition';
+$string['applyfilters'] = 'Apply filters';
+$string['clearfilterrow'] = 'Remove filter row';
+$string['clearfilters'] = 'Clear filters';
$string['countparticipantsfound'] = '{$a} participants found';
+$string['match'] = 'Match';
$string['privacy:courserequestpath'] = 'Requested courses';
$string['privacy:descriptionpath'] = 'Profile description';
$string['privacy:devicespath'] = 'User devices';
@@ -126,6 +131,8 @@ $string['privacy:passwordresetpath'] = 'Password resets';
$string['privacy:profileimagespath'] = 'Profile images';
$string['privacy:privatefilespath'] = 'Private files';
$string['privacy:sessionpath'] = 'Session data';
+$string['selectfiltertype'] = 'Select';
$string['target:upcomingactivitiesdue'] = 'Upcoming activities due';
$string['target:upcomingactivitiesdue_help'] = 'This target generates reminders for upcoming activities due.';
$string['target:upcomingactivitiesdueinfo'] = 'All upcoming activities due insights are listed here. These students have received these insights directly.';
+$string['typeorselect'] = 'Type or select...';
diff --git a/user/amd/build/local/participantsfilter/filter.min.js b/user/amd/build/local/participantsfilter/filter.min.js
new file mode 100644
index 00000000000..cb8df333ee8
--- /dev/null
+++ b/user/amd/build/local/participantsfilter/filter.min.js
@@ -0,0 +1,2 @@
+define ("core_user/local/participantsfilter/filter",["exports","core/form-autocomplete","./selectors","core/str"],function(a,b,c,d){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=e(b);c=e(c);function e(a){return a&&a.__esModule?a:{default:a}}function f(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function g(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var i=a.apply(b,c);function g(a){f(i,d,e,g,h,"next",a)}function h(a){f(i,d,e,g,h,"throw",a)}g(void 0)})}}function h(a,b){if(!(a instanceof b)){throw new TypeError("Cannot call a class as a function")}}function i(a,b){for(var c=0,d;c.\n\n/**\n * Base Filter class for a filter type in the participants filter UI.\n *\n * @module core_user/local/participantsfilter/filter\n * @package core_user\n * @copyright 2020 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport Autocomplete from 'core/form-autocomplete';\nimport Selectors from './selectors';\nimport {get_string as getString} from 'core/str';\n\n/**\n * Fetch all checked options in the select.\n *\n * This is a poor-man's polyfill for select.selectedOptions, which is not available in IE11.\n *\n * @param {HTMLSelectElement} select\n * @returns {HTMLOptionElement[]} All selected options\n */\nconst getOptionsForSelect = select => {\n return select.querySelectorAll(':checked');\n};\n\nexport default class {\n\n /**\n * Constructor for a new filter.\n *\n * @param {String} filterType The type of filter that this relates to\n * @param {HTMLElement} rootNode The root node for the participants filterset\n */\n constructor(filterType, rootNode) {\n this.filterType = filterType;\n this.rootNode = rootNode;\n\n this.addValueSelector();\n }\n\n /**\n * Perform any tear-down for this filter type.\n */\n tearDown() {\n // eslint-disable-line no-empty-function\n }\n\n /**\n * Add the value selector to the filter row.\n */\n async addValueSelector() {\n const filterValueNode = this.getFilterValueNode();\n\n // Copy the data in place.\n filterValueNode.innerHTML = this.getSourceDataForFilter().outerHTML;\n\n const dataSource = filterValueNode.querySelector('select');\n\n Autocomplete.enhance(\n // The source select element.\n dataSource,\n\n // Whether to allow 'tags' (custom entries).\n dataSource.dataset.allowCustom == \"1\",\n\n // We do not require AJAX at all as standard.\n null,\n\n // The string to use as a placeholder.\n await getString('typeorselect', 'core_user'),\n\n // Disable case sensitivity on searches.\n false,\n\n // Show suggestions.\n true,\n\n // Do not override the 'no suggestions' string.\n null,\n\n // Close the suggestions if this is not a multi-select.\n !dataSource.multiple\n );\n }\n\n /**\n * Get the root node for this filter.\n *\n * @returns {HTMLElement}\n */\n get filterRoot() {\n return this.rootNode.querySelector(Selectors.filter.byName(this.filterType));\n }\n\n /**\n * Get the possible data for this filter type.\n *\n * @returns {Array}\n */\n getSourceDataForFilter() {\n const filterDataNode = this.rootNode.querySelector(Selectors.filterset.regions.datasource);\n\n return filterDataNode.querySelector(Selectors.data.fields.byName(this.filterType));\n }\n\n /**\n * Get the HTMLElement which contains the value selector.\n *\n * @returns {HTMLElement}\n */\n getFilterValueNode() {\n return this.filterRoot.querySelector(Selectors.filter.regions.values);\n }\n\n /**\n * Get the name of this filter.\n *\n * @returns {String}\n */\n get name() {\n return this.filterType;\n }\n\n /**\n * Get the type of join specified.\n *\n * @returns {Number}\n */\n get jointype() {\n return this.filterRoot.querySelector(Selectors.filter.fields.join).value;\n }\n\n /**\n * Get the list of raw values for this filter type.\n *\n * @returns {Array}\n */\n get rawValues() {\n const filterValueNode = this.getFilterValueNode();\n const filterValueSelect = filterValueNode.querySelector('select');\n\n return Object.values(getOptionsForSelect(filterValueSelect)).map(option => option.value);\n }\n\n /**\n * Get the list of values for this filter type.\n *\n * @returns {Array}\n */\n get values() {\n return this.rawValues.map(option => parseInt(option, 10));\n }\n\n /**\n * Get the composed value for this filter.\n *\n * @returns {Object}\n */\n get filterValue() {\n return {\n name: this.name,\n jointype: this.jointype,\n values: this.values,\n };\n }\n}\n"],"file":"filter.min.js"}
\ No newline at end of file
diff --git a/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js
new file mode 100644
index 00000000000..61445dc5cfd
--- /dev/null
+++ b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js
@@ -0,0 +1,2 @@
+define ("core_user/local/participantsfilter/filtertypes/courseid",["exports","../filter"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);function c(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){c=function(a){return typeof a}}else{c=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return c(a)}function d(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function e(a){return function(){var b=this,c=arguments;return new Promise(function(e,f){var i=a.apply(b,c);function g(a){d(i,e,f,g,h,"next",a)}function h(a){d(i,e,f,g,h,"throw",a)}g(void 0)})}}function f(a,b){if(!(a instanceof b)){throw new TypeError("Cannot call a class as a function")}}function g(a,b){for(var c=0,d;c.\n\n/**\n * Course ID filter.\n *\n * @module core_user/local/participantsfilter/filtertypes/courseid\n * @package core_user\n * @copyright 2020 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport Filter from '../filter';\n\nexport default class extends Filter {\n constructor(filterType, filterSet) {\n super(filterType, filterSet);\n }\n\n async addValueSelector() {\n // eslint-disable-line no-empty-function\n }\n\n /**\n * Get the composed value for this filter.\n *\n * @returns {Object}\n */\n get filterValue() {\n return {\n name: this.name,\n jointype: 1,\n values: [parseInt(this.rootNode.dataset.tableCourseId, 10)],\n };\n }\n}\n"],"file":"courseid.min.js"}
\ No newline at end of file
diff --git a/user/amd/build/local/participantsfilter/selectors.min.js b/user/amd/build/local/participantsfilter/selectors.min.js
new file mode 100644
index 00000000000..1f1dcc5dc02
--- /dev/null
+++ b/user/amd/build/local/participantsfilter/selectors.min.js
@@ -0,0 +1,2 @@
+define ("core_user/local/participantsfilter/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;var b=function(a){return"[data-filterregion=\"".concat(a,"\"]")},c=function(a){return"[data-filteraction=\"".concat(a,"\"]")},d=function(a){return"[data-filterfield=\"".concat(a,"\"]")},e={filter:{region:b("filter"),actions:{remove:c("remove")},fields:{join:d("join"),type:d("type")},regions:{values:b("value")},byName:function byName(a){return"".concat(b("filter"),"[data-filter-type=\"").concat(a,"\"]")}},filterset:{region:b("actions"),actions:{addRow:c("add"),applyFilters:c("apply"),resetFilters:c("reset")},regions:{filterlist:b("filters"),datasource:b("filtertypedata")}},data:{fields:{byName:function byName(a){return"[data-field-name=\"".concat(a,"\"]")}},typeList:b("filtertypelist")}};a.default=e;return a.default});
+//# sourceMappingURL=selectors.min.js.map
diff --git a/user/amd/build/local/participantsfilter/selectors.min.js.map b/user/amd/build/local/participantsfilter/selectors.min.js.map
new file mode 100644
index 00000000000..31cd5e66e02
--- /dev/null
+++ b/user/amd/build/local/participantsfilter/selectors.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../src/local/participantsfilter/selectors.js"],"names":["getFilterRegion","region","getFilterAction","action","getFilterField","field","filter","actions","remove","fields","join","type","regions","values","byName","name","filterset","addRow","applyFilters","resetFilters","filterlist","datasource","data","typeList"],"mappings":"iKAwBMA,CAAAA,CAAe,CAAG,SAAAC,CAAM,uCAA2BA,CAA3B,Q,CACxBC,CAAe,CAAG,SAAAC,CAAM,uCAA2BA,CAA3B,Q,CACxBC,CAAc,CAAG,SAAAC,CAAK,sCAA0BA,CAA1B,Q,GAEb,CACXC,MAAM,CAAE,CACJL,MAAM,CAAED,CAAe,CAAC,QAAD,CADnB,CAEJO,OAAO,CAAE,CACLC,MAAM,CAAEN,CAAe,CAAC,QAAD,CADlB,CAFL,CAKJO,MAAM,CAAE,CACJC,IAAI,CAAEN,CAAc,CAAC,MAAD,CADhB,CAEJO,IAAI,CAAEP,CAAc,CAAC,MAAD,CAFhB,CALJ,CASJQ,OAAO,CAAE,CACLC,MAAM,CAAEb,CAAe,CAAC,OAAD,CADlB,CATL,CAYJc,MAAM,CAAE,gBAAAC,CAAI,kBAAOf,CAAe,CAAC,QAAD,CAAtB,gCAAsDe,CAAtD,QAZR,CADG,CAeXC,SAAS,CAAE,CACPf,MAAM,CAAED,CAAe,CAAC,SAAD,CADhB,CAEPO,OAAO,CAAE,CACLU,MAAM,CAAEf,CAAe,CAAC,KAAD,CADlB,CAELgB,YAAY,CAAEhB,CAAe,CAAC,OAAD,CAFxB,CAGLiB,YAAY,CAAEjB,CAAe,CAAC,OAAD,CAHxB,CAFF,CAOPU,OAAO,CAAE,CACLQ,UAAU,CAAEpB,CAAe,CAAC,SAAD,CADtB,CAELqB,UAAU,CAAErB,CAAe,CAAC,gBAAD,CAFtB,CAPF,CAfA,CA2BXsB,IAAI,CAAE,CACFb,MAAM,CAAE,CACJK,MAAM,CAAE,gBAAAC,CAAI,qCAAyBA,CAAzB,QADR,CADN,CAIFQ,QAAQ,CAAEvB,CAAe,CAAC,gBAAD,CAJvB,CA3BK,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module containing the selectors for user filters.\n *\n * @module core_user/local/user_filter/selectors\n * @package core_user\n * @copyright 2020 Michael Hawkins \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst getFilterRegion = region => `[data-filterregion=\"${region}\"]`;\nconst getFilterAction = action => `[data-filteraction=\"${action}\"]`;\nconst getFilterField = field => `[data-filterfield=\"${field}\"]`;\n\nexport default {\n filter: {\n region: getFilterRegion('filter'),\n actions: {\n remove: getFilterAction('remove'),\n },\n fields: {\n join: getFilterField('join'),\n type: getFilterField('type'),\n },\n regions: {\n values: getFilterRegion('value'),\n },\n byName: name => `${getFilterRegion('filter')}[data-filter-type=\"${name}\"]`,\n },\n filterset: {\n region: getFilterRegion('actions'),\n actions: {\n addRow: getFilterAction('add'),\n applyFilters: getFilterAction('apply'),\n resetFilters: getFilterAction('reset'),\n },\n regions: {\n filterlist: getFilterRegion('filters'),\n datasource: getFilterRegion('filtertypedata'),\n },\n },\n data: {\n fields: {\n byName: name => `[data-field-name=\"${name}\"]`,\n },\n typeList: getFilterRegion('filtertypelist'),\n },\n};\n"],"file":"selectors.min.js"}
\ No newline at end of file
diff --git a/user/amd/build/participantsfilter.min.js b/user/amd/build/participantsfilter.min.js
new file mode 100644
index 00000000000..31b031a3683
--- /dev/null
+++ b/user/amd/build/participantsfilter.min.js
@@ -0,0 +1,2 @@
+function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_user/participantsfilter",["exports","./local/participantsfilter/filtertypes/courseid","core_table/dynamic","./local/participantsfilter/filter","core/notification","./local/participantsfilter/selectors","core/templates"],function(a,b,c,d,e,f,g){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=j(b);c=i(c);d=j(d);e=j(e);f=j(f);g=j(g);var m="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function h(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;h=function(){return a};return a}function i(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=h();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function j(a){return a&&a.__esModule?a:{default:a}}function k(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function l(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){k(h,d,e,f,g,"next",a)}function g(a){k(h,d,e,f,g,"throw",a)}f(void 0)})}}var n=function(a){var h=document.querySelector("#".concat(a)),i={courseid:new b.default("courseid",h)},j=function(){return h.querySelector(f.default.filterset.regions.filterlist)},k=function(){return g.default.renderForPromise("core_user/local/participantsfilter/filterrow",{}).then(function(a){var b=a.html,c=a.js,d=g.default.appendNodeContents(j(),b,c);return d}).then(function(a){var b=h.querySelector(f.default.data.typeList);a.forEach(function(a){var c=a.querySelector(f.default.filter.fields.type);if(c){c.innerHTML=b.innerHTML}});return a}).then(function(a){v();return a}).catch(e.default.exception)},n=function(a){var b=h.querySelector(f.default.filterset.regions.datasource);return b.querySelector(f.default.data.fields.byName(a))},o=function(){var a=l(regeneratorRuntime.mark(function a(b,c){var e,g,j;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:b.dataset.filterType=c;e=n(c);g=d.default;if(!e.dataset.filterTypeClass){a.next=7;break}a.next=6;return"function"==typeof m.define&&m.define.amd?new Promise(function(a,b){m.require([e.dataset.filterTypeClass],a,b)}):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&m.require&&"component"===m.require.loader?Promise.resolve(require((e.dataset.filterTypeClass))):Promise.resolve(m[e.dataset.filterTypeClass]);case 6:g=a.sent;case 7:i[c]=new g(c,h);j=b.querySelector(f.default.filter.fields.type);j.disabled="disabled";v();case 11:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}(),p=function(a){return i[a]},q=function(a){var b=j().querySelectorAll(f.default.filter.region).length;if(1===b){s(a)}else{r(a)}},r=function(a){t(a.dataset.filterType);a.remove();w();v()},s=function(a){t(a.dataset.filterType);return g.default.renderForPromise("core_user/local/participantsfilter/filterrow",{}).then(function(b){var c=b.html,d=b.js,e=g.default.replaceNode(a,c,d);return e}).then(function(a){var b=h.querySelector(f.default.data.typeList);a.forEach(function(a){var c=a.querySelector(f.default.filter.fields.type);if(c){c.innerHTML=b.innerHTML}});return a}).then(function(a){v();return a}).then(function(a){w();return a}).catch(e.default.exception)},t=function(a){if(a){var b=p(a);if(b){b.tearDown();delete i[a]}}},u=function(){var a=l(regeneratorRuntime.mark(function a(){var b;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:b=j().querySelectorAll(f.default.filter.region);b.forEach(function(a){q(a)});w();case 3:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}(),v=function(){var a=j().querySelectorAll(f.default.filter.region);a.forEach(function(a){var b=a.querySelectorAll(f.default.filter.fields.type+" option");b.forEach(function(b){if(b.value===a.dataset.filterType){b.classList.remove("hidden");b.disabled=!1}else if(i[b.value]){b.classList.add("hidden");b.disabled=!0}else{b.classList.remove("hidden");b.disabled=!1}})})},w=function(){return c.setFilters(c.getTableFromId(h.dataset.tableRegion),{filters:Object.values(i).map(function(a){return a.filterValue}),jointype:1})};h.querySelector(f.default.filterset.region).addEventListener("click",function(a){if(a.target.closest(f.default.filterset.actions.addRow)){a.preventDefault();k()}if(a.target.closest(f.default.filterset.actions.applyFilters)){a.preventDefault();w()}if(a.target.closest(f.default.filterset.actions.resetFilters)){a.preventDefault();u()}});h.querySelector(f.default.filterset.regions.filterlist).addEventListener("click",function(a){if(a.target.closest(f.default.filter.actions.remove)){a.preventDefault();q(a.target.closest(f.default.filter.region))}});h.querySelector(f.default.filterset.regions.filterlist).addEventListener("change",function(a){var b=a.target.closest(f.default.filter.fields.type);if(b&&b.value){var c=a.target.closest(f.default.filter.region);o(c,b.value)}})};a.init=n});
+//# sourceMappingURL=participantsfilter.min.js.map
diff --git a/user/amd/build/participantsfilter.min.js.map b/user/amd/build/participantsfilter.min.js.map
new file mode 100644
index 00000000000..1a05bb3bc55
--- /dev/null
+++ b/user/amd/build/participantsfilter.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../src/participantsfilter.js"],"names":["init","participantsRegionId","filterSet","document","querySelector","activeFilters","courseid","CourseFilter","getFilterRegion","Selectors","filterset","regions","filterlist","addFilterRow","Templates","renderForPromise","then","html","js","newContentNodes","appendNodeContents","filterRow","typeList","data","forEach","contentNode","contentTypeList","filter","fields","type","innerHTML","updateFiltersOptions","catch","Notification","exception","getFilterDataSource","filterType","filterDataNode","datasource","byName","addFilter","dataset","Filter","GenericFilter","filterTypeClass","typeField","disabled","getFilterObject","name","removeOrReplaceFilterRow","filterCount","querySelectorAll","region","length","replaceFilterRow","removeFilterRow","removeFilterObject","remove","updateTableFromFilter","replaceNode","filterName","tearDown","removeAllFilters","filters","options","option","value","classList","add","DynamicTable","setFilters","getTableFromId","tableRegion","Object","values","map","filterValue","jointype","addEventListener","e","target","closest","actions","addRow","preventDefault","applyFilters","resetFilters"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Participants filter managemnet.\n *\n * @module core_user/participants_filter\n * @package core_user\n * @copyright 2020 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport CourseFilter from './local/participantsfilter/filtertypes/courseid';\nimport * as DynamicTable from 'core_table/dynamic';\nimport GenericFilter from './local/participantsfilter/filter';\nimport Notification from 'core/notification';\nimport Selectors from './local/participantsfilter/selectors';\nimport Templates from 'core/templates';\n\n/**\n * Initialise the participants filter on the element with the given id.\n *\n * @param {String} participantsRegionId\n */\nexport const init = participantsRegionId => {\n // Keep a reference to the filterset.\n const filterSet = document.querySelector(`#${participantsRegionId}`);\n\n // Keep a reference to all of the active filters.\n const activeFilters = {\n courseid: new CourseFilter('courseid', filterSet),\n };\n\n /**\n * Get the filter list region.\n *\n * @return {HTMLElement}\n */\n const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);\n\n /**\n * Add an unselected filter row.\n *\n * @return {Promise}\n */\n const addFilterRow = () => {\n return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})\n .then(({html, js}) => {\n const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);\n\n return newContentNodes;\n })\n .then(filterRow => {\n // Note: This is a nasty hack.\n // We should try to find a better way of doing this.\n // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy\n // it in place.\n const typeList = filterSet.querySelector(Selectors.data.typeList);\n\n filterRow.forEach(contentNode => {\n const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);\n\n if (contentTypeList) {\n contentTypeList.innerHTML = typeList.innerHTML;\n }\n });\n\n return filterRow;\n })\n .then(filterRow => {\n updateFiltersOptions();\n\n return filterRow;\n })\n .catch(Notification.exception);\n };\n\n /**\n * Get the filter data source node fro the specified filter type.\n *\n * @param {String} filterType\n * @return {HTMLElement}\n */\n const getFilterDataSource = filterType => {\n const filterDataNode = filterSet.querySelector(Selectors.filterset.regions.datasource);\n\n return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));\n };\n\n /**\n * Add a filter to the list of active filters, performing any necessary setup.\n *\n * @param {HTMLElement} filterRow\n * @param {String} filterType\n */\n const addFilter = async(filterRow, filterType) => {\n // Name the filter on the filter row.\n filterRow.dataset.filterType = filterType;\n\n const filterDataNode = getFilterDataSource(filterType);\n\n // Instantiate the Filter class.\n let Filter = GenericFilter;\n if (filterDataNode.dataset.filterTypeClass) {\n Filter = await import(filterDataNode.dataset.filterTypeClass);\n }\n activeFilters[filterType] = new Filter(filterType, filterSet);\n\n // Disable the select.\n const typeField = filterRow.querySelector(Selectors.filter.fields.type);\n typeField.disabled = 'disabled';\n\n // Update the list of available filter types.\n updateFiltersOptions();\n };\n\n /**\n * Get the registered filter class for the named filter.\n *\n * @param {String} name\n * @return {Object} See the Filter class.\n */\n const getFilterObject = name => {\n return activeFilters[name];\n };\n\n /**\n * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,\n * that it is replaced instead of being removed.\n *\n * @param {HTMLElement} filterRow\n */\n const removeOrReplaceFilterRow = filterRow => {\n const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;\n\n if (filterCount === 1) {\n replaceFilterRow(filterRow);\n } else {\n removeFilterRow(filterRow);\n }\n };\n\n /**\n * Remove the specified filter row and associated class.\n *\n * @param {HTMLElement} filterRow\n */\n const removeFilterRow = filterRow => {\n // Remove the filter object.\n removeFilterObject(filterRow.dataset.filterType);\n\n // Remove the actual filter HTML.\n filterRow.remove();\n\n // Refresh the table.\n updateTableFromFilter();\n\n // Update the list of available filter types.\n updateFiltersOptions();\n };\n\n /**\n * Replace the specified filter row with a new one.\n *\n * @param {HTMLElement} filterRow\n * @return {Promise}\n */\n const replaceFilterRow = filterRow => {\n // Remove the filter object.\n removeFilterObject(filterRow.dataset.filterType);\n\n return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})\n .then(({html, js}) => {\n const newContentNodes = Templates.replaceNode(filterRow, html, js);\n\n return newContentNodes;\n })\n .then(filterRow => {\n // Note: This is a nasty hack.\n // We should try to find a better way of doing this.\n // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy\n // it in place.\n const typeList = filterSet.querySelector(Selectors.data.typeList);\n\n filterRow.forEach(contentNode => {\n const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);\n\n if (contentTypeList) {\n contentTypeList.innerHTML = typeList.innerHTML;\n }\n });\n\n return filterRow;\n })\n .then(filterRow => {\n updateFiltersOptions();\n\n return filterRow;\n })\n .then(filterRow => {\n // Refresh the table.\n updateTableFromFilter();\n\n return filterRow;\n })\n .catch(Notification.exception);\n };\n\n /**\n * Remove the Filter Object from the register.\n *\n * @param {string} filterName The name of the filter to be removed\n */\n const removeFilterObject = filterName => {\n if (filterName) {\n const filter = getFilterObject(filterName);\n if (filter) {\n filter.tearDown();\n\n // Remove from the list of active filters.\n delete activeFilters[filterName];\n }\n }\n };\n\n /**\n * Remove all filters.\n */\n const removeAllFilters = async() => {\n const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);\n filters.forEach((filterRow) => {\n removeOrReplaceFilterRow(filterRow);\n });\n\n // Refresh the table.\n updateTableFromFilter();\n };\n\n /**\n * Update the list of filter types to filter out those already selected.\n */\n const updateFiltersOptions = () => {\n const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);\n filters.forEach(filterRow => {\n const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');\n options.forEach(option => {\n if (option.value === filterRow.dataset.filterType) {\n option.classList.remove('hidden');\n option.disabled = false;\n } else if (activeFilters[option.value]) {\n option.classList.add('hidden');\n option.disabled = true;\n } else {\n option.classList.remove('hidden');\n option.disabled = false;\n }\n });\n });\n };\n\n /**\n * Update the Dynamic table based upon the current filter.\n *\n * @return {Promise}\n */\n const updateTableFromFilter = () => {\n // TODO The main join type does not exist yet.\n const joinType = 1;\n\n return DynamicTable.setFilters(\n DynamicTable.getTableFromId(filterSet.dataset.tableRegion),\n {\n filters: Object.values(activeFilters).map(filter => filter.filterValue),\n jointype: joinType,\n }\n );\n };\n\n // Add listeners for the main actions.\n filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {\n if (e.target.closest(Selectors.filterset.actions.addRow)) {\n e.preventDefault();\n\n addFilterRow();\n }\n\n if (e.target.closest(Selectors.filterset.actions.applyFilters)) {\n e.preventDefault();\n\n updateTableFromFilter();\n }\n\n if (e.target.closest(Selectors.filterset.actions.resetFilters)) {\n e.preventDefault();\n\n removeAllFilters();\n }\n });\n\n // Add the listener to remove a single filter.\n filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {\n if (e.target.closest(Selectors.filter.actions.remove)) {\n e.preventDefault();\n\n removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));\n }\n });\n\n // Add listeners for the filter type selection.\n filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {\n const typeField = e.target.closest(Selectors.filter.fields.type);\n if (typeField && typeField.value) {\n const filter = e.target.closest(Selectors.filter.region);\n\n addFilter(filter, typeField.value);\n }\n });\n};\n"],"file":"participantsfilter.min.js"}
\ No newline at end of file
diff --git a/user/amd/src/local/participantsfilter/filter.js b/user/amd/src/local/participantsfilter/filter.js
new file mode 100644
index 00000000000..2cc72afec34
--- /dev/null
+++ b/user/amd/src/local/participantsfilter/filter.js
@@ -0,0 +1,180 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Base Filter class for a filter type in the participants filter UI.
+ *
+ * @module core_user/local/participantsfilter/filter
+ * @package core_user
+ * @copyright 2020 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import Autocomplete from 'core/form-autocomplete';
+import Selectors from './selectors';
+import {get_string as getString} from 'core/str';
+
+/**
+ * Fetch all checked options in the select.
+ *
+ * This is a poor-man's polyfill for select.selectedOptions, which is not available in IE11.
+ *
+ * @param {HTMLSelectElement} select
+ * @returns {HTMLOptionElement[]} All selected options
+ */
+const getOptionsForSelect = select => {
+ return select.querySelectorAll(':checked');
+};
+
+export default class {
+
+ /**
+ * Constructor for a new filter.
+ *
+ * @param {String} filterType The type of filter that this relates to
+ * @param {HTMLElement} rootNode The root node for the participants filterset
+ */
+ constructor(filterType, rootNode) {
+ this.filterType = filterType;
+ this.rootNode = rootNode;
+
+ this.addValueSelector();
+ }
+
+ /**
+ * Perform any tear-down for this filter type.
+ */
+ tearDown() {
+ // eslint-disable-line no-empty-function
+ }
+
+ /**
+ * Add the value selector to the filter row.
+ */
+ async addValueSelector() {
+ const filterValueNode = this.getFilterValueNode();
+
+ // Copy the data in place.
+ filterValueNode.innerHTML = this.getSourceDataForFilter().outerHTML;
+
+ const dataSource = filterValueNode.querySelector('select');
+
+ Autocomplete.enhance(
+ // The source select element.
+ dataSource,
+
+ // Whether to allow 'tags' (custom entries).
+ dataSource.dataset.allowCustom == "1",
+
+ // We do not require AJAX at all as standard.
+ null,
+
+ // The string to use as a placeholder.
+ await getString('typeorselect', 'core_user'),
+
+ // Disable case sensitivity on searches.
+ false,
+
+ // Show suggestions.
+ true,
+
+ // Do not override the 'no suggestions' string.
+ null,
+
+ // Close the suggestions if this is not a multi-select.
+ !dataSource.multiple
+ );
+ }
+
+ /**
+ * Get the root node for this filter.
+ *
+ * @returns {HTMLElement}
+ */
+ get filterRoot() {
+ return this.rootNode.querySelector(Selectors.filter.byName(this.filterType));
+ }
+
+ /**
+ * Get the possible data for this filter type.
+ *
+ * @returns {Array}
+ */
+ getSourceDataForFilter() {
+ const filterDataNode = this.rootNode.querySelector(Selectors.filterset.regions.datasource);
+
+ return filterDataNode.querySelector(Selectors.data.fields.byName(this.filterType));
+ }
+
+ /**
+ * Get the HTMLElement which contains the value selector.
+ *
+ * @returns {HTMLElement}
+ */
+ getFilterValueNode() {
+ return this.filterRoot.querySelector(Selectors.filter.regions.values);
+ }
+
+ /**
+ * Get the name of this filter.
+ *
+ * @returns {String}
+ */
+ get name() {
+ return this.filterType;
+ }
+
+ /**
+ * Get the type of join specified.
+ *
+ * @returns {Number}
+ */
+ get jointype() {
+ return this.filterRoot.querySelector(Selectors.filter.fields.join).value;
+ }
+
+ /**
+ * Get the list of raw values for this filter type.
+ *
+ * @returns {Array}
+ */
+ get rawValues() {
+ const filterValueNode = this.getFilterValueNode();
+ const filterValueSelect = filterValueNode.querySelector('select');
+
+ return Object.values(getOptionsForSelect(filterValueSelect)).map(option => option.value);
+ }
+
+ /**
+ * Get the list of values for this filter type.
+ *
+ * @returns {Array}
+ */
+ get values() {
+ return this.rawValues.map(option => parseInt(option, 10));
+ }
+
+ /**
+ * Get the composed value for this filter.
+ *
+ * @returns {Object}
+ */
+ get filterValue() {
+ return {
+ name: this.name,
+ jointype: this.jointype,
+ values: this.values,
+ };
+ }
+}
diff --git a/user/amd/src/local/participantsfilter/filtertypes/courseid.js b/user/amd/src/local/participantsfilter/filtertypes/courseid.js
new file mode 100644
index 00000000000..49698846219
--- /dev/null
+++ b/user/amd/src/local/participantsfilter/filtertypes/courseid.js
@@ -0,0 +1,47 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Course ID filter.
+ *
+ * @module core_user/local/participantsfilter/filtertypes/courseid
+ * @package core_user
+ * @copyright 2020 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import Filter from '../filter';
+
+export default class extends Filter {
+ constructor(filterType, filterSet) {
+ super(filterType, filterSet);
+ }
+
+ async addValueSelector() {
+ // eslint-disable-line no-empty-function
+ }
+
+ /**
+ * Get the composed value for this filter.
+ *
+ * @returns {Object}
+ */
+ get filterValue() {
+ return {
+ name: this.name,
+ jointype: 1,
+ values: [parseInt(this.rootNode.dataset.tableCourseId, 10)],
+ };
+ }
+}
diff --git a/user/amd/src/local/participantsfilter/selectors.js b/user/amd/src/local/participantsfilter/selectors.js
new file mode 100644
index 00000000000..d17b28e28f3
--- /dev/null
+++ b/user/amd/src/local/participantsfilter/selectors.js
@@ -0,0 +1,62 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Module containing the selectors for user filters.
+ *
+ * @module core_user/local/user_filter/selectors
+ * @package core_user
+ * @copyright 2020 Michael Hawkins
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+const getFilterRegion = region => `[data-filterregion="${region}"]`;
+const getFilterAction = action => `[data-filteraction="${action}"]`;
+const getFilterField = field => `[data-filterfield="${field}"]`;
+
+export default {
+ filter: {
+ region: getFilterRegion('filter'),
+ actions: {
+ remove: getFilterAction('remove'),
+ },
+ fields: {
+ join: getFilterField('join'),
+ type: getFilterField('type'),
+ },
+ regions: {
+ values: getFilterRegion('value'),
+ },
+ byName: name => `${getFilterRegion('filter')}[data-filter-type="${name}"]`,
+ },
+ filterset: {
+ region: getFilterRegion('actions'),
+ actions: {
+ addRow: getFilterAction('add'),
+ applyFilters: getFilterAction('apply'),
+ resetFilters: getFilterAction('reset'),
+ },
+ regions: {
+ filterlist: getFilterRegion('filters'),
+ datasource: getFilterRegion('filtertypedata'),
+ },
+ },
+ data: {
+ fields: {
+ byName: name => `[data-field-name="${name}"]`,
+ },
+ typeList: getFilterRegion('filtertypelist'),
+ },
+};
diff --git a/user/amd/src/participantsfilter.js b/user/amd/src/participantsfilter.js
new file mode 100644
index 00000000000..23e54327e06
--- /dev/null
+++ b/user/amd/src/participantsfilter.js
@@ -0,0 +1,330 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Participants filter managemnet.
+ *
+ * @module core_user/participants_filter
+ * @package core_user
+ * @copyright 2020 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import CourseFilter from './local/participantsfilter/filtertypes/courseid';
+import * as DynamicTable from 'core_table/dynamic';
+import GenericFilter from './local/participantsfilter/filter';
+import Notification from 'core/notification';
+import Selectors from './local/participantsfilter/selectors';
+import Templates from 'core/templates';
+
+/**
+ * Initialise the participants filter on the element with the given id.
+ *
+ * @param {String} participantsRegionId
+ */
+export const init = participantsRegionId => {
+ // Keep a reference to the filterset.
+ const filterSet = document.querySelector(`#${participantsRegionId}`);
+
+ // Keep a reference to all of the active filters.
+ const activeFilters = {
+ courseid: new CourseFilter('courseid', filterSet),
+ };
+
+ /**
+ * Get the filter list region.
+ *
+ * @return {HTMLElement}
+ */
+ const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
+
+ /**
+ * Add an unselected filter row.
+ *
+ * @return {Promise}
+ */
+ const addFilterRow = () => {
+ return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+ .then(({html, js}) => {
+ const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
+
+ return newContentNodes;
+ })
+ .then(filterRow => {
+ // Note: This is a nasty hack.
+ // We should try to find a better way of doing this.
+ // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
+ // it in place.
+ const typeList = filterSet.querySelector(Selectors.data.typeList);
+
+ filterRow.forEach(contentNode => {
+ const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
+
+ if (contentTypeList) {
+ contentTypeList.innerHTML = typeList.innerHTML;
+ }
+ });
+
+ return filterRow;
+ })
+ .then(filterRow => {
+ updateFiltersOptions();
+
+ return filterRow;
+ })
+ .catch(Notification.exception);
+ };
+
+ /**
+ * Get the filter data source node fro the specified filter type.
+ *
+ * @param {String} filterType
+ * @return {HTMLElement}
+ */
+ const getFilterDataSource = filterType => {
+ const filterDataNode = filterSet.querySelector(Selectors.filterset.regions.datasource);
+
+ return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
+ };
+
+ /**
+ * Add a filter to the list of active filters, performing any necessary setup.
+ *
+ * @param {HTMLElement} filterRow
+ * @param {String} filterType
+ */
+ const addFilter = async(filterRow, filterType) => {
+ // Name the filter on the filter row.
+ filterRow.dataset.filterType = filterType;
+
+ const filterDataNode = getFilterDataSource(filterType);
+
+ // Instantiate the Filter class.
+ let Filter = GenericFilter;
+ if (filterDataNode.dataset.filterTypeClass) {
+ Filter = await import(filterDataNode.dataset.filterTypeClass);
+ }
+ activeFilters[filterType] = new Filter(filterType, filterSet);
+
+ // Disable the select.
+ const typeField = filterRow.querySelector(Selectors.filter.fields.type);
+ typeField.disabled = 'disabled';
+
+ // Update the list of available filter types.
+ updateFiltersOptions();
+ };
+
+ /**
+ * Get the registered filter class for the named filter.
+ *
+ * @param {String} name
+ * @return {Object} See the Filter class.
+ */
+ const getFilterObject = name => {
+ return activeFilters[name];
+ };
+
+ /**
+ * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
+ * that it is replaced instead of being removed.
+ *
+ * @param {HTMLElement} filterRow
+ */
+ const removeOrReplaceFilterRow = filterRow => {
+ const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
+
+ if (filterCount === 1) {
+ replaceFilterRow(filterRow);
+ } else {
+ removeFilterRow(filterRow);
+ }
+ };
+
+ /**
+ * Remove the specified filter row and associated class.
+ *
+ * @param {HTMLElement} filterRow
+ */
+ const removeFilterRow = filterRow => {
+ // Remove the filter object.
+ removeFilterObject(filterRow.dataset.filterType);
+
+ // Remove the actual filter HTML.
+ filterRow.remove();
+
+ // Refresh the table.
+ updateTableFromFilter();
+
+ // Update the list of available filter types.
+ updateFiltersOptions();
+ };
+
+ /**
+ * Replace the specified filter row with a new one.
+ *
+ * @param {HTMLElement} filterRow
+ * @return {Promise}
+ */
+ const replaceFilterRow = filterRow => {
+ // Remove the filter object.
+ removeFilterObject(filterRow.dataset.filterType);
+
+ return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+ .then(({html, js}) => {
+ const newContentNodes = Templates.replaceNode(filterRow, html, js);
+
+ return newContentNodes;
+ })
+ .then(filterRow => {
+ // Note: This is a nasty hack.
+ // We should try to find a better way of doing this.
+ // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
+ // it in place.
+ const typeList = filterSet.querySelector(Selectors.data.typeList);
+
+ filterRow.forEach(contentNode => {
+ const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
+
+ if (contentTypeList) {
+ contentTypeList.innerHTML = typeList.innerHTML;
+ }
+ });
+
+ return filterRow;
+ })
+ .then(filterRow => {
+ updateFiltersOptions();
+
+ return filterRow;
+ })
+ .then(filterRow => {
+ // Refresh the table.
+ updateTableFromFilter();
+
+ return filterRow;
+ })
+ .catch(Notification.exception);
+ };
+
+ /**
+ * Remove the Filter Object from the register.
+ *
+ * @param {string} filterName The name of the filter to be removed
+ */
+ const removeFilterObject = filterName => {
+ if (filterName) {
+ const filter = getFilterObject(filterName);
+ if (filter) {
+ filter.tearDown();
+
+ // Remove from the list of active filters.
+ delete activeFilters[filterName];
+ }
+ }
+ };
+
+ /**
+ * Remove all filters.
+ */
+ const removeAllFilters = async() => {
+ const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
+ filters.forEach((filterRow) => {
+ removeOrReplaceFilterRow(filterRow);
+ });
+
+ // Refresh the table.
+ updateTableFromFilter();
+ };
+
+ /**
+ * Update the list of filter types to filter out those already selected.
+ */
+ const updateFiltersOptions = () => {
+ const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
+ filters.forEach(filterRow => {
+ const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
+ options.forEach(option => {
+ if (option.value === filterRow.dataset.filterType) {
+ option.classList.remove('hidden');
+ option.disabled = false;
+ } else if (activeFilters[option.value]) {
+ option.classList.add('hidden');
+ option.disabled = true;
+ } else {
+ option.classList.remove('hidden');
+ option.disabled = false;
+ }
+ });
+ });
+ };
+
+ /**
+ * Update the Dynamic table based upon the current filter.
+ *
+ * @return {Promise}
+ */
+ const updateTableFromFilter = () => {
+ // TODO The main join type does not exist yet.
+ const joinType = 1;
+
+ return DynamicTable.setFilters(
+ DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
+ {
+ filters: Object.values(activeFilters).map(filter => filter.filterValue),
+ jointype: joinType,
+ }
+ );
+ };
+
+ // Add listeners for the main actions.
+ filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
+ if (e.target.closest(Selectors.filterset.actions.addRow)) {
+ e.preventDefault();
+
+ addFilterRow();
+ }
+
+ if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
+ e.preventDefault();
+
+ updateTableFromFilter();
+ }
+
+ if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
+ e.preventDefault();
+
+ removeAllFilters();
+ }
+ });
+
+ // Add the listener to remove a single filter.
+ filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
+ if (e.target.closest(Selectors.filter.actions.remove)) {
+ e.preventDefault();
+
+ removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
+ }
+ });
+
+ // Add listeners for the filter type selection.
+ filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
+ const typeField = e.target.closest(Selectors.filter.fields.type);
+ if (typeField && typeField.value) {
+ const filter = e.target.closest(Selectors.filter.region);
+
+ addFilter(filter, typeField.value);
+ }
+ });
+};
diff --git a/user/classes/output/participants_filter.php b/user/classes/output/participants_filter.php
new file mode 100644
index 00000000000..12dddd63dcd
--- /dev/null
+++ b/user/classes/output/participants_filter.php
@@ -0,0 +1,150 @@
+.
+
+/**
+ * Class for rendering user filters on the course participants page.
+ *
+ * @package core_user
+ * @copyright 2020 Michael Hawkins
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_user\output;
+
+use context_course;
+use renderable;
+use renderer_base;
+use stdClass;
+use templatable;
+
+/**
+ * Class for rendering user filters on the course participants page.
+ *
+ * @copyright 2020 Michael Hawkins
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class participants_filter implements renderable, templatable {
+
+ /** @var context_course $context The context where the filters are being rendered. */
+ protected $context;
+
+ /** @var string $tableregionid The table to be updated by this filter */
+ protected $tableregionid;
+
+ /**
+ * Participants filter constructor.
+ *
+ * @param context_course $context The context where the filters are being rendered.
+ * @param string $tableregionid The table to be updated by this filter
+ */
+ public function __construct(context_course $context, string $tableregionid) {
+ $this->context = $context;
+ $this->tableregionid = $tableregionid;
+ }
+
+ /**
+ * Get data for all filter types.
+ *
+ * @return array
+ */
+ protected function get_filtertypes(): array {
+ $filtertypes = [];
+
+ if ($filtertype = $this->get_enrolmentstatus_filter()) {
+ $filtertypes[] = $filtertype;
+ }
+
+ return $filtertypes;
+ }
+
+ /**
+ * Get data for the enrolment status filter.
+ *
+ * @return stdClass|null
+ */
+ protected function get_enrolmentstatus_filter(): ?stdClass {
+ if (!has_capability('moodle/course:enrolreview', $this->context)) {
+ return null;
+ }
+
+ return $this->get_filter_object(
+ 'status',
+ get_string('participationstatus', 'core_enrol'),
+ false,
+ true,
+ null,
+ [
+ (object) [
+ 'value' => ENROL_USER_ACTIVE,
+ 'title' => get_string('active'),
+ ],
+ (object) [
+ 'value' => ENROL_USER_SUSPENDED,
+ 'title' => get_string('inactive'),
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Export the renderer data in a mustache template friendly format.
+ *
+ * @param renderer_base $output Unused.
+ * @return stdClass Data in a format compatible with a mustache template.
+ */
+ public function export_for_template(renderer_base $output): stdClass {
+ return (object) [
+ 'tableregionid' => $this->tableregionid,
+ 'courseid' => $this->context->instanceid,
+ 'filtertypes' => $this->get_filtertypes(),
+ ];
+
+ return $data;
+ }
+
+ /**
+ * Get a standardised filter object.
+ *
+ * @param string $name
+ * @param string $title
+ * @param bool $custom
+ * @param bool $multiple
+ * @param string|null $filterclass
+ * @param array $values
+ * @return stdClass|null
+ */
+ protected function get_filter_object(
+ string $name,
+ string $title,
+ bool $custom,
+ bool $multiple,
+ ?string $filterclass,
+ array $values
+ ): ?stdClass {
+ if (empty($values)) {
+ // Do not show empty filters.
+ return null;
+ }
+
+ return (object) [
+ 'name' => $name,
+ 'title' => $title,
+ 'allowcustom' => $custom,
+ 'allowmultiple' => $multiple,
+ 'filtertypeclass' => $filterclass,
+ 'values' => $values,
+ ];
+ }
+}
diff --git a/user/index.php b/user/index.php
index 3078724573d..cf0f155c878 100644
--- a/user/index.php
+++ b/user/index.php
@@ -143,6 +143,8 @@ $lastaccess = 0;
$searchkeywords = [];
$enrolid = 0;
+$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
+
$filterset = new \core_user\table\participants_filterset();
$filterset->add_filter(new integer_filter('courseid', filter::JOINTYPE_DEFAULT, [(int)$course->id]));
$enrolfilter = new integer_filter('enrolments');
@@ -249,6 +251,10 @@ echo html_writer::div($enrolbuttonsout, 'float-right', [
$renderer = $PAGE->get_renderer('core_user');
echo $renderer->unified_filter($course, $context, $filtersapplied, $baseurl);
+// Render the user filters.
+$userrenderer = $PAGE->get_renderer('core_user');
+echo $userrenderer->participants_filter($context, $participanttable->uniqueid);
+
echo '
';
// Add filters to the baseurl after creating unified_filter to avoid losing them.
diff --git a/user/renderer.php b/user/renderer.php
index 11f2190e5c8..7be0ce5103d 100644
--- a/user/renderer.php
+++ b/user/renderer.php
@@ -259,6 +259,20 @@ class core_user_renderer extends plugin_renderer_base {
return $this->output->render_from_template('core_user/unified_filter', $context);
}
+ /**
+ * Render the data required for the participants filter on the course participants page.
+ *
+ * @param context $context The context of the course being displayed
+ * @param string $tableregionid The table to be updated by this filter
+ * @return string
+ */
+ public function participants_filter(context $context, string $tableregionid): string {
+ $renderable = new \core_user\output\participants_filter($context, $tableregionid);
+ $templatecontext = $renderable->export_for_template($this->output);
+
+ return $this->output->render_from_template('core_user/participantsfilter', $templatecontext);
+ }
+
/**
* Returns a formatted filter option.
*
diff --git a/user/templates/local/participantsfilter/filterrow.mustache b/user/templates/local/participantsfilter/filterrow.mustache
new file mode 100644
index 00000000000..c97bab55d3b
--- /dev/null
+++ b/user/templates/local/participantsfilter/filterrow.mustache
@@ -0,0 +1,56 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core_user/local/participantsfilter/filterrow
+
+ Template for use by each filter condition.
+
+ Context variables required for this template:
+ * filtertypes - Array of filter types available.
+
+ Example context (json):
+ {
+ "filtertypes": [
+ {
+ "name": "status",
+ "title": "Status"
+ }
+ ]
+ }
+}}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/user/templates/local/participantsfilter/filtertype.mustache b/user/templates/local/participantsfilter/filtertype.mustache
new file mode 100644
index 00000000000..f38eb931786
--- /dev/null
+++ b/user/templates/local/participantsfilter/filtertype.mustache
@@ -0,0 +1,61 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core_user/local/participantsfilter/filtertype
+
+ Filter type data, not shown to users but used as a source of data for form autocompletion.
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * none
+
+ Context variables required for this template:
+ * filtertypes
+
+ Example context (json):
+ {
+ "name": "status",
+ "title": "Enrolment Status",
+ "allowcustom": "0",
+ "allowmultiple" false,
+ "filtertypeclass": "core_user/local/participantsfilter/filtertypes/courseid",
+ "values": [
+ {
+ "value": "0",
+ "title": "Inactive"
+ },
+ {
+ "value": "1",
+ "title": "Active"
+ }
+ ]
+ }
+}}
+
diff --git a/user/templates/local/participantsfilter/filtertypes.mustache b/user/templates/local/participantsfilter/filtertypes.mustache
new file mode 100644
index 00000000000..b8aea7c8789
--- /dev/null
+++ b/user/templates/local/participantsfilter/filtertypes.mustache
@@ -0,0 +1,64 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core_user/local/participantsfilter/filtertypes
+
+ Placeholder to fetch all filter types.
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * data-filterregion="filtertypedata"
+
+ Context variables required for this template:
+ * filtertypes
+
+ Example context (json):
+ {
+ "filtertypes": [
+ {
+ "name": "status",
+ "title": "Enrolment Status",
+ "allowcustom": "0",
+ "values": [
+ {
+ "value": "0",
+ "title": "Inactive"
+ },
+ {
+ "value": "1",
+ "title": "Active"
+ }
+ ]
+ }
+ ]
+ }
+}}
+
diff --git a/user/templates/participantsfilter.mustache b/user/templates/participantsfilter.mustache
new file mode 100644
index 00000000000..15a26ba034f
--- /dev/null
+++ b/user/templates/participantsfilter.mustache
@@ -0,0 +1,67 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core_user/participantsfilter
+
+ Template for the form containing one or more filter rows.
+
+ Example context (json):
+ {
+ "filtertypes": [
+ {
+ "name": "status",
+ "title": "Status",
+ "values": [
+ {
+ "value": 1,
+ "title": "Active"
+ },
+ {
+ "value": 0,
+ "title": "Suspended"
+ }
+ ]
+ }
+ ]
+ }
+}}
+
+