Merge branch 'MDL-78885-main' of https://github.com/rezaies/moodle

This commit is contained in:
Jun Pataleta 2024-03-25 23:37:57 +08:00
commit 281fecbd54
No known key found for this signature in database
GPG key ID: F83510526D99E2C7
72 changed files with 723 additions and 817 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -40,6 +40,38 @@ export default class GradeItemSearch extends search_combobox {
};
const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;
this.instance = this.component.querySelector(this.selectors.instance).dataset.instance;
const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`);
searchValueElement.addEventListener('change', () => {
this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.
const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
if (valueElement.value !== searchValueElement.value) {
valueElement.value = searchValueElement.value;
valueElement.dispatchEvent(new Event('change', {bubbles: true}));
}
searchValueElement.value = '';
});
this.$component.on('hide.bs.dropdown', () => {
this.searchInput.removeAttribute('aria-activedescendant');
const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role="listbox"]`);
listbox.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
listbox.scrollTop = 0;
// Use setTimeout to make sure the following code is executed after the click event is handled.
setTimeout(() => {
if (this.searchInput.value !== '') {
this.searchInput.value = '';
this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));
}
});
});
this.renderDefault();
}
@ -66,25 +98,19 @@ export default class GradeItemSearch extends search_combobox {
return '.gradesearchdropdown';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.gradesearchwidget';
}
/**
* Build the content then replace the node.
*/
async renderDropdown() {
const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', {
instance: this.instance,
results: this.getMatchedResults(),
hasresults: this.getMatchedResults().length > 0,
searchterm: this.getSearchTerm(),
});
replaceNodeContents(this.selectors.placeholder, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**
@ -98,11 +124,6 @@ export default class GradeItemSearch extends search_combobox {
this.updateNodes();
this.registerInputEvents();
// Add a small BS listener so that we can set the focus correctly on open.
this.$component.on('shown.bs.dropdown', () => {
this.searchInput.focus({preventScroll: true});
});
}
/**
@ -142,7 +163,6 @@ export default class GradeItemSearch extends search_combobox {
return {
id: grade.id,
name: grade.name,
link: this.selectOneLink(grade.id),
};
})
);
@ -174,46 +194,25 @@ export default class GradeItemSearch extends search_combobox {
* @param {MouseEvent} e The triggering event that we are working with.
*/
async clickHandler(e) {
if (e.target.closest(this.selectors.dropdown)) {
// Forcibly prevent BS events so that we can control the open and close.
// Really needed because by default input elements cant trigger a dropdown.
e.stopImmediatePropagation();
}
this.clearSearchButton.addEventListener('click', async() => {
if (e.target.closest(this.selectors.clearSearch)) {
e.stopPropagation();
// Clear the entered search query in the search bar.
this.searchInput.value = '';
this.setSearchTerms(this.searchInput.value);
this.searchInput.focus();
this.clearSearchButton.classList.add('d-none');
// Display results.
await this.filterrenderpipe();
});
// Prevent normal key presses activating this.
if (e.target.closest('.dropdown-item') && e.button === 0) {
window.location = e.target.closest('.dropdown-item').href;
}
}
/**
* The handler for when a user presses a key within the component.
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {KeyboardEvent} e The triggering event that we are working with.
* @param {Event} e The change event.
*/
keyHandler(e) {
super.keyHandler(e);
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'Tab':
if (e.target.closest(this.selectors.input)) {
e.preventDefault();
this.clearSearchButton.focus({preventScroll: true});
}
break;
case 'Escape':
if (document.activeElement.getAttribute('role') === 'option') {
e.stopPropagation();
this.searchInput.focus({preventScroll: true});
} else if (e.target.closest(this.selectors.input)) {
const trigger = this.component.querySelector(this.selectors.trigger);
trigger.focus({preventScroll: true});
}
}
changeHandler(e) {
window.location = this.selectOneLink(e.target.value);
}
/**
@ -236,6 +235,7 @@ export default class GradeItemSearch extends search_combobox {
/**
* Build up the view all link that is dedicated to a particular result.
* We will call this function when a user interacts with the combobox to redirect them to show their results in the page.
*
* @param {Number} gradeID The ID of the grade item selected.
*/

View file

@ -45,12 +45,18 @@ class core_grades_renderer extends plugin_renderer_base {
* Renders the group selector trigger element.
*
* @param object $course The course object.
* @param string|null $groupactionbaseurl The base URL for the group action.
* @param string|null $groupactionbaseurl This parameter has been deprecated since 4.4 and should not be used anymore.
* @return string|null The raw HTML to render.
*/
public function group_selector(object $course, ?string $groupactionbaseurl = null): ?string {
global $USER;
if ($groupactionbaseurl !== null) {
debugging(
'The $groupactionbaseurl argument has been deprecated. Please remove it from your method calls.',
DEBUG_DEVELOPER,
);
}
// Make sure that group mode is enabled.
if (!$groupmode = $course->groupmode) {
return null;
@ -59,17 +65,12 @@ class core_grades_renderer extends plugin_renderer_base {
$sbody = $this->render_from_template('core_group/comboboxsearch/searchbody', [
'courseid' => $course->id,
'currentvalue' => optional_param('groupsearchvalue', '', PARAM_NOTAGS),
'instance' => rand(),
]);
$label = $groupmode == VISIBLEGROUPS ? get_string('selectgroupsvisible') :
get_string('selectgroupsseparate');
$label = $groupmode == VISIBLEGROUPS ? get_string('selectgroupsvisible') : get_string('selectgroupsseparate');
$data = [
'name' => 'group',
'label' => $label,
'courseid' => $course->id,
'groupactionbaseurl' => $groupactionbaseurl
];
$buttondata = ['label' => $label];
$context = context_course::instance($course->id);
@ -80,22 +81,27 @@ class core_grades_renderer extends plugin_renderer_base {
}
$activegroup = groups_get_course_group($course, true, $allowedgroups);
$data['group'] = $activegroup;
$buttondata['group'] = $activegroup;
if ($activegroup) {
$group = groups_get_group($activegroup);
$data['selectedgroup'] = format_string($group->name, true, ['context' => $context]);
$buttondata['selectedgroup'] = format_string($group->name, true, ['context' => $context]);
} else if ($activegroup === 0) {
$data['selectedgroup'] = get_string('allparticipants');
$buttondata['selectedgroup'] = get_string('allparticipants');
}
$groupdropdown = new comboboxsearch(
false,
$this->render_from_template('core_group/comboboxsearch/group_selector', $data),
$this->render_from_template('core_group/comboboxsearch/group_selector', $buttondata),
$sbody,
'group-search',
'groupsearchwidget',
'groupsearchdropdown overflow-auto w-100',
'groupsearchdropdown overflow-auto',
null,
true,
$label,
'group',
$activegroup
);
return $this->render_from_template($groupdropdown->get_template(), $groupdropdown->export_for_template($this));
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
{"version":3,"file":"group.min.js","sources":["../src/group.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for groups within the grader report.\n *\n * @module gradereport_grader/group\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n static init() {\n return new Group();\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"2WAyBqBA,cAAcC,eAI/BC,6LAISC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,8BAIlE,IAAIL,MASfY,cAAcC,gBACHC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,UACR"}
{"version":3,"file":"group.min.js","sources":["../src/group.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for groups within the grader report.\n *\n * @module gradereport_grader/group\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n static init() {\n return new Group();\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"2WAyBqBA,cAAcC,eAI/BC,6LAISC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,8BAIlE,IAAIL,MASfY,cAAcC,gBACHC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,UACR"}

View file

@ -1 +1 @@
{"version":3,"file":"user.min.js","sources":["../src/user.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for learners within the grader report.\n *\n * @module gradereport_grader/user\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport * as Repository from 'gradereport_grader/local/user/repository';\n\n// Define our standard lookups.\nconst selectors = {\n component: '.user-search',\n courseid: '[data-region=\"courseid\"]',\n};\nconst component = document.querySelector(selectors.component);\nconst courseID = component.querySelector(selectors.courseid).dataset.courseid;\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n return Repository.userFetch(courseID).then((r) => r.users);\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm(),\n gpr_userid: userID,\n }, false);\n }\n}\n"],"names":["selectors","courseID","document","querySelector","dataset","courseid","User","UserSearch","constructor","fetchDataset","Repository","userFetch","then","r","users","selectAllResultsLink","Url","relativeUrl","id","gpr_search","this","getSearchTerm","selectOneLink","userID","gpr_userid"],"mappings":";;;;;;;q0BA2BMA,oBACS,eADTA,mBAEQ,2BAGRC,SADYC,SAASC,cAAcH,qBACdG,cAAcH,oBAAoBI,QAAQC,eAEhDC,aAAaC,cAE9BC,2CAKW,IAAIF,KAQfG,sBACWC,WAAWC,UAAUV,UAAUW,MAAMC,GAAMA,EAAEC,QAQxDC,8BACWC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,kBAClB,GASPC,cAAcC,eACHP,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,gBACjBG,WAAYD,SACb"}
{"version":3,"file":"user.min.js","sources":["../src/user.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for learners within the grader report.\n *\n * @module gradereport_grader/user\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport * as Repository from 'gradereport_grader/local/user/repository';\n\n// Define our standard lookups.\nconst selectors = {\n component: '.user-search',\n courseid: '[data-region=\"courseid\"]',\n};\nconst component = document.querySelector(selectors.component);\nconst courseID = component.querySelector(selectors.courseid).dataset.courseid;\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n return Repository.userFetch(courseID).then((r) => r.users);\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm(),\n gpr_userid: userID,\n }, false);\n }\n}\n"],"names":["selectors","courseID","document","querySelector","dataset","courseid","User","UserSearch","constructor","fetchDataset","Repository","userFetch","then","r","users","selectAllResultsLink","Url","relativeUrl","id","gpr_search","this","getSearchTerm","selectOneLink","userID","gpr_userid"],"mappings":";;;;;;;q0BA2BMA,oBACS,eADTA,mBAEQ,2BAGRC,SADYC,SAASC,cAAcH,qBACdG,cAAcH,oBAAoBI,QAAQC,eAEhDC,aAAaC,cAE9BC,2CAKW,IAAIF,KAQfG,sBACWC,WAAWC,UAAUV,UAAUW,MAAMC,GAAMA,EAAEC,QAQxDC,8BACWC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,kBAClB,GASPC,cAAcC,eACHP,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,gBACjBG,WAAYD,SACb"}

View file

@ -56,6 +56,7 @@ const selectors = {
count: '[data-collapse="count"]',
placeholder: '.collapsecolumndropdown [data-region="placeholder"]',
fullDropdown: '.collapsecolumndropdown',
searchResultContainer: '.searchresultitemscontainer',
};
const countIndicator = document.querySelector(selectors.count);
@ -98,6 +99,19 @@ export default class ColumnSearch extends search_combobox {
document.querySelector('.gradereport-grader-table').classList.remove('d-none');
}, 10);
}).then(() => pendingPromise.resolve()).catch(Notification.exception);
this.$component.on('hide.bs.dropdown', () => {
const searchResultContainer = this.component.querySelector(selectors.searchResultContainer);
searchResultContainer.scrollTop = 0;
// Use setTimeout to make sure the following code is executed after the click event is handled.
setTimeout(() => {
if (this.searchInput.value !== '') {
this.searchInput.value = '';
this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));
}
});
});
}
/**
@ -118,15 +132,6 @@ export default class ColumnSearch extends search_combobox {
return '.searchresultitemscontainer';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.collapsecolumn';
}
/**
* Return the dataset that we will be searching upon.
*
@ -218,25 +223,6 @@ export default class ColumnSearch extends search_combobox {
}
}
/**
* The handler for when a user presses a key within the component.
*
* @param {KeyboardEvent} e The triggering event that we are working with.
*/
async keyHandler(e) {
super.keyHandler(e);
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'Tab':
if (e.target.closest(this.selectors.input)) {
e.preventDefault();
this.clearSearchButton.focus({preventScroll: true});
}
break;
}
}
/**
* Handle any keyboard inputs.
*/
@ -492,6 +478,7 @@ export default class ColumnSearch extends search_combobox {
// Update the collapsed button pill.
this.countUpdate();
const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', {
'instance': this.instance,
'results': this.getMatchedResults(),
'userid': this.userID,
});
@ -505,6 +492,7 @@ export default class ColumnSearch extends search_combobox {
// Add a small BS listener so that we can set the focus correctly on open.
this.$component.on('shown.bs.dropdown', () => {
this.searchInput.focus({preventScroll: true});
this.selectallEnable();
});
}
@ -512,14 +500,26 @@ export default class ColumnSearch extends search_combobox {
* Build the content then replace the node.
*/
async renderDropdown() {
const form = this.component.querySelector(selectors.formDropdown);
const selectall = form.querySelector('[data-action="selectall"]');
const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', {
instance: this.instance,
'results': this.getMatchedResults(),
'searchTerm': this.getSearchTerm(),
});
selectall.disabled = this.getMatchedResults().length === 0;
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
this.selectallEnable();
// Reset the expand button to be disabled as we have re-rendered the dropdown.
const form = this.component.querySelector(selectors.formDropdown);
const expandButton = form.querySelector(`[data-action="${selectors.formItems.save}"`);
expandButton.disabled = true;
}
/**
* Given we render the dropdown, Determine if we want to enable the select all checkbox.
*/
selectallEnable() {
const form = this.component.querySelector(selectors.formDropdown);
const selectall = form.querySelector('[data-action="selectall"]');
selectall.disabled = this.getMatchedResults().length === 0;
}
/**

View file

@ -43,7 +43,7 @@ export default class Group extends GroupSearch {
}
/**
* Build up the view all link that is dedicated to a particular result.
* Build up the link that is dedicated to a particular result.
*
* @param {Number} groupID The ID of the group selected.
* @returns {string|*}

View file

@ -64,7 +64,7 @@ export default class User extends UserSearch {
}
/**
* Build up the view all link that is dedicated to a particular result.
* Build up the link that is dedicated to a particular result.
*
* @param {Number} userID The ID of the user selected.
* @returns {string|*}

View file

@ -32,6 +32,9 @@ class action_bar extends \core_grades\output\action_bar {
/** @var string $usersearch The content that the current user is looking for. */
protected string $usersearch = '';
/** @var int $userid The ID of the user that the current user is looking for. */
protected int $userid = 0;
/**
* The class constructor.
*
@ -40,7 +43,13 @@ class action_bar extends \core_grades\output\action_bar {
public function __construct(\context_course $context) {
parent::__construct($context);
$this->userid = optional_param('gpr_userid', 0, PARAM_INT);
$this->usersearch = optional_param('gpr_search', '', PARAM_NOTAGS);
if ($this->userid) {
$user = \core_user::get_user($this->userid);
$this->usersearch = fullname($user);
}
}
/**
@ -80,6 +89,10 @@ class action_bar extends \core_grades\output\action_bar {
$this->context,
'/grade/report/grader/index.php'
);
$firstnameinitial = $SESSION->gradereport["filterfirstname-{$this->context->id}"] ?? '';
$lastnameinitial = $SESSION->gradereport["filtersurname-{$this->context->id}"] ?? '';
$initialselector = new comboboxsearch(
false,
$initialscontent->buttoncontent,
@ -88,6 +101,13 @@ class action_bar extends \core_grades\output\action_bar {
'initialswidget',
'initialsdropdown',
$initialscontent->buttonheader,
true,
get_string('filterbyname', 'core_grades'),
'nameinitials',
json_encode([
'first' => $firstnameinitial,
'last' => $lastnameinitial,
])
);
$data['initialselector'] = $initialselector->export_for_template($output);
$data['groupselector'] = $gradesrenderer->group_selector($course);
@ -96,14 +116,20 @@ class action_bar extends \core_grades\output\action_bar {
$searchinput = $OUTPUT->render_from_template('core_user/comboboxsearch/user_selector', [
'currentvalue' => $this->usersearch,
'courseid' => $courseid,
'instance' => rand(),
'resetlink' => $resetlink->out(false),
'group' => 0,
'name' => 'usersearch',
'value' => json_encode([
'userid' => $this->userid,
'search' => $this->usersearch,
]),
]);
$searchdropdown = new comboboxsearch(
true,
$searchinput,
null,
'user-search dropdown d-flex',
'user-search d-flex',
null,
'usersearchdropdown overflow-auto',
null,
@ -123,6 +149,8 @@ class action_bar extends \core_grades\output\action_bar {
'collapsecolumndropdown p-3 flex-column ' . $collapsemenudirection,
null,
true,
get_string('aria:dropdowncolumns', 'gradereport_grader'),
'collapsedcolumns'
);
$data['collapsedcolumns'] = [
'classes' => 'd-none',
@ -135,10 +163,12 @@ class action_bar extends \core_grades\output\action_bar {
$allowedgroups = groups_get_all_groups($course->id, $USER->id, $course->defaultgroupingid);
}
if (!empty($SESSION->gradereport["filterfirstname-{$this->context->id}"]) ||
!empty($SESSION->gradereport["filterlastname-{$this->context->id}"]) ||
if (
$firstnameinitial ||
$lastnameinitial ||
groups_get_course_group($course, true, $allowedgroups) ||
$this->usersearch) {
$this->usersearch
) {
$reset = new moodle_url('/grade/report/grader/index.php', [
'id' => $courseid,
'group' => 0,

View file

@ -18,6 +18,7 @@
Example context (json):
{
"instance": 25,
"results": [
{
"name": "42",
@ -39,24 +40,18 @@
{{$placeholder}}{{#str}}
searchcollapsedcolumns, core_grades
{{/str}}{{/placeholder}}
{{$additionalattributes}}
role="combobox"
aria-expanded="true"
aria-controls="collapse-listbox"
aria-autocomplete="list"
data-input-element="collapse-input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
<input type="hidden" name="search" id="collapse-input-{{uniqid}}"/>
<form class="columnsdropdownform flex-column h-100">
<ul id="collapse-listbox" class="searchresultitemscontainer overflow-auto py-2 px-1 list-group mx-0 text-truncate" role="listbox" data-region="search-result-items-container" tabindex="-1" aria-label="{{#cleanstr}} aria:dropdowncolumns, gradereport_grader {{/cleanstr}}" aria-busy="true">
<fieldset>
<legend class="sr-only">{{#str}} aria:dropdowncolumns, gradereport_grader {{/str}}</legend>
<ul id="collapse-{{instance}}-listbox" class="searchresultitemscontainer overflow-auto py-2 px-1 list-group mx-0 text-truncate" data-region="search-result-items-container">
{{>gradereport_grader/collapse/collapseresults}}
</ul>
</fieldset>
<div class="d-flex mt-2">
<div class="d-flex align-items-center form-check">
<label id="check-all" for="check-all-input" class="selected-option-info text-truncate form-check-label d-block" tabindex="-1">
<input disabled id="check-all-input" class="form-check-input" type="checkbox" data-action="selectall" aria-label="{{#str}}selectall, core{{/str}}">
<label id="check-all" class="selected-option-info text-truncate form-check-label d-block">
<input disabled class="form-check-input" type="checkbox" data-action="selectall">
{{#str}}selectall, core{{/str}}
</label>
</div>

View file

@ -26,9 +26,9 @@
"category": "Hitchhikers grade category"
}
}}
<li class="w-100 result-row form-check mb-1" role="none">
<input id="check-{{name}}-input" class="form-check-input" role="option" data-collapse="{{name}}" type="checkbox" value="" aria-labelledby="check-{{name}}">
<label id="check-{{name}}" class="selected-option-info d-block pr-3 text-truncate form-check-label" tabindex="-1">
<li class="w-100 result-row form-check mb-1">
<label class="selected-option-info d-block pr-3 text-truncate form-check-label">
<input class="form-check-input" data-collapse="{{name}}" type="checkbox" value="">
<span class="selected-option-text w-100 p-0">
{{{displayName}}}
</span>

View file

@ -110,9 +110,9 @@ Feature: Within the grader report, test that we can collapse columns
And I click on "Collapsed columns" "combobox"
# This is checking that the column name search dropdown exists.
When I wait until "Search collapsed columns" "field" exists
And I click on "Test assignment one" "option_role" in the "form" "gradereport_grader > collapse search"
And I click on "Test assignment three" "option_role" in the "form" "gradereport_grader > collapse search"
And I click on "Phone" "option_role" in the "form" "gradereport_grader > collapse search"
And I click on "Test assignment one" "checkbox" in the "form" "gradereport_grader > collapse search"
And I click on "Test assignment three" "checkbox" in the "form" "gradereport_grader > collapse search"
And I click on "Phone" "checkbox" in the "form" "gradereport_grader > collapse search"
And I click on "Expand" "button" in the "form" "gradereport_grader > collapse search"
And "Test assignment one" "link" in the "First name / Last name" "table_row" should be visible
And "Test assignment three" "link" in the "First name / Last name" "table_row" should be visible
@ -181,41 +181,18 @@ Feature: Within the grader report, test that we can collapse columns
And I choose "Collapse" in the open action menu
# Basic tests for the page.
When I click on "Collapsed columns" "combobox"
And the page should meet accessibility standards
And the page should meet "wcag131, wcag141, wcag412" accessibility standards
And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
# Move onto general keyboard navigation testing.
Then the focused element is "Search collapsed columns" "field"
And I press the down key
And the focused element is "Email address" "option_role"
And I press the end key
And the focused element is "Country" "option_role"
And I press the home key
And the focused element is "Email address" "option_role"
And I press the up key
And the focused element is "Country" "option_role"
And I press the down key
And the focused element is "Email address" "option_role"
And I press the end key
And I press the tab key
And the focused element is "Select all" "checkbox"
And I press the escape key
And the focused element is "Collapsed columns" "combobox"
And I click on "Collapsed columns" "combobox"
Then I set the field "Search collapsed columns" to "Goodmeme"
And I wait until "No results for \"Goodmeme\"" "text" exists
And I press the down key
And the focused element is "Search collapsed columns" "field"
# Lets check the tabbing order.
And I set the field "Search collapsed columns" to "phone"
And I wait until "Mobile phone" "option_role" exists
And I wait until "Mobile phone" "checkbox" exists
And I press the tab key
And the focused element is "Clear search input" "button" in the ".dropdown-menu.show" "css_element"
And I press the tab key
And I press the tab key
And I press the tab key
And I press the tab key
And the focused element is "Close" "button" in the ".dropdown-menu.show" "css_element"
And I press the escape key
And I press the tab key
# The course grade category menu.
And the focused element is "Cell actions" "button"
@ -306,6 +283,6 @@ Feature: Within the grader report, test that we can collapse columns
And I wait until "Collapsed columns" "combobox" exists
When I click on "Collapsed columns" "combobox"
And I click on "Select all" "checkbox"
And I click on "Email" "option_role" in the "form" "gradereport_grader > collapse search"
And I click on "Email" "checkbox" in the "form" "gradereport_grader > collapse search"
# The select all option should now be unchecked, Checking the form or option role is iffy with behat so we use the id.
Then "input#check-all-input:not([checked=checked])" "css_element" should exist
Then the field "Select all" matches value ""

View file

@ -80,17 +80,14 @@ Feature: Group searching functionality within the grader report.
And I click on "Search groups" "field"
And I wait until "Default group" "option_role" exists
And I press the down key
And the focused element is "All participants" "option_role"
And I press the end key
And the focused element is "Tutor group" "option_role"
And I press the home key
And the focused element is "All participants" "option_role"
And I press the up key
And the focused element is "Tutor group" "option_role"
And I press the down key
And the focused element is "All participants" "option_role"
And I press the escape key
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "All participants" "option_role"
And I press the up key
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "Tutor group" "option_role"
And I press the down key
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "All participants" "option_role"
Then I set the field "Search groups" to "Goodmeme"
And I wait until "Tutor group" "option_role" does not exist
And I press the down key
@ -101,7 +98,8 @@ Feature: Group searching functionality within the grader report.
And I set the field "Search groups" to "Tutor"
And I wait until "All participants" "option_role" does not exist
And I press the down key
And the focused element is "Tutor group" "option_role"
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "Tutor group" "option_role"
# Lets check the tabbing order.
And I set the field "Search groups" to "Marker"

View file

@ -120,6 +120,7 @@ Feature: Within the grader report, test that we can open our generic filter drop
# Click off the drop down
And I click on "Filter by name" "combobox"
And "input[data-action=save]" "css_element" should be visible
And I change window size to "large"
And I click on user profile field menu "fullname"
And "input[data-action=save]" "css_element" should not be visible

View file

@ -156,13 +156,12 @@ Feature: Within the grader report, test that we can search for users
| Dummy User |
Scenario: A teacher can quickly tell that a search is active on the current table
Given I click on "Turtle" in the "user" search widget
# The search input remains in the field on reload this is in keeping with other search implementations.
When the field "Search users" matches value "Turtle"
And I wait until "View all results (1)" "link" does not exist
When I click on "Turtle" in the "user" search widget
# The search input should contain the name of the user we have selected, so that it is clear that the result pertains to a specific user.
Then the field "Search users" matches value "Turtle Manatee"
# Test if we can then further retain the turtle result set and further filter from there.
Then I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not exist
And I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not be visible
And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name
@ -244,15 +243,11 @@ Feature: Within the grader report, test that we can search for users
And the page should meet "wcag131, wcag141, wcag412" accessibility standards
And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
And I press the down key
And the focused element is "Student 1" "option_role"
And I press the end key
And the focused element is "View all results (5)" "option_role"
And I press the home key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
And I press the up key
And the focused element is "View all results (5)" "option_role"
And ".active" "css_element" should exist in the "View all results (5)" "option_role"
And I press the down key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
And I press the escape key
And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme"
@ -263,20 +258,20 @@ Feature: Within the grader report, test that we can search for users
And I set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists
And I press the down key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
# Lets check the tabbing order.
And I set the field "Search users" to "ABC"
And I wait until "View all results (5)" "option_role" exists
And I click on "Search users" "field"
And I wait until "Turtle Manatee" "option_role" exists
And I press the tab key
And the focused element is "Clear search input" "button"
And I press the tab key
And the focused element is "View all results (5)" "option_role"
And I press the tab key
And ".groupsearchwidget" "css_element" should exist
# Ensure we can interact with the input & clear search options with the keyboard.
# Space & Enter have the same handling for triggering the two functionalities.
And I set the field "Search users" to "User"
And I press the up key
And I press the enter key
And I wait to be redirected
And the following should exist in the "user-grades" table:
@ -289,15 +284,6 @@ Feature: Within the grader report, test that we can search for users
| Teacher 1 |
| Student 1 |
| Turtle Manatee |
# Sometimes with behat we get unattached nodes causing spurious failures.
And I wait "1" seconds
And I set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists
And I press the tab key
And the focused element is "Clear search input" "button"
And I press the enter key
And I wait until the page is ready
And I confirm "Turtle Manatee" in "user" search within the gradebook widget does not exist
Scenario: Once a teacher searches, it'll apply the currently set filters and inform the teacher as such
# Set up a basic filtering case.
@ -357,7 +343,7 @@ Feature: Within the grader report, test that we can search for users
And the field "perpage" matches value "20"
When I set the field "Search users" to "42"
# One of the users' phone numbers also matches.
And I wait until "View all results (2)" "link" exists
And I wait until "View all results (2)" "option_role" exists
Then I confirm "Student s42" in "user" search within the gradebook widget exists
Scenario: As a teacher I save grades using search and pagination
@ -378,13 +364,13 @@ Feature: Within the grader report, test that we can search for users
And I wait until the page is ready
# Search for a single user on second page and save grades.
When I set the field "Search users" to "test32"
And I wait until "View all results (1)" "link" exists
And I wait until "View all results (1)" "option_role" exists
And I click on "Student test32" "option_role"
And I wait until the page is ready
And I give the grade "80.00" to the user "Student test32" for the grade item "Test assignment one"
And I press "Save changes"
And I wait until the page is ready
Then the field "Search users" matches value "test32"
Then the field "Search users" matches value "Student test32"
And the following should exist in the "user-grades" table:
| -1- |
| Student test32 |
@ -394,7 +380,7 @@ Feature: Within the grader report, test that we can search for users
And I give the grade "70.00" to the user "Student test31" for the grade item "Test assignment one"
And I press "Save changes"
And I wait until the page is ready
Then the field "Search users" matches value "test3"
Then the field "Search users" matches value "Student test31"
And the following should exist in the "user-grades" table:
| -1- |
| Student test31 |
@ -423,7 +409,7 @@ Feature: Within the grader report, test that we can search for users
| Student test32 |
# Search for multiple users on second page and save grades.
And I set the field "Search users" to "test3"
And I wait until "View all results (11)" "link" exists
And I wait until "View all results (11)" "option_role" exists
And I click on "View all results (11)" "option_role"
And I wait until the page is ready
And I give the grade "10.00" to the user "Student test32" for the grade item "Test assignment one"

View file

@ -1 +1 @@
{"version":3,"file":"group.min.js","sources":["../src/group.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for groups within the singleview report.\n *\n * @module gradereport_singleview/group\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n item = null;\n\n /**\n * Construct the class.\n *\n * @param {string} item The page type we are currently on.\n */\n constructor(item) {\n super();\n this.item = item;\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n /**\n * Allow the class to be invoked via PHP.\n *\n * @param {string} item The page type we are currently on.\n * @returns {Group}\n */\n static init(item) {\n return new Group(item);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/singleview/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n item: this.item\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","item","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"mhBAyBqBA,cAAcC,eAW/BC,YAAYC,kFAPL,WASEA,KAAOA,UAGPC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,qBASjEH,aACD,IAAIH,MAAMG,MASrBU,cAAcC,gBACHC,aAAIC,YAAY,qCAAsC,CACzDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,QACPX,KAAME,KAAKF,OACZ"}
{"version":3,"file":"group.min.js","sources":["../src/group.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for groups within the singleview report.\n *\n * @module gradereport_singleview/group\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n item = null;\n\n /**\n * Construct the class.\n *\n * @param {string} item The page type we are currently on.\n */\n constructor(item) {\n super();\n this.item = item;\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n /**\n * Allow the class to be invoked via PHP.\n *\n * @param {string} item The page type we are currently on.\n * @returns {Group}\n */\n static init(item) {\n return new Group(item);\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/singleview/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n item: this.item\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","item","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"mhBAyBqBA,cAAcC,eAW/BC,YAAYC,kFAPL,WASEA,KAAOA,UAGPC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,qBASjEH,aACD,IAAIH,MAAMG,MASrBU,cAAcC,gBACHC,aAAIC,YAAY,qCAAsC,CACzDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,QACPX,KAAME,KAAKF,OACZ"}

View file

@ -5,6 +5,6 @@ define("gradereport_singleview/user",["exports","core_user/comboboxsearch/user",
* @module gradereport_singleview/user
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_user=_interopRequireDefault(_user),_url=_interopRequireDefault(_url),Repository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Repository);class User extends _user.default{constructor(){super()}static init(){return new User}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_user/comboboxsearch/resultset",{users:this.getMatchedResults().slice(0,5),hasresults:this.getMatchedResults().length>0,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js)}selectAllResultsLink(){return null}selectOneLink(userID){return _url.default.relativeUrl("/grade/report/singleview/index.php",{id:this.courseID,searchvalue:this.getSearchTerm(),item:"user",userid:userID},!1)}fetchDataset(){const gts="string"==typeof this.groupID&&""===this.groupID?0:this.groupID;return Repository.userFetch(this.courseID,gts).then((r=>r.users))}}return _exports.default=User,_exports.default}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_user=_interopRequireDefault(_user),_url=_interopRequireDefault(_url),Repository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Repository);class User extends _user.default{constructor(){super()}static init(){return new User}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_user/comboboxsearch/resultset",{instance:this.instance,users:this.getMatchedResults().slice(0,5),hasresults:this.getMatchedResults().length>0,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js),this.searchInput.removeAttribute("aria-activedescendant")}selectAllResultsLink(){return null}selectOneLink(userID){return _url.default.relativeUrl("/grade/report/singleview/index.php",{id:this.courseID,searchvalue:this.getSearchTerm(),item:"user",userid:userID},!1)}fetchDataset(){const gts="string"==typeof this.groupID&&""===this.groupID?0:this.groupID;return Repository.userFetch(this.courseID,gts).then((r=>r.users))}}return _exports.default=User,_exports.default}));
//# sourceMappingURL=user.min.js.map

View file

@ -1 +1 @@
{"version":3,"file":"user.min.js","sources":["../src/user.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for learners within the singleview report.\n *\n * @module gradereport_singleview/user\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport * as Repository from 'core_grades/searchwidget/repository';\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {\n users: this.getMatchedResults().slice(0, 5),\n hasresults: this.getMatchedResults().length > 0,\n searchterm: this.getSearchTerm(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n }\n\n /**\n * Stub out default required function unused here.\n * @returns {null}\n */\n selectAllResultsLink() {\n return null;\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/singleview/index.php', {\n id: this.courseID,\n searchvalue: this.getSearchTerm(),\n item: 'user',\n userid: userID,\n }, false);\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n // Small typing checks as sometimes groups don't exist therefore the element returns a empty string.\n const gts = typeof (this.groupID) === \"string\" && this.groupID === '' ? 0 : this.groupID;\n return Repository.userFetch(this.courseID, gts).then((r) => r.users);\n }\n}\n"],"names":["User","UserSearch","constructor","html","js","users","this","getMatchedResults","slice","hasresults","length","searchterm","getSearchTerm","getHTMLElements","searchDropdown","selectAllResultsLink","selectOneLink","userID","Url","relativeUrl","id","courseID","searchvalue","item","userid","fetchDataset","gts","groupID","Repository","userFetch","then","r"],"mappings":";;;;;;;q0BA2BqBA,aAAaC,cAE9BC,2CAKW,IAAIF,kCAOLG,KAACA,KAADC,GAAOA,UAAY,+BAAiB,qCAAsC,CAC5EC,MAAOC,KAAKC,oBAAoBC,MAAM,EAAG,GACzCC,WAAYH,KAAKC,oBAAoBG,OAAS,EAC9CC,WAAYL,KAAKM,qDAEDN,KAAKO,kBAAkBC,eAAgBX,KAAMC,IAOrEW,8BACW,KASXC,cAAcC,eACHC,aAAIC,YAAY,qCAAsC,CACzDC,GAAId,KAAKe,SACTC,YAAahB,KAAKM,gBAClBW,KAAM,OACNC,OAAQP,SACT,GAQPQ,qBAEUC,IAAgC,iBAAlBpB,KAAKqB,SAA0C,KAAjBrB,KAAKqB,QAAiB,EAAIrB,KAAKqB,eAC1EC,WAAWC,UAAUvB,KAAKe,SAAUK,KAAKI,MAAMC,GAAMA,EAAE1B"}
{"version":3,"file":"user.min.js","sources":["../src/user.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for learners within the singleview report.\n *\n * @module gradereport_singleview/user\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport * as Repository from 'core_grades/searchwidget/repository';\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {\n instance: this.instance,\n users: this.getMatchedResults().slice(0, 5),\n hasresults: this.getMatchedResults().length > 0,\n searchterm: this.getSearchTerm(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n // Remove aria-activedescendant when the available options change.\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n\n /**\n * Stub out default required function unused here.\n * @returns {null}\n */\n selectAllResultsLink() {\n return null;\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/singleview/index.php', {\n id: this.courseID,\n searchvalue: this.getSearchTerm(),\n item: 'user',\n userid: userID,\n }, false);\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n // Small typing checks as sometimes groups don't exist therefore the element returns a empty string.\n const gts = typeof (this.groupID) === \"string\" && this.groupID === '' ? 0 : this.groupID;\n return Repository.userFetch(this.courseID, gts).then((r) => r.users);\n }\n}\n"],"names":["User","UserSearch","constructor","html","js","instance","this","users","getMatchedResults","slice","hasresults","length","searchterm","getSearchTerm","getHTMLElements","searchDropdown","searchInput","removeAttribute","selectAllResultsLink","selectOneLink","userID","Url","relativeUrl","id","courseID","searchvalue","item","userid","fetchDataset","gts","groupID","Repository","userFetch","then","r"],"mappings":";;;;;;;q0BA2BqBA,aAAaC,cAE9BC,2CAKW,IAAIF,kCAOLG,KAACA,KAADC,GAAOA,UAAY,+BAAiB,qCAAsC,CAC5EC,SAAUC,KAAKD,SACfE,MAAOD,KAAKE,oBAAoBC,MAAM,EAAG,GACzCC,WAAYJ,KAAKE,oBAAoBG,OAAS,EAC9CC,WAAYN,KAAKO,qDAEDP,KAAKQ,kBAAkBC,eAAgBZ,KAAMC,SAE5DY,YAAYC,gBAAgB,yBAOrCC,8BACW,KASXC,cAAcC,eACHC,aAAIC,YAAY,qCAAsC,CACzDC,GAAIjB,KAAKkB,SACTC,YAAanB,KAAKO,gBAClBa,KAAM,OACNC,OAAQP,SACT,GAQPQ,qBAEUC,IAAgC,iBAAlBvB,KAAKwB,SAA0C,KAAjBxB,KAAKwB,QAAiB,EAAIxB,KAAKwB,eAC1EC,WAAWC,UAAU1B,KAAKkB,SAAUK,KAAKI,MAAMC,GAAMA,EAAE3B"}

View file

@ -57,7 +57,7 @@ export default class Group extends GroupSearch {
}
/**
* Build up the view all link that is dedicated to a particular result.
* Build up the link that is dedicated to a particular result.
*
* @param {Number} groupID The ID of the group selected.
* @returns {string|*}

View file

@ -40,11 +40,14 @@ export default class User extends UserSearch {
*/
async renderDropdown() {
const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
instance: this.instance,
users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0,
searchterm: this.getSearchTerm(),
});
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**

