MDL-67914 core_table: implement dynamic table sorting
authorSimey Lameze <simey@moodle.com>
Tue, 31 Mar 2020 06:54:41 +0000 (14:54 +0800)
committerSimey Lameze <simey@moodle.com>
Wed, 1 Apr 2020 07:13:31 +0000 (15:13 +0800)
17 files changed:
lib/db/services.php
lib/table/amd/build/dynamic.min.js [new file with mode: 0644]
lib/table/amd/build/dynamic.min.js.map [new file with mode: 0644]
lib/table/amd/build/local/dynamic/repository.min.js [new file with mode: 0644]
lib/table/amd/build/local/dynamic/repository.min.js.map [new file with mode: 0644]
lib/table/amd/build/local/dynamic/selectors.min.js [new file with mode: 0644]
lib/table/amd/build/local/dynamic/selectors.min.js.map [new file with mode: 0644]
lib/table/amd/src/dynamic.js [new file with mode: 0644]
lib/table/amd/src/local/dynamic/repository.js [new file with mode: 0644]
lib/table/amd/src/local/dynamic/selectors.js [new file with mode: 0644]
lib/table/classes/dynamic.php
lib/table/classes/external/dynamic/fetch.php [new file with mode: 0644]
lib/table/classes/local/filter/filter.php
lib/table/classes/local/filter/filterset.php
lib/tablelib.php
user/classes/participants_table.php
version.php

index 9442f9c..ed00b5f 100644 (file)
@@ -2744,6 +2744,14 @@ $functions = array(
         'capabilities'  => '',
         'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
     ],
