MDL-51324 forms: Add a new course selector
authorDamyon Wiese <damyon@moodle.com>
Fri, 4 Sep 2015 06:59:04 +0000 (14:59 +0800)
committerDamyon Wiese <damyon@moodle.com>
Wed, 9 Mar 2016 05:30:18 +0000 (13:30 +0800)
This is a squashed commit containing a number of changes:

This is an ajax driven course selector that has searching etc. It can select single, or multiple courses.
Make course selector accept a list of courses to exclude
courseselector - lookup coursename on setValue
Use the get_course_display_name_in_list function to generate the course names
Add a throttle to auto-complete to reduce spamming the server
Do a single query to fetch all the courses in the mform element when validation fails
Fix core course search function to return results when there are less than 2 chars in the query.
Handle setData with an empty array in new course selector

course/externallib.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-course-selector.min.js [new file with mode: 0644]
lib/amd/src/form-autocomplete.js
lib/amd/src/form-course-selector.js [new file with mode: 0644]
lib/coursecatlib.php
lib/datalib.php
lib/db/services.php
lib/form/course.php [new file with mode: 0644]
lib/formslib.php
lib/upgrade.txt

index da480bb..6214828 100644 (file)
@@ -2135,7 +2135,11 @@ class core_course_external extends external_api {
                                                         (search, modulelist (only admins), blocklist (only admins), tagid)'),
                 'criteriavalue' => new external_value(PARAM_RAW, 'criteria value'),
                 'page'          => new external_value(PARAM_INT, 'page number (0 based)', VALUE_DEFAULT, 0),
-                'perpage'       => new external_value(PARAM_INT, 'items per page', VALUE_DEFAULT, 0)
+                'perpage'       => new external_value(PARAM_INT, 'items per page', VALUE_DEFAULT, 0),
+                'requiredcapabilities' => new external_multiple_structure(
+                    new external_value(PARAM_CAPABILITY, 'Capability string used to filter courses by permission'),
+                    VALUE_OPTIONAL
+                )
             )
         );
     }
