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()); const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid; 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(); this.renderDefault();
} }
@ -66,25 +98,19 @@ export default class GradeItemSearch extends search_combobox {
return '.gradesearchdropdown'; return '.gradesearchdropdown';
} }
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.gradesearchwidget';
}
/** /**
* Build the content then replace the node. * Build the content then replace the node.
*/ */
async renderDropdown() { async renderDropdown() {
const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', { const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', {
instance: this.instance,
results: this.getMatchedResults(), results: this.getMatchedResults(),
hasresults: this.getMatchedResults().length > 0, hasresults: this.getMatchedResults().length > 0,
searchterm: this.getSearchTerm(), searchterm: this.getSearchTerm(),
}); });
replaceNodeContents(this.selectors.placeholder, html, js); 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.updateNodes();
this.registerInputEvents(); 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 { return {
id: grade.id, id: grade.id,
name: grade.name, 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. * @param {MouseEvent} e The triggering event that we are working with.
*/ */
async clickHandler(e) { async clickHandler(e) {
if (e.target.closest(this.selectors.dropdown)) { if (e.target.closest(this.selectors.clearSearch)) {
// Forcibly prevent BS events so that we can control the open and close. e.stopPropagation();
// Really needed because by default input elements cant trigger a dropdown. // Clear the entered search query in the search bar.
e.stopImmediatePropagation();
}
this.clearSearchButton.addEventListener('click', async() => {
this.searchInput.value = ''; this.searchInput.value = '';
this.setSearchTerms(this.searchInput.value); this.setSearchTerms(this.searchInput.value);
this.searchInput.focus();
this.clearSearchButton.classList.add('d-none');
// Display results.
await this.filterrenderpipe(); 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) { changeHandler(e) {
super.keyHandler(e); window.location = this.selectOneLink(e.target.value);
// 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});
}
}
} }
/** /**
@ -236,6 +235,7 @@ export default class GradeItemSearch extends search_combobox {
/** /**
* Build up the view all link that is dedicated to a particular result. * 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. * @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. * Renders the group selector trigger element.
* *
* @param object $course The course object. * @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. * @return string|null The raw HTML to render.
*/ */
public function group_selector(object $course, ?string $groupactionbaseurl = null): ?string { public function group_selector(object $course, ?string $groupactionbaseurl = null): ?string {
global $USER; 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. // Make sure that group mode is enabled.
if (!$groupmode = $course->groupmode) { if (!$groupmode = $course->groupmode) {
return null; return null;
@ -59,17 +65,12 @@ class core_grades_renderer extends plugin_renderer_base {
$sbody = $this->render_from_template('core_group/comboboxsearch/searchbody', [ $sbody = $this->render_from_template('core_group/comboboxsearch/searchbody', [
'courseid' => $course->id, 'courseid' => $course->id,
'currentvalue' => optional_param('groupsearchvalue', '', PARAM_NOTAGS), 'currentvalue' => optional_param('groupsearchvalue', '', PARAM_NOTAGS),
'instance' => rand(),
]); ]);
$label = $groupmode == VISIBLEGROUPS ? get_string('selectgroupsvisible') : $label = $groupmode == VISIBLEGROUPS ? get_string('selectgroupsvisible') : get_string('selectgroupsseparate');
get_string('selectgroupsseparate');
$data = [ $buttondata = ['label' => $label];
'name' => 'group',
'label' => $label,
'courseid' => $course->id,
'groupactionbaseurl' => $groupactionbaseurl
];
$context = context_course::instance($course->id); $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); $activegroup = groups_get_course_group($course, true, $allowedgroups);
$data['group'] = $activegroup; $buttondata['group'] = $activegroup;
if ($activegroup) { if ($activegroup) {
$group = groups_get_group($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) { } else if ($activegroup === 0) {
$data['selectedgroup'] = get_string('allparticipants'); $buttondata['selectedgroup'] = get_string('allparticipants');
} }
$groupdropdown = new comboboxsearch( $groupdropdown = new comboboxsearch(
false, false,
$this->render_from_template('core_group/comboboxsearch/group_selector', $data), $this->render_from_template('core_group/comboboxsearch/group_selector', $buttondata),
$sbody, $sbody,
'group-search', 'group-search',
'groupsearchwidget', '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)); 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"]', count: '[data-collapse="count"]',
placeholder: '.collapsecolumndropdown [data-region="placeholder"]', placeholder: '.collapsecolumndropdown [data-region="placeholder"]',
fullDropdown: '.collapsecolumndropdown', fullDropdown: '.collapsecolumndropdown',
searchResultContainer: '.searchresultitemscontainer',
}; };
const countIndicator = document.querySelector(selectors.count); 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'); document.querySelector('.gradereport-grader-table').classList.remove('d-none');
}, 10); }, 10);
}).then(() => pendingPromise.resolve()).catch(Notification.exception); }).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'; return '.searchresultitemscontainer';
} }
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.collapsecolumn';
}
/** /**
* Return the dataset that we will be searching upon. * 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. * Handle any keyboard inputs.
*/ */
@ -492,6 +478,7 @@ export default class ColumnSearch extends search_combobox {
// Update the collapsed button pill. // Update the collapsed button pill.
this.countUpdate(); this.countUpdate();
const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', { const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', {
'instance': this.instance,
'results': this.getMatchedResults(), 'results': this.getMatchedResults(),
'userid': this.userID, '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. // Add a small BS listener so that we can set the focus correctly on open.
this.$component.on('shown.bs.dropdown', () => { this.$component.on('shown.bs.dropdown', () => {
this.searchInput.focus({preventScroll: true}); 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. * Build the content then replace the node.
*/ */
async renderDropdown() { 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', { const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', {
instance: this.instance,
'results': this.getMatchedResults(), 'results': this.getMatchedResults(),
'searchTerm': this.getSearchTerm(), 'searchTerm': this.getSearchTerm(),
}); });
selectall.disabled = this.getMatchedResults().length === 0;
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js); 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. * @param {Number} groupID The ID of the group selected.
* @returns {string|*} * @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. * @param {Number} userID The ID of the user selected.
* @returns {string|*} * @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. */ /** @var string $usersearch The content that the current user is looking for. */
protected string $usersearch = ''; 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. * The class constructor.
* *
@ -40,7 +43,13 @@ class action_bar extends \core_grades\output\action_bar {
public function __construct(\context_course $context) { public function __construct(\context_course $context) {
parent::__construct($context); parent::__construct($context);
$this->userid = optional_param('gpr_userid', 0, PARAM_INT);
$this->usersearch = optional_param('gpr_search', '', PARAM_NOTAGS); $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, $this->context,
'/grade/report/grader/index.php' '/grade/report/grader/index.php'
); );
$firstnameinitial = $SESSION->gradereport["filterfirstname-{$this->context->id}"] ?? '';
$lastnameinitial = $SESSION->gradereport["filtersurname-{$this->context->id}"] ?? '';
$initialselector = new comboboxsearch( $initialselector = new comboboxsearch(
false, false,
$initialscontent->buttoncontent, $initialscontent->buttoncontent,
@ -88,6 +101,13 @@ class action_bar extends \core_grades\output\action_bar {
'initialswidget', 'initialswidget',
'initialsdropdown', 'initialsdropdown',
$initialscontent->buttonheader, $initialscontent->buttonheader,
true,
get_string('filterbyname', 'core_grades'),
'nameinitials',
json_encode([
'first' => $firstnameinitial,
'last' => $lastnameinitial,
])
); );
$data['initialselector'] = $initialselector->export_for_template($output); $data['initialselector'] = $initialselector->export_for_template($output);
$data['groupselector'] = $gradesrenderer->group_selector($course); $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', [ $searchinput = $OUTPUT->render_from_template('core_user/comboboxsearch/user_selector', [
'currentvalue' => $this->usersearch, 'currentvalue' => $this->usersearch,
'courseid' => $courseid, 'courseid' => $courseid,
'instance' => rand(),
'resetlink' => $resetlink->out(false), 'resetlink' => $resetlink->out(false),
'group' => 0, 'group' => 0,
'name' => 'usersearch',
'value' => json_encode([
'userid' => $this->userid,
'search' => $this->usersearch,
]),
]); ]);
$searchdropdown = new comboboxsearch( $searchdropdown = new comboboxsearch(
true, true,
$searchinput, $searchinput,
null, null,
'user-search dropdown d-flex', 'user-search d-flex',
null, null,
'usersearchdropdown overflow-auto', 'usersearchdropdown overflow-auto',
null, null,
@ -123,6 +149,8 @@ class action_bar extends \core_grades\output\action_bar {
'collapsecolumndropdown p-3 flex-column ' . $collapsemenudirection, 'collapsecolumndropdown p-3 flex-column ' . $collapsemenudirection,
null, null,
true, true,
get_string('aria:dropdowncolumns', 'gradereport_grader'),
'collapsedcolumns'
); );
$data['collapsedcolumns'] = [ $data['collapsedcolumns'] = [
'classes' => 'd-none', '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); $allowedgroups = groups_get_all_groups($course->id, $USER->id, $course->defaultgroupingid);
} }
if (!empty($SESSION->gradereport["filterfirstname-{$this->context->id}"]) || if (
!empty($SESSION->gradereport["filterlastname-{$this->context->id}"]) || $firstnameinitial ||
$lastnameinitial ||
groups_get_course_group($course, true, $allowedgroups) || groups_get_course_group($course, true, $allowedgroups) ||
$this->usersearch) { $this->usersearch
) {
$reset = new moodle_url('/grade/report/grader/index.php', [ $reset = new moodle_url('/grade/report/grader/index.php', [
'id' => $courseid, 'id' => $courseid,
'group' => 0, 'group' => 0,

View file

@ -18,6 +18,7 @@
Example context (json): Example context (json):
{ {
"instance": 25,
"results": [ "results": [
{ {
"name": "42", "name": "42",
@ -39,24 +40,18 @@
{{$placeholder}}{{#str}} {{$placeholder}}{{#str}}
searchcollapsedcolumns, core_grades searchcollapsedcolumns, core_grades
{{/str}}{{/placeholder}} {{/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 }} {{/ core/search_input_auto }}
<input type="hidden" name="search" id="collapse-input-{{uniqid}}"/>
<form class="columnsdropdownform flex-column h-100"> <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>
{{>gradereport_grader/collapse/collapseresults}} <legend class="sr-only">{{#str}} aria:dropdowncolumns, gradereport_grader {{/str}}</legend>
</ul> <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 mt-2">
<div class="d-flex align-items-center form-check"> <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"> <label id="check-all" class="selected-option-info text-truncate form-check-label d-block">
<input disabled id="check-all-input" class="form-check-input" type="checkbox" data-action="selectall" aria-label="{{#str}}selectall, core{{/str}}"> <input disabled class="form-check-input" type="checkbox" data-action="selectall">
{{#str}}selectall, core{{/str}} {{#str}}selectall, core{{/str}}
</label> </label>
</div> </div>

View file

@ -26,9 +26,9 @@
"category": "Hitchhikers grade category" "category": "Hitchhikers grade category"
} }
}} }}
<li class="w-100 result-row form-check mb-1" role="none"> <li class="w-100 result-row form-check mb-1">
<input id="check-{{name}}-input" class="form-check-input" role="option" data-collapse="{{name}}" type="checkbox" value="" aria-labelledby="check-{{name}}"> <label class="selected-option-info d-block pr-3 text-truncate form-check-label">
<label id="check-{{name}}" class="selected-option-info d-block pr-3 text-truncate form-check-label" tabindex="-1"> <input class="form-check-input" data-collapse="{{name}}" type="checkbox" value="">
<span class="selected-option-text w-100 p-0"> <span class="selected-option-text w-100 p-0">
{{{displayName}}} {{{displayName}}}
</span> </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" And I click on "Collapsed columns" "combobox"
# This is checking that the column name search dropdown exists. # This is checking that the column name search dropdown exists.
When I wait until "Search collapsed columns" "field" 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 one" "checkbox" 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 "Test assignment three" "checkbox" 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 "Phone" "checkbox" in the "form" "gradereport_grader > collapse search"
And I click on "Expand" "button" 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 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 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 And I choose "Collapse" in the open action menu
# Basic tests for the page. # Basic tests for the page.
When I click on "Collapsed columns" "combobox" 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 And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
# Move onto general keyboard navigation testing. # Move onto general keyboard navigation testing.
Then the focused element is "Search collapsed columns" "field" 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 I press the escape key
And the focused element is "Collapsed columns" "combobox" And the focused element is "Collapsed columns" "combobox"
And I click on "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. # Lets check the tabbing order.
And I set the field "Search collapsed columns" to "phone" 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 I press the tab key
And the focused element is "Clear search input" "button" in the ".dropdown-menu.show" "css_element" 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 escape 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 tab key And I press the tab key
# The course grade category menu. # The course grade category menu.
And the focused element is "Cell actions" "button" 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 And I wait until "Collapsed columns" "combobox" exists
When I click on "Collapsed columns" "combobox" When I click on "Collapsed columns" "combobox"
And I click on "Select all" "checkbox" 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. # 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 click on "Search groups" "field"
And I wait until "Default group" "option_role" exists And I wait until "Default group" "option_role" exists
And I press the down key 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 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" Then I set the field "Search groups" to "Goodmeme"
And I wait until "Tutor group" "option_role" does not exist And I wait until "Tutor group" "option_role" does not exist
And I press the down key 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 set the field "Search groups" to "Tutor"
And I wait until "All participants" "option_role" does not exist And I wait until "All participants" "option_role" does not exist
And I press the down key 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. # Lets check the tabbing order.
And I set the field "Search groups" to "Marker" 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 # Click off the drop down
And I click on "Filter by name" "combobox" And I click on "Filter by name" "combobox"
And "input[data-action=save]" "css_element" should be visible 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 I click on user profile field menu "fullname"
And "input[data-action=save]" "css_element" should not be visible 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 | | Dummy User |
Scenario: A teacher can quickly tell that a search is active on the current table 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 When 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. # 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.
When the field "Search users" matches value "Turtle" Then the field "Search users" matches value "Turtle Manatee"
And I wait until "View all results (1)" "link" does not exist
# Test if we can then further retain the turtle result set and further filter from there. # 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 I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not exist And "Turtle Manatee" "list_item" should not be visible
And I should see "No results for \"Turtle plagiarism\"" And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name 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 "wcag131, wcag141, wcag412" accessibility standards
And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
And I press the down key 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 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 I press the up key 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 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 I press the escape key
And the focused element is "Search users" "field" And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme" 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 set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists And I wait until "Turtle Manatee" "option_role" exists
And I press the down key 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. # Lets check the tabbing order.
And I set the field "Search users" to "ABC" 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 I press the tab key
And the focused element is "Clear search input" "button" And the focused element is "Clear search input" "button"
And I press the tab key 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 And ".groupsearchwidget" "css_element" should exist
# Ensure we can interact with the input & clear search options with the keyboard. # Ensure we can interact with the input & clear search options with the keyboard.
# Space & Enter have the same handling for triggering the two functionalities. # Space & Enter have the same handling for triggering the two functionalities.
And I set the field "Search users" to "User" And I set the field "Search users" to "User"
And I press the up key
And I press the enter key And I press the enter key
And I wait to be redirected And I wait to be redirected
And the following should exist in the "user-grades" table: 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 | | Teacher 1 |
| Student 1 | | Student 1 |
| Turtle Manatee | | 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 Scenario: Once a teacher searches, it'll apply the currently set filters and inform the teacher as such
# Set up a basic filtering case. # 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" And the field "perpage" matches value "20"
When I set the field "Search users" to "42" When I set the field "Search users" to "42"
# One of the users' phone numbers also matches. # 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 Then I confirm "Student s42" in "user" search within the gradebook widget exists
Scenario: As a teacher I save grades using search and pagination 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 And I wait until the page is ready
# Search for a single user on second page and save grades. # Search for a single user on second page and save grades.
When I set the field "Search users" to "test32" 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 click on "Student test32" "option_role"
And I wait until the page is ready 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 give the grade "80.00" to the user "Student test32" for the grade item "Test assignment one"
And I press "Save changes" And I press "Save changes"
And I wait until the page is ready 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: And the following should exist in the "user-grades" table:
| -1- | | -1- |
| Student test32 | | 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 give the grade "70.00" to the user "Student test31" for the grade item "Test assignment one"
And I press "Save changes" And I press "Save changes"
And I wait until the page is ready 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: And the following should exist in the "user-grades" table:
| -1- | | -1- |
| Student test31 | | Student test31 |
@ -423,7 +409,7 @@ Feature: Within the grader report, test that we can search for users
| Student test32 | | Student test32 |
# Search for multiple users on second page and save grades. # Search for multiple users on second page and save grades.
And I set the field "Search users" to "test3" 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 click on "View all results (11)" "option_role"
And I wait until the page is ready 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" 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 * @module gradereport_singleview/user
* @copyright 2023 Mathew May <mathew.solutions> * @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @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 //# 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. * @param {Number} groupID The ID of the group selected.
* @returns {string|*} * @returns {string|*}

View file

@ -40,11 +40,14 @@ export default class User extends UserSearch {
*/ */
async renderDropdown() { async renderDropdown() {
const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', { const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
instance: this.instance,
users: this.getMatchedResults().slice(0, 5), users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0, hasresults: this.getMatchedResults().length > 0,
searchterm: this.getSearchTerm(), searchterm: this.getSearchTerm(),
}); });
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js); 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() { protected function setup_groups() {
parent::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. * so all reports would automatically use it.
* *
* @param stdClass $course * @param stdClass $course
* @param moodle_url $urlroot
* @return string * @return string
*/ */
protected static function groups_course_menu(stdClass $course, moodle_url $urlroot) { protected static function groups_course_menu(stdClass $course) {
global $PAGE; global $PAGE;
$renderer = $PAGE->get_renderer('core_grades'); $renderer = $PAGE->get_renderer('core_grades');
$params = $urlroot->params(); return $renderer->group_selector($course);
if ($params['item'] == 'user') {
$params['item'] = 'user_select';
$urlroot->params($params);
}
return $renderer->group_selector($course, $urlroot->out());
} }
/** /**

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 { 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]); $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 = [ $data = [
'currentvalue' => optional_param('searchvalue', '', PARAM_NOTAGS), 'currentvalue' => $currentvalue,
'courseid' => $course->id, 'courseid' => $course->id,
'instance' => rand(),
'group' => $groupid ?? 0, 'group' => $groupid ?? 0,
'resetlink' => $resetlink->out(false), 'resetlink' => $resetlink->out(false),
'userid' => $userid ?? 0 'name' => 'userid',
'value' => $submitteduserid ?? '',
]; ];
$dropdown = new comboboxsearch( $dropdown = new comboboxsearch(
true, true,
$this->render_from_template('core_user/comboboxsearch/user_selector', $data), $this->render_from_template('core_user/comboboxsearch/user_selector', $data),
null, null,
'user-search dropdown d-flex', 'user-search d-flex',
null, null,
'usersearchdropdown overflow-auto', 'usersearchdropdown overflow-auto',
null, null,
@ -78,6 +89,7 @@ class gradereport_singleview_renderer extends plugin_renderer_base {
$data = [ $data = [
'name' => 'itemid', 'name' => 'itemid',
'courseid' => $course->id, 'courseid' => $course->id,
'instance' => rand(),
]; ];
// If a particular grade item option is selected (not in zero state). // 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', [ $sbody = $this->render_from_template('core/local/comboboxsearch/searchbody', [
'courseid' => $course->id, 'courseid' => $course->id,
'currentvalue' => optional_param('gradesearchvalue', '', PARAM_NOTAGS), 'currentvalue' => optional_param('gradesearchvalue', '', PARAM_NOTAGS),
'instance' => $data['instance'],
]); ]);
$dropdown = new comboboxsearch( $dropdown = new comboboxsearch(
false, false,
@ -99,7 +112,12 @@ class gradereport_singleview_renderer extends plugin_renderer_base {
$sbody, $sbody,
'grade-search h-100', 'grade-search h-100',
'gradesearchwidget 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)); 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: Context variables required for this template:
* itemid - The value of the grade item selector element (id of the preselected grade item) * 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. * courseid - The course ID.
* selectedoption - (optional) Object containing information about the selected option. * selectedoption - (optional) Object containing information about the selected option.
* text - The text of the selected option. * text - The text of the selected option.
@ -25,6 +26,7 @@
Example context (json): Example context (json):
{ {
"itemid": "21", "itemid": "21",
"instance": "25",
"courseid": "2", "courseid": "2",
"selectedoption": { "selectedoption": {
"text": "Grade item 1" "text": "Grade item 1"
@ -34,7 +36,7 @@
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
{{#selectedoption}} {{#selectedoption}}
<div class="d-block pr-3 text-truncate"> <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}} {{#str}} selectagrade, gradereport_singleview {{/str}}
</span> </span>
<span class="p-0 font-weight-bold"> <span class="p-0 font-weight-bold">
@ -48,5 +50,6 @@
</div> </div>
{{/selectedoption}} {{/selectedoption}}
</div> </div>
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" 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}}" aria-hidden="true"></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 click on "Search items" "field"
And I wait until "Test assignment one" "option_role" exists And I wait until "Test assignment one" "option_role" exists
And I press the down key 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 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" Then I set the field "Search items" to "Goodmeme"
And I wait until "Test assignment one" "option_role" does not exist And I wait until "Test assignment one" "option_role" does not exist
And I press the down key 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" Given I click on user menu "Grainne Beauchamp"
And I choose "Single view for this user" in the open action menu And I choose "Single view for this user" in the open action menu
Then I should see "Gronya,Beecham" 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" 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" Then I should see "Gronya,Beecham"
And I open the action menu in "Test assignment four" "table_row" And I open the action menu in "Test assignment four" "table_row"
And I choose "Show all grades" in the open action menu 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 Given I click on "Turtle" in the "user" search widget
And I wait until the page is ready 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. # 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 the field "Search users" matches value "Turtle Manatee"
And "Turtle Manatee" "option_role" should not exist # 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. # 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" 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\"" And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name 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. # Move onto general keyboard navigation testing.
When I wait until "Turtle Manatee" "option_role" exists When I wait until "Turtle Manatee" "option_role" exists
And I press the down key 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 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 I press the up key 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 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 I press the escape key
And the focused element is "Search users" "field" And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme" 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 set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists And I wait until "Turtle Manatee" "option_role" exists
And I press the down key 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. # Lets check the tabbing order.
And I set the field "Search users" to "ABC" 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 * @module gradereport_user/user
* @copyright 2023 Mathew May <mathew.solutions> * @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @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 //# 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. * @param {Number} groupID The ID of the group selected.
* @returns {string|*} * @returns {string|*}

View file

@ -42,11 +42,14 @@ export default class User extends UserSearch {
const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', { const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
users: this.getMatchedResults().slice(0, 5), users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0, hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
matches: this.getDatasetSize(), matches: this.getDatasetSize(),
searchterm: this.getSearchTerm(), searchterm: this.getSearchTerm(),
selectall: this.selectAllResultsLink(), selectall: this.selectAllResultsLink(),
}); });
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js); 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. * @param {Number} userID The ID of the user selected.
* @returns {string|*} * @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 { 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]); $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 = [ $data = [
'currentvalue' => optional_param('searchvalue', '', PARAM_NOTAGS), 'currentvalue' => $currentvalue,
'instance' => rand(),
'resetlink' => $resetlink->out(false), 'resetlink' => $resetlink->out(false),
'name' => 'userid', 'name' => 'userid',
'value' => $submitteduserid ?? '',
'courseid' => $course->id, 'courseid' => $course->id,
'groupid' => $groupid ?? 0, 'group' => $groupid ?? 0,
]; ];
$searchdropdown = new comboboxsearch( $searchdropdown = new comboboxsearch(
true, true,
$this->render_from_template('core_user/comboboxsearch/user_selector', $data), $this->render_from_template('core_user/comboboxsearch/user_selector', $data),
null, null,
'user-search dropdown d-flex', 'user-search d-flex',
null, null,
'usersearchdropdown overflow-auto', 'usersearchdropdown overflow-auto',
null, null,

View file

@ -82,17 +82,14 @@ Feature: Group searching functionality within the user report.
And I click on "Search groups" "field" And I click on "Search groups" "field"
And I wait until "Default group" "option_role" exists And I wait until "Default group" "option_role" exists
And I press the down key 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 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" Then I set the field "Search groups" to "Goodmeme"
And I wait until "Tutor group" "option_role" does not exist And I wait until "Tutor group" "option_role" does not exist
And I press the down key 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 set the field "Search groups" to "Tutor"
And I wait until "All participants" "option_role" does not exist And I wait until "All participants" "option_role" does not exist
And I press the down key 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. # Lets check the tabbing order.
And I set the field "Search groups" to "Marker" 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 "Student 1" "heading" should exist
And "Turtle Manatee" "heading" should exist And "Turtle Manatee" "heading" should exist
And "Teacher 1" "heading" should not exist And "Teacher 1" "heading" should not exist
And I click on "Clear" "link" in the ".user-search" "css_element" And "Clear" "link" should not exist in the ".user-search" "css_element"
And I wait until the page is ready
And "Dummy User" "heading" should exist And "Dummy User" "heading" should exist
And "User Example" "heading" should exist And "User Example" "heading" should exist
And "User Test" "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\"" And I should see "No results for \"a\""
Scenario: A teacher can quickly tell that a search is active on the current table 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 When 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. # 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.
When the field "Search users" matches value "Turtle" Then the field "Search users" matches value "Turtle Manatee"
And I wait until "View all results (5)" "link" does not exist 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. # 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 I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not exist And I wait until "Turtle Manatee" "list_item" does not exist
And I should see "No results for \"Turtle plagiarism\"" And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name 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 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 "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 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 press the enter key
And I wait until the page is ready And I wait until the page is ready
And "Student 1" "heading" should exist 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 Scenario: A teacher can set focus and search using the input are with a keyboard
Given I set the field "Search users" to "ABC" Given I set the field "Search users" to "ABC"
# Basic tests for the page. # 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 And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
# Move onto general keyboard navigation testing. # Move onto general keyboard navigation testing.
When "Turtle Manatee" "option_role" should exist When "Turtle Manatee" "option_role" should exist
And I press the down key 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 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 I press the up key 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 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 I press the escape key
And the focused element is "Search users" "field" And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme" 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 I set the field "Search users" to "ABC"
And "Turtle Manatee" "option_role" should exist And "Turtle Manatee" "option_role" should exist
And I press the down key 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. # Lets check the tabbing order.
And I set the field "Search users" to "ABC" 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 I press the tab key
And the focused element is "Clear search input" "button" in the ".user-search" "css_element" And the focused element is "Clear search input" "button" in the ".user-search" "css_element"
And I press the tab key 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 And ".groupsearchwidget" "css_element" should exist
# Ensure we can interact with the input & clear search options with the keyboard. # Ensure we can interact with the input & clear search options with the keyboard.
# Space & Enter have the same handling for triggering the two functionalities. # 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 "User Test" "heading" should not exist
And "Teacher 1" "heading" should not exist And "Teacher 1" "heading" should not exist
And "Turtle Manatee" "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 //# 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()); const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid; 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); this.renderDefault().catch(Notification.exception);
} }
@ -64,15 +98,6 @@ export default class GroupSearch extends search_combobox {
return '.groupsearchdropdown'; return '.groupsearchdropdown';
} }
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.groupsearchwidget';
}
/** /**
* Build the content then replace the node. * 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', { const {html, js} = await renderForPromise('core_group/comboboxsearch/resultset', {
groups: this.getMatchedResults(), groups: this.getMatchedResults(),
hasresults: this.getMatchedResults().length > 0, hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
searchterm: this.getSearchTerm(), searchterm: this.getSearchTerm(),
}); });
replaceNodeContents(this.selectors.placeholder, html, js); 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(); await this.renderDropdown();
this.updateNodes(); 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 { return {
id: group.id, id: group.id,
name: group.name, name: group.name,
link: this.selectOneLink(group.id),
groupimageurl: group.groupimageurl, 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. // Register & handle the text input.
this.searchInput.addEventListener('input', debounce(async() => { this.searchInput.addEventListener('input', debounce(async() => {
this.setSearchTerms(this.searchInput.value); this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search. // 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. // Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none'); this.clearSearchButton.classList.add('d-none');
} else { } else {
@ -167,75 +215,9 @@ export default class GroupSearch extends search_combobox {
}, 300)); }, 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. * 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. * @param {Number} groupID The ID of the group selected.
*/ */

View file

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

View file

@ -19,14 +19,12 @@
Context variables required for this template: Context variables required for this template:
* id - Group system ID. * id - Group system ID.
* name - Groups' name. * name - Groups' name.
* link - The link used to redirect upon self to show only this specific group.
* groupimageurl - The link of the groups picture. * groupimageurl - The link of the groups picture.
Example context (json): Example context (json):
{ {
"id": 2, "id": 2,
"name": "Foo bar", "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" "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: Context variables required for this template:
* groups - Our returned groups to render. * groups - Our returned groups to render.
* instance - The instance ID of the combo box.
* searchterm - The entered text to find these results. * searchterm - The entered text to find these results.
* hasgroups - Allow the handling where no users exist for the returned search term. * 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" "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
} }
], ],
"instance": 25,
"searchterm": "Foo", "searchterm": "Foo",
"hasresults": true "hasresults": true
} }

View file

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

View file

@ -115,3 +115,6 @@ gradeitemadvanced,core_grades
gradeitemadvanced_help,core_grades gradeitemadvanced_help,core_grades
to,core to,core
from,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['yes'] = 'Yes';
$string['yourgrade'] = 'Your grade'; $string['yourgrade'] = 'Your grade';
$string['aria-toggledropdown'] = 'Toggle the following dropdown';
$string['aria:dropdowngrades'] = 'Grade items found';
// Deprecated since Moodle 4.2. // Deprecated since Moodle 4.2.
$string['showanalysisicon'] = 'Show grade analysis icon'; $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.'; $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['grade'] = 'Grade';
$string['gradeitemadvanced'] = 'Advanced grade item options'; $string['gradeitemadvanced'] = 'Advanced grade item options';
$string['gradeitemadvanced_help'] = 'Select all elements that should be displayed as advanced when editing grade items.'; $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['viewmore'] = 'View more';
$string['viewallsubcategories'] = 'View all subcategories'; $string['viewallsubcategories'] = 'View all subcategories';
$string['viewfileinpopup'] = 'View file in a popup window'; $string['viewfileinpopup'] = 'View file in a popup window';
$string['viewresults'] = 'View results for {$a}';
$string['viewprofile'] = 'View profile'; $string['viewprofile'] = 'View profile';
$string['views'] = 'Views'; $string['views'] = 'Views';
$string['viewsolution'] = 'view solution'; $string['viewsolution'] = 'view solution';
@ -2504,3 +2503,4 @@ $string['updatingain'] = 'Updating {$a->what} in {$a->in}';
$string['summaryof'] = 'Summary of {$a}'; $string['summaryof'] = 'Summary of {$a}';
$string['from'] = 'From'; $string['from'] = 'From';
$string['to'] = 'To'; $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 //# 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/>. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery'; import $ from 'jquery';
import CustomEvents from "core/custom_interaction_events";
import {debounce} from 'core/utils'; import {debounce} from 'core/utils';
import Pending from 'core/pending'; import Pending from 'core/pending';
@ -25,25 +24,19 @@ import Pending from 'core/pending';
* @copyright 2023 Mathew May <mathew.solutions> * @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @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 { export default class {
// Define our standard lookups. // Define our standard lookups.
selectors = { selectors = {
component: this.componentSelector(), component: this.componentSelector(),
trigger: this.triggerSelector(), toggle: '[data-toggle="dropdown"]',
instance: '[data-region="instance"]',
input: '[data-action="search"]', input: '[data-action="search"]',
clearSearch: '[data-action="clearsearch"]', clearSearch: '[data-action="clearsearch"]',
dropdown: this.dropdownSelector(), dropdown: this.dropdownSelector(),
resultitems: '[role="option"]', resultitems: '[role="option"]',
viewall: '#select-all', viewall: '#select-all',
combobox: '[role="combobox"]',
}; };
// The results from the called filter function. // The results from the called filter function.
@ -70,9 +63,12 @@ export default class {
// DOM nodes that persist. // DOM nodes that persist.
component = document.querySelector(this.selectors.component); 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); searchInput = this.component.querySelector(this.selectors.input);
searchDropdown = this.component.querySelector(this.selectors.dropdown); searchDropdown = this.component.querySelector(this.selectors.dropdown);
clearSearchButton = this.component.querySelector(this.selectors.clearSearch); clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
combobox = this.component.querySelector(this.selectors.combobox);
$component = $(this.component); $component = $(this.component);
constructor() { constructor() {
@ -80,10 +76,18 @@ export default class {
this.setSearchTerms(this.searchInput?.value ?? ''); this.setSearchTerms(this.searchInput?.value ?? '');
// Begin handling the base search component. // Begin handling the base search component.
this.registerClickHandlers(); this.registerClickHandlers();
this.registerKeyHandlers();
// Conditionally set up the input handler since we don't know exactly how we were called. // 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) { if (this.searchInput !== null) {
this.registerInputHandlers(); 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. * Stub out a required function.
* @deprecated since Moodle 4.4
*/ */
triggerSelector() { 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) { closeSearch(clear = false) {
this.toggleDropdown(); this.toggleDropdown();
// Hide the "clear" search button search bar.
this.clearSearchButton.classList.add('d-none');
if (clear) { if (clear) {
// Hide the "clear" search button search bar.
this.clearSearchButton.classList.add('d-none');
// Clear the entered search query in the search bar and hide the search results container. // Clear the entered search query in the search bar and hide the search results container.
this.setSearchTerms(''); this.setSearchTerms('');
this.searchInput.value = ""; this.searchInput.value = "";
@ -254,13 +259,10 @@ export default class {
* @param {Boolean} on Flag to toggle hiding or showing values. * @param {Boolean} on Flag to toggle hiding or showing values.
*/ */
toggleDropdown(on = false) { toggleDropdown(on = false) {
this.$component.dropdown('toggle');
if (on) { if (on) {
this.searchDropdown.classList.add('show'); $(this.toggle).dropdown('show');
$(this.searchDropdown).show();
} else { } else {
this.searchDropdown.classList.remove('show'); $(this.toggle).dropdown('hide');
$(this.searchDropdown).hide();
} }
} }
@ -285,15 +287,11 @@ export default class {
} }
/** /**
* Register key event listeners. * Register change event listeners.
*/ */
registerKeyHandlers() { registerChangeHandlers() {
CustomEvents.define(document, events); const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
valueElement.addEventListener('change', this.changeHandler.bind(this));
// Register click events.
events.forEach((event) => {
this.component.addEventListener(event, this.keyHandler.bind(this));
});
} }
/** /**
@ -308,22 +306,17 @@ export default class {
return; return;
} }
this.setSearchTerms(this.searchInput.value); this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
const pendingPromise = new Pending();
if (this.getSearchTerm() === '') { if (this.getSearchTerm() === '') {
this.toggleDropdown(); this.toggleDropdown();
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none'); this.clearSearchButton.classList.add('d-none');
await this.filterrenderpipe();
} else { } else {
const pendingPromise = new Pending(); this.clearSearchButton.classList.remove('d-none');
await this.renderAndShow().then(() => { await this.renderAndShow();
// Display the "clear" search button in the search bar.
this.clearSearchButton.classList.remove('d-none');
return;
}).then(() => {
pendingPromise.resolve();
return true;
});
} }
pendingPromise.resolve();
}, 300, {pending: true})); }, 300, {pending: true}));
} }
@ -354,48 +347,6 @@ export default class {
this.toggleDropdown(true); 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. * The handler for when a user interacts with the component.
* *
@ -403,90 +354,30 @@ export default class {
*/ */
async clickHandler(e) { async clickHandler(e) {
this.updateNodes(); 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. // 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.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. // 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(); 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) { // eslint-disable-next-line no-unused-vars
this.updateNodes(); changeHandler(e) {
// Switch the key presses to handle keyboard nav. // Components may override this method to do something.
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;
}
} }
/**
* 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]', hidden: '[aria-hidden]',
}, },
elements: { 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]', 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', 'group_message' => 'group_message',
'autocomplete' => 'autocomplete', 'autocomplete' => 'autocomplete',
'iframe' => 'iframe', 'iframe' => 'iframe',
'option_role' => 'option_role',
); );
/** /**

View file

@ -30,7 +30,9 @@ use templatable;
*/ */
class comboboxsearch implements renderable, 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; protected $renderlater;
/** @var string $buttoncontent What is the content of the "Button" that users will always see. */ /** @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. */ /** @var boolean $usesbutton Whether to provide a A11y button. */
protected $usesbutton; 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. * The class constructor.
* *
* @param bool $renderlater How we figure out if we should render the template instantly. * @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 $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 $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 $buttonclasses Any special classes that may be needed.
* @param ?string $dropdownclasses 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 ?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 ?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 $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( public function __construct(
bool $renderlater, bool $renderlater,
@ -75,7 +89,10 @@ class comboboxsearch implements renderable, templatable {
?string $buttonclasses = null, ?string $buttonclasses = null,
?string $dropdownclasses = null, ?string $dropdownclasses = null,
?string $buttonheader = 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. // Ensure implementors cant request to render the content now and not provide us any to show.
if (!$renderlater && empty($dropdowncontent)) { 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->renderlater = $renderlater;
$this->buttoncontent = $buttoncontent; $this->buttoncontent = $buttoncontent;
$this->dropdowncontent = $dropdowncontent; $this->dropdowncontent = $dropdowncontent;
@ -95,6 +126,9 @@ class comboboxsearch implements renderable, templatable {
$this->dropdownclasses = $dropdownclasses; $this->dropdownclasses = $dropdownclasses;
$this->buttonheader = $buttonheader; $this->buttonheader = $buttonheader;
$this->usesbutton = $usebutton; $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 { public function export_for_template(renderer_base $output): array {
return [ return [
'rtl' => right_to_left(),
'renderlater' => $this->renderlater, 'renderlater' => $this->renderlater,
'buttoncontent' => $this->buttoncontent , 'buttoncontent' => $this->buttoncontent ,
'dropdowncontent' => $this->dropdowncontent, 'dropdowncontent' => $this->dropdowncontent,
@ -115,6 +148,9 @@ class comboboxsearch implements renderable, templatable {
'buttonheader' => $this->buttonheader, 'buttonheader' => $this->buttonheader,
'usebutton' => $this->usesbutton, 'usebutton' => $this->usesbutton,
'instance' => rand(), // Template uniqid is per render out so sometimes these conflict. '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. Combobox search selector dropdown.
Context variables required for this template: 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 * 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 * 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 * 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 * dropdowncontent - If rendering now, The content within the dropdown
@ -29,10 +32,13 @@
Example context (json): Example context (json):
{ {
"rtl": false, "label": "Example searchable combobox",
"name": "input-1",
"value": "0",
"renderlater": false, "renderlater": false,
"buttonheader": "Example:",
"usebutton": true, "usebutton": true,
"buttoncontent": "Example dropdown button", "buttoncontent": "Dropdown button",
"dropdowncontent": "Some body content to render right now", "dropdowncontent": "Some body content to render right now",
"parentclasses": "my-dropdown", "parentclasses": "my-dropdown",
"buttonclasses": "my-button", "buttonclasses": "my-button",
@ -43,17 +49,7 @@
{{#buttonheader}} {{#buttonheader}}
<small>{{.}}</small> <small>{{.}}</small>
{{/buttonheader}} {{/buttonheader}}
<div class="{{#parentclasses}}{{.}}{{/parentclasses}}" <div class="{{#parentclasses}}{{.}}{{/parentclasses}} dropdown" data-instance="{{instance}}">
{{^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}}>
{{#usebutton}} {{#usebutton}}
<div tabindex="0" <div tabindex="0"
@ -62,20 +58,23 @@
role="combobox" role="combobox"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-controls="dialog-{{instance}}-{{uniqid}}" 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" class="{{#buttonclasses}}{{.}}{{/buttonclasses}} btn dropdown-toggle d-flex text-left align-items-center p-0 font-weight-bold"
aria-label="{{#cleanstr}} aria-toggledropdown, core_grades {{/cleanstr}}" aria-label="{{label}}"
data-input-element="input-{{uniqid}}"> data-input-element="input-{{instance}}-{{uniqid}}">
{{{buttoncontent}}} {{{buttoncontent}}}
</div> </div>
<input type="hidden" name="{{name}}" value="{{value}}" id="input-{{instance}}-{{uniqid}}"/>
{{/usebutton}} {{/usebutton}}
{{^usebutton}}{{{buttoncontent}}}{{/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}}" id="dialog-{{instance}}-{{uniqid}}"
role="dialog" {{#usebutton}}
aria-modal="true" role="dialog"
aria-label="{{#cleanstr}} selectagroup, core {{/cleanstr}}" aria-modal="true"
aria-label="{{label}}"
{{/usebutton}}
> >
<div class="w-100 p-3" data-region="placeholder"> <div class="w-100 p-3" data-region="placeholder">
{{#renderlater}} {{#renderlater}}

View file

@ -18,22 +18,31 @@
Context variables required for this template: Context variables required for this template:
* id - Result item system ID. * id - Result item system ID.
* instance - The instance ID of the combo box.
* name - Result item human readable name. * 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): Example context (json):
{ {
"id": 2, "id": 2,
"name": "Foo bar", "instance": 25,
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2" "name": "Foo bar"
} }
}} }}
<li class="w-100 result-row" role="none" id="result-row-{{id}}"> <li
<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}}"> id="result-{{instance}}-{{id}}"
{{$content}} class="w-100 dropdown-item d-flex px-0 align-items-center"
<span class="d-block w-100 px-2 text-truncate"> role="option"
{{name}} {{! aria.js would use the data-short-text attribute if it is provided. }}
</span> data-short-text="{{$shorttext}}{{/shorttext}}"
{{/content}} data-value="{{id}}"
</a> aria-label="{{$arialabel}}{{name}}{{/arialabel}}"
>
{{$content}}
<span class="d-block w-100 px-2 text-truncate">
{{name}}
</span>
{{/content}}
</li> </li>

View file

@ -17,23 +17,22 @@
Wrapping template for returned result items. Wrapping template for returned result items.
Context variables required for this template: Context variables required for this template:
* instance - The instance ID of the combo box.
* results - Our returned results to render. * results - Our returned results to render.
* searchterm - The entered text to find these results. * searchterm - The entered text to find these results.
* hasresult - Allow the handling where no results exist for the returned search term. * hasresult - Allow the handling where no results exist for the returned search term.
* noresults - Our fall through case if nothing matches.
Example context (json): Example context (json):
{ {
"instance": 25,
"results": [ "results": [
{ {
"id": 2, "id": 2,
"name": "Foo bar", "name": "Foo bar"
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
}, },
{ {
"id": 3, "id": 3,
"name": "Bar Foo", "name": "Bar Foo"
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
} }
], ],
"searchterm": "Foo", "searchterm": "Foo",
@ -41,17 +40,22 @@
} }
}} }}
<div class="d-flex flex-column mh-100 h-100"> <div class="d-flex flex-column mh-100 h-100">
{{#hasresults}} <ul
<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}}"> 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}}
{{$results}} {{$results}}
{{#results}} {{#results}}
{{>core/local/comboboxsearch/resultitem}} {{>core/local/comboboxsearch/resultitem}}
{{/results}} {{/results}}
{{/results}} {{/results}}
{{$selectall}}{{/selectall}} {{$selectall}}{{/selectall}}
</ul> {{/hasresults}}
{{/hasresults}} </ul>
{{^hasresults}} {{^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}} {{/hasresults}}
</div> </div>

View file

@ -28,39 +28,22 @@
}} }}
<div class="flex-column h-100 w-100"> <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="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
{{< core/search_input_auto }}
{{#currentvalue}} {{$label}}{{#str}}
{{< core/search_input_auto }} searchitems, core
{{$label}}{{#str}} {{/str}}{{/label}}
searchitems, core {{$placeholder}}{{#str}}
{{/str}}{{/label}} searchitems, core
{{$value}}{{currentvalue}}{{/value}} {{/str}}{{/placeholder}}
{{$additionalattributes}} {{$value}}{{currentvalue}}{{/value}}
role="combobox" {{$additionalattributes}}
aria-expanded="true" role="combobox"
aria-controls="list-result-listbox" aria-expanded="true"
aria-autocomplete="list" aria-controls="list-{{instance}}-result-listbox"
data-input-element="result-input-{{uniqid}}" aria-autocomplete="list"
{{/additionalattributes}} data-input-element="result-input-{{uniqid}}-{{instance}}"
{{/ core/search_input_auto }} {{/additionalattributes}}
{{/currentvalue}} {{/ core/search_input_auto }}
{{^currentvalue}} <input type="hidden" name="search" id="result-input-{{uniqid}}-{{instance}}"/>
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchitems, core
{{/str}}{{/label}}
{{$placeholder}}{{#str}}
searchitems, core
{{/str}}{{/placeholder}}
{{$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}}
<input type="hidden" name="search" id="result-input-{{uniqid}}"/>
<div data-region="searchplaceholder"></div> <div data-region="searchplaceholder"></div>
</div> </div>

View file

@ -99,6 +99,7 @@ information provided here is intended especially for developers.
to bool if the original functionality is desired. 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 * 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. 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 === === 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 $ from 'jquery';
import Pending from 'core/pending'; import Pending from 'core/pending';
import * as FocusLockManager from 'core/local/aria/focuslock';
/** /**
* Drop downs from bootstrap don't support keyboard accessibility by default. * 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"]'); const menu = e.target.parentElement.querySelector('[role="menu"]');
let menuItems = false; let menuItems = false;
let foundMenuItem = false; let foundMenuItem = false;
let textInput = false;
if (menu) { if (menu) {
menuItems = menu.querySelectorAll('[role="menuitem"]'); menuItems = menu.querySelectorAll('[role="menuitem"]');
textInput = e.target.parentElement.querySelector('[data-action="search"]');
} }
if (menuItems && menuItems.length > 0) { if (menuItems && menuItems.length > 0) {
// Up key opens the menu at the end. // Up key opens the menu at the end.
if (trigger === 'ArrowUp') { if (trigger === 'ArrowUp') {
@ -101,10 +99,7 @@ const dropdownFix = () => {
} }
} }
if (textInput) { if (foundMenuItem) {
shiftFocus(textInput);
}
if (foundMenuItem && textInput === null) {
shiftFocus(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 => { $('.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. // We need to focus on the menu trigger.
const trigger = e.target.querySelector('[data-toggle="dropdown"]'); 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)) { if (trigger && focused && e.target.contains(focused)) {
shiftFocus(trigger, () => { shiftFocus(trigger, () => {
if (document.activeElement === document.body) { if (document.activeElement === document.body) {
@ -297,9 +308,9 @@ const comboboxFix = () => {
if (editable && !next) { if (editable && !next) {
next = options[options.length - 1]; next = options[options.length - 1];
} }
} else if (trigger == 'Home') { } else if (trigger == 'Home' && !editable) {
next = options[0]; next = options[0];
} else if (trigger == 'End') { } else if (trigger == 'End' && !editable) {
next = options[options.length - 1]; next = options[options.length - 1];
} else if ((trigger == ' ' && !editable) || trigger == 'Enter') { } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
e.preventDefault(); e.preventDefault();
@ -338,7 +349,6 @@ const comboboxFix = () => {
const listbox = option.closest('[role="listbox"]'); const listbox = option.closest('[role="listbox"]');
const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`); const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
if (combobox) { if (combobox) {
combobox.focus();
selectOption(combobox, option); selectOption(combobox, option);
} }
} }
@ -368,9 +378,9 @@ const comboboxFix = () => {
} }
if (combobox.hasAttribute('value')) { 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 { } else {
combobox.textContent = option.textContent; combobox.textContent = option.dataset.shortText || option.textContent;
} }
if (combobox.dataset.inputElement) { if (combobox.dataset.inputElement) {

View file

@ -3121,14 +3121,16 @@ blockquote {
.usersearchdropdown, .usersearchdropdown,
.gradesearchdropdown, .gradesearchdropdown,
.groupsearchdropdown { .groupsearchdropdown {
max-width: 350px; &.dropdown-menu {
.searchresultitemscontainer { width: 350px;
max-height: 170px; .searchresultitemscontainer {
overflow: auto; max-height: 170px;
/* stylelint-disable declaration-no-important */ overflow: auto;
img { /* stylelint-disable declaration-no-important */
height: 48px !important; img {
width: 48px !important; height: 48px !important;
width: 48px !important;
}
} }
} }
} }

View file

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

View file

@ -26026,21 +26026,21 @@ blockquote {
} }
/* Combobox search dropdowns */ /* Combobox search dropdowns */
.usersearchdropdown, .usersearchdropdown.dropdown-menu,
.gradesearchdropdown, .gradesearchdropdown.dropdown-menu,
.groupsearchdropdown { .groupsearchdropdown.dropdown-menu {
max-width: 350px; width: 350px;
} }
.usersearchdropdown .searchresultitemscontainer, .usersearchdropdown.dropdown-menu .searchresultitemscontainer,
.gradesearchdropdown .searchresultitemscontainer, .gradesearchdropdown.dropdown-menu .searchresultitemscontainer,
.groupsearchdropdown .searchresultitemscontainer { .groupsearchdropdown.dropdown-menu .searchresultitemscontainer {
max-height: 170px; max-height: 170px;
overflow: auto; overflow: auto;
/* stylelint-disable declaration-no-important */ /* stylelint-disable declaration-no-important */
} }
.usersearchdropdown .searchresultitemscontainer img, .usersearchdropdown.dropdown-menu .searchresultitemscontainer img,
.gradesearchdropdown .searchresultitemscontainer img, .gradesearchdropdown.dropdown-menu .searchresultitemscontainer img,
.groupsearchdropdown .searchresultitemscontainer img { .groupsearchdropdown.dropdown-menu .searchresultitemscontainer img {
height: 48px !important; height: 48px !important;
width: 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 {getStrings} from 'core/str';
import {renderForPromise, replaceNodeContents} from 'core/templates'; import {renderForPromise, replaceNodeContents} from 'core/templates';
import $ from 'jquery'; import $ from 'jquery';
import Notification from 'core/notification';
export default class UserSearch extends search_combobox { export default class UserSearch extends search_combobox {
@ -36,14 +35,19 @@ export default class UserSearch extends search_combobox {
constructor() { constructor() {
super(); super();
// Register a small click event onto the document since we need to check if they are clicking off the component. // Register a couple of events onto the document since we need to check if they are moving off the component.
document.addEventListener('click', (e) => { ['click', 'focus'].forEach(eventType => {
// Since we are handling dropdowns manually, ensure we can close it when clicking off. // Since we are handling dropdowns manually, ensure we can close it when moving off.
if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) { document.addEventListener(eventType, e => {
this.toggleDropdown(); 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. // Define our standard lookups.
this.selectors = {...this.selectors, this.selectors = {...this.selectors,
courseid: '[data-region="courseid"]', courseid: '[data-region="courseid"]',
@ -51,9 +55,12 @@ export default class UserSearch extends search_combobox {
resetPageButton: '[data-action="resetpage"]', resetPageButton: '[data-action="resetpage"]',
}; };
const component = document.querySelector(this.componentSelector()); this.courseID = this.component.querySelector(this.selectors.courseid).dataset.courseid;
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;
this.groupID = document.querySelector(this.selectors.groupid)?.dataset?.groupid; 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() { static init() {
@ -78,15 +85,6 @@ export default class UserSearch extends search_combobox {
return '.usersearchdropdown'; return '.usersearchdropdown';
} }
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.usersearchwidget';
}
/** /**
* Build the content then replace the node. * 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', { const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
users: this.getMatchedResults().slice(0, 5), users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0, hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
matches: this.getMatchedResults().length, matches: this.getMatchedResults().length,
searchterm: this.getSearchTerm(), searchterm: this.getSearchTerm(),
selectall: this.selectAllResultsLink(), selectall: this.selectAllResultsLink(),
}); });
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js); 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,13 +128,17 @@ export default class UserSearch extends search_combobox {
* @returns {Array} The users that match the given criteria. * @returns {Array} The users that match the given criteria.
*/ */
async filterDataset(filterableData) { async filterDataset(filterableData) {
const stringMap = await this.getStringMap(); if (this.getPreppedSearchTerm()) {
return filterableData.filter((user) => Object.keys(user).some((key) => { const stringMap = await this.getStringMap();
if (user[key] === "" || user[key] === null || !stringMap.get(key)) { return filterableData.filter((user) => Object.keys(user).some((key) => {
return false; if (user[key] === "" || user[key] === null || !stringMap.get(key)) {
} return false;
return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm()); }
})); 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.matchingField = `${escapedMatchingField} (${user.email})`;
user.link = this.selectOneLink(user.id);
break; 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) { changeHandler(e) {
super.clickHandler(e).catch(Notification.exception); this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.
if (e.target.closest(this.selectors.component)) {
// Forcibly prevent BS events so that we can control the open and close. if (e.target.value === '0') {
// Really needed because by default input elements cant trigger a dropdown.
e.stopImmediatePropagation();
}
if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) {
window.location = this.selectAllResultsLink(); window.location = this.selectAllResultsLink();
} } else {
if (e.target.closest(this.selectors.resetPageButton)) { window.location = this.selectOneLink(e.target.value);
window.location = e.target.closest(this.selectors.resetPageButton).href;
} }
} }
@ -193,50 +202,30 @@ export default class UserSearch extends search_combobox {
* @param {KeyboardEvent} e The triggering event that we are working with. * @param {KeyboardEvent} e The triggering event that we are working with.
*/ */
keyHandler(e) { 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 the key presses to handle keyboard nav.
switch (e.key) { 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 'Enter':
case ' ': 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)) { if (e.target.closest(this.selectors.resetPageButton)) {
e.stopPropagation();
window.location = e.target.closest(this.selectors.resetPageButton).href; window.location = e.target.closest(this.selectors.resetPageButton).href;
break; break;
} }
if (e.target.closest('.dropdown-item')) {
e.preventDefault();
window.location = e.target.closest('.dropdown-item').href;
break;
}
break; break;
case 'Escape': case 'Escape':
this.toggleDropdown(); this.toggleDropdown();
this.searchInput.focus({preventScroll: true}); this.searchInput.focus({preventScroll: true});
break; 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) { if (on) {
this.searchDropdown.classList.add('show'); this.searchDropdown.classList.add('show');
$(this.searchDropdown).show(); $(this.searchDropdown).show();
this.component.setAttribute('aria-expanded', 'true'); this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'true');
this.searchInput.focus({preventScroll: true});
} else { } else {
this.searchDropdown.classList.remove('show'); this.searchDropdown.classList.remove('show');
$(this.searchDropdown).hide(); $(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. * 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. * @param {Number} userID The ID of the user selected.
*/ */

View file

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

View file

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

View file

@ -20,6 +20,8 @@
The user selector trigger element. The user selector trigger element.
Context variables required for this template: 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. * currentvalue - If the user has already searched, set the value to that.
* courseid - The course ID. * courseid - The course ID.
* group - The group ID. * group - The group ID.
@ -27,41 +29,33 @@
Example context (json): Example context (json):
{ {
"name": "input-1",
"value": "0",
"currentvalue": "bar", "currentvalue": "bar",
"courseid": 2, "courseid": 2,
"group": 25, "group": 25,
"resetlink": "grade/report/grader/index.php?id=2" "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="courseid" data-courseid="{{courseid}}"></span>
<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>
<span class="d-none" data-region="instance" data-instance="{{instance}}"></span>
{{< core/search_input_auto }}
{{$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"
aria-haspopup="listbox"
data-input-element="user-input-{{uniqid}}-{{instance}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{#currentvalue}} {{#currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core
{{/str}}{{/label}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
aria-autocomplete="list"
data-input-element="user-input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
<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}}"> <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}} {{#str}}clear{{/str}}
</a> </a>
{{/currentvalue}} {{/currentvalue}}
{{^currentvalue}} <input type="hidden" name="{{name}}" value="{{value}}" id="user-input-{{uniqid}}-{{instance}}"/>
{{< 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}}"/>