View file

@ -131,7 +131,7 @@ class singleview extends grade_report {
protected function setup_groups() {
parent::setup_groups();
$this->group_selector = static::groups_course_menu($this->course, $this->pbarurl);
$this->group_selector = static::groups_course_menu($this->course);
}
/**
@ -139,19 +139,13 @@ class singleview extends grade_report {
* so all reports would automatically use it.
*
* @param stdClass $course
* @param moodle_url $urlroot
* @return string
*/
protected static function groups_course_menu(stdClass $course, moodle_url $urlroot) {
protected static function groups_course_menu(stdClass $course) {
global $PAGE;
$renderer = $PAGE->get_renderer('core_grades');
$params = $urlroot->params();
if ($params['item'] == 'user') {
$params['item'] = 'user_select';
$urlroot->params($params);
}
return $renderer->group_selector($course, $urlroot->out());
return $renderer->group_selector($course);
}
/**

View file

@ -46,18 +46,29 @@ class gradereport_singleview_renderer extends plugin_renderer_base {
*/
public function users_selector(object $course, ?int $userid = null, ?int $groupid = null): string {
$resetlink = new moodle_url('/grade/report/singleview/index.php', ['id' => $course->id, 'group' => $groupid ?? 0]);
$submitteduserid = optional_param('userid', '', PARAM_INT);
if ($submitteduserid) {
$user = core_user::get_user($submitteduserid);
$currentvalue = fullname($user);
} else {
$currentvalue = '';
}
$data = [
'currentvalue' => optional_param('searchvalue', '', PARAM_NOTAGS),
'currentvalue' => $currentvalue,
'courseid' => $course->id,
'instance' => rand(),
'group' => $groupid ?? 0,
'resetlink' => $resetlink->out(false),
'userid' => $userid ?? 0
'name' => 'userid',
'value' => $submitteduserid ?? '',
];
$dropdown = new comboboxsearch(
true,
$this->render_from_template('core_user/comboboxsearch/user_selector', $data),
null,
'user-search dropdown d-flex',
'user-search d-flex',
null,
'usersearchdropdown overflow-auto',
null,
@ -78,6 +89,7 @@ class gradereport_singleview_renderer extends plugin_renderer_base {
$data = [
'name' => 'itemid',
'courseid' => $course->id,
'instance' => rand(),
];
// If a particular grade item option is selected (not in zero state).
@ -92,6 +104,7 @@ class gradereport_singleview_renderer extends plugin_renderer_base {
$sbody = $this->render_from_template('core/local/comboboxsearch/searchbody', [
'courseid' => $course->id,
'currentvalue' => optional_param('gradesearchvalue', '', PARAM_NOTAGS),
'instance' => $data['instance'],
]);
$dropdown = new comboboxsearch(
false,
@ -99,7 +112,12 @@ class gradereport_singleview_renderer extends plugin_renderer_base {
$sbody,
'grade-search h-100',
'gradesearchwidget h-100',
'gradesearchdropdown overflow-auto w-100',
'gradesearchdropdown overflow-auto',
null,
true,
get_string('selectagrade', 'gradereport_singleview'),
'itemid',
$gradeitemid
);
return $this->render_from_template($dropdown->get_template(), $dropdown->export_for_template($this));
}

View file

@ -18,6 +18,7 @@
Context variables required for this template:
* itemid - The value of the grade item selector element (id of the preselected grade item)
* instance - The instance ID of the combo box.
* courseid - The course ID.
* selectedoption - (optional) Object containing information about the selected option.
* text - The text of the selected option.
@ -25,6 +26,7 @@
Example context (json):
{
"itemid": "21",
"instance": "25",
"courseid": "2",
"selectedoption": {
"text": "Grade item 1"
@ -34,7 +36,7 @@
<div class="align-items-center d-flex">
{{#selectedoption}}
<div class="d-block pr-3 text-truncate">
<span class="d-block small">
<span class="d-block small" aria-hidden="true">
{{#str}} selectagrade, gradereport_singleview {{/str}}
</span>
<span class="p-0 font-weight-bold">
@ -48,5 +50,6 @@
</div>
{{/selectedoption}}
</div>
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
<span class="d-none" data-region="itemid" data-itemid="{{itemid}}" aria-hidden="true"></span>
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}"></span>
<span class="d-none" data-region="itemid" data-itemid="{{itemid}}"></span>
<span class="d-none" data-region="instance" data-instance="{{instance}}"></span>

View file

@ -47,17 +47,14 @@ Feature: Given we have opted to search for a grade item, Lets find and search th
And I click on "Search items" "field"
And I wait until "Test assignment one" "option_role" exists
And I press the down key
And the focused element is "Test assignment one" "option_role"
And I press the end key
And the focused element is "Course total" "option_role"
And I press the home key
And the focused element is "Test assignment one" "option_role"
And I press the up key
And the focused element is "Course total" "option_role"
And I press the down key
And the focused element is "Test assignment one" "option_role"
And I press the escape key
And the focused element is "Search items" "field"
And ".active" "css_element" should exist in the "Test assignment one" "option_role"
And I press the up key
And the focused element is "Search items" "field"
And ".active" "css_element" should exist in the "Course total" "option_role"
And I press the down key
And the focused element is "Search items" "field"
And ".active" "css_element" should exist in the "Test assignment one" "option_role"
Then I set the field "Search items" to "Goodmeme"
And I wait until "Test assignment one" "option_role" does not exist
And I press the down key

View file

@ -150,9 +150,9 @@ Feature: We can use Single view
Given I click on user menu "Grainne Beauchamp"
And I choose "Single view for this user" in the open action menu
Then I should see "Gronya,Beecham"
And I follow "Nee,Chumlee"
And I click on "Nee,Chumlee" "link" in the ".stickyfooter" "css_element"
Then I should see "Nee,Chumlee"
And I follow "Gronya,Beecham"
And I click on "Gronya,Beecham" "link" in the ".stickyfooter" "css_element"
Then I should see "Gronya,Beecham"
And I open the action menu in "Test assignment four" "table_row"
And I choose "Show all grades" in the open action menu

View file

@ -104,11 +104,12 @@ Feature: Within the singleview report, a teacher can search for users.
Given I click on "Turtle" in the "user" search widget
And I wait until the page is ready
# The search input remains in the field on reload this is in keeping with other search implementations.
When the field "Search users" matches value "Turtle"
And "Turtle Manatee" "option_role" should not exist
When the field "Search users" matches value "Turtle Manatee"
# The users get preloaded for accessibility reasons.
And "Turtle Manatee" "option_role" should exist
# Test if we can then further retain the turtle result set and further filter from there.
Then I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not exist
And I wait until "Turtle Manatee" "option_role" does not exist
And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name
@ -186,15 +187,11 @@ Feature: Within the singleview report, a teacher can search for users.
# Move onto general keyboard navigation testing.
When I wait until "Turtle Manatee" "option_role" exists
And I press the down key
And the focused element is "Student 1" "option_role"
And I press the end key
And the focused element is "Dummy User" "option_role"
And I press the home key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
And I press the up key
And the focused element is "Dummy User" "option_role"
And ".active" "css_element" should exist in the "Dummy User" "option_role"
And I press the down key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
And I press the escape key
And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme"
@ -206,7 +203,7 @@ Feature: Within the singleview report, a teacher can search for users.
And I set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists
And I press the down key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
# Lets check the tabbing order.
And I set the field "Search users" to "ABC"

View file

@ -1 +1 @@
{"version":3,"file":"group.min.js","sources":["../src/group.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for groups within the user report.\n *\n * @module gradereport_user/group\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n static init() {\n return new Group();\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/user/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"yWAyBqBA,cAAcC,eAI/BC,6LAISC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,8BAIlE,IAAIL,MASfY,cAAcC,gBACHC,aAAIC,YAAY,+BAAgC,CACnDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,UACR"}
{"version":3,"file":"group.min.js","sources":["../src/group.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for groups within the user report.\n *\n * @module gradereport_user/group\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n static init() {\n return new Group();\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/user/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"yWAyBqBA,cAAcC,eAI/BC,6LAISC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,8BAIlE,IAAIL,MASfY,cAAcC,gBACHC,aAAIC,YAAY,+BAAgC,CACnDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,UACR"}

View file

@ -5,6 +5,6 @@ define("gradereport_user/user",["exports","core_user/comboboxsearch/user","core/
* @module gradereport_user/user
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_user=_interopRequireDefault(_user),_url=_interopRequireDefault(_url),Repository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Repository);class User extends _user.default{constructor(){super()}static init(){return new User}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_user/comboboxsearch/resultset",{users:this.getMatchedResults().slice(0,5),hasresults:this.getMatchedResults().length>0,matches:this.getDatasetSize(),searchterm:this.getSearchTerm(),selectall:this.selectAllResultsLink()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js)}selectAllResultsLink(){return _url.default.relativeUrl("/grade/report/user/index.php",{id:this.courseID,userid:0,searchvalue:this.getSearchTerm()},!1)}selectOneLink(userID){return _url.default.relativeUrl("/grade/report/user/index.php",{id:this.courseID,searchvalue:this.getSearchTerm(),userid:userID},!1)}fetchDataset(){const gts="string"==typeof this.groupID&&""===this.groupID?0:this.groupID;return Repository.userFetch(this.courseID,gts).then((r=>r.users))}}return _exports.default=User,_exports.default}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_user=_interopRequireDefault(_user),_url=_interopRequireDefault(_url),Repository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Repository);class User extends _user.default{constructor(){super()}static init(){return new User}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_user/comboboxsearch/resultset",{users:this.getMatchedResults().slice(0,5),hasresults:this.getMatchedResults().length>0,instance:this.instance,matches:this.getDatasetSize(),searchterm:this.getSearchTerm(),selectall:this.selectAllResultsLink()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js),this.searchInput.removeAttribute("aria-activedescendant")}selectAllResultsLink(){return _url.default.relativeUrl("/grade/report/user/index.php",{id:this.courseID,userid:0,searchvalue:this.getSearchTerm()},!1)}selectOneLink(userID){return _url.default.relativeUrl("/grade/report/user/index.php",{id:this.courseID,searchvalue:this.getSearchTerm(),userid:userID},!1)}fetchDataset(){const gts="string"==typeof this.groupID&&""===this.groupID?0:this.groupID;return Repository.userFetch(this.courseID,gts).then((r=>r.users))}}return _exports.default=User,_exports.default}));
//# sourceMappingURL=user.min.js.map

View file

@ -1 +1 @@
{"version":3,"file":"user.min.js","sources":["../src/user.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for learners within the user report.\n *\n * @module gradereport_user/user\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport * as Repository from 'core_grades/searchwidget/repository';\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {\n users: this.getMatchedResults().slice(0, 5),\n hasresults: this.getMatchedResults().length > 0,\n matches: this.getDatasetSize(),\n searchterm: this.getSearchTerm(),\n selectall: this.selectAllResultsLink(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/user/index.php', {\n id: this.courseID,\n userid: 0,\n searchvalue: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/user/index.php', {\n id: this.courseID,\n searchvalue: this.getSearchTerm(),\n userid: userID,\n }, false);\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n // Small typing checks as sometimes groups don't exist therefore the element returns a empty string.\n const gts = typeof (this.groupID) === \"string\" && this.groupID === '' ? 0 : this.groupID;\n return Repository.userFetch(this.courseID, gts).then((r) => r.users);\n }\n}\n"],"names":["User","UserSearch","constructor","html","js","users","this","getMatchedResults","slice","hasresults","length","matches","getDatasetSize","searchterm","getSearchTerm","selectall","selectAllResultsLink","getHTMLElements","searchDropdown","Url","relativeUrl","id","courseID","userid","searchvalue","selectOneLink","userID","fetchDataset","gts","groupID","Repository","userFetch","then","r"],"mappings":";;;;;;;q0BA2BqBA,aAAaC,cAE9BC,2CAKW,IAAIF,kCAOLG,KAACA,KAADC,GAAOA,UAAY,+BAAiB,qCAAsC,CAC5EC,MAAOC,KAAKC,oBAAoBC,MAAM,EAAG,GACzCC,WAAYH,KAAKC,oBAAoBG,OAAS,EAC9CC,QAASL,KAAKM,iBACdC,WAAYP,KAAKQ,gBACjBC,UAAWT,KAAKU,4DAEAV,KAAKW,kBAAkBC,eAAgBf,KAAMC,IAQrEY,8BACWG,aAAIC,YAAY,+BAAgC,CACnDC,GAAIf,KAAKgB,SACTC,OAAQ,EACRC,YAAalB,KAAKQ,kBACnB,GASPW,cAAcC,eACHP,aAAIC,YAAY,+BAAgC,CACnDC,GAAIf,KAAKgB,SACTE,YAAalB,KAAKQ,gBAClBS,OAAQG,SACT,GAQPC,qBAEUC,IAAgC,iBAAlBtB,KAAKuB,SAA0C,KAAjBvB,KAAKuB,QAAiB,EAAIvB,KAAKuB,eAC1EC,WAAWC,UAAUzB,KAAKgB,SAAUM,KAAKI,MAAMC,GAAMA,EAAE5B"}
{"version":3,"file":"user.min.js","sources":["../src/user.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allow the user to search for learners within the user report.\n *\n * @module gradereport_user/user\n * @copyright 2023 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport * as Repository from 'core_grades/searchwidget/repository';\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {\n users: this.getMatchedResults().slice(0, 5),\n hasresults: this.getMatchedResults().length > 0,\n instance: this.instance,\n matches: this.getDatasetSize(),\n searchterm: this.getSearchTerm(),\n selectall: this.selectAllResultsLink(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n // Remove aria-activedescendant when the available options change.\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/user/index.php', {\n id: this.courseID,\n userid: 0,\n searchvalue: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/user/index.php', {\n id: this.courseID,\n searchvalue: this.getSearchTerm(),\n userid: userID,\n }, false);\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n // Small typing checks as sometimes groups don't exist therefore the element returns a empty string.\n const gts = typeof (this.groupID) === \"string\" && this.groupID === '' ? 0 : this.groupID;\n return Repository.userFetch(this.courseID, gts).then((r) => r.users);\n }\n}\n"],"names":["User","UserSearch","constructor","html","js","users","this","getMatchedResults","slice","hasresults","length","instance","matches","getDatasetSize","searchterm","getSearchTerm","selectall","selectAllResultsLink","getHTMLElements","searchDropdown","searchInput","removeAttribute","Url","relativeUrl","id","courseID","userid","searchvalue","selectOneLink","userID","fetchDataset","gts","groupID","Repository","userFetch","then","r"],"mappings":";;;;;;;q0BA2BqBA,aAAaC,cAE9BC,2CAKW,IAAIF,kCAOLG,KAACA,KAADC,GAAOA,UAAY,+BAAiB,qCAAsC,CAC5EC,MAAOC,KAAKC,oBAAoBC,MAAM,EAAG,GACzCC,WAAYH,KAAKC,oBAAoBG,OAAS,EAC9CC,SAAUL,KAAKK,SACfC,QAASN,KAAKO,iBACdC,WAAYR,KAAKS,gBACjBC,UAAWV,KAAKW,4DAEAX,KAAKY,kBAAkBC,eAAgBhB,KAAMC,SAE5DgB,YAAYC,gBAAgB,yBAQrCJ,8BACWK,aAAIC,YAAY,+BAAgC,CACnDC,GAAIlB,KAAKmB,SACTC,OAAQ,EACRC,YAAarB,KAAKS,kBACnB,GASPa,cAAcC,eACHP,aAAIC,YAAY,+BAAgC,CACnDC,GAAIlB,KAAKmB,SACTE,YAAarB,KAAKS,gBAClBW,OAAQG,SACT,GAQPC,qBAEUC,IAAgC,iBAAlBzB,KAAK0B,SAA0C,KAAjB1B,KAAK0B,QAAiB,EAAI1B,KAAK0B,eAC1EC,WAAWC,UAAU5B,KAAKmB,SAAUM,KAAKI,MAAMC,GAAMA,EAAE/B"}

View file

@ -43,7 +43,7 @@ export default class Group extends GroupSearch {
}
/**
* Build up the view all link that is dedicated to a particular result.
* Build up the link that is dedicated to a particular result.
*
* @param {Number} groupID The ID of the group selected.
* @returns {string|*}

View file

@ -42,11 +42,14 @@ export default class User extends UserSearch {
const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
matches: this.getDatasetSize(),
searchterm: this.getSearchTerm(),
selectall: this.selectAllResultsLink(),
});
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**
@ -63,7 +66,7 @@ export default class User extends UserSearch {
}
/**
* Build up the view all link that is dedicated to a particular result.
* Build up the link that is dedicated to a particular result.
*
* @param {Number} userID The ID of the user selected.
* @returns {string|*}

View file

@ -93,19 +93,30 @@ class gradereport_user_renderer extends plugin_renderer_base {
*/
public function users_selector(object $course, ?int $userid = null, ?int $groupid = null): string {
$resetlink = new moodle_url('/grade/report/user/index.php', ['id' => $course->id, 'group' => 0]);
$submitteduserid = optional_param('userid', '', PARAM_INT);
if ($submitteduserid) {
$user = core_user::get_user($submitteduserid);
$currentvalue = fullname($user);
} else {
$currentvalue = '';
}
$data = [
'currentvalue' => optional_param('searchvalue', '', PARAM_NOTAGS),
'currentvalue' => $currentvalue,
'instance' => rand(),
'resetlink' => $resetlink->out(false),
'name' => 'userid',
'value' => $submitteduserid ?? '',
'courseid' => $course->id,
'groupid' => $groupid ?? 0,
'group' => $groupid ?? 0,
];
$searchdropdown = new comboboxsearch(
true,
$this->render_from_template('core_user/comboboxsearch/user_selector', $data),
null,
'user-search dropdown d-flex',
'user-search d-flex',
null,
'usersearchdropdown overflow-auto',
null,

View file

@ -82,17 +82,14 @@ Feature: Group searching functionality within the user report.
And I click on "Search groups" "field"
And I wait until "Default group" "option_role" exists
And I press the down key
And the focused element is "All participants" "option_role"
And I press the end key
And the focused element is "Tutor group" "option_role"
And I press the home key
And the focused element is "All participants" "option_role"
And I press the up key
And the focused element is "Tutor group" "option_role"
And I press the down key
And the focused element is "All participants" "option_role"
And I press the escape key
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "All participants" "option_role"
And I press the up key
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "Tutor group" "option_role"
And I press the down key
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "All participants" "option_role"
Then I set the field "Search groups" to "Goodmeme"
And I wait until "Tutor group" "option_role" does not exist
And I press the down key
@ -103,7 +100,8 @@ Feature: Group searching functionality within the user report.
And I set the field "Search groups" to "Tutor"
And I wait until "All participants" "option_role" does not exist
And I press the down key
And the focused element is "Tutor group" "option_role"
And the focused element is "Search groups" "field"
And ".active" "css_element" should exist in the "Tutor group" "option_role"
# Lets check the tabbing order.
And I set the field "Search groups" to "Marker"

View file

@ -107,8 +107,7 @@ Feature: Within the User report, a teacher can search for users.
And "Student 1" "heading" should exist
And "Turtle Manatee" "heading" should exist
And "Teacher 1" "heading" should not exist
And I click on "Clear" "link" in the ".user-search" "css_element"
And I wait until the page is ready
And "Clear" "link" should not exist in the ".user-search" "css_element"
And "Dummy User" "heading" should exist
And "User Example" "heading" should exist
And "User Test" "heading" should exist
@ -122,13 +121,13 @@ Feature: Within the User report, a teacher can search for users.
And I should see "No results for \"a\""
Scenario: A teacher can quickly tell that a search is active on the current table
Given I click on "Turtle" in the "user" search widget
# The search input remains in the field on reload this is in keeping with other search implementations.
When the field "Search users" matches value "Turtle"
When I click on "Turtle" in the "user" search widget
# The search input should contain the name of the user we have selected, so that it is clear that the result pertains to a specific user.
Then the field "Search users" matches value "Turtle Manatee"
And I wait until "View all results (5)" "link" does not exist
# Test if we can then further retain the turtle result set and further filter from there.
Then I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not exist
And I set the field "Search users" to "Turtle plagiarism"
And I wait until "Turtle Manatee" "list_item" does not exist
And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name
@ -187,6 +186,7 @@ Feature: Within the User report, a teacher can search for users.
And I confirm "User Example" in "user" search within the gradebook widget exists
And I confirm "User Test" in "user" search within the gradebook widget exists
And I confirm "Student 1" in "user" search within the gradebook widget exists
And I press the up key
And I press the enter key
And I wait until the page is ready
And "Student 1" "heading" should exist
@ -200,21 +200,15 @@ Feature: Within the User report, a teacher can search for users.
Scenario: A teacher can set focus and search using the input are with a keyboard
Given I set the field "Search users" to "ABC"
# Basic tests for the page.
And the page should meet accessibility standards
And the page should meet "wcag131, wcag141, wcag412" accessibility standards
And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
# Move onto general keyboard navigation testing.
When "Turtle Manatee" "option_role" should exist
And I press the down key
And the focused element is "Student 1" "option_role"
And I press the end key
And the focused element is "View all results (5)" "option_role"
And I press the home key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
And I press the up key
And the focused element is "View all results (5)" "option_role"
And ".active" "css_element" should exist in the "View all results (5)" "option_role"
And I press the down key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
And I press the escape key
And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme"
@ -225,16 +219,15 @@ Feature: Within the User report, a teacher can search for users.
And I set the field "Search users" to "ABC"
And "Turtle Manatee" "option_role" should exist
And I press the down key
And the focused element is "Student 1" "option_role"
And ".active" "css_element" should exist in the "Student 1" "option_role"
# Lets check the tabbing order.
And I set the field "Search users" to "ABC"
And "View all results (5)" "option_role" should exist
And I wait until "Clear search input" "button" exists
And I click on "Search users" "field"
And I press the tab key
And the focused element is "Clear search input" "button" in the ".user-search" "css_element"
And I press the tab key
And the focused element is "View all results (5)" "option_role"
And I press the tab key
And ".groupsearchwidget" "css_element" should exist
# Ensure we can interact with the input & clear search options with the keyboard.
# Space & Enter have the same handling for triggering the two functionalities.
@ -248,12 +241,3 @@ Feature: Within the User report, a teacher can search for users.
And "User Test" "heading" should not exist
And "Teacher 1" "heading" should not exist
And "Turtle Manatee" "heading" should not exist
# Sometimes with behat we get unattached nodes causing spurious failures.
And I wait "1" seconds
And I set the field "Search users" to "ABC"
And "Turtle Manatee" "option_role" should exist
And I press the tab key
And the focused element is "Clear search input" "button" in the ".user-search" "css_element"
And I press the enter key
And I wait until the page is ready
And I confirm "Turtle Manatee" in "user" search within the gradebook widget does not exist

View file

@ -1,3 +1,3 @@
define("core_group/comboboxsearch/group",["exports","core/comboboxsearch/search_combobox","core_group/comboboxsearch/repository","core/templates","core/utils","core/notification"],(function(_exports,_search_combobox,_repository,_templates,_utils,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=_interopRequireDefault(_search_combobox),_notification=_interopRequireDefault(_notification);class GroupSearch extends _search_combobox.default{constructor(){super(),_defineProperty(this,"courseID",void 0),_defineProperty(this,"bannedFilterFields",["id","link","groupimageurl"]),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',placeholder:'.groupsearchdropdown [data-region="searchplaceholder"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.renderDefault().catch(_notification.default.exception)}static init(){return new GroupSearch}componentSelector(){return".group-search"}dropdownSelector(){return".groupsearchdropdown"}triggerSelector(){return".groupsearchwidget"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_group/comboboxsearch/resultset",{groups:this.getMatchedResults(),hasresults:this.getMatchedResults().length>0,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.selectors.placeholder,html,js)}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown(),this.updateNodes(),this.registerInputEvents(),this.$component.on("shown.bs.dropdown",(()=>{this.searchInput.focus({preventScroll:!0})}))}async fetchDataset(){return await(0,_repository.groupFetch)(this.courseID).then((r=>r.groups))}async filterDataset(filterableData){return""===this.getPreppedSearchTerm()?filterableData:filterableData.filter((group=>Object.keys(group).some((key=>""!==group[key]&&!this.bannedFilterFields.includes(key)&&group[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((group=>({id:group.id,name:group.name,link:this.selectOneLink(group.id),groupimageurl:group.groupimageurl}))))}registerInputEvents(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.searchInput.value?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none"),await this.filterrenderpipe()}),300))}async clickHandler(e){e.target.closest(this.selectors.dropdown)&&e.stopImmediatePropagation(),this.clearSearchButton.addEventListener("click",(async()=>{this.searchInput.value="",this.setSearchTerms(this.searchInput.value),await this.filterrenderpipe()})),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href)}keyHandler(e){switch(super.keyHandler(e),e.key){case"Tab":e.target.closest(this.selectors.input)&&(e.preventDefault(),this.clearSearchButton.focus({preventScroll:!0}));break;case"Escape":if("option"===document.activeElement.getAttribute("role"))e.stopPropagation(),this.searchInput.focus({preventScroll:!0});else if(e.target.closest(this.selectors.input)){this.component.querySelector(this.selectors.trigger).focus({preventScroll:!0})}}}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none")}),300))}selectOneLink(groupID){throw new Error("selectOneLink(".concat(groupID,") must be implemented in ").concat(this.constructor.name))}}return _exports.default=GroupSearch,_exports.default}));
define("core_group/comboboxsearch/group",["exports","core/comboboxsearch/search_combobox","core_group/comboboxsearch/repository","core/templates","core/utils","core/notification"],(function(_exports,_search_combobox,_repository,_templates,_utils,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=_interopRequireDefault(_search_combobox),_notification=_interopRequireDefault(_notification);class GroupSearch extends _search_combobox.default{constructor(){super(),_defineProperty(this,"courseID",void 0),_defineProperty(this,"bannedFilterFields",["id","link","groupimageurl"]),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',placeholder:'.groupsearchdropdown [data-region="searchplaceholder"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.instance=component.querySelector(this.selectors.instance).dataset.instance;const searchValueElement=this.component.querySelector("#".concat(this.searchInput.dataset.inputElement));searchValueElement.addEventListener("change",(()=>{this.toggleDropdown();const valueElement=this.component.querySelector("#".concat(this.combobox.dataset.inputElement));valueElement.value!==searchValueElement.value&&(valueElement.value=searchValueElement.value,valueElement.dispatchEvent(new Event("change",{bubbles:!0}))),searchValueElement.value=""})),this.$component.on("hide.bs.dropdown",(()=>{this.searchInput.removeAttribute("aria-activedescendant");const listbox=document.querySelector("#".concat(this.searchInput.getAttribute("aria-controls"),'[role="listbox"]'));listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")})),listbox.scrollTop=0,setTimeout((()=>{""!==this.searchInput.value&&(this.searchInput.value="",this.searchInput.dispatchEvent(new Event("input",{bubbles:!0})))}))})),this.renderDefault().catch(_notification.default.exception)}static init(){return new GroupSearch}componentSelector(){return".group-search"}dropdownSelector(){return".groupsearchdropdown"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_group/comboboxsearch/resultset",{groups:this.getMatchedResults(),hasresults:this.getMatchedResults().length>0,instance:this.instance,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.selectors.placeholder,html,js),this.searchInput.removeAttribute("aria-activedescendant")}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown(),this.updateNodes()}async fetchDataset(){return await(0,_repository.groupFetch)(this.courseID).then((r=>r.groups))}async filterDataset(filterableData){return""===this.getPreppedSearchTerm()?filterableData:filterableData.filter((group=>Object.keys(group).some((key=>""!==group[key]&&!this.bannedFilterFields.includes(key)&&group[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((group=>({id:group.id,name:group.name,groupimageurl:group.groupimageurl}))))}async clickHandler(e){e.target.closest(this.selectors.clearSearch)&&(e.stopPropagation(),this.searchInput.value="",this.setSearchTerms(this.searchInput.value),this.searchInput.focus(),this.clearSearchButton.classList.add("d-none"),await this.filterrenderpipe())}changeHandler(e){window.location=this.selectOneLink(e.target.value)}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none"),await this.filterrenderpipe()}),300))}selectOneLink(groupID){throw new Error("selectOneLink(".concat(groupID,") must be implemented in ").concat(this.constructor.name))}}return _exports.default=GroupSearch,_exports.default}));
//# sourceMappingURL=group.min.js.map

File diff suppressed because one or more lines are too long

View file

@ -39,6 +39,40 @@ export default class GroupSearch extends search_combobox {
};
const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;
// Override the instance since the body is built outside the constructor for the combobox.
this.instance = component.querySelector(this.selectors.instance).dataset.instance;
const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`);
searchValueElement.addEventListener('change', () => {
this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.
const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
if (valueElement.value !== searchValueElement.value) {
valueElement.value = searchValueElement.value;
valueElement.dispatchEvent(new Event('change', {bubbles: true}));
}
searchValueElement.value = '';
});
this.$component.on('hide.bs.dropdown', () => {
this.searchInput.removeAttribute('aria-activedescendant');
const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role="listbox"]`);
listbox.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
listbox.scrollTop = 0;
// Use setTimeout to make sure the following code is executed after the click event is handled.
setTimeout(() => {
if (this.searchInput.value !== '') {
this.searchInput.value = '';
this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));
}
});
});
this.renderDefault().catch(Notification.exception);
}
@ -64,15 +98,6 @@ export default class GroupSearch extends search_combobox {
return '.groupsearchdropdown';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.groupsearchwidget';
}
/**
* Build the content then replace the node.
*/
@ -80,9 +105,12 @@ export default class GroupSearch extends search_combobox {
const {html, js} = await renderForPromise('core_group/comboboxsearch/resultset', {
groups: this.getMatchedResults(),
hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
searchterm: this.getSearchTerm(),
});
replaceNodeContents(this.selectors.placeholder, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**
@ -95,12 +123,6 @@ export default class GroupSearch extends search_combobox {
await this.renderDropdown();
this.updateNodes();
this.registerInputEvents();
// Add a small BS listener so that we can set the focus correctly on open.
this.$component.on('shown.bs.dropdown', () => {
this.searchInput.focus({preventScroll: true});
});
}
/**
@ -140,7 +162,6 @@ export default class GroupSearch extends search_combobox {
return {
id: group.id,
name: group.name,
link: this.selectOneLink(group.id),
groupimageurl: group.groupimageurl,
};
})
@ -148,14 +169,41 @@ export default class GroupSearch extends search_combobox {
}
/**
* Handle any keyboard inputs.
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
registerInputEvents() {
async clickHandler(e) {
if (e.target.closest(this.selectors.clearSearch)) {
e.stopPropagation();
// Clear the entered search query in the search bar.
this.searchInput.value = '';
this.setSearchTerms(this.searchInput.value);
this.searchInput.focus();
this.clearSearchButton.classList.add('d-none');
// Display results.
await this.filterrenderpipe();
}
}
/**
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {Event} e The change event.
*/
changeHandler(e) {
window.location = this.selectOneLink(e.target.value);
}
/**
* Override the input event listener for the text input area.
*/
registerInputHandlers() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(async() => {
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
if (this.searchInput.value === '') {
if (this.getSearchTerm() === '') {
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none');
} else {
@ -167,75 +215,9 @@ export default class GroupSearch extends search_combobox {
}, 300));
}
/**
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
async clickHandler(e) {
if (e.target.closest(this.selectors.dropdown)) {
// Forcibly prevent BS events so that we can control the open and close.
// Really needed because by default input elements cant trigger a dropdown.
e.stopImmediatePropagation();
}
this.clearSearchButton.addEventListener('click', async() => {
this.searchInput.value = '';
this.setSearchTerms(this.searchInput.value);
await this.filterrenderpipe();
});
// Prevent normal key presses activating this.
if (e.target.closest('.dropdown-item') && e.button === 0) {
window.location = e.target.closest('.dropdown-item').href;
}
}
/**
* The handler for when a user presses a key within the component.
*
* @param {KeyboardEvent} e The triggering event that we are working with.
*/
keyHandler(e) {
super.keyHandler(e);
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'Tab':
if (e.target.closest(this.selectors.input)) {
e.preventDefault();
this.clearSearchButton.focus({preventScroll: true});
}
break;
case 'Escape':
if (document.activeElement.getAttribute('role') === 'option') {
e.stopPropagation();
this.searchInput.focus({preventScroll: true});
} else if (e.target.closest(this.selectors.input)) {
const trigger = this.component.querySelector(this.selectors.trigger);
trigger.focus({preventScroll: true});
}
break;
}
}
/**
* Override the input event listener for the text input area.
*/
registerInputHandlers() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(() => {
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
if (this.getSearchTerm() === '') {
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none');
} else {
// Display the "clear" search button in the search bar.
this.clearSearchButton.classList.remove('d-none');
}
}, 300));
}
/**
* Build up the view all link that is dedicated to a particular result.
* We will call this function when a user interacts with the combobox to redirect them to show their results in the page.
*
* @param {Number} groupID The ID of the group selected.
*/

View file

@ -31,10 +31,10 @@
"selectedgroup": "Group 1"
}
}}
<span class="d-none" data-region="groupid" data-groupid="{{group}}" aria-hidden="true"></span>
<span class="d-none" data-region="groupid" data-groupid="{{group}}"></span>
<div class="align-items-center d-flex">
<div class="d-block pr-3 text-truncate">
<span class="d-block small">
<span class="d-block small" aria-hidden="true">
{{label}}
</span>
<span class="p-0 font-weight-bold">

View file

@ -19,14 +19,12 @@
Context variables required for this template:
* id - Group system ID.
* name - Groups' name.
* link - The link used to redirect upon self to show only this specific group.
* groupimageurl - The link of the groups picture.
Example context (json):
{
"id": 2,
"name": "Foo bar",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2",
"groupimageurl": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
}
}}

View file

@ -18,6 +18,7 @@
Context variables required for this template:
* groups - Our returned groups to render.
* instance - The instance ID of the combo box.
* searchterm - The entered text to find these results.
* hasgroups - Allow the handling where no users exist for the returned search term.
@ -35,6 +36,7 @@
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
}
],
"instance": 25,
"searchterm": "Foo",
"hasresults": true
}

View file

@ -29,25 +29,10 @@
}
}}
<div class="flex-column h-100 w-100">
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
<span class="d-none" data-region="groupid" data-groupid="{{groupid}}" aria-hidden="true"></span>
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}"></span>
<span class="d-none" data-region="groupid" data-groupid="{{groupid}}"></span>
<span class="d-none" data-region="instance" data-instance="{{instance}}"></span>
{{#currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchgroups, core
{{/str}}{{/label}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
role="combobox"
aria-expanded="true"
aria-controls="groups-result-listbox"
aria-autocomplete="list"
data-input-element="input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{/currentvalue}}
{{^currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchgroups, core
@ -55,16 +40,16 @@
{{$placeholder}}{{#str}}
searchgroups, core
{{/str}}{{/placeholder}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
role="combobox"
aria-expanded="true"
aria-controls="groups-result-listbox"
aria-controls="groups-{{instance}}-result-listbox"
aria-autocomplete="list"
data-input-element="input-{{uniqid}}"
data-input-element="input-{{uniqid}}-{{instance}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{/currentvalue}}
<input type="hidden" name="search" id="input-{{uniqid}}"/>
<input type="hidden" name="search" id="input-{{uniqid}}-{{instance}}"/>
<div data-region="searchplaceholder"></div>
</div>

View file

@ -115,3 +115,6 @@ gradeitemadvanced,core_grades
gradeitemadvanced_help,core_grades
to,core
from,core
aria-toggledropdown,core_grades
aria:dropdowngrades,core_grades
viewresults,core

View file

@ -889,9 +889,6 @@ $string['xml'] = 'XML';
$string['yes'] = 'Yes';
$string['yourgrade'] = 'Your grade';
$string['aria-toggledropdown'] = 'Toggle the following dropdown';
$string['aria:dropdowngrades'] = 'Grade items found';
// Deprecated since Moodle 4.2.
$string['showanalysisicon'] = 'Show grade analysis icon';
$string['showanalysisicon_desc'] = 'Whether to show grade analysis icon by default. If the activity module supports it, the grade analysis icon links to a page with more detailed explanation of the grade and how it was obtained.';
@ -918,3 +915,5 @@ $string['studentsperpage_help'] = 'This setting determines the number of student
$string['grade'] = 'Grade';
$string['gradeitemadvanced'] = 'Advanced grade item options';
$string['gradeitemadvanced_help'] = 'Select all elements that should be displayed as advanced when editing grade items.';
$string['aria-toggledropdown'] = 'Toggle the following dropdown';
$string['aria:dropdowngrades'] = 'Grade items found';

View file

@ -2384,7 +2384,6 @@ $string['viewallresults'] = 'View all results ({$a})';
$string['viewmore'] = 'View more';
$string['viewallsubcategories'] = 'View all subcategories';
$string['viewfileinpopup'] = 'View file in a popup window';
$string['viewresults'] = 'View results for {$a}';
$string['viewprofile'] = 'View profile';
$string['views'] = 'Views';
$string['viewsolution'] = 'view solution';
@ -2504,3 +2503,4 @@ $string['updatingain'] = 'Updating {$a->what} in {$a->in}';
$string['summaryof'] = 'Summary of {$a}';
$string['from'] = 'From';
$string['to'] = 'To';
$string['viewresults'] = 'View results for {$a}';

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,3 @@
define("core/local/aria/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={aria:{hidden:"[aria-hidden]"},elements:{focusable:'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',focusableToUnhide:"[data-aria-hidden-tab-index]"}},_exports.default}));
define("core/local/aria/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={aria:{hidden:"[aria-hidden]"},elements:{focusable:'input:not([type="hidden"]):not([disabled]):not([tabindex^="-"]),a[href]:not([disabled]):not([tabindex^="-"]),button:not([disabled]):not([tabindex^="-"]),textarea:not([disabled]):not([tabindex^="-"]),select:not([disabled]):not([tabindex^="-"]),[tabindex]:not([disabled]):not([tabindex^="-"])',focusableToUnhide:"[data-aria-hidden-tab-index]"}},_exports.default}));
//# sourceMappingURL=selectors.min.js.map

View file

@ -1 +1 @@
{"version":3,"file":"selectors.min.js","sources":["../../../src/local/aria/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Selectors used for ARIA.\n *\n * @module core/local/aria/selectors\n * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n aria: {\n hidden: '[aria-hidden]',\n },\n elements: {\n focusable: 'input:not([type=\"hidden\"]), a[href], button, textarea, select, [tabindex]',\n focusableToUnhide: '[data-aria-hidden-tab-index]',\n },\n};\n"],"names":["aria","hidden","elements","focusable","focusableToUnhide"],"mappings":"2KAsBe,CACXA,KAAM,CACFC,OAAQ,iBAEZC,SAAU,CACNC,UAAW,4EACXC,kBAAmB"}
{"version":3,"file":"selectors.min.js","sources":["../../../src/local/aria/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Selectors used for ARIA.\n *\n * @module core/local/aria/selectors\n * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n aria: {\n hidden: '[aria-hidden]',\n },\n elements: {\n focusable: 'input:not([type=\"hidden\"]):not([disabled]):not([tabindex^=\"-\"]),' +\n 'a[href]:not([disabled]):not([tabindex^=\"-\"]),' +\n 'button:not([disabled]):not([tabindex^=\"-\"]),' +\n 'textarea:not([disabled]):not([tabindex^=\"-\"]),' +\n 'select:not([disabled]):not([tabindex^=\"-\"]),' +\n '[tabindex]:not([disabled]):not([tabindex^=\"-\"])',\n focusableToUnhide: '[data-aria-hidden-tab-index]',\n },\n};\n"],"names":["aria","hidden","elements","focusable","focusableToUnhide"],"mappings":"2KAsBe,CACXA,KAAM,CACFC,OAAQ,iBAEZC,SAAU,CACNC,UAAW,qSAMXC,kBAAmB"}

View file

@ -14,7 +14,6 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import CustomEvents from "core/custom_interaction_events";
import {debounce} from 'core/utils';
import Pending from 'core/pending';
@ -25,25 +24,19 @@ import Pending from 'core/pending';
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// Reused variables for the class.
const events = [
'keydown',
CustomEvents.events.activate,
CustomEvents.events.keyboardActivate
];
const UP = -1;
const DOWN = 1;
export default class {
// Define our standard lookups.
selectors = {
component: this.componentSelector(),
trigger: this.triggerSelector(),
toggle: '[data-toggle="dropdown"]',
instance: '[data-region="instance"]',
input: '[data-action="search"]',
clearSearch: '[data-action="clearsearch"]',
dropdown: this.dropdownSelector(),
resultitems: '[role="option"]',
viewall: '#select-all',
combobox: '[role="combobox"]',
};
// The results from the called filter function.
@ -70,9 +63,12 @@ export default class {
// DOM nodes that persist.
component = document.querySelector(this.selectors.component);
instance = this.component.dataset.instance;
toggle = this.component.querySelector(this.selectors.toggle);
searchInput = this.component.querySelector(this.selectors.input);
searchDropdown = this.component.querySelector(this.selectors.dropdown);
clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
combobox = this.component.querySelector(this.selectors.combobox);
$component = $(this.component);
constructor() {
@ -80,10 +76,18 @@ export default class {
this.setSearchTerms(this.searchInput?.value ?? '');
// Begin handling the base search component.
this.registerClickHandlers();
this.registerKeyHandlers();
// Conditionally set up the input handler since we don't know exactly how we were called.
// If the combobox is rendered later, then you'll need to call this.registerInputHandlers() manually.
// An example of this is the collapse columns in the gradebook.
if (this.searchInput !== null) {
this.registerInputHandlers();
this.registerChangeHandlers();
}
// If we have a search term, show the clear button.
if (this.getSearchTerm() !== '') {
this.clearSearchButton.classList.remove('d-none');
}
}
@ -132,9 +136,10 @@ export default class {
/**
* Stub out a required function.
* @deprecated since Moodle 4.4
*/
triggerSelector() {
throw new Error(`triggerSelector() must be implemented in ${this.constructor.name}`);
window.console.warning('triggerSelector() is deprecated. Consider using this.selectors.toggle');
}
/**
@ -228,9 +233,9 @@ export default class {
*/
closeSearch(clear = false) {
this.toggleDropdown();
if (clear) {
// Hide the "clear" search button search bar.
this.clearSearchButton.classList.add('d-none');
if (clear) {
// Clear the entered search query in the search bar and hide the search results container.
this.setSearchTerms('');
this.searchInput.value = "";
@ -254,13 +259,10 @@ export default class {
* @param {Boolean} on Flag to toggle hiding or showing values.
*/
toggleDropdown(on = false) {
this.$component.dropdown('toggle');
if (on) {
this.searchDropdown.classList.add('show');
$(this.searchDropdown).show();
$(this.toggle).dropdown('show');
} else {
this.searchDropdown.classList.remove('show');
$(this.searchDropdown).hide();
$(this.toggle).dropdown('hide');
}
}
@ -285,15 +287,11 @@ export default class {
}
/**
* Register key event listeners.
* Register change event listeners.
*/
registerKeyHandlers() {
CustomEvents.define(document, events);
// Register click events.
events.forEach((event) => {
this.component.addEventListener(event, this.keyHandler.bind(this));
});
registerChangeHandlers() {
const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
valueElement.addEventListener('change', this.changeHandler.bind(this));
}
/**
@ -308,22 +306,17 @@ export default class {
return;
}
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
const pendingPromise = new Pending();
if (this.getSearchTerm() === '') {
this.toggleDropdown();
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none');
await this.filterrenderpipe();
} else {
const pendingPromise = new Pending();
await this.renderAndShow().then(() => {
// Display the "clear" search button in the search bar.
this.clearSearchButton.classList.remove('d-none');
return;
}).then(() => {
pendingPromise.resolve();
return true;
});
await this.renderAndShow();
}
pendingPromise.resolve();
}, 300, {pending: true}));
}
@ -354,48 +347,6 @@ export default class {
this.toggleDropdown(true);
}
/**
* Set the current focus either on the preceding or next result item.
*
* @param {Number} direction Is the user moving up or down the resultset?
* @param {KeyboardEvent} e The JS event from the event handler.
*/
keyUpDown(direction, e) {
e.preventDefault();
// Stop Bootstrap from being clever.
e.stopPropagation();
// Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.
if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {
if (direction === UP) {
this.moveToLastNode();
} else {
this.moveToFirstNode();
}
}
const index = this.resultNodes.indexOf(this.currentNode);
if (this.currentNode) {
if (direction === UP) {
if (index === 0) {
this.moveToLastNode();
} else {
this.moveToNode(index - 1);
}
} else {
if (index + 1 >= this.resultNodes.length) {
this.moveToFirstNode();
} else {
this.moveToNode(index + 1);
}
}
} else {
if (direction === UP) {
this.moveToLastNode();
} else {
this.moveToFirstNode();
}
}
}
/**
* The handler for when a user interacts with the component.
*
@ -403,90 +354,30 @@ export default class {
*/
async clickHandler(e) {
this.updateNodes();
// Prevent normal key presses activating this.
if (e.target.closest('.dropdown-item') && e.button === 0) {
window.location = e.target.closest('.dropdown-item').href;
}
// The "clear search" button is triggered.
if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {
if (e.target.closest(this.selectors.clearSearch)) {
this.closeSearch(true);
this.searchInput.focus({preventScroll: true});
this.searchInput.focus();
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
// User may have accidentally clicked off the dropdown and wants to reopen it.
if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {
if (
this.getSearchTerm() !== ''
&& !this.getHTMLElements().searchDropdown.classList.contains('show')
&& e.target.closest(this.selectors.input)
) {
await this.renderAndShow();
}
}
/**
* The handler for when a user presses a key within the component.
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {KeyboardEvent} e The triggering event that we are working with.
* @param {Event} e The change event.
*/
keyHandler(e) {
this.updateNodes();
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'ArrowUp':
this.keyUpDown(UP, e);
break;
case 'ArrowDown':
this.keyUpDown(DOWN, e);
break;
case 'Home':
e.preventDefault();
this.moveToFirstNode();
break;
case 'End':
e.preventDefault();
this.moveToLastNode();
break;
case 'Tab':
// If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.
if (e.target.closest(this.selectors.viewall)) {
this.closeSearch();
}
break;
// eslint-disable-next-line no-unused-vars
changeHandler(e) {
// Components may override this method to do something.
}
}
/**
* Set focus on a given node after parsed through the calling functions.
*
* @param {HTMLElement} node The node to set focus upon.
*/
selectNode = (node) => {
node.focus({preventScroll: true});
this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);
};
/**
* Set the focus on the first node within the array.
*/
moveToFirstNode = () => {
if (this.resultNodes.length > 0) {
this.selectNode(this.resultNodes[0]);
}
};
/**
* Set the focus to the final node within the array.
*/
moveToLastNode = () => {
if (this.resultNodes.length > 0) {
this.selectNode(this.resultNodes[this.resultNodes.length - 1]);
}
};
/**
* Set focus on any given specified node within the node array.
*
* @param {Number} index Which item within the array to set focus upon.
*/
moveToNode = (index) => {
if (this.resultNodes.length > 0) {
this.selectNode(this.resultNodes[index]);
}
};
}

View file

@ -25,7 +25,12 @@ export default {
hidden: '[aria-hidden]',
},
elements: {
focusable: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
focusable: 'input:not([type="hidden"]):not([disabled]):not([tabindex^="-"]),' +
'a[href]:not([disabled]):not([tabindex^="-"]),' +
'button:not([disabled]):not([tabindex^="-"]),' +
'textarea:not([disabled]):not([tabindex^="-"]),' +
'select:not([disabled]):not([tabindex^="-"]),' +
'[tabindex]:not([disabled]):not([tabindex^="-"])',
focusableToUnhide: '[data-aria-hidden-tab-index]',
},
};

View file

@ -80,6 +80,7 @@ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSele
'group_message' => 'group_message',
'autocomplete' => 'autocomplete',
'iframe' => 'iframe',
'option_role' => 'option_role',
);
/**

View file

@ -30,7 +30,9 @@ use templatable;
*/
class comboboxsearch implements renderable, templatable {
/** @var bool $renderlater Should the dropdown render straightaway? */
/** @var bool $renderlater Should the dropdown render straightaway? We sometimes need to output the component without all of the
* data and leave the rendering of any defaults and actual data to the caller. We will give you a basic placeholder that can
* then be easily replaced.*/
protected $renderlater;
/** @var string $buttoncontent What is the content of the "Button" that users will always see. */
@ -54,18 +56,30 @@ class comboboxsearch implements renderable, templatable {
/** @var boolean $usesbutton Whether to provide a A11y button. */
protected $usesbutton;
/** @var null|string $label The label of the combobox. */
protected $label;
/** @var null|string $name The name of the input element representing the combobox. */
protected $name;
/** @var null|string $value The value of the input element representing the combobox. */
protected $value;
/**
* The class constructor.
*
* @param bool $renderlater How we figure out if we should render the template instantly.
* @param string $buttoncontent What gets placed in the button.
* @param ?string $dropdowncontent What can be placed in the dropdown if we are rendering now.
* @param ?string $dropdowncontent What will be placed in the dropdown if we are rendering now.
* @param ?string $parentclasses The classes that can be added that the bootstrap events are attached to.
* @param ?string $buttonclasses Any special classes that may be needed.
* @param ?string $dropdownclasses Any special classes that may be needed.
* @param ?string $buttonheader If the button item in the tertiary nav needs an extra top header for context.
* @param bool $usebutton If we want the mustache to add the button roles for us or do we have another aria role node?
* @throws moodle_exception If the implementor incorrectly call this module.
* @param ?string $buttonheader Sometimes we want extra context for a button before it is shown, basically a pseudo header.
* @param ?bool $usebutton If we want the mustache to add the button roles for us or do we have another aria role node?
* @param ?string $label The label of the combobox.
* @param ?string $name The name of the input element representing the combobox.
* @param ?string $value The value of the input element representing the combobox.
* @throws moodle_exception If the implementor incorrectly calls this module.
*/
public function __construct(
bool $renderlater,
@ -75,7 +89,10 @@ class comboboxsearch implements renderable, templatable {
?string $buttonclasses = null,
?string $dropdownclasses = null,
?string $buttonheader = null,
?bool $usebutton = true
?bool $usebutton = true,
?string $label = null,
?string $name = null,
?string $value = null
) {
// Ensure implementors cant request to render the content now and not provide us any to show.
if (!$renderlater && empty($dropdowncontent)) {
@ -87,6 +104,20 @@ class comboboxsearch implements renderable, templatable {
);
}
if ($usebutton && !$label) {
debugging(
'You have requested to use the button but have not provided a label for the combobox.',
DEBUG_DEVELOPER
);
}
if ($usebutton && !$name) {
debugging(
'You have requested to use the button but have not provided a name for the input element.',
DEBUG_DEVELOPER
);
}
$this->renderlater = $renderlater;
$this->buttoncontent = $buttoncontent;
$this->dropdowncontent = $dropdowncontent;
@ -95,6 +126,9 @@ class comboboxsearch implements renderable, templatable {
$this->dropdownclasses = $dropdownclasses;
$this->buttonheader = $buttonheader;
$this->usesbutton = $usebutton;
$this->label = $label;
$this->name = $name;
$this->value = $value;
}
/**
@ -105,7 +139,6 @@ class comboboxsearch implements renderable, templatable {
*/
public function export_for_template(renderer_base $output): array {
return [
'rtl' => right_to_left(),
'renderlater' => $this->renderlater,
'buttoncontent' => $this->buttoncontent ,
'dropdowncontent' => $this->dropdowncontent,
@ -115,6 +148,9 @@ class comboboxsearch implements renderable, templatable {
'buttonheader' => $this->buttonheader,
'usebutton' => $this->usesbutton,
'instance' => rand(), // Template uniqid is per render out so sometimes these conflict.
'label' => $this->label,
'name' => $this->name,
'value' => $this->value,
];
}

View file

@ -17,8 +17,11 @@
Combobox search selector dropdown.
Context variables required for this template:
* rtl - Is this dropdown being used in a RTL case? if so, we need to ensure it drops down in the right place.
* label - The label for the the combobox.
* name - The name of the input element representing the combobox.
* value - The value of the input element representing the combobox.
* renderlater - This determines if we show a placeholder whilst fetching content to replace within the placeholder region
* buttonheader - The header to be shown above the button
* buttoncontent - The string to be shown to users to trigger the dropdown
* usebutton - If we want to use a button to trigger the dropdown, or just the dropdown itself
* dropdowncontent - If rendering now, The content within the dropdown
@ -29,10 +32,13 @@
Example context (json):
{
"rtl": false,
"label": "Example searchable combobox",
"name": "input-1",
"value": "0",
"renderlater": false,
"buttonheader": "Example:",
"usebutton": true,
"buttoncontent": "Example dropdown button",
"buttoncontent": "Dropdown button",
"dropdowncontent": "Some body content to render right now",
"parentclasses": "my-dropdown",
"buttonclasses": "my-button",
@ -43,17 +49,7 @@
{{#buttonheader}}
<small>{{.}}</small>
{{/buttonheader}}
<div class="{{#parentclasses}}{{.}}{{/parentclasses}}"
{{^usebutton}}
tabindex="-1"
data-toggle="dropdown"
aria-expanded="false"
role="combobox"
aria-haspopup="dialog"
aria-controls="dialog-{{instance}}-{{uniqid}}"
aria-label="{{#cleanstr}} aria-toggledropdown, core_grades {{/cleanstr}}"
data-input-element="input-{{uniqid}}"
{{/usebutton}}>
<div class="{{#parentclasses}}{{.}}{{/parentclasses}} dropdown" data-instance="{{instance}}">
{{#usebutton}}
<div tabindex="0"
@ -62,20 +58,23 @@
role="combobox"
aria-haspopup="dialog"
aria-controls="dialog-{{instance}}-{{uniqid}}"
class="{{#buttonclasses}}{{.}}{{/buttonclasses}} btn dropdown-toggle keep-open d-flex text-left align-items-center p-0 font-weight-bold"
aria-label="{{#cleanstr}} aria-toggledropdown, core_grades {{/cleanstr}}"
data-input-element="input-{{uniqid}}">
class="{{#buttonclasses}}{{.}}{{/buttonclasses}} btn dropdown-toggle d-flex text-left align-items-center p-0 font-weight-bold"
aria-label="{{label}}"
data-input-element="input-{{instance}}-{{uniqid}}">
{{{buttoncontent}}}
</div>
<input type="hidden" name="{{name}}" value="{{value}}" id="input-{{instance}}-{{uniqid}}"/>
{{/usebutton}}
{{^usebutton}}{{{buttoncontent}}}{{/usebutton}}
<div class="{{#dropdownclasses}}{{.}}{{/dropdownclasses}} dropdown-menu {{#rtl}}dropdown-menu-right{{/rtl}}"
<div class="{{#dropdownclasses}}{{.}}{{/dropdownclasses}} dropdown-menu"
id="dialog-{{instance}}-{{uniqid}}"
{{#usebutton}}
role="dialog"
aria-modal="true"
aria-label="{{#cleanstr}} selectagroup, core {{/cleanstr}}"
aria-label="{{label}}"
{{/usebutton}}
>
<div class="w-100 p-3" data-region="placeholder">
{{#renderlater}}

View file

@ -18,22 +18,31 @@
Context variables required for this template:
* id - Result item system ID.
* instance - The instance ID of the combo box.
* name - Result item human readable name.
* link - The link used to redirect upon self to show only this specific result item.
The shorttext pragma can be used to provide a short text for the result item.
This is especially useful when the result item content contains html tags.
Example context (json):
{
"id": 2,
"name": "Foo bar",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
"instance": 25,
"name": "Foo bar"
}
}}
<li class="w-100 result-row" role="none" id="result-row-{{id}}">
<a role="option" id="result-{{id}}" href="{{{link}}}" class="dropdown-item d-flex px-0 py-1 align-items-center" tabindex="-1" aria-label="{{$arialabel}}{{#str}}viewresults, core, {{name}}{{/str}}{{/arialabel}}">
<li
id="result-{{instance}}-{{id}}"
class="w-100 dropdown-item d-flex px-0 align-items-center"
role="option"
{{! aria.js would use the data-short-text attribute if it is provided. }}
data-short-text="{{$shorttext}}{{/shorttext}}"
data-value="{{id}}"
aria-label="{{$arialabel}}{{name}}{{/arialabel}}"
>
{{$content}}
<span class="d-block w-100 px-2 text-truncate">
{{name}}
</span>
{{/content}}
</a>
</li>

View file

@ -17,23 +17,22 @@
Wrapping template for returned result items.
Context variables required for this template:
* instance - The instance ID of the combo box.
* results - Our returned results to render.
* searchterm - The entered text to find these results.
* hasresult - Allow the handling where no results exist for the returned search term.
* noresults - Our fall through case if nothing matches.
Example context (json):
{
"instance": 25,
"results": [
{
"id": 2,
"name": "Foo bar",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
"name": "Foo bar"
},
{
"id": 3,
"name": "Bar Foo",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
"name": "Bar Foo"
}
],
"searchterm": "Foo",
@ -41,17 +40,22 @@
}
}}
<div class="d-flex flex-column mh-100 h-100">
<ul
id="{{$listid}}list{{/listid}}-{{instance}}-result-listbox"
class="searchresultitemscontainer d-flex flex-column mw-100 position-relative py-2 list-group h-100 mx-0 {{$listclasses}}{{/listclasses}}"
role="listbox"
data-region="search-result-items-container"
>
{{#hasresults}}
<ul id="{{$listid}}list{{/listid}}-result-listbox" class="searchresultitemscontainer d-flex flex-column mw-100 position-relative py-2 list-group h-100 mx-0 {{$listclasses}}{{/listclasses}}" role="listbox" data-region="search-result-items-container" tabindex="-1" aria-label="{{#cleanstr}} aria:dropdowngrades, core_grades {{/cleanstr}}">
{{$results}}
{{#results}}
{{>core/local/comboboxsearch/resultitem}}
{{/results}}
{{/results}}
{{$selectall}}{{/selectall}}
</ul>
{{/hasresults}}
</ul>
{{^hasresults}}
<span class="small d-block px-4 my-4">{{#str}} noresultsfor, core, {{searchterm}}{{/str}}</span>
<span class="small d-block px-4 mt-2 mb-4">{{#str}} noresultsfor, core, {{searchterm}}{{/str}}</span>
{{/hasresults}}
</div>

View file

@ -28,23 +28,6 @@
}}
<div class="flex-column h-100 w-100">
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
{{#currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchitems, core
{{/str}}{{/label}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
role="combobox"
aria-expanded="true"
aria-controls="list-result-listbox"
aria-autocomplete="list"
data-input-element="result-input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{/currentvalue}}
{{^currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchitems, core
@ -52,15 +35,15 @@
{{$placeholder}}{{#str}}
searchitems, core
{{/str}}{{/placeholder}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
role="combobox"
aria-expanded="true"
aria-controls="list-result-listbox"
aria-controls="list-{{instance}}-result-listbox"
aria-autocomplete="list"
data-input-element="result-input-{{uniqid}}"
data-input-element="result-input-{{uniqid}}-{{instance}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{/currentvalue}}
<input type="hidden" name="search" id="result-input-{{uniqid}}"/>
<input type="hidden" name="search" id="result-input-{{uniqid}}-{{instance}}"/>
<div data-region="searchplaceholder"></div>
</div>

View file

@ -99,6 +99,7 @@ information provided here is intended especially for developers.
to bool if the original functionality is desired.
* core\hook\manager::phpunit_get_instance() now sets self::$instance to the mocked instance if the optional $persist argument is
true, so future calls to ::get_instance() will return it.
* The triggerSelector method in the `core/comboboxsearch/search_combobox` JS module is deprecated. It was not used.
=== 4.3 ===

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,6 +23,7 @@
import $ from 'jquery';
import Pending from 'core/pending';
import * as FocusLockManager from 'core/local/aria/focuslock';
/**
* Drop downs from bootstrap don't support keyboard accessibility by default.
@ -78,13 +79,10 @@ const dropdownFix = () => {
const menu = e.target.parentElement.querySelector('[role="menu"]');
let menuItems = false;
let foundMenuItem = false;
let textInput = false;
if (menu) {
menuItems = menu.querySelectorAll('[role="menuitem"]');
textInput = e.target.parentElement.querySelector('[data-action="search"]');
}
if (menuItems && menuItems.length > 0) {
// Up key opens the menu at the end.
if (trigger === 'ArrowUp') {
@ -101,10 +99,7 @@ const dropdownFix = () => {
}
}
if (textInput) {
shiftFocus(textInput);
}
if (foundMenuItem && textInput === null) {
if (foundMenuItem) {
shiftFocus(foundMenuItem);
}
};
@ -198,10 +193,26 @@ const dropdownFix = () => {
}
});
$('.dropdown').on('shown.bs.dropdown', e => {
const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role="dialog"]`);
if (dialog) {
// Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling.
setTimeout(() => {
FocusLockManager.trapFocus(dialog);
});
}
});
$('.dropdown').on('hidden.bs.dropdown', e => {
const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role="dialog"]`);
if (dialog) {
FocusLockManager.untrapFocus();
}
// We need to focus on the menu trigger.
const trigger = e.target.querySelector('[data-toggle="dropdown"]');
const focused = document.activeElement != document.body ? document.activeElement : null;
// If it's a click event, then no element is focused because the clicked element is inside a closed dropdown.
const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null);
if (trigger && focused && e.target.contains(focused)) {
shiftFocus(trigger, () => {
if (document.activeElement === document.body) {
@ -297,9 +308,9 @@ const comboboxFix = () => {
if (editable && !next) {
next = options[options.length - 1];
}
} else if (trigger == 'Home') {
} else if (trigger == 'Home' && !editable) {
next = options[0];
} else if (trigger == 'End') {
} else if (trigger == 'End' && !editable) {
next = options[options.length - 1];
} else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
e.preventDefault();
@ -338,7 +349,6 @@ const comboboxFix = () => {
const listbox = option.closest('[role="listbox"]');
const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
if (combobox) {
combobox.focus();
selectOption(combobox, option);
}
}
@ -368,9 +378,9 @@ const comboboxFix = () => {
}
if (combobox.hasAttribute('value')) {
combobox.value = option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
combobox.value = option.dataset.shortText || option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
} else {
combobox.textContent = option.textContent;
combobox.textContent = option.dataset.shortText || option.textContent;
}
if (combobox.dataset.inputElement) {

View file

@ -3121,7 +3121,8 @@ blockquote {
.usersearchdropdown,
.gradesearchdropdown,
.groupsearchdropdown {
max-width: 350px;
&.dropdown-menu {
width: 350px;
.searchresultitemscontainer {
max-height: 170px;
overflow: auto;
@ -3132,6 +3133,7 @@ blockquote {
}
}
}
}
/* Bulk actions in sticky footer. */
#sticky-footer {

View file

@ -26026,21 +26026,21 @@ blockquote {
}
/* Combobox search dropdowns */
.usersearchdropdown,
.gradesearchdropdown,
.groupsearchdropdown {
max-width: 350px;
.usersearchdropdown.dropdown-menu,
.gradesearchdropdown.dropdown-menu,
.groupsearchdropdown.dropdown-menu {
width: 350px;
}
.usersearchdropdown .searchresultitemscontainer,
.gradesearchdropdown .searchresultitemscontainer,
.groupsearchdropdown .searchresultitemscontainer {
.usersearchdropdown.dropdown-menu .searchresultitemscontainer,
.gradesearchdropdown.dropdown-menu .searchresultitemscontainer,
.groupsearchdropdown.dropdown-menu .searchresultitemscontainer {
max-height: 170px;
overflow: auto;
/* stylelint-disable declaration-no-important */
}
.usersearchdropdown .searchresultitemscontainer img,
.gradesearchdropdown .searchresultitemscontainer img,
.groupsearchdropdown .searchresultitemscontainer img {
.usersearchdropdown.dropdown-menu .searchresultitemscontainer img,
.gradesearchdropdown.dropdown-menu .searchresultitemscontainer img,
.groupsearchdropdown.dropdown-menu .searchresultitemscontainer img {
height: 48px !important;
width: 48px !important;
}

View file

@ -26026,21 +26026,21 @@ blockquote {
}
/* Combobox search dropdowns */
.usersearchdropdown,
.gradesearchdropdown,
.groupsearchdropdown {
max-width: 350px;
.usersearchdropdown.dropdown-menu,
.gradesearchdropdown.dropdown-menu,
.groupsearchdropdown.dropdown-menu {
width: 350px;
}
.usersearchdropdown .searchresultitemscontainer,
.gradesearchdropdown .searchresultitemscontainer,
.groupsearchdropdown .searchresultitemscontainer {
.usersearchdropdown.dropdown-menu .searchresultitemscontainer,
.gradesearchdropdown.dropdown-menu .searchresultitemscontainer,
.groupsearchdropdown.dropdown-menu .searchresultitemscontainer {
max-height: 170px;
overflow: auto;
/* stylelint-disable declaration-no-important */
}
.usersearchdropdown .searchresultitemscontainer img,
.gradesearchdropdown .searchresultitemscontainer img,
.groupsearchdropdown .searchresultitemscontainer img {
.usersearchdropdown.dropdown-menu .searchresultitemscontainer img,
.gradesearchdropdown.dropdown-menu .searchresultitemscontainer img,
.groupsearchdropdown.dropdown-menu .searchresultitemscontainer img {
height: 48px !important;
width: 48px !important;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,6 @@ import search_combobox from 'core/comboboxsearch/search_combobox';
import {getStrings} from 'core/str';
import {renderForPromise, replaceNodeContents} from 'core/templates';
import $ from 'jquery';
import Notification from 'core/notification';
export default class UserSearch extends search_combobox {
@ -36,14 +35,19 @@ export default class UserSearch extends search_combobox {
constructor() {
super();
// Register a small click event onto the document since we need to check if they are clicking off the component.
document.addEventListener('click', (e) => {
// Since we are handling dropdowns manually, ensure we can close it when clicking off.
if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) {
// Register a couple of events onto the document since we need to check if they are moving off the component.
['click', 'focus'].forEach(eventType => {
// Since we are handling dropdowns manually, ensure we can close it when moving off.
document.addEventListener(eventType, e => {
if (this.searchDropdown.classList.contains('show') && !this.combobox.contains(e.target)) {
this.toggleDropdown();
}
}, true);
});
// Register keyboard events.
this.component.addEventListener('keydown', this.keyHandler.bind(this));
// Define our standard lookups.
this.selectors = {...this.selectors,
courseid: '[data-region="courseid"]',
@ -51,9 +55,12 @@ export default class UserSearch extends search_combobox {
resetPageButton: '[data-action="resetpage"]',
};
const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;
this.courseID = this.component.querySelector(this.selectors.courseid).dataset.courseid;
this.groupID = document.querySelector(this.selectors.groupid)?.dataset?.groupid;
this.instance = this.component.querySelector(this.selectors.instance).dataset.instance;
// We need to render some content by default for ARIA purposes.
this.renderDefault();
}
static init() {
@ -78,15 +85,6 @@ export default class UserSearch extends search_combobox {
return '.usersearchdropdown';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.usersearchwidget';
}
/**
* Build the content then replace the node.
*/
@ -94,11 +92,24 @@ export default class UserSearch extends search_combobox {
const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
matches: this.getMatchedResults().length,
searchterm: this.getSearchTerm(),
selectall: this.selectAllResultsLink(),
});
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**
* Build the content then replace the node by default we want our form to exist.
*/
async renderDefault() {
this.setMatchedResults(await this.filterDataset(await this.getDataset()));
this.filterMatchDataset();
await this.renderDropdown();
}
/**
@ -117,6 +128,7 @@ export default class UserSearch extends search_combobox {
* @returns {Array} The users that match the given criteria.
*/
async filterDataset(filterableData) {
if (this.getPreppedSearchTerm()) {
const stringMap = await this.getStringMap();
return filterableData.filter((user) => Object.keys(user).some((key) => {
if (user[key] === "" || user[key] === null || !stringMap.get(key)) {
@ -124,6 +136,9 @@ export default class UserSearch extends search_combobox {
}
return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());
}));
} else {
return [];
}
}
/**
@ -158,7 +173,6 @@ export default class UserSearch extends search_combobox {
);
user.matchingField = `${escapedMatchingField} (${user.email})`;
user.link = this.selectOneLink(user.id);
break;
}
}
@ -168,22 +182,17 @@ export default class UserSearch extends search_combobox {
}
/**
* The handler for when a user interacts with the component.
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {MouseEvent} e The triggering event that we are working with.
* @param {Event} e The change event.
*/
clickHandler(e) {
super.clickHandler(e).catch(Notification.exception);
if (e.target.closest(this.selectors.component)) {
// Forcibly prevent BS events so that we can control the open and close.
// Really needed because by default input elements cant trigger a dropdown.
e.stopImmediatePropagation();
}
if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) {
changeHandler(e) {
this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.
if (e.target.value === '0') {
window.location = this.selectAllResultsLink();
}
if (e.target.closest(this.selectors.resetPageButton)) {
window.location = e.target.closest(this.selectors.resetPageButton).href;
} else {
window.location = this.selectOneLink(e.target.value);
}
}
@ -193,50 +202,30 @@ export default class UserSearch extends search_combobox {
* @param {KeyboardEvent} e The triggering event that we are working with.
*/
keyHandler(e) {
super.keyHandler(e);
if (e.target === this.getHTMLElements().currentViewAll && (e.key === 'Enter' || e.key === 'Space')) {
window.location = this.selectAllResultsLink();
}
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
if (
this.getSearchTerm() !== ''
&& !this.searchDropdown.classList.contains('show')
&& e.target.contains(this.combobox)
) {
this.renderAndShow();
}
break;
case 'Enter':
case ' ':
if (document.activeElement === this.getHTMLElements().searchInput) {
if (e.key === 'Enter' && this.selectAllResultsLink() !== null) {
window.location = this.selectAllResultsLink();
}
}
if (document.activeElement === this.getHTMLElements().clearSearchButton) {
this.closeSearch(true);
break;
}
if (e.target.closest(this.selectors.resetPageButton)) {
e.stopPropagation();
window.location = e.target.closest(this.selectors.resetPageButton).href;
break;
}
if (e.target.closest('.dropdown-item')) {
e.preventDefault();
window.location = e.target.closest('.dropdown-item').href;
break;
}
break;
case 'Escape':
this.toggleDropdown();
this.searchInput.focus({preventScroll: true});
break;
case 'Tab':
// If the current focus is on clear search, then check if viewall exists then around tab to it.
if (e.target.closest(this.selectors.clearSearch)) {
if (this.currentViewAll && !e.shiftKey) {
e.preventDefault();
this.currentViewAll.focus({preventScroll: true});
} else {
this.closeSearch();
}
}
break;
}
}
@ -249,11 +238,18 @@ export default class UserSearch extends search_combobox {
if (on) {
this.searchDropdown.classList.add('show');
$(this.searchDropdown).show();
this.component.setAttribute('aria-expanded', 'true');
this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'true');
this.searchInput.focus({preventScroll: true});
} else {
this.searchDropdown.classList.remove('show');
$(this.searchDropdown).hide();
this.component.setAttribute('aria-expanded', 'false');
// As we are manually handling the dropdown, we need to do some housekeeping manually.
this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'false');
this.searchInput.removeAttribute('aria-activedescendant');
this.searchDropdown.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
}
}
@ -266,6 +262,7 @@ export default class UserSearch extends search_combobox {
/**
* Build up the view all link that is dedicated to a particular result.
* We will call this function when a user interacts with the combobox to redirect them to show their results in the page.
*
* @param {Number} userID The ID of the user selected.
*/

View file

@ -22,7 +22,6 @@
* profileimageurl - Link for the users' large profile image.
* matchingField - The field in the user object that matched the search criteria.
* matchingFieldName - The name of the field that was matched upon for A11y purposes.
* link - The link used to redirect upon self to show only this specific user.
Example context (json):
{
@ -30,12 +29,12 @@
"fullname": "Foo bar",
"profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630",
"matchingField": "<span class=\"font-weight-bold\">Foo</span> bar",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
"matchingFieldName": "Fullname"
}
}}
{{<core/local/comboboxsearch/resultitem }}
{{$arialabel}}{{#str}}viewresults, core, {{fullname}}{{/str}}{{/arialabel}}
{{$arialabel}}{{fullname}}{{/arialabel}}
{{$shorttext}}{{fullname}}{{/shorttext}}
{{$content}}
<span class="d-block px-2 w-25">
{{#profileimageurl}}

View file

@ -17,35 +17,35 @@
Wrapping template for returned result items.
Context variables required for this template:
* instance - The instance ID of the combo box.
* users - Our returned users to render.
* found - Count of the found users.
* total - Total count of users within this report.
* selectall - The created link that allows users to select all of the results.
* selectall - Whether to show the select all option.
* searchterm - The entered text to find these results.
* hasusers - Allow the handling where no users exist for the returned search term.
Example context (json):
{
"instance": 25,
"users": [
{
"id": 2,
"fullname": "Foo bar",
"profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630",
"matchingField": "<span class=\"font-weight-bold\">Foo</span> bar",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
"matchingFieldName": "Fullname"
},
{
"id": 3,
"fullname": "Bar Foo",
"profileimageurl": "http://foo.bar/pluginfile.php/80/user/icon/boost/f1?rev=7631",
"matchingField": "Bar <span class=\"font-weight-bold\">Foo</span>",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
"matchingFieldName": "Fullname"
}
],
"matches": 20,
"selectall": "https://foo.bar/grade/report/grader/index.php?id=2&searchvalue=abe",
"selectall": true,
"searchterm": "Foo",
"hasresults": true
}
@ -59,10 +59,15 @@
{{/results}}
{{$selectall}}
{{#selectall}}
<li class="w-100 result-row p-1 border-top bottom-0 position-sticky" role="none" id="result-row-{{id}}">
<a role="option" class="dropdown-item d-flex small p-3" id="select-all" href="{{{selectall}}}" tabindex="-1">
<li
id="result-row-{{instance}}-0"
class="w-100 p-1 border-top bottom-0 position-sticky dropdown-item d-flex small p-3"
role="option"
{{! The data-short-text attribute is provided so that aria.js would use it rather than the whole content. }}
data-short-text="{{searchterm}}"
data-value="0"
>
{{#str}}viewallresults, core, {{matches}}{{/str}}
</a>
</li>
{{/selectall}}
{{/selectall}}

View file

@ -20,6 +20,8 @@
The user selector trigger element.
Context variables required for this template:
* name - The name of the input element representing the user search combobox.
* value - The value of the input element representing the user search combobox.
* currentvalue - If the user has already searched, set the value to that.
* courseid - The course ID.
* group - The group ID.
@ -27,41 +29,33 @@
Example context (json):
{
"name": "input-1",
"value": "0",
"currentvalue": "bar",
"courseid": 2,
"group": 25,
"resetlink": "grade/report/grader/index.php?id=2"
}
}}
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
<span class="d-none" data-region="groupid" data-groupid="{{group}}" aria-hidden="true"></span>
{{#currentvalue}}
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}"></span>
<span class="d-none" data-region="groupid" data-groupid="{{group}}"></span>
<span class="d-none" data-region="instance" data-instance="{{instance}}"></span>
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core
{{/str}}{{/label}}
{{$label}}{{#str}}searchusers, core{{/str}}{{/label}}
{{$placeholder}}{{#str}}searchusers, core{{/str}}{{/placeholder}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
role="combobox"
aria-expanded="false"
aria-controls="user-{{instance}}-result-listbox"
aria-autocomplete="list"
data-input-element="user-input-{{uniqid}}"
aria-haspopup="listbox"
data-input-element="user-input-{{uniqid}}-{{instance}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{#currentvalue}}
<a class="ml-2 btn btn-link border-0 align-self-center" href="{{resetlink}}" data-action="resetpage" role="link" aria-label="{{#str}}clearsearch, core{{/str}}">
{{#str}}clear{{/str}}
</a>
{{/currentvalue}}
{{^currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core
{{/str}}{{/label}}
{{$placeholder}}{{#str}}
searchusers, core
{{/str}}{{/placeholder}}
{{$additionalattributes}}
aria-autocomplete="list"
data-input-element="user-input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{/currentvalue}}
<input type="hidden" name="search" id="user-input-{{uniqid}}"/>
<input type="hidden" name="{{name}}" value="{{value}}" id="user-input-{{uniqid}}-{{instance}}"/>