+    'core_table_dynamic_fetch' => [
+        'classname' => 'core_table\external\dynamic\fetch',
+        'methodname' => 'execute',
+        'description' => 'Fetch a dynamic table view raw html',
+        'type' => 'read',
+        'ajax' => true,
+        'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
+    ],
 );
 
 $services = array(
diff --git a/lib/table/amd/build/dynamic.min.js b/lib/table/amd/build/dynamic.min.js
new file mode 100644 (file)
index 0000000..d00b78f
Binary files /dev/null and b/lib/table/amd/build/dynamic.min.js differ
diff --git a/lib/table/amd/build/dynamic.min.js.map b/lib/table/amd/build/dynamic.min.js.map
new file mode 100644 (file)
index 0000000..2d81312
Binary files /dev/null and b/lib/table/amd/build/dynamic.min.js.map differ
diff --git a/lib/table/amd/build/local/dynamic/repository.min.js b/lib/table/amd/build/local/dynamic/repository.min.js
new file mode 100644 (file)
index 0000000..d07cd46
Binary files /dev/null and b/lib/table/amd/build/local/dynamic/repository.min.js differ
diff --git a/lib/table/amd/build/local/dynamic/repository.min.js.map b/lib/table/amd/build/local/dynamic/repository.min.js.map
new file mode 100644 (file)
index 0000000..7629807
Binary files /dev/null and b/lib/table/amd/build/local/dynamic/repository.min.js.map differ
diff --git a/lib/table/amd/build/local/dynamic/selectors.min.js b/lib/table/amd/build/local/dynamic/selectors.min.js
new file mode 100644 (file)
index 0000000..e45e5ff
Binary files /dev/null and b/lib/table/amd/build/local/dynamic/selectors.min.js differ
diff --git a/lib/table/amd/build/local/dynamic/selectors.min.js.map b/lib/table/amd/build/local/dynamic/selectors.min.js.map
new file mode 100644 (file)
index 0000000..6af6cf1
Binary files /dev/null and b/lib/table/amd/build/local/dynamic/selectors.min.js.map differ
diff --git a/lib/table/amd/src/dynamic.js b/lib/table/amd/src/dynamic.js
new file mode 100644 (file)
index 0000000..d3b9d06
--- /dev/null
@@ -0,0 +1,160 @@
+// 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 to handle dynamic table features.
+ *
+ * @module     core_table/dynamic
+ * @package    core_table
+ * @copyright  2020 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import {fetch as fetchTableData} from 'core_table/local/dynamic/repository';
+import * as Selectors from 'core_table/local/dynamic/selectors';
+
+let watching = false;
+
+/**
+ * Ensure that a table is a dynamic table.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Bool}
+ */
+const checkTableIsDynamic = tableRoot => {
+    if (!tableRoot) {
+        // The table is not a dynamic table.
+        throw new Error("The table specified is not a dynamic table and cannot be updated");
+    }
+
+    if (!tableRoot.matches(Selectors.table.region)) {
+        // The table is not a dynamic table.
+        throw new Error("The table specified is not a dynamic table and cannot be updated");
+    }
+
+    return true;
+};
+
+/**
+ * Get the filterset data from a known dynamic table.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Object}
+ */
+const getFiltersetFromTable = tableRoot => {
+    return JSON.parse(tableRoot.dataset.tableFilters);
+};
+
+/**
+ * Update the specified table based on its current values.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Promise}
+ */
+export const refreshTableContent = tableRoot => {
+    const filterset = getFiltersetFromTable(tableRoot);
+
+    return fetchTableData(
+        tableRoot.dataset.tableHandler,
+        tableRoot.dataset.tableUniqueid,
+        {
+            sortBy: tableRoot.dataset.tableSortBy,
+            sortOrder: tableRoot.dataset.tableSortOrder,
+            joinType: filterset.jointype,
+            filters: filterset.filters,
+        }
+    )
+    .then(data => {
+        const placeholder = document.createElement('div');
+        placeholder.innerHTML = data.html;
+        tableRoot.replaceWith(...placeholder.childNodes);
+
+        return data;
+    });
+};
+
+export const updateTable = (tableRoot, {
+    sortBy = null,
+    sortOrder = null,
+    filters = null,
+} = {}, refreshContent = true) => {
+    checkTableIsDynamic(tableRoot);
+
+    // Update sort fields.
+    if (sortBy && sortOrder) {
+        tableRoot.dataset.tableSortBy = sortBy;
+        tableRoot.dataset.tableSortOrder = sortOrder;
+    }
+
+    // Update filters.
+    if (filters) {
+        tableRoot.dataset.tableFilters = JSON.stringify(filters);
+    }
+
+    // Refresh.
+    if (refreshContent) {
+        return refreshTableContent(tableRoot);
+    } else {
+        return Promise.resolve();
+    }
+};
+
+/**
+ * Update the specified table using the new filters.
+ *
+ * @param {HTMLElement} tableRoot
+ * @param {Object} filters
+ * @param {Bool} refreshContent
+ * @returns {Promise}
+ */
+export const setFilters = (tableRoot, filters, refreshContent = true) =>
+    updateTable(tableRoot, {filters}, refreshContent);
+
+/**
+ * Update the sort order.
+ *
+ * @param {HTMLElement} tableRoot
+ * @param {String} sortBy
+ * @param {Number} sortOrder
+ * @param {Bool} refreshContent
+ * @returns {Promise}
+ */
+export const setSortOrder = (tableRoot, sortBy, sortOrder, refreshContent = true) =>
+    updateTable(tableRoot, {sortBy, sortOrder}, refreshContent);
+
+/**
+ * Set up listeners to handle table updates.
+ */
+export const init = () => {
+    if (watching) {
+        // Already watching.
+        return;
+    }
+    watching = true;
+
+    document.addEventListener('click', e => {
+        const tableRoot = e.target.closest(Selectors.table.region);
+
+        if (!tableRoot) {
+            return;
+        }
+
+        const sortableLink = e.target.closest(Selectors.table.links.sortableColumn);
+        if (sortableLink) {
+            e.preventDefault();
+
+            setSortOrder(tableRoot, sortableLink.dataset.sortby, sortableLink.dataset.sortorder);
+        }
+    });
+};
diff --git a/lib/table/amd/src/local/dynamic/repository.js b/lib/table/amd/src/local/dynamic/repository.js
new file mode 100644 (file)
index 0000000..4fe7219
--- /dev/null
@@ -0,0 +1,54 @@
+// 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/>.
+
+/**
+ * A javascript module to handle calendar ajax actions.
+ *
+ * @module     core_calendar/repository
+ * @class      repository
+ * @package    core_calendar
+ * @copyright  2017 Simey Lameze <lameze@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import {call as fetchMany} from 'core/ajax';
+
+/**
+ * Fetch table view.
+ *
+ * @method fetch
+ * @param {String} handler The name of the handler
+ * @param {String} uniqueid The unique id of the table
+ * @param {Number} params parameters to request table
+ * @return {Promise} Resolved with requested table view
+ */
+export const fetch = (handler, uniqueid, {
+        sortBy = null,
+        sortOrder = null,
+        joinType = null,
+        filters = {}
+    } = {}
+) => {
+    return fetchMany([{
+        methodname: `core_table_dynamic_fetch`,
+        args: {
+            handler,
+            uniqueid,
+            sortby: sortBy,
+            sortorder: sortOrder,
+            jointype: joinType,
+            filters,
+        },
+    }])[0];
+};
diff --git a/lib/table/amd/src/local/dynamic/selectors.js b/lib/table/amd/src/local/dynamic/selectors.js
new file mode 100644 (file)
index 0000000..68a0a3b
--- /dev/null
@@ -0,0 +1,31 @@
+// 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/>.
+
+/**
+ * Dynamic table selectors.
+ *
+ * @module     core_table/selectors
+ * @package    core_table
+ * @copyright  2020 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+export default {
+    table: {
+        region: '[data-region="core_table/dynamic"]',
+        links: {
+            sortableColumn: 'a[data-sortable="1"]',
+        },
+    },
+};
index f528685..69701a0 100644 (file)
@@ -30,6 +30,7 @@ namespace core_table;
 defined('MOODLE_INTERNAL') || die();
 
 use moodle_url;
+use context;
 use core_table\local\filter\filterset;
 
 /**
@@ -57,7 +58,7 @@ interface dynamic {
      *
      * @return moodle_url
      */
-    public static function get_base_url(): moodle_url;
+    public function get_base_url(): moodle_url;
 
     /**
      * Set the filterset filters build table object.
@@ -66,4 +67,20 @@ interface dynamic {
      * @return void
      */
     public function set_filterset(filterset $filterset): void;
+
+    /**
+     * Get the currently defined filterset.
+     *
+     * @return filterset
+     */
+    public function get_filterset(): ?filterset;
+
+    /**
+     * Get the context of the current table.
+     *
+     * Note: This function should not be called until after the filterset has been provided.
+     *
+     * @return context
+     */
+    public function get_context(): ?context;
 }