@@ -2147,11 +2151,16 @@ class core_course_external extends external_api {
      * @param string $criteriavalue Criteria value
      * @param int $page             Page number (for pagination)
      * @param int $perpage          Items per page
+     * @param array $requiredcapabilities Optional list of required capabilities (used to filter the list).
      * @return array of course objects and warnings
      * @since Moodle 3.0
      * @throws moodle_exception
      */
-    public static function search_courses($criterianame, $criteriavalue, $page=0, $perpage=0) {
+    public static function search_courses($criterianame,
+                                          $criteriavalue,
+                                          $page=0,
+                                          $perpage=0,
+                                          $requiredcapabilities=array()) {
         global $CFG;
         require_once($CFG->libdir . '/coursecatlib.php');
 
@@ -2161,7 +2170,8 @@ class core_course_external extends external_api {
             'criterianame'  => $criterianame,
             'criteriavalue' => $criteriavalue,
             'page'          => $page,
-            'perpage'       => $perpage
+            'perpage'       => $perpage,
+            'requiredcapabilities' => $requiredcapabilities
         );
         $params = self::validate_parameters(self::search_courses_parameters(), $parameters);
 
@@ -2194,8 +2204,8 @@ class core_course_external extends external_api {
         }
 
         // Search the courses.
-        $courses = coursecat::search_courses($searchcriteria, $options);
-        $totalcount = coursecat::search_courses_count($searchcriteria);
+        $courses = coursecat::search_courses($searchcriteria, $options, $params['requiredcapabilities']);
+        $totalcount = coursecat::search_courses_count($searchcriteria, $options, $params['requiredcapabilities']);
 
         $finalcourses = array();
         $categoriescache = array();
@@ -2244,10 +2254,12 @@ class core_course_external extends external_api {
             list($summary, $summaryformat) =
                 external_format_text($course->summary, $course->summaryformat, $coursecontext->id, 'course', 'summary', null);
 
+            $displayname = get_course_display_name_for_list($course);
             $coursereturns = array();
             $coursereturns['id']                = $course->id;
-            $coursereturns['fullname']          = $course->get_formatted_fullname();
-            $coursereturns['shortname']         = $course->get_formatted_shortname();
+            $coursereturns['fullname']          = external_format_string($course->fullname, $coursecontext->id);
+            $coursereturns['displayname']       = external_format_string($displayname, $coursecontext->id);
+            $coursereturns['shortname']         = external_format_string($course->shortname, $coursecontext->id);
             $coursereturns['categoryid']        = $course->category;
             $coursereturns['categoryname']      = $category->name;
             $coursereturns['summary']           = $summary;
@@ -2281,6 +2293,7 @@ class core_course_external extends external_api {
                         array(
                             'id' => new external_value(PARAM_INT, 'course id'),
                             'fullname' => new external_value(PARAM_TEXT, 'course full name'),
+                            'displayname' => new external_value(PARAM_TEXT, 'course display name'),
                             'shortname' => new external_value(PARAM_TEXT, 'course short name'),
                             'categoryid' => new external_value(PARAM_INT, 'category id'),
                             'categoryname' => new external_value(PARAM_TEXT, 'category name'),
index 1adc2b0..9b1d497 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
diff --git a/lib/amd/build/form-course-selector.min.js b/lib/amd/build/form-course-selector.min.js
new file mode 100644 (file)
index 0000000..6eaaee6
Binary files /dev/null and b/lib/amd/build/form-course-selector.min.js differ
index 24d32c4..7a727c3 100644 (file)
@@ -747,11 +747,22 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 // If this field uses ajax, set it up.
                 if (options.ajax) {
                     require([options.ajax], function(ajaxHandler) {
+                        var throttleTimeout = null;
                         var handler = function(e) {
                             updateAjax(e, options, state, originalSelect, ajaxHandler);
                         };
+
+                        // For input events, we do not want to trigger many, many updates.
+                        var throttledHandler = function(e) {
+                            if (throttleTimeout !== null) {
+                                window.clearTimeout(throttleTimeout);
+                                throttleTimeout = null;
+                            }
+                            throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
+                        };
                         // Trigger an ajax update after the text field value changes.
-                        inputElement.on("input keypress", handler);
+                        inputElement.on("input keypress", throttledHandler);
+
                         var arrowElement = $(document.getElementById(state.downArrowId));
                         arrowElement.on("click", handler);
                     });
diff --git a/lib/amd/src/form-course-selector.js b/lib/amd/src/form-course-selector.js
new file mode 100644 (file)
index 0000000..2bf7aed
--- /dev/null
@@ -0,0 +1,76 @@
+// 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 selector adaptor for auto-complete form element.
+ *
+ * @module     core/form-course-selector
+ * @class      form-course-selector
+ * @package    core
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['core/ajax', 'jquery'], function(ajax, $) {
+
+    return /** @alias module:core/form-course-selector */ {
+        // Public variables and functions.
+        processResults: function(selector, data) {
+            // Mangle the results into an array of objects.
+            var results = [], i = 0;
+            var excludelist = String($(selector).data('exclude')).split(',');
+
+            for (i = 0; i < data.courses.length; i++) {
+                if (excludelist.indexOf(String(data.courses[i].id)) === -1) {
+                    results.push({ value: data.courses[i].id, label: data.courses[i].displayname });
+                }
+            }
+            return results;
+        },
+
+        transport: function(selector, query, success, failure) {
+            // Parse some data-attributes from the form element.
+            var requiredcapabilities = $(selector).data('requiredcapabilities');
+            if (requiredcapabilities.trim() !== "") {
+                requiredcapabilities = requiredcapabilities.split(',');
+            } else {
+                requiredcapabilities = [];
+            }
+            // Build the query.
+            var promise = null;
+
+            if (typeof query === "undefined") {
+                query = '';
+            }
+
+            var searchargs = {
+                criterianame: 'search',
+                criteriavalue: query,
+                page: 0,
+                perpage: 100,
+                requiredcapabilities: requiredcapabilities
+            };
+            // Go go go!
+            promise = ajax.call([{
+                methodname: 'core_course_search_courses', args: searchargs
+            }]);
+
+            promise[0].done(success);
+            promise[0].fail(failure);
+
+            return promise;
+        }
+    };
+});
index fc8811f..fb6a4ed 100644 (file)
@@ -1276,16 +1276,19 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate {
      *     - tagid - id of tag
      * @param array $options display options, same as in get_courses() except 'recursive' is ignored -
      *                       search is always category-independent
+     * @param array $requiredcapabilites List of capabilities required to see return course.
      * @return course_in_list[]
      */
-    public static function search_courses($search, $options = array()) {
+    public static function search_courses($search, $options = array(), $requiredcapabilities = array()) {
         global $DB;
         $offset = !empty($options['offset']) ? $options['offset'] : 0;
         $limit = !empty($options['limit']) ? $options['limit'] : null;
         $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
 
         $coursecatcache = cache::make('core', 'coursecat');
-        $cachekey = 's-'. serialize($search + array('sort' => $sortfields));
+        $cachekey = 's-'. serialize(
+            $search + array('sort' => $sortfields) + array('requiredcapabilities' => $requiredcapabilities)
+        );
         $cntcachekey = 'scnt-'. serialize($search);
 
         $ids = $coursecatcache->get($cachekey);
@@ -1315,11 +1318,16 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate {
         $preloadcoursecontacts = !empty($options['coursecontacts']);
         unset($options['coursecontacts']);
 
-        if (!empty($search['search'])) {
+        // Empty search string will return all results.
+        if (!isset($search['search'])) {
+            $search['search'] = '';
+        }
+
+        if (empty($search['blocklist']) && empty($search['modulelist']) && empty($search['tagid'])) {
             // Search courses that have specified words in their names/summaries.
             $searchterms = preg_split('|\s+|', trim($search['search']), 0, PREG_SPLIT_NO_EMPTY);
-            $searchterms = array_filter($searchterms, create_function('$v', 'return strlen($v) > 1;'));
-            $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount);
+
+            $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount, $requiredcapabilities);
             self::sort_records($courselist, $sortfields);
             $coursecatcache->set($cachekey, array_keys($courselist));
             $coursecatcache->set($cntcachekey, $totalcount);
@@ -1365,6 +1373,15 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate {
                 return array();
             }
             $courselist = self::get_course_records($where, $params, $options, true);
+            if (!empty($requiredcapabilities)) {
+                foreach ($courselist as $key => $course) {
+                    context_helper::preload_from_record($course);
+                    $coursecontext = context_course::instance($course->id);
+                    if (!has_all_capabilities($requiredcapabilities, $coursecontext)) {
+                        unset($courselist[$key]);
+                    }
+                }
+            }
             self::sort_records($courselist, $sortfields);
             $coursecatcache->set($cachekey, array_keys($courselist));
             $coursecatcache->set($cntcachekey, count($courselist));
@@ -1397,11 +1414,12 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate {
      * @param array $search search criteria, see method search_courses() for more details
      * @param array $options display options. They do not affect the result but
      *     the 'sort' property is used in cache key for storing list of course ids
+     * @param array $requiredcapabilites List of capabilities required to see return course.
      * @return int
      */
-    public static function search_courses_count($search, $options = array()) {
+    public static function search_courses_count($search, $options = array(), $requiredcapabilities = array()) {
         $coursecatcache = cache::make('core', 'coursecat');
-        $cntcachekey = 'scnt-'. serialize($search);
+        $cntcachekey = 'scnt-'. serialize($search) . serialize($requiredcapabilities);
         if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
             // Cached value not found. Retrieve ALL courses and return their count.
             unset($options['offset']);
@@ -1409,7 +1427,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate {
             unset($options['summary']);
             unset($options['coursecontacts']);
             $options['idonly'] = true;
-            $courses = self::search_courses($search, $options);
+            $courses = self::search_courses($search, $options, $requiredcapabilities);
             $cnt = count($courses);
         }
         return $cnt;
index 334253b..4301c53 100644 (file)
@@ -741,9 +741,11 @@ function get_courses_page($categoryid="all", $sort="c.sortorder ASC", $fields="c
  * @param int $page The page number to get
  * @param int $recordsperpage The number of records per page
  * @param int $totalcount Passed in by reference.
+ * @param array $requiredcapabilities Extra list of capabilities used to filter courses
  * @return object {@link $COURSE} records
  */
-function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$totalcount) {
+function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$totalcount,
+                            $requiredcapabilities = array()) {
     global $CFG, $DB;
 
     if ($DB->sql_regex_supported()) {
@@ -798,8 +800,7 @@ function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$total
     }
 
     if (empty($searchcond)) {
-        $totalcount = 0;
-        return array();
+        $searchcond = array('1 = 1');
     }
 
     $searchcond = implode(" AND ", $searchcond);
@@ -823,11 +824,14 @@ function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$total
 
     $rs = $DB->get_recordset_sql($sql, $params);
     foreach($rs as $course) {
-        if (!$course->visible) {
-            // preload contexts only for hidden courses or courses we need to return
-            context_helper::preload_from_record($course);
-            $coursecontext = context_course::instance($course->id);
-            if (!has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+        // Preload contexts only for hidden courses or courses we need to return.
+        context_helper::preload_from_record($course);
+        $coursecontext = context_course::instance($course->id);
+        if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+            continue;
+        }
+        if (!empty($requiredcapabilities)) {
+            if (!has_all_capabilities($requiredcapabilities, $coursecontext)) {
                 continue;
             }
         }
index 7d65f86..5137a7b 100644 (file)
@@ -669,6 +669,7 @@ $functions = array(
         'description' => 'Return course details',
         'type'        => 'read',
         'capabilities'=> 'moodle/course:view,moodle/course:update,moodle/course:viewhiddencourses',
+        'ajax'        => true,
     ),
 
     'core_course_search_courses' => array(
@@ -678,6 +679,7 @@ $functions = array(
         'description'   => 'Search courses by (name, module, block, tag)',
         'type'          => 'read',
         'capabilities'  => '',
+        'ajax'          => true,
     ),
 
     'moodle_course_create_courses' => array(
diff --git a/lib/form/course.php b/lib/form/course.php
new file mode 100644 (file)
index 0000000..9121447
--- /dev/null
@@ -0,0 +1,142 @@
+<?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/>.
+
+
+/**
+ * Course selector field.
+ *
+ * Allows auto-complete ajax searching for courses and can restrict by enrolment, permissions, viewhidden...
+ *
+ * @package   core_form
+ * @copyright 2015 Damyon Wiese <damyon@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+global $CFG;
+require_once($CFG->libdir . '/form/autocomplete.php');
+
+/**
+ * Form field type for choosing a course.
+ *
+ * Allows auto-complete ajax searching for courses and can restrict by enrolment, permissions, viewhidden...
+ *
+ * @package   core_form
+ * @copyright 2015 Damyon Wiese <damyon@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
+
+    /**
+     * @var array $exclude Exclude a list of courses from the list (e.g. the current course).
+     */
+    protected $exclude = array();
+
+    /**
+     * @var boolean $allowmultiple Allow selecting more than one course.
+     */
+    protected $multiple = false;
+
+    /**
+     * @var array $requiredcapabilities Array of extra capabilities to check at the course context.
+     */
+    protected $requiredcapabilities = array();
+
+    /**
+     * Constructor
+     *
+     * @param string $elementname Element name
+     * @param mixed $elementlabel Label(s) for an element
+     * @param array $options Options to control the element's display
+     *                       Valid options are:
+     *                       'multiple' - boolean multi select
+     *                       'exclude' - array or int, list of course ids to never show
+     *                       'requiredcapabilities' - array of capabilities. Uses ANY to combine them.
+     */
+    public function __construct($elementname = null, $elementlabel = null, $options = array()) {
+        if (isset($options['multiple'])) {
+            $this->multiple = $options['multiple'];
+        }
+        if (isset($options['exclude'])) {
+            $this->exclude = $options['exclude'];
+            if (!is_array($this->exclude)) {
+                $this->exclude = array($this->exclude);
+            }
+        }
+        if (isset($options['requiredcapabilities'])) {
+            $this->requiredcapabilities = $options['requiredcapabilities'];
+        }
+
+        $validattributes = array(
+            'ajax' => 'core/form-course-selector',
+            'data-requiredcapabilities' => implode(',', $this->requiredcapabilities),
+            'data-exclude' => implode(',', $this->exclude)
+        );
+        if ($this->multiple) {
+            $validattributes['multiple'] = 'multiple';
+        }
+
+        parent::__construct($elementname, $elementlabel, array(), $validattributes);
+    }
+
+    /**
+     * Set the value of this element. If values can be added or are unknown, we will
+     * make sure they exist in the options array.
+     * @param string|array $value The value to set.
+     * @return boolean
+     */
+    public function setValue($value) {
+        global $DB;
+        $values = (array) $value;
+        $coursestofetch = array();
+
+        foreach ($values as $onevalue) {
+            if ((!$this->optionExists($onevalue)) &&
+                    ($onevalue !== '_qf__force_multiselect_submission')) {
+                array_push($coursestofetch, $onevalue);
+            }
+        }
+
+        if (empty($coursestofetch)) {
+            return $this->setSelected(array());
+        }
+
+        // There is no API function to load a list of course from a list of ids.
+        $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
+        $fields = array('c.id', 'c.category', 'c.sortorder',
+                        'c.shortname', 'c.fullname', 'c.idnumber',
+                        'c.startdate', 'c.visible', 'c.cacherev');
+        list($whereclause, $params) = $DB->get_in_or_equal($coursestofetch, SQL_PARAMS_NAMED, 'id');
+
+        $sql = "SELECT ". join(',', $fields). ", $ctxselect
+                FROM {course} c
+                JOIN {context} ctx ON c.id = ctx.instanceid AND ctx.contextlevel = :contextcourse
+                WHERE c.id ". $whereclause." ORDER BY c.sortorder";
+        $list = $DB->get_records_sql($sql, array('contextcourse' => CONTEXT_COURSE) + $params);
+
+        $coursestoselect = array();
+        foreach ($list as $course) {
+            context_helper::preload_from_record($course);
+            // Make sure we can see the course.
+            if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
+                continue;
+            }
+            $label = get_course_display_name_for_list($course);
+            $this->addOption($label, $course->id);
+            array_push($coursestoselect, $course->id);
+        }
+        return $this->setSelected($coursestoselect);
+    }
+}
index e0639a2..6307a4c 100644 (file)
@@ -2995,6 +2995,7 @@ MoodleQuickForm::registerElementType('advcheckbox', "$CFG->libdir/form/advcheckb
 MoodleQuickForm::registerElementType('autocomplete', "$CFG->libdir/form/autocomplete.php", 'MoodleQuickForm_autocomplete');
 MoodleQuickForm::registerElementType('button', "$CFG->libdir/form/button.php", 'MoodleQuickForm_button');
 MoodleQuickForm::registerElementType('cancel', "$CFG->libdir/form/cancel.php", 'MoodleQuickForm_cancel');
+MoodleQuickForm::registerElementType('course', "$CFG->libdir/form/course.php", 'MoodleQuickForm_course');
 MoodleQuickForm::registerElementType('searchableselector', "$CFG->libdir/form/searchableselector.php", 'MoodleQuickForm_searchableselector');
 MoodleQuickForm::registerElementType('checkbox', "$CFG->libdir/form/checkbox.php", 'MoodleQuickForm_checkbox');
 MoodleQuickForm::registerElementType('date_selector', "$CFG->libdir/form/dateselector.php", 'MoodleQuickForm_date_selector');
index d23f702..6b87dd1 100644 (file)
@@ -3,6 +3,11 @@ information provided here is intended especially for developers.
 
 === 3.1 ===
 
+* Webservice function core_course_search_courses now returns results when the search string
+  is less than 2 chars long.
+* Webservice function core_course_search_courses accepts a new parameter 'requiredcapabilities' to filter the results
+  by the capabilities of the current user.
+* New mform element 'course' handles thousands of courses with good performance and usability.
 * The redirect() function will now redirect immediately if output has not
   already started. Messages will be displayed on the subsequent page using
   session notifications. The type of message output can be configured using the