MDL-67917 user: Add skeleton for new participants filter
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 25 Mar 2020 07:22:00 +0000 (15:22 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 27 May 2020 02:49:43 +0000 (10:49 +0800)
Part of MDL-67743

AMOS BEGIN
  CPY [select,core],[selectfiltertype,core_user]
AMOS END

20 files changed:
lang/en/user.php
user/amd/build/local/participantsfilter/filter.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/filter.min.js.map [new file with mode: 0644]
user/amd/build/local/participantsfilter/filtertypes/courseid.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map [new file with mode: 0644]
user/amd/build/local/participantsfilter/selectors.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/selectors.min.js.map [new file with mode: 0644]
user/amd/build/participantsfilter.min.js [new file with mode: 0644]
user/amd/build/participantsfilter.min.js.map [new file with mode: 0644]
user/amd/src/local/participantsfilter/filter.js [new file with mode: 0644]
user/amd/src/local/participantsfilter/filtertypes/courseid.js [new file with mode: 0644]
user/amd/src/local/participantsfilter/selectors.js [new file with mode: 0644]
user/amd/src/participantsfilter.js [new file with mode: 0644]
user/classes/output/participants_filter.php [new file with mode: 0644]
user/index.php
user/renderer.php
user/templates/local/participantsfilter/filterrow.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/filtertype.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/filtertypes.mustache [new file with mode: 0644]
user/templates/participantsfilter.mustache [new file with mode: 0644]

index e010e09..0c768b1 100644 (file)
  * @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 (file)
index 0000000..cb8df33
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filter.min.js differ
diff --git a/user/amd/build/local/participantsfilter/filter.min.js.map b/user/amd/build/local/participantsfilter/filter.min.js.map
new file mode 100644 (file)
index 0000000..a04aeda
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filter.min.js.map differ
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 (file)
index 0000000..61445dc
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js differ
diff --git a/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map
new file mode 100644 (file)
index 0000000..273a359
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map differ
diff --git a/user/amd/build/local/participantsfilter/selectors.min.js b/user/amd/build/local/participantsfilter/selectors.min.js
new file mode 100644 (file)
index 0000000..1f1dcc5
Binary files /dev/null and b/user/amd/build/local/participantsfilter/selectors.min.js differ
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 (file)
index 0000000..31cd5e6
Binary files /dev/null and b/user/amd/build/local/participantsfilter/selectors.min.js.map differ
diff --git a/user/amd/build/participantsfilter.min.js b/user/amd/build/participantsfilter.min.js
new file mode 100644 (file)
index 0000000..31b031a
Binary files /dev/null and b/user/amd/build/participantsfilter.min.js differ
diff --git a/user/amd/build/participantsfilter.min.js.map b/user/amd/build/participantsfilter.min.js.map
new file mode 100644 (file)
index 0000000..1a05bb3
Binary files /dev/null and b/user/amd/build/participantsfilter.min.js.map differ
diff --git a/user/amd/src/local/participantsfilter/filter.js b/user/amd/src/local/participantsfilter/filter.js
new file mode 100644 (file)
index 0000000..2cc72af
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..4969884
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Course ID filter.
+ *
+ * @module     core_user/local/participantsfilter/filtertypes/courseid
+ * @package    core_user
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..d17b28e
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Module containing the selectors for user filters.
+ *
+ * @module     core_user/local/user_filter/selectors
+ * @package    core_user
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @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 (file)
index 0000000..23e5432
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Participants filter managemnet.
+ *
+ * @module     core_user/participants_filter
+ * @package    core_user
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..12dddd6
--- /dev/null
@@ -0,0 +1,150 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for rendering user filters on the course participants page.
+ *
+ * @package    core_user
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @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 <michaelh@moodle.com>
+ * @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,
+        ];
+    }
+}
index 3078724..cf0f155 100644 (file)
@@ -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 '<div class="userlist">';
 
 // Add filters to the baseurl after creating unified_filter to avoid losing them.