diff --git a/lib/table/classes/external/dynamic/fetch.php b/lib/table/classes/external/dynamic/fetch.php
new file mode 100644 (file)
index 0000000..8cef49c
--- /dev/null
@@ -0,0 +1,170 @@
+<?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/>.
+
+/**
+ * Table external API.
+ *
+ * @package    core_table
+ * @category   external
+ * @copyright  2020 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_table\external\dynamic;
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_warnings;
+use moodle_url;
+
+/**
+ * Core table external functions.
+ *
+ * @package    core_table
+ * @category   external
+ * @copyright  2020 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class fetch extends external_api {
+
+    /**
+     * Describes the parameters for fetching the table html.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.9
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters ([
+            'handler' => new external_value(
+                // Note: We do not have a PARAM_CLASSNAME which would have been ideal.
+                PARAM_RAW,
+                'Handler',
+                VALUE_REQUIRED
+            ),
+            'uniqueid' => new external_value(
+                PARAM_ALPHANUMEXT,
+                'Unique ID for the container',
+                VALUE_REQUIRED
+            ),
+            'sortby' => new external_value(
+                PARAM_ALPHANUMEXT,
+                'The name of a sortable column',
+                VALUE_REQUIRED
+            ),
+            'sortorder' => new external_value(
+                PARAM_ALPHANUMEXT,
+                'The sort order',
+                VALUE_REQUIRED
+            ),
+            'filters' => new external_multiple_structure(
+                new external_single_structure([
+                    'name' => new external_value(PARAM_ALPHANUM, 'Name of the filter', VALUE_REQUIRED),
+                    'jointype' => new external_value(PARAM_INT, 'Type of join for filter values', VALUE_REQUIRED),
+                    'values' => new external_multiple_structure(
+                        new external_value(PARAM_RAW, 'Filter value'),
+                        'The value to filter on',
+                        VALUE_REQUIRED
+                    )
+                ]),
+                'The filters that will be applied in the request',
+                VALUE_OPTIONAL
+            ),
+            'jointype' => new external_value(PARAM_INT, 'Type of join to join all filters together', VALUE_REQUIRED),
+        ]);
+    }
+
+    /**
+     * External function to fetch a table view.
+     *
+     * @param string $handler Dynamic table class name.
+     * @param string $uniqueid Unique ID for the container.
+     * @param string $sortby The name of a sortable column.
+     * @param string $sortorder The sort order.
+     * @param array $filters The filters that will be applied in the request.
+     * @param string $jointype The join type.
+     *
+     * @return array
+     */
+    public static function execute(string $handler, string $uniqueid, string $sortby, string $sortorder,
+            array $filters = [], string $jointype = null) {
+
+        global $PAGE;
+
+        if (!class_exists($handler) || !is_subclass_of($handler, \core_table\dynamic::class)) {
+            throw new \UnexpectedValueException('Unknown table handler, or table handler does not support dynamic updating.');
+        }
+
+        [
+            'handler' => $handler,
+            'uniqueid' => $uniqueid,
+            'sortby' => $sortby,
+            'sortorder' => $sortorder,
+            'filters' => $filters,
+            'jointype' => $jointype,
+        ] = self::validate_parameters(self::execute_parameters(), [
+            'handler' => $handler,
+            'uniqueid' => $uniqueid,
+            'sortby' => $sortby,
+            'sortorder' => $sortorder,
+            'filters' => $filters,
+            'jointype' => $jointype,
+        ]);
+
+        $filterset = new \core_user\table\participants_filterset();
+        foreach ($filters as $rawfilter) {
+            $filterset->add_filter_from_params(
+                $rawfilter['name'],
+                $rawfilter['jointype'],
+                $rawfilter['values']
+            );
+        }
+
+        $instance = new $handler($uniqueid);
+        $instance->set_filterset($filterset);
+        $instance->set_sorting($sortby, $sortorder);
+
+        $context = $instance->get_context();
+
+        self::validate_context($context);
+        $PAGE->set_url($instance->get_base_url());
+
+        ob_start();
+        $instance->out(20, true);
+        $participanttablehtml = ob_get_contents();
+        ob_end_clean();
+
+        return [
+            'html' => $participanttablehtml,
+            'warnings' => []
+        ];
+    }
+
+    /**
+     * Describes the data returned from the external function.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.9
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'html' => new external_value(PARAM_RAW, 'The raw html of the requested table.'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+}
index f98d3f6..eda6039 100644 (file)
@@ -28,6 +28,7 @@ declare(strict_types=1);
 namespace core_table\local\filter;
 
 use Countable;
+use JsonSerializable;
 use InvalidArgumentException;
 use Iterator;
 
@@ -38,7 +39,7 @@ use Iterator;
  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class filter implements Countable, Iterator {
+class filter implements Countable, Iterator, JsonSerializable {
 
     /** @var in The default filter type (ANY) */
     const JOINTYPE_DEFAULT = 1;
