MDL-51324 forms: Add a new course selector

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
This commit is contained in:
Damyon Wiese 2015-09-04 14:59:04 +08:00
parent 9502c7f539
commit 235ef57a3d
11 changed files with 298 additions and 25 deletions

@ -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'),

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
define(["core/ajax","jquery"],function(a,b){return{processResults:function(a,c){var d=[],e=0,f=String(b(a).data("exclude")).split(",");for(e=0;e<c.courses.length;e++)-1===f.indexOf(String(c.courses[e].id))&&d.push({value:c.courses[e].id,label:c.courses[e].displayname});return d},transport:function(c,d,e,f){var g=b(c).data("requiredcapabilities");g=""!==g.trim()?g.split(","):[];var h=null;"undefined"==typeof d&&(d="");var i={criterianame:"search",criteriavalue:d,page:0,perpage:100,requiredcapabilities:g};return h=a.call([{methodname:"core_course_search_courses",args:i}]),h[0].done(e),h[0].fail(f),h}}});

@ -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);
});

@ -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;
}
};
});

@ -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;

@ -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;
}
}

@ -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(

142
lib/form/course.php Normal file

@ -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);
}
}

@ -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');

@ -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