index 11f2190..7be0ce5 100644 (file)
@@ -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 (file)
index 0000000..c97bab5
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @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"
+            }
+        ]
+    }
+}}
+<div data-filterregion="filter" class="rounded mb-3 p-2 bg-white border border-secondary d-flex align-items-center">
+    <label for="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}" class="pt-2">{{#str}}match, core_user{{/str}}</label>
+    <select class="custom-select" data-filterfield="join" id="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}">
+        <option value="0">{{#str}}none{{/str}}</option>
+        <option selected=selected value="1">{{#str}}any{{/str}}</option>
+        <option value="2">{{#str}}all{{/str}}</option>
+    </select>
+
+    <label class="sr-only pt-2" for="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">filtertype</label>
+    <select class="custom-select" data-filterfield="type" id="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">
+        <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
+        {{#filtertypes}}
+        <option value="{{name}}">{{title}}</option>
+        {{/filtertypes}}
+    </select>
+
+    <div data-filterregion="value"></div>
+
+    <button data-filteraction="remove" class="ml-auto btn btn-link text-reset" aria-label="{{#str}}clearfilterrow, core_user{{/str}}">
+        <i class="icon fa fa-times-circle pt-2"></i>
+    </button>
+</div>
diff --git a/user/templates/local/participantsfilter/filtertype.mustache b/user/templates/local/participantsfilter/filtertype.mustache
new file mode 100644 (file)
index 0000000..f38eb93
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @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"
+            }
+        ]
+    }
+}}
+<select {{!
+    }}{{#allowmultiple}}multiple="multiple"{{/allowmultiple}} {{!
+    }}data-field-name="{{name}}" {{!
+    }}data-field-title="{{title}}" {{!
+    }}data-allow-custom="{{allowcustom}}" {{!
+    }}class="hidden" {{!
+    }}{{#filtertypeclass}}data-filter-type-class="{{filtertypeclass}}" {{/filtertypeclass}}{{!
+}}>
+    {{#values}}
+        <option value="{{value}}">{{title}}</option>
+    {{/values}}
+</select>
diff --git a/user/templates/local/participantsfilter/filtertypes.mustache b/user/templates/local/participantsfilter/filtertypes.mustache
new file mode 100644 (file)
index 0000000..b8aea7c
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @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"
+                    }
+                ]
+            }
+        ]
+    }
+}}
+<div class="hidden" data-filterregion="filtertypedata">
+    {{#filtertypes}}
+        {{> core_user/local/participantsfilter/filtertype}}
+    {{/filtertypes}}
+</div>
+<div class="hidden">
+    <select disabled="disabled" data-filterfield="type" data-filterregion="filtertypelist">
+        <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
+        {{#filtertypes}}
+        <option value="{{name}}">{{title}}</option>
+        {{/filtertypes}}
+    </select>
+</div>
diff --git a/user/templates/participantsfilter.mustache b/user/templates/participantsfilter.mustache
new file mode 100644 (file)
index 0000000..15a26ba
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @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"
+                    }
+                ]
+            }
+        ]
+    }
+}}
+
+<div id="core_user-participantsfilter-{{uniqid}}" class="filter-group mt-5 p-3 rounded border border-secondary" data-table-region="{{tableregionid}}" data-table-course-id="{{courseid}}">
+    <div data-filterregion="filters">
+        {{> core_user/local/participantsfilter/filterrow }}
+    </div>
+
+    <div class="display-block" data-filterregion="actions">
+        &nbsp;
+        <button type="button" class="btn btn-link d-inline-block float-left" data-filteraction="add">
+            <i class="fa fa-plus"></i><span class="pl-3">{{#str}}addcondition, core_user{{/str}}</span>
+        </button>
+
+        <div class="float-right">
+            <button data-filteraction="reset" type="button" class="btn btn-light d-inline-block">{{#str}}clearfilters, core_user{{/str}}</button>
+            <button data-filteraction="apply" type="button" class="btn btn-primary d-inline-block">{{#str}}applyfilters, core_user{{/str}}</button>
+        </div>
+    </div>
+
+    {{> core_user/local/participantsfilter/filtertypes}}
+</div>
+
+{{#js}}
+require(['core_user/participantsfilter'], function(ParticipantsFilter) {
+    ParticipantsFilter.init('core_user-participantsfilter-{{uniqid}}');
+});
+{{/js}}