@@ -251,4 +252,17 @@ class filter implements Countable, Iterator {
         $this->sort_filter_values();
         return $this->filtervalues;
     }
+
+    /**
+     * Serialize filter.
+     *
+     * @return mixed|object
+     */
+    public function jsonSerialize() {
+        return (object) [
+            'name' => $this->get_name(),
+            'jointype' => $this->get_join_type(),
+            'values' => $this->get_filter_values(),
+        ];
+    }
 }
index e49fb5a..0afddeb 100644 (file)
@@ -28,6 +28,7 @@ declare(strict_types=1);
 namespace core_table\local\filter;
 
 use InvalidArgumentException;
+use JsonSerializable;
 use UnexpectedValueException;
 use moodle_exception;
 
@@ -38,7 +39,7 @@ use moodle_exception;
  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-abstract class filterset {
+abstract class filterset implements JsonSerializable {
     /** @var in The default filter type (ANY) */
     const JOINTYPE_DEFAULT = 1;
 
@@ -285,4 +286,16 @@ abstract class filterset {
 
         return $this->filtertypes;
     }
+
+    /**
+     * Serialize filterset.
+     *
+     * @return mixed|object
+     */
+    public function jsonSerialize() {
+        return (object) [
+            'jointype' => $this->get_join_type(),
+            'filters' => $this->get_filters(),
+        ];
+    }
 }
index c1093c2..e5f5b03 100644 (file)
@@ -84,6 +84,17 @@ class flexible_table {
     private $persistent = false;
     var $is_collapsible = false;
     var $is_sortable    = false;
+
+    /**
+     * @var string The field name to sort by.
+     */
+    protected $sortby;
+
+    /**
+     * @var string $sortorder The direction for sorting.
+     */
+    protected $sortorder;
+
     var $use_pages      = false;
     var $use_initials   = false;
 
@@ -513,35 +524,7 @@ class flexible_table {
             }
         }
 
-        if (($sortcol = optional_param($this->request[TABLE_VAR_SORT], '', PARAM_ALPHANUMEXT)) &&
-                $this->is_sortable($sortcol) && empty($this->prefs['collapse'][$sortcol]) &&
-                (isset($this->columns[$sortcol]) || in_array($sortcol, get_all_user_name_fields())
-                && isset($this->columns['fullname']))) {
-
-            $sortdir = optional_param($this->request[TABLE_VAR_DIR], $this->sort_default_order, PARAM_INT);
-
-            if (array_key_exists($sortcol, $this->prefs['sortby'])) {
-                // This key already exists somewhere. Change its sortorder and bring it to the top.
-                $sortorder = $this->prefs['sortby'][$sortcol] = $sortdir;
-                unset($this->prefs['sortby'][$sortcol]);
-                $this->prefs['sortby'] = array_merge(array($sortcol => $sortorder), $this->prefs['sortby']);
-            } else {
-                // Key doesn't exist, so just add it to the beginning of the array, ascending order
-                $this->prefs['sortby'] = array_merge(array($sortcol => $sortdir), $this->prefs['sortby']);
-            }
-
-            // Finally, make sure that no more than $this->maxsortkeys are present into the array
-            $this->prefs['sortby'] = array_slice($this->prefs['sortby'], 0, $this->maxsortkeys);
-        }
-
-        // MDL-35375 - If a default order is defined and it is not in the current list of order by columns, add it at the end.
-        // This prevents results from being returned in a random order if the only order by column contains equal values.
-        if (!empty($this->sort_default_column))  {
-            if (!array_key_exists($this->sort_default_column, $this->prefs['sortby'])) {
-                $defaultsort = array($this->sort_default_column => $this->sort_default_order);
-                $this->prefs['sortby'] = array_merge($this->prefs['sortby'], $defaultsort);
-            }
-        }
+        $this->set_sorting_preferences();
 
         $ilast = optional_param($this->request[TABLE_VAR_ILAST], null, PARAM_RAW);
         if (!is_null($ilast) && ($ilast ==='' || strpos(get_string('alphabet', 'langconfig'), $ilast) !== false)) {
@@ -1158,7 +1141,8 @@ class flexible_table {
      * This function is not part of the public api.
      */
     function finish_html() {
-        global $OUTPUT;
+        global $OUTPUT, $PAGE;
+
         if (!$this->started_output) {
             //no data has been added to the table.
             $this->print_nothing_to_display();
@@ -1187,6 +1171,13 @@ class flexible_table {
                 $pagingbar->pagevar = $this->request[TABLE_VAR_PAGE];
                 echo $OUTPUT->render($pagingbar);
             }
+
+            // Dynamic Table content.
+            if (is_a($this, \core_table\dynamic::class)) {
+                echo html_writer::end_tag('div');
+
+                $PAGE->requires->js_call_amd('core_table/dynamic', 'init');
+            }
         }
     }
 
@@ -1322,6 +1313,57 @@ class flexible_table {
         echo html_writer::end_tag('thead');
     }
 
+    /**
+     * Calculate the preferences for sort order based on user-supplied values and get params.
+     */
+    protected function set_sorting_preferences(): void {
+        $sortorder = $this->sortorder;
+        $sortby = $this->sortby;
+
+        if ($sortorder === null || $sortby === null) {
+            $sortorder = optional_param($this->request[TABLE_VAR_DIR], $this->sort_default_order, PARAM_INT);
+            $sortby = optional_param($this->request[TABLE_VAR_SORT], '', PARAM_ALPHANUMEXT);
+        }
+
+        $isvalidsort = $sortby && $this->is_sortable($sortby);
+        $isvalidsort = $isvalidsort && empty($this->prefs['collapse'][$sortby]);
+        $isrealcolumn = isset($this->columns[$sortby]);
+        $isfullnamefield = isset($this->columns['fullname']) && in_array($sortby, get_all_user_name_fields());
+
+        if ($isvalidsort && ($isrealcolumn || $isfullnamefield)) {
+            if (array_key_exists($sortby, $this->prefs['sortby'])) {
+                // This key already exists somewhere. Change its sortorder and bring it to the top.
+                $sortorder = $this->prefs['sortby'][$sortby] = $sortorder;
+                unset($this->prefs['sortby'][$sortby]);
+                $this->prefs['sortby'] = array_merge(array($sortby => $sortorder), $this->prefs['sortby']);
+            } else {
+                // Key doesn't exist, so just add it to the beginning of the array, ascending order.
+                $this->prefs['sortby'] = array_merge(array($sortby => $sortorder), $this->prefs['sortby']);
+            }
+
+            // Finally, make sure that no more than $this->maxsortkeys are present into the array.
+            $this->prefs['sortby'] = array_slice($this->prefs['sortby'], 0, $this->maxsortkeys);
+        }
+
+        // If a default order is defined and it is not in the current list of order by columns, add it at the end.
+        // This prevents results from being returned in a random order if the only order by column contains equal values.
+        if (!empty($this->sort_default_column) && !array_key_exists($this->sort_default_column, $this->prefs['sortby'])) {
+            $defaultsort = array($this->sort_default_column => $this->sort_default_order);
+            $this->prefs['sortby'] = array_merge($this->prefs['sortby'], $defaultsort);
+        }
+    }
+
+    /**
+     * Set the preferred table sorting attributes.
+     *
+     * @param string $sortby The field to sort by.
+     * @param int $sortorder The sort order.
+     */
+    public function set_sorting(string $sortby, int $sortorder): void {
+        $this->sortby = $sortby;
+        $this->sortorder = $sortorder;
+    }
+
     /**
      * Generate the HTML for the sort icon. This is a helper method used by {@link sort_link()}.
      * @param bool $isprimary whether an icon is needed (it is only needed for the primary sort column.)
@@ -1380,8 +1422,27 @@ class flexible_table {
 
         return html_writer::link($this->baseurl->out(false, $params),
                 $text . get_accesshide(get_string('sortby') . ' ' .
-                $text . ' ' . $this->sort_order_name($isprimary, $order))) . ' ' .
-                $this->sort_icon($isprimary, $order);
+                $text . ' ' . $this->sort_order_name($isprimary, $order)),
+                [
+                    'data-sortable' => $this->is_sortable($column),
+                    'data-sortby' => $column,
+                    'data-sortorder' => $sortorder,
+                ]) . ' ' . $this->sort_icon($isprimary, $order);
+    }
+
+    /**
+     * Return sorting attributes values.
+     *
+     * @return array
+     */
+    protected function get_sort_order(): array {
+        $sortbys = $this->prefs['sortby'];
+        $sortby = key($sortbys);
+
+        return [
+            'sortby' => $sortby,
+            'sortorder' => $sortbys[$sortby],
+        ];
     }
 
     /**
@@ -1390,6 +1451,18 @@ class flexible_table {
     function start_html() {
         global $OUTPUT;
 
+        if (is_a($this, \core_table\dynamic::class)) {
+            $sortdata = $this->get_sort_order();
+            echo html_writer::start_tag('div', [
+                'data-region' => 'core_table/dynamic',
+                'data-table-handler' => get_class($this),
+                'data-table-uniqueid' => $this->uniqueid,
+                'data-table-filters' => json_encode($this->get_filterset()),
+                'data-table-sort-by' => $sortdata['sortby'],
+                'data-table-sort-order' => $sortdata['sortorder'],
+            ]);
+        }
+
         // Render button to allow user to reset table preferences.
         echo $this->render_reset_button();
 
@@ -1460,10 +1533,8 @@ class flexible_table {
      * @return bool
      */
     protected function can_be_reset() {
-
         // Loop through preferences and make sure they are empty or set to the default value.
         foreach ($this->prefs as $prefname => $prefval) {
-
             if ($prefname === 'sortby' and !empty($this->sort_default_column)) {
                 // Check if the actual sorting differs from the default one.
                 if (empty($prefval) or $prefval !== array($this->sort_default_column => $this->sort_default_order)) {
index 32aac2b..21ba357 100644 (file)
@@ -135,6 +135,9 @@ class participants_table extends \table_sql implements dynamic_table {
     /** @var \stdClass[] $viewableroles */
     private $viewableroles;
 
+    /** @var moodle_url $baseurl The base URL for the report. */
+    public $baseurl;
+
     /**
      * Render the participants table.
      *
@@ -143,7 +146,91 @@ class participants_table extends \table_sql implements dynamic_table {
      * @param string $downloadhelpbutton
      */
     public function out($pagesize, $useinitialsbar, $downloadhelpbutton = '') {
-        global $PAGE;
+        global $CFG, $OUTPUT, $PAGE;
+
+        // Define the headers and columns.
+        $headers = [];
+        $columns = [];
+
+        $bulkoperations = has_capability('moodle/course:bulkmessaging', $this->context);
+        if ($bulkoperations) {
+            $mastercheckbox = new \core\output\checkbox_toggleall('participants-table', true, [
+                'id' => 'select-all-participants',
+                'name' => 'select-all-participants',
+                'label' => $this->selectall ? get_string('deselectall') : get_string('selectall'),
+                'labelclasses' => 'sr-only',
+                'classes' => 'm-1',
+                'checked' => $this->selectall
+            ]);
+            $headers[] = $OUTPUT->render($mastercheckbox);
+            $columns[] = 'select';
+        }
+
+        $headers[] = get_string('fullname');
+        $columns[] = 'fullname';
+
+        $extrafields = get_extra_user_fields($this->context);
+        foreach ($extrafields as $field) {
+            $headers[] = get_user_field_name($field);
+            $columns[] = $field;
+        }
+
+        $headers[] = get_string('roles');
+        $columns[] = 'roles';
+
+        // Get the list of fields we have to hide.
+        $hiddenfields = array();
+        if (!has_capability('moodle/course:viewhiddenuserfields', $this->context)) {
+            $hiddenfields = array_flip(explode(',', $CFG->hiddenuserfields));
+        }
+
+        // Add column for groups if the user can view them.
+        $canseegroups = !isset($hiddenfields['groups']);
+        if ($canseegroups) {
+            $headers[] = get_string('groups');
+            $columns[] = 'groups';
+        }
+
+        // Do not show the columns if it exists in the hiddenfields array.
+        if (!isset($hiddenfields['lastaccess'])) {
+            if ($this->courseid == SITEID) {
+                $headers[] = get_string('lastsiteaccess');
+            } else {
+                $headers[] = get_string('lastcourseaccess');
+            }
+            $columns[] = 'lastaccess';
+        }
+
+        $canreviewenrol = has_capability('moodle/course:enrolreview', $this->context);
+        if ($canreviewenrol && $this->courseid != SITEID) {
+            $columns[] = 'status';
+            $headers[] = get_string('participationstatus', 'enrol');
+            $this->no_sorting('status');
+        };
+
+        $this->define_columns($columns);
+        $this->define_headers($headers);
+
+        // Make this table sorted by last name by default.
+        $this->sortable(true, 'lastname');
+
+        $this->no_sorting('select');
+        $this->no_sorting('roles');
+        if ($canseegroups) {
+            $this->no_sorting('groups');
+        }
+
+        $this->set_attribute('id', 'participants');
+
+        $this->countries = get_string_manager()->get_list_of_countries(true);
+        $this->extrafields = $extrafields;
+        if ($canseegroups) {
+            $this->groups = groups_get_all_groups($this->courseid, 0, 0, 'g.*', true);
+        }
+        $this->allroles = role_fix_names(get_all_roles($this->context), $this->context);
+        $this->assignableroles = get_assignable_roles($this->context, ROLENAME_ALIAS, false);
+        $this->profileroles = get_profile_roles($this->context);
+        $this->viewableroles = get_viewable_roles($this->context);
 
         parent::out($pagesize, $useinitialsbar, $downloadhelpbutton);
 
@@ -401,141 +488,46 @@ class participants_table extends \table_sql implements dynamic_table {
      * @param filterset $filterset The filterset object to get the filters from.
      */
     public function set_filterset(filterset $filterset): void {
-        global $CFG, $OUTPUT;
+        // Store the filterset for later.
+        $this->filterset = $filterset;
 
-        $courseid = $filterset->get_filter('courseid')->current();
+        // Get the context.
+        $this->courseid = $filterset->get_filter('courseid')->current();
+        $this->course = get_course($this->courseid);
+        $this->context = \context_course::instance($this->courseid, MUST_EXIST);
 
         // Process the filterset.
-        $currentgroup = null;
+        $this->currentgroup = null;
         if ($filterset->has_filter('groups')) {
-            $currentgroup = $filterset->get_filter('groups')->current();
-        }
-
-        $contextid = null;
-        if ($filterset->has_filter('contextid')) {
-            $contextid = $filterset->get_filter('contextid')->current();
+            $this->currentgroup = $filterset->get_filter('groups')->current();
         }
 
-        $roleid = null;
+        $this->roleid = null;
         if ($filterset->has_filter('roles')) {
-            $roleid = $filterset->get_filter('roles')->current();
+            $this->roleid = $filterset->get_filter('roles')->current();
         }
 
-        $enrolid = null;
+        $this->enrolid = null;
         if ($filterset->has_filter('enrolments')) {
-            $enrolid = $filterset->get_filter('enrolments')->current();
+            $this->enrolid = $filterset->get_filter('enrolments')->current();
         }
 
-        $status = -1;
+        $this->status = -1;
         if ($filterset->has_filter('status')) {
-            $status = $filterset->get_filter('status')->current();
+            $this->status = $filterset->get_filter('status')->current();
         }
 
-        $accesssince = null;
+        $this->accesssince = null;
         if ($filterset->has_filter('accesssince')) {
-            $accesssince = $filterset->get_filter('accesssince')->current();
+            $this->accesssince = $filterset->get_filter('accesssince')->current();
         }
 
-        $keywords = null;
+        $this->search = null;
         if ($filterset->has_filter('keywords')) {
             $this->search = $filterset->get_filter('keywords')->get_filter_values();
         }
 
-        // Get the context.
-        $this->course = get_course($courseid);
-        $context = \context_course::instance($courseid, MUST_EXIST);
-        $this->context = $context;
-
-        // Define the headers and columns.
-        $headers = [];
-        $columns = [];
-
-        $bulkoperations = has_capability('moodle/course:bulkmessaging', $context);
-        if ($bulkoperations) {
-            $mastercheckbox = new \core\output\checkbox_toggleall('participants-table', true, [
-                'id' => 'select-all-participants',
-                'name' => 'select-all-participants',
-                'label' => $this->selectall ? get_string('deselectall') : get_string('selectall'),
-                'labelclasses' => 'sr-only',
-                'classes' => 'm-1',
-                'checked' => $this->selectall
-            ]);
-            $headers[] = $OUTPUT->render($mastercheckbox);
-            $columns[] = 'select';
-        }
-
-        $headers[] = get_string('fullname');
-        $columns[] = 'fullname';
-
-        $extrafields = get_extra_user_fields($context);
-        foreach ($extrafields as $field) {
-            $headers[] = get_user_field_name($field);
-            $columns[] = $field;
-        }
-
-        $headers[] = get_string('roles');
-        $columns[] = 'roles';
-
-        // Get the list of fields we have to hide.
-        $hiddenfields = array();
-        if (!has_capability('moodle/course:viewhiddenuserfields', $context)) {
-            $hiddenfields = array_flip(explode(',', $CFG->hiddenuserfields));
-        }
-
-        // Add column for groups if the user can view them.
-        $canseegroups = !isset($hiddenfields['groups']);
-        if ($canseegroups) {
-            $headers[] = get_string('groups');
-            $columns[] = 'groups';
-        }
-
-        // Do not show the columns if it exists in the hiddenfields array.
-        if (!isset($hiddenfields['lastaccess'])) {
-            if ($courseid == SITEID) {
-                $headers[] = get_string('lastsiteaccess');
-            } else {
-                $headers[] = get_string('lastcourseaccess');
-            }
-            $columns[] = 'lastaccess';
-        }
-
-        $canreviewenrol = has_capability('moodle/course:enrolreview', $context);
-        if ($canreviewenrol && $courseid != SITEID) {
-            $columns[] = 'status';
-            $headers[] = get_string('participationstatus', 'enrol');
-            $this->no_sorting('status');
-        };
-
-        $this->define_columns($columns);
-        $this->define_headers($headers);
-
-        // Make this table sorted by last name by default.
-        $this->sortable(true, 'lastname');
-
-        $this->no_sorting('select');
-        $this->no_sorting('roles');
-        if ($canseegroups) {
-            $this->no_sorting('groups');
-        }
-
-        $this->set_attribute('id', 'participants');
-
-        // Set the variables we need to use later.
-        $this->currentgroup = $currentgroup;
-        $this->accesssince = $accesssince;
-        $this->roleid = $roleid;
-        $this->enrolid = $enrolid;
-        $this->status = $status;
-        $this->countries = get_string_manager()->get_list_of_countries(true);
-        $this->extrafields = $extrafields;
-        $this->context = $context;
-        if ($canseegroups) {
-            $this->groups = groups_get_all_groups($courseid, 0, 0, 'g.*', true);
-        }
-        $this->allroles = role_fix_names(get_all_roles($this->context), $this->context);
-        $this->assignableroles = get_assignable_roles($this->context, ROLENAME_ALIAS, false);
-        $this->profileroles = get_profile_roles($this->context);
-        $this->viewableroles = get_viewable_roles($this->context);
+        $this->define_baseurl($this->get_base_url());
     }
 
     /**
@@ -552,7 +544,31 @@ class participants_table extends \table_sql implements dynamic_table {
      *
      * @return moodle_url
      */
-    public static function get_base_url(): moodle_url {
-        return new moodle_url('');
+    public function get_base_url(): moodle_url {
+        if ($this->baseurl === null) {
+            return new moodle_url('/user/index.php', ['id' => $this->courseid]);
+        }
+
+        return $this->baseurl;
+    }
+
+    /**
+     * Get the context of the current table.
+     *
+     * Note: This function should not be called until after the filterset has been provided.
+     *
+     * @return context
+     */
+    public function get_context(): ?context {
+        return $this->context;
+    }
+
+    /**
+     * Get the currently defined filterset.
+     *
+     * @return filterset
+     */
+    public function get_filterset(): ?filterset {
+        return $this->filterset;
     }
 }
index cbc67e6..a62461c 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020032700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020032700.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '3.9dev (Build: 20200327)'; // Human-friendly version name