MDL-76143 gradebook: Add search into graderreport

This commit is contained in:
Mathew May
2023-03-30 11:29:01 +08:00
parent f7a8df253b
commit d80fb6ce2c
29 changed files with 1628 additions and 7 deletions
+1 -1
View File
@@ -5,6 +5,6 @@ define("core_grades/searchwidget/initials",["exports","core/pending","core/url",
* @module core_grades/searchwidget/initials
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_pending=_interopRequireDefault(_pending),Url=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}(Url),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_jquery=_interopRequireDefault(_jquery);let registered=!1;const selectors_pageListItem="page-item",selectors_pageClickableItem=".page-link",selectors_activeItem="active",selectors_formDropdown=".initialsdropdownform",selectors_parentDomNode=".initials-selector",selectors_firstInitial="firstinitial",selectors_lastInitial="lastinitial",selectors_initialBars=".initialbar",selectors_targetButton="initialswidget",selectors_formItems={type:"submit",save:"save",cancel:"cancel"};_exports.init=callingLink=>{if(registered)return;const pendingPromise=new _pending.default;registerListenerEvents(callingLink),(0,_jquery.default)(selectors_parentDomNode).on("shown.bs.dropdown",(()=>{document.querySelector(selectors_pageClickableItem).focus({preventScroll:!0})})),pendingPromise.resolve(),registered=!0};const registerListenerEvents=callingLink=>{const events=["click",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];_custom_interaction_events.default.define(document,events),events.forEach((event=>{document.addEventListener(event,(e=>{let{firstActive:firstActive,lastActive:lastActive,sifirst:sifirst,silast:silast}=onClickVariables(),itemToReset="";if(e.target.closest(selectors_formDropdown)&&e.preventDefault(),e.target.closest("".concat(selectors_formDropdown," .").concat(selectors_pageListItem))){if(e.target.classList.contains(selectors_pageListItem))return;e.target.closest(selectors_initialBars).classList.contains(selectors_firstInitial)?(sifirst=e.target,itemToReset=firstActive):(silast=e.target,itemToReset=lastActive),swapActiveItems(itemToReset,e)}e.target.type===selectors_formItems.type&&(e.target.dataset.action===selectors_formItems.save&&(window.location=Url.relativeUrl(callingLink,{id:e.target.closest(selectors_formDropdown).dataset.courseid,sifirst:sifirst.parentElement.classList.contains("initialbarall")?"":sifirst.value,silast:silast.parentElement.classList.contains("initialbarall")?"":silast.value})),e.target.dataset.action===selectors_formItems.cancel&&(0,_jquery.default)(".".concat(selectors_targetButton)).dropdown("toggle"))}))}))},onClickVariables=()=>{const firstItems=[...document.querySelectorAll(".".concat(selectors_firstInitial," li"))],lastItems=[...document.querySelectorAll(".".concat(selectors_lastInitial," li"))],firstActive=firstItems.filter((item=>item.classList.contains(selectors_activeItem)))[0],lastActive=lastItems.filter((item=>item.classList.contains(selectors_activeItem)))[0];let sifirst=firstActive.querySelector(selectors_pageClickableItem),silast=lastActive.querySelector(selectors_pageClickableItem);return{firstActive:firstActive,lastActive:lastActive,sifirst:sifirst,silast:silast}},swapActiveItems=(itemToReset,e)=>{itemToReset.classList.remove(selectors_activeItem),itemToReset.querySelector(selectors_pageClickableItem).ariaCurrent=!1;e.target.parentElement.classList.add(selectors_activeItem),e.target.ariaCurrent=!0}}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_pending=_interopRequireDefault(_pending),Url=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}(Url),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_jquery=_interopRequireDefault(_jquery);let registered=!1;const selectors_pageListItem="page-item",selectors_pageClickableItem=".page-link",selectors_activeItem="active",selectors_formDropdown=".initialsdropdownform",selectors_parentDomNode=".initials-selector",selectors_firstInitial="firstinitial",selectors_lastInitial="lastinitial",selectors_initialBars=".initialbar",selectors_targetButton="initialswidget",selectors_formItems={type:"submit",save:"save",cancel:"cancel"};_exports.init=callingLink=>{if(registered)return;const pendingPromise=new _pending.default;registerListenerEvents(callingLink),(0,_jquery.default)(selectors_parentDomNode).on("shown.bs.dropdown",(()=>{document.querySelector(selectors_pageClickableItem).focus({preventScroll:!0})})),pendingPromise.resolve(),registered=!0};const registerListenerEvents=callingLink=>{const events=["click",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];_custom_interaction_events.default.define(document,events),events.forEach((event=>{document.addEventListener(event,(e=>{let{firstActive:firstActive,lastActive:lastActive,sifirst:sifirst,silast:silast}=onClickVariables(),itemToReset="";if(e.target.closest(selectors_formDropdown)&&e.preventDefault(),e.target.closest("".concat(selectors_formDropdown," .").concat(selectors_pageListItem))){if(e.target.classList.contains(selectors_pageListItem))return;e.target.closest(selectors_initialBars).classList.contains(selectors_firstInitial)?(sifirst=e.target,itemToReset=firstActive):(silast=e.target,itemToReset=lastActive),swapActiveItems(itemToReset,e)}e.target.closest("".concat(selectors_formDropdown))&&e.target.type===selectors_formItems.type&&(e.target.dataset.action===selectors_formItems.save&&(window.location=Url.relativeUrl(callingLink,{id:e.target.closest(selectors_formDropdown).dataset.courseid,sifirst:sifirst.parentElement.classList.contains("initialbarall")?"":sifirst.value,silast:silast.parentElement.classList.contains("initialbarall")?"":silast.value})),e.target.dataset.action===selectors_formItems.cancel&&(0,_jquery.default)(".".concat(selectors_targetButton)).dropdown("toggle"))}))}))},onClickVariables=()=>{const firstItems=[...document.querySelectorAll(".".concat(selectors_firstInitial," li"))],lastItems=[...document.querySelectorAll(".".concat(selectors_lastInitial," li"))],firstActive=firstItems.filter((item=>item.classList.contains(selectors_activeItem)))[0],lastActive=lastItems.filter((item=>item.classList.contains(selectors_activeItem)))[0];let sifirst=firstActive.querySelector(selectors_pageClickableItem),silast=lastActive.querySelector(selectors_pageClickableItem);return{firstActive:firstActive,lastActive:lastActive,sifirst:sifirst,silast:silast}},swapActiveItems=(itemToReset,e)=>{itemToReset.classList.remove(selectors_activeItem),itemToReset.querySelector(selectors_pageClickableItem).ariaCurrent=!1;e.target.parentElement.classList.add(selectors_activeItem),e.target.ariaCurrent=!0}}));
//# sourceMappingURL=initials.min.js.map
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -117,7 +117,7 @@ const registerListenerEvents = (callingLink) => {
}
// Handle form submissions.
if (e.target.type === selectors.formItems.type) {
if (e.target.closest(`${selectors.formDropdown}`) && e.target.type === selectors.formItems.type) {
if (e.target.dataset.action === selectors.formItems.save) {
// Ensure we strip out the value (All) as it messes with the PHP side of the initials bar.
// Then we will redirect the user back onto the page with new filters applied.
+8 -1
View File
@@ -56,6 +56,9 @@ class gradebook_dropdown implements renderable, templatable {
/** @var null|string $buttonheader If the button item in the tertiary nav needs an extra top header for context. */
protected $buttonheader;
/** @var boolean $usesbutton Whether to provide a A11y button. */
protected $usesbutton;
/**
* The class constructor.
*
@@ -66,6 +69,7 @@ class gradebook_dropdown implements renderable, templatable {
* @param ?string $buttonclasses Any special classes that may be needed.
* @param ?string $dropdownclasses Any special classes that may be needed.
* @param ?string $buttonheader If the button item in the tertiary nav needs an extra top header for context.
* @param bool $usebutton If we want the mustache to add the button roles for us or do we have another aria role node?
* @throws moodle_exception If the implementor incorrectly call this module.
*/
public function __construct(
@@ -75,7 +79,8 @@ class gradebook_dropdown implements renderable, templatable {
?string $parentclasses = null,
?string $buttonclasses = null,
?string $dropdownclasses = null,
?string $buttonheader = null
?string $buttonheader = null,
?bool $usebutton = true
) {
// Ensure implementors cant request to render the content now and not provide us any to show.
if (!$renderlater && empty($dropdowncontent)) {
@@ -94,6 +99,7 @@ class gradebook_dropdown implements renderable, templatable {
$this->buttonclasses = $buttonclasses;
$this->dropdownclasses = $dropdownclasses;
$this->buttonheader = $buttonheader;
$this->usesbutton = $usebutton;
}
/**
@@ -112,6 +118,7 @@ class gradebook_dropdown implements renderable, templatable {
'buttonclasses' => $this->buttonclasses,
'dropdownclasses' => $this->dropdownclasses,
'buttonheader' => $this->buttonheader,
'usebutton' => $this->usesbutton,
];
}
+3
View File
@@ -0,0 +1,3 @@
define("gradereport_grader/search",["exports","gradereport_grader/search/search_class","gradereport_grader/search/repository","core/str","core/url","core/templates"],(function(_exports,_search_class,Repository,_str,_url,_templates){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_class=_interopRequireDefault(_search_class),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),_url=_interopRequireDefault(_url);const selectors_component=".user-search",selectors_courseid='[data-region="courseid"]',courseID=document.querySelector(selectors_component).querySelector(selectors_courseid).dataset.courseid,bannedFilterFields=["profileimageurlsmall","profileimageurl","id","link","matchingField","matchingFieldName"];class UserSearch extends _search_class.default{constructor(){var obj,key,value;super(),value=null,(key="profilestringmap")in(obj=this)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}static init(){return new UserSearch}setComponentSelector(){return".user-search"}setDropdownSelector(){return".usersearchdropdown"}setTriggerSelector(){return".usersearchwidget"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("gradereport_grader/search/resultset",{users:this.getMatchedResults(),hasusers:this.getMatchedResults().length>0,total:this.getDatasetSize(),found:this.getMatchedResults().length,searchterm:this.getSearchTerm(),selectall:this.selectAllResultsLink()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js)}fetchDataset(){return Repository.userFetch(courseID).then((r=>r.users))}async filterDataset(filterableData){return filterableData.filter((user=>Object.keys(user).some((key=>""!==user[key]&&!bannedFilterFields.includes(key)&&user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}async filterMatchDataset(){const stringMap=await this.getStringMap();this.setMatchedResults(this.getMatchedResults().map((user=>{for(const[key,value]of Object.entries(user)){var _stringMap$get;const valueString=value.toString().toLowerCase();if(valueString.includes(this.getPreppedSearchTerm())){user.matchingFieldName=null!==(_stringMap$get=stringMap.get(key))&&void 0!==_stringMap$get?_stringMap$get:key,user.matchingField=valueString.replace(this.getPreppedSearchTerm(),'<span class="font-weight-bold">'.concat(this.getSearchTerm(),"</span>")),user.link=this.selectOneLink(user.id);break}}return user})))}clickHandler(e){super.clickHandler(e),e.target===this.getHTMLElements().currentViewAll&&0===e.button&&(window.location=this.selectAllResultsLink())}keyHandler(e){switch(super.keyHandler(e),e.target!==this.getHTMLElements().currentViewAll||"Enter"!==e.key&&"Space"!==e.key||(window.location=this.selectAllResultsLink()),e.key){case"Enter":case" ":if(document.activeElement===this.getHTMLElements().searchInput){if(" "===e.key)break;window.location=this.selectAllResultsLink();break}if(document.activeElement===this.getHTMLElements().clearSearchButton){this.closeSearch();break}e.preventDefault(),window.location=e.target.closest(".dropdown-item").href}}selectAllResultsLink(){return _url.default.relativeUrl("/grade/report/grader/index.php",{id:courseID,searchvalue:this.getSearchTerm()},!1)}selectOneLink(userID){return _url.default.relativeUrl("/grade/report/grader/index.php",{id:courseID,searchvalue:this.getSearchTerm(),userid:userID},!1)}getStringMap(){if(!this.profilestringmap){const requiredStrings=["username","firstname","lastname","email","city","country","department","institution","idnumber","phone1","phone2"];this.profilestringmap=(0,_str.get_strings)(requiredStrings.map((key=>({key:key})))).then((stringArray=>new Map(requiredStrings.map(((key,index)=>[key,stringArray[index]])))))}return this.profilestringmap}}return _exports.default=UserSearch,_exports.default}));
//# sourceMappingURL=search.min.js.map
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
define("gradereport_grader/search/repository",["exports","core/ajax"],(function(_exports,_ajax){var obj;
/**
* A repo for the search partial in the grader report.
*
* @module gradereport_grader/search/repository
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.userFetch=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.userFetch=courseid=>{const request={methodname:"gradereport_grader_get_users_in_report",args:{courseid:courseid}};return _ajax.default.call([request])[0]}}));
//# sourceMappingURL=repository.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"repository.min.js","sources":["../../src/search/repository.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 * A repo for the search partial in the grader report.\n *\n * @module gradereport_grader/search/repository\n * @copyright 2022 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport ajax from 'core/ajax';\n\n/**\n * Given a course ID, we want to fetch the learners within this report.\n *\n * @method userFetch\n * @param {int} courseid ID of the course to fetch the users of.\n * @return {object} jQuery promise\n */\nexport const userFetch = (courseid) => {\n const request = {\n methodname: 'gradereport_grader_get_users_in_report',\n args: {\n courseid: courseid,\n },\n };\n return ajax.call([request])[0];\n};\n"],"names":["courseid","request","methodname","args","ajax","call"],"mappings":";;;;;;;8JAgC0BA,iBAChBC,QAAU,CACZC,WAAY,yCACZC,KAAM,CACFH,SAAUA,kBAGXI,cAAKC,KAAK,CAACJ,UAAU"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+246
View File
@@ -0,0 +1,246 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Allow the user to search for learners within the grader report.
* Have to basically search twice on the dataset to avoid passing around massive csv params whilst allowing debouncing.
*
* @module gradereport_grader/search
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import GradebookSearchClass from 'gradereport_grader/search/search_class';
import * as Repository from 'gradereport_grader/search/repository';
import {get_strings as getStrings} from 'core/str';
import Url from 'core/url';
import {renderForPromise, replaceNodeContents} from 'core/templates';
// Define our standard lookups.
const selectors = {
component: '.user-search',
courseid: '[data-region="courseid"]',
};
const component = document.querySelector(selectors.component);
const courseID = component.querySelector(selectors.courseid).dataset.courseid;
const bannedFilterFields = ['profileimageurlsmall', 'profileimageurl', 'id', 'link', 'matchingField', 'matchingFieldName'];
export default class UserSearch extends GradebookSearchClass {
// A map of user profile field names that is human-readable.
profilestringmap = null;
constructor() {
super();
}
static init() {
return new UserSearch();
}
/**
* The overall div that contains the searching widget.
*
* @returns {string}
*/
setComponentSelector() {
return '.user-search';
}
/**
* The dropdown div that contains the searching widget result space.
*
* @returns {string}
*/
setDropdownSelector() {
return '.usersearchdropdown';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
setTriggerSelector() {
return '.usersearchwidget';
}
/**
* Build the content then replace the node.
*/
async renderDropdown() {
const {html, js} = await renderForPromise('gradereport_grader/search/resultset', {
users: this.getMatchedResults(),
hasusers: this.getMatchedResults().length > 0,
total: this.getDatasetSize(),
found: this.getMatchedResults().length,
searchterm: this.getSearchTerm(),
selectall: this.selectAllResultsLink(),
});
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
}
/**
* Get the data we will be searching against in this component.
*
* @returns {Promise<*>}
*/
fetchDataset() {
return Repository.userFetch(courseID).then((r) => r.users);
}
/**
* Dictate to the search component how and what we want to match upon.
*
* @param {Array} filterableData
* @returns {Array} The users that match the given criteria.
*/
async filterDataset(filterableData) {
return filterableData.filter((user) => Object.keys(user).some((key) => {
if (user[key] === "" || bannedFilterFields.includes(key)) {
return false;
}
return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());
}));
}
/**
* Given we have a subset of the dataset, set the field that we matched upon to inform the end user.
*
* @returns {Array} The results with the matched fields inserted.
*/
async filterMatchDataset() {
const stringMap = await this.getStringMap();
this.setMatchedResults(
this.getMatchedResults().map((user) => {
for (const [key, value] of Object.entries(user)) {
const valueString = value.toString().toLowerCase();
if (!valueString.includes(this.getPreppedSearchTerm())) {
continue;
}
// Ensure we have a good string, otherwise fallback to the key.
user.matchingFieldName = stringMap.get(key) ?? key;
user.matchingField = valueString.replace(
this.getPreppedSearchTerm(),
`<span class="font-weight-bold">${this.getSearchTerm()}</span>`
);
user.link = this.selectOneLink(user.id);
break;
}
return user;
})
);
}
/**
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
clickHandler(e) {
super.clickHandler(e);
if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) {
window.location = this.selectAllResultsLink();
}
}
/**
* 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);
if (e.target === this.getHTMLElements().currentViewAll && (e.key === 'Enter' || e.key === 'Space')) {
window.location = this.selectAllResultsLink();
}
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'Enter':
case ' ':
if (document.activeElement === this.getHTMLElements().searchInput) {
if (e.key === ' ') {
break;
} else {
window.location = this.selectAllResultsLink();
break;
}
}
if (document.activeElement === this.getHTMLElements().clearSearchButton) {
this.closeSearch();
break;
}
e.preventDefault();
window.location = e.target.closest('.dropdown-item').href;
break;
}
}
/**
* Build up the view all link.
*
* @returns {string|*}
*/
selectAllResultsLink() {
return Url.relativeUrl('/grade/report/grader/index.php', {
id: courseID,
searchvalue: this.getSearchTerm()
}, false);
}
/**
* Build up the view all link that is dedicated to a particular result.
*
* @param {Number} userID The ID of the user selected.
* @returns {string|*}
*/
selectOneLink(userID) {
return Url.relativeUrl('/grade/report/grader/index.php', {
id: courseID,
searchvalue: this.getSearchTerm(),
userid: userID,
}, false);
}
/**
* Given the set of profile fields we can possibly search, fetch their strings,
* so we can report to screen readers the field that matched.
*
* @returns {Promise<void>}
*/
getStringMap() {
if (!this.profilestringmap) {
const requiredStrings = [
'username',
'firstname',
'lastname',
'email',
'city',
'country',
'department',
'institution',
'idnumber',
'phone1',
'phone2',
];
this.profilestringmap = getStrings(requiredStrings.map((key) => ({key})))
.then((stringArray) => new Map(
requiredStrings.map((key, index) => ([key, stringArray[index]]))
));
}
return this.profilestringmap;
}
}
@@ -0,0 +1,41 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A repo for the search partial in the grader report.
*
* @module gradereport_grader/search/repository
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import ajax from 'core/ajax';
/**
* Given a course ID, we want to fetch the learners within this report.
*
* @method userFetch
* @param {int} courseid ID of the course to fetch the users of.
* @return {object} jQuery promise
*/
export const userFetch = (courseid) => {
const request = {
methodname: 'gradereport_grader_get_users_in_report',
args: {
courseid: courseid,
},
};
return ajax.call([request])[0];
};
@@ -0,0 +1,467 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import CustomEvents from "core/custom_interaction_events";
import {debounce} from 'core/utils';
/**
* The class that manages the state of the search.
*
* @module gradereport_grader/search/search_class
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// Reused variables for the class.
const events = [
'keydown',
CustomEvents.events.activate,
CustomEvents.events.keyboardActivate
];
const UP = -1;
const DOWN = 1;
export default class {
// Define our standard lookups.
selectors = {
component: this.setComponentSelector(),
trigger: this.setTriggerSelector(),
input: '[data-action="search"]',
clearSearch: '[data-action="clearsearch"]',
dropdown: this.setDropdownSelector(),
resultitems: '[role="option"]',
viewall: '#select-all',
};
// The results from the called filter function.
matchedResults = [];
// What did the user search for?
searchTerm = '';
// What the user searched for as a lowercase.
preppedSearchTerm = null;
// The DOM nodes after the dropdown render.
resultNodes = [];
// Where does the user currently have focus?
currentNode = null;
// The current node for the view all link.
currentViewAll = null;
dataset = null;
datasetSize = 0;
// DOM nodes that persist.
component = document.querySelector(this.selectors.component);
searchInput = this.component.querySelector(this.selectors.input);
searchDropdown = this.component.querySelector(this.selectors.dropdown);
$searchButton = $(this.selectors.trigger);
clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
$component = $(this.component);
constructor() {
this.setSearchTerms(this.searchInput.value ?? '');
// Begin handling the base search component.
this.registerClickHandlers();
this.registerKeyHandlers();
this.registerInputHandlers();
}
/**
* Stub out a required function.
*/
fetchDataset() {
throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
* @param {Array} dataset
*/
filterDataset(dataset) {
throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
filterMatchDataset() {
throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
renderDropdown() {
throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
setComponentSelector() {
throw new Error(`setComponentSelector() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
setDropdownSelector() {
throw new Error(`setDropdownSelector() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
setTriggerSelector() {
throw new Error(`setTriggerSelector() must be implemented in ${this.constructor.name}`);
}
/**
* Return the dataset that we will be searching upon.
*
* @returns {Promise<null>}
*/
async getDataset() {
if (!this.dataset) {
this.dataset = await this.fetchDataset();
}
this.datasetSize = this.dataset.length;
return this.dataset;
}
/**
* Return the size of the dataset.
*
* @returns {number}
*/
getDatasetSize() {
return this.datasetSize;
}
/**
* Return the results of the filter upon the dataset.
*
* @returns {Array}
*/
getMatchedResults() {
return this.matchedResults;
}
/**
* Given a filter has been run across the dataset, store the matched results.
*
* @param {Array} result
*/
setMatchedResults(result) {
this.matchedResults = result;
}
/**
* Get the value that the user entered.
*
* @returns {string}
*/
getSearchTerm() {
return this.searchTerm;
}
/**
* Get the transformed search value.
*
* @returns {string}
*/
getPreppedSearchTerm() {
return this.preppedSearchTerm;
}
/**
* When a user searches for something, set our variable to manage it.
*
* @param {string} result
*/
setSearchTerms(result) {
this.searchTerm = result;
this.preppedSearchTerm = result.toLowerCase();
}
/**
* Return an object containing a handfull of dom nodes that we sometimes need the value of.
*
* @returns {object}
*/
getHTMLElements() {
this.updateNodes();
return {
searchDropdown: this.searchDropdown,
currentViewAll: this.currentViewAll,
searchInput: this.searchInput,
clearSearchButton: this.clearSearchButton
};
}
/**
* When called, close the dropdown and reset the input field attributes.
*/
closeSearch() {
this.toggleDropdown();
// 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.
this.setSearchTerms('');
this.searchInput.value = "";
}
/**
* When called, update the dropdown fields.
*
* @param {Boolean} on Flag to toggle hiding or showing values.
*/
toggleDropdown(on = false) {
this.$component.dropdown('toggle');
this.$searchButton.attr('aria-expanded', on);
if (on) {
this.searchDropdown.classList.add('show');
$(this.searchDropdown).show();
} else {
this.searchDropdown.classList.remove('show');
$(this.searchDropdown).hide();
}
}
/**
* These class members change when a new result set is rendered. So update for fresh data.
*/
updateNodes() {
this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];
this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);
this.currentViewAll = this.component.querySelector(this.selectors.viewall);
}
/**
* Register clickable event listeners.
*/
registerClickHandlers() {
// Prevent the click triggering the dropdown.
this.$searchButton.on('click', () => {
this.toggleDropdown();
});
// Register click events within the component.
this.component.addEventListener('click', this.clickHandler.bind(this));
// Register a small click event onto the document since we need to check if they are clicking off the component.
document.addEventListener('click', (e) => {
// Since we are handling dropdowns manually, ensure we can close it when clicking off.
if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) {
this.toggleDropdown();
}
});
}
/**
* Register key event listeners.
*/
registerKeyHandlers() {
CustomEvents.define(document, events);
// Register click events.
events.forEach((event) => {
this.component.addEventListener(event, this.keyHandler.bind(this));
});
}
/**
* Register input event listener for the text input area.
*/
registerInputHandlers() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(async() => {
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
if (this.getSearchTerm() === '') {
this.toggleDropdown();
// 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');
await this.renderAndShow();
}
}, 300));
}
/**
* A combo method to take the matching fields and render out the results.
*
* @returns {Promise<void>}
*/
async renderAndShow() {
// User has given something for us to filter against.
this.setMatchedResults(await this.filterDataset(await this.getDataset()));
await this.filterMatchDataset();
// Replace the dropdown node contents and show the results.
await this.renderDropdown();
// Set the dropdown to open.
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);
}
}
}
}
/**
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
async clickHandler(e) {
this.updateNodes();
// Prevent normal key presses activating this.
if (e.target.closest('.dropdown-item') && e.button === 0) {
window.location = e.target.closest('.dropdown-item').href;
}
// The "clear search" button is triggered.
if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {
this.closeSearch();
this.searchInput.focus({preventScroll: true});
}
// 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) {
await this.renderAndShow();
}
}
/**
* 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) {
this.updateNodes();
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'ArrowUp':
this.keyUpDown(UP, e);
break;
case 'ArrowDown':
this.keyUpDown(DOWN, e);
break;
case 'Home':
e.preventDefault();
this.moveToFirstNode();
break;
case 'End':
e.preventDefault();
this.moveToLastNode();
break;
case 'Escape':
this.toggleDropdown();
this.searchInput.focus({preventScroll: true});
break;
case 'Tab':
// If the current focus is on clear search, then check if viewall exists then around tab to it.
if (e.target.closest(this.selectors.clearSearch)) {
if (this.currentViewAll) {
e.preventDefault();
this.currentViewAll.focus({preventScroll: true});
} else {
this.closeSearch();
}
}
// 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]);
}
};
}
@@ -0,0 +1,124 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace gradereport_grader\external;
use coding_exception;
use context_course;
use core_user_external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_multiple_structure;
use core_external\external_single_structure;
use core_external\external_value;
use core_external\external_warnings;
use grade_report_grader;
use invalid_parameter_exception;
use moodle_exception;
use restricted_context_exception;
use user_picture;
defined('MOODLE_INTERNAL') || die;
require_once($CFG->dirroot.'/course/externallib.php');
require_once($CFG->dirroot .'/user/externallib.php');
require_once($CFG->dirroot.'/grade/lib.php');
require_once($CFG->dirroot.'/grade/report/grader/lib.php');
/**
* External grade report grader API
*
* @package gradereport_grader
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class get_users_in_report extends external_api {
/**
* Describes the parameters for get_users_in_report
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters (
[
'courseid' => new external_value(PARAM_INT, 'Course ID', VALUE_REQUIRED)
]
);
}
/**
* Given a course ID find Fetch the grader report and add some fields to the returned users.
*
* @param int $courseid Course ID to fetch the grader report for.
* @return array Users and warnings to pass back to the calling widget.
* @throws coding_exception
* @throws invalid_parameter_exception
* @throws moodle_exception
* @throws restricted_context_exception
*/
protected static function execute(int $courseid): array {
global $PAGE;
self::validate_parameters(
self::execute_parameters(),
[
'courseid' => $courseid,
]
);
$warnings = [];
$context = context_course::instance($courseid);
self::validate_context($context);
// Return tracking object.
$gpr = new \grade_plugin_return(
[
'type' => 'report',
'plugin' => 'grader',
'courseid' => $courseid
]
);
$report = new grade_report_grader($courseid, $gpr, $context);
// For the returned users, Add a couple of extra fields that we need for the search module.
$users = array_map(function ($user) use ($PAGE) {
$user->fullname = fullname($user);
$userpicture = new user_picture($user);
$userpicture->size = 1;
$user->profileimageurl = $userpicture->get_url($PAGE)->out(false);
$userpicture->size = 0; // Size f2.
$user->profileimageurlsmall = $userpicture->get_url($PAGE)->out(false);
return $user;
}, $report->load_users());
sort($users);
return [
'users' => $users,
'warnings' => $warnings,
];
}
/**
* Returns description of what the users & warnings should return.
*
* @return external_single_structure
*/
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'users' => new external_multiple_structure(core_user_external::user_description()),
'warnings' => new external_warnings(),
]);
}
}
@@ -29,6 +29,9 @@ use core_grades\output\gradebook_dropdown;
*/
class action_bar extends \core_grades\output\action_bar {
/** @var string $usersearch The content that the current user is looking for. */
protected string $usersearch = '';
/**
* The class constructor.
*
@@ -36,6 +39,8 @@ class action_bar extends \core_grades\output\action_bar {
*/
public function __construct(\context_course $context) {
parent::__construct($context);
$this->usersearch = optional_param('searchvalue', '', PARAM_NOTAGS);
}
/**
@@ -52,9 +57,10 @@ class action_bar extends \core_grades\output\action_bar {
*
* @param \renderer_base $output renderer to be used to render the action bar elements.
* @return array
* @throws \moodle_exception
*/
public function export_for_template(\renderer_base $output): array {
global $PAGE;
global $PAGE, $OUTPUT;
// If in the course context, we should display the general navigation selector in gradebook.
$courseid = $this->context->instanceid;
// Get the data used to output the general navigation selector.
@@ -85,6 +91,22 @@ class action_bar extends \core_grades\output\action_bar {
);
$data['initialselector'] = $initialselector->export_for_template($output);
$data['groupselector'] = $gradesrenderer->group_selector($course);
$searchinput = $OUTPUT->render_from_template('gradereport_grader/search/searchinput', [
'currentvalue' => $this->usersearch,
'courseid' => $courseid,
]);
$searchdropdown = new gradebook_dropdown(
true,
$searchinput,
null,
'user-search',
'usersearchwidget',
'usersearchdropdown overflow-auto',
null,
false,
);
$data['searchdropdown'] = $searchdropdown->export_for_template($output);
}
return $data;
+37
View File
@@ -0,0 +1,37 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Grader grade report external functions and service definitions.
*
* @package gradereport_grader
* @copyright 2022 Mathew May <Mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die;
$functions = [
'gradereport_grader_get_users_in_report' => [
'classname' => 'gradereport_grader\\external\\get_users_in_report',
'methodname' => 'execute',
'description' => 'Returns the dataset of users within the report',
'type' => 'read',
'ajax' => true,
'capabilities' => 'gradereport/grader:view',
'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
],
];
+1
View File
@@ -49,6 +49,7 @@ $studentsperpage = optional_param('perpage', null, PARAM_INT);
$PAGE->set_url(new moodle_url('/grade/report/grader/index.php', array('id'=>$courseid)));
$PAGE->set_pagelayout('report');
$PAGE->requires->js_call_amd('gradereport_grader/stickycolspan', 'init');
$PAGE->requires->js_call_amd('gradereport_grader/search', 'init');
// basic access checks
if (!$course = $DB->get_record('course', array('id' => $courseid))) {
@@ -44,10 +44,13 @@ $string['privacy:metadata:preference:grade_report_showuserimage'] = 'Whether to
$string['privacy:metadata:preference:grade_report_studentsperpage'] = 'The number of students displayed per page in the grader report';
$string['privacy:request:preference:grade_report_grader_collapsed_categories'] = 'You have some gradebook categories collapsed in the "{$a->name}" course';
$string['summarygrader'] = 'A table with the names of students in the first column, with assessable activities grouped by course and category across the top.';
$string['showingxofy'] = 'Showing {$a->found} of {$a->total}';
$string['useractivitygrade'] = '{$a} grade';
$string['overriddengrade'] = 'Overridden grade';
$string['advancedgrading'] = 'View {$a} results';
$string['cellactions'] = 'Cell actions';
$string['viewallresults'] = 'View all results for "{$a}"';
$string['viewresultsuser'] = 'View results for {$a}';
// Deprecated since Moodle 4.2.
$string['privacy:metadata:preference:grade_report_showanalysisicon'] = '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.';
+30
View File
@@ -244,3 +244,33 @@
content: '';
}
.path-grade-report-grader .usersearchwidget::after {
content: '';
}
.path-grade-report-grader .usersearchwidget {
pointer-events: none;
}
.path-grade-report-grader .usersearchwidget input {
pointer-events: initial;
z-index: 1;
}
.path-grade-report-grader .usersearchwidget button {
pointer-events: initial;
}
.path-grade-report-grader .usersearchdropdown {
/*Extra height to allow non result items.*/
max-height: 400px;
width: 300px;
pointer-events: all;
padding: 0;
}
.path-grade-report-grader .usersearchdropdown .unsearchablecontentcontainer {
position: sticky;
bottom: 0;
background-color: white;
}
.path-grade-report-grader .usersearchdropdown .footer {
border-top: 1px solid #dee2e6;
font-size: 90%;
}
@@ -71,6 +71,12 @@
</div>
<div class="navitem-divider"></div>
{{/generalnavselector}}
{{#searchdropdown}}
<div class="navitem">
{{>core_grades/tertiary_navigation_dropdown}}
</div>
<div class="navitem-divider"></div>
{{/searchdropdown}}
{{#groupselector}}
<div class="navitem">
{{{groupselector}}}
@@ -0,0 +1,57 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template gradereport_grader/search/resultitem
Template for the individual result item.
Context variables required for this template:
* id - User system ID.
* fullname - Users' full name.
* profileimageurl - Link for the users' large profile image.
* matchingField - The field in the user object that matched the search criteria.
* matchingFieldName - The name of the field that was matched upon for A11y purposes.
* link - The link used to redirect upon self to show only this specific user.
Example context (json):
{
"id": 2,
"fullname": "Foo bar",
"profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630",
"matchingField": "<span class=\"font-weight-bold\">Foo</span> bar",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
}
}}
<li class="w-100 result-row" role="none" id="result-row-{{id}}">
<a role="option" id="user-{{id}}" href="{{{link}}}" class="dropdown-item d-flex px-2 py-1 align-items-center" tabindex="-1" aria-label="{{#str}}viewresultsuser, gradereport_grader, {{fullname}}{{/str}}">
<div class="d-block pr-2 w-25">
{{#profileimageurl}}
<img class="userpicture w-75 mx-auto d-block" src="{{profileimageurl}}" alt=""/>
{{/profileimageurl}}
{{^profileimageurl}}
<span class="userinitials"></span>
{{/profileimageurl}}
</div>
<div class="d-block pr-3 w-75">
<span class="w-100 p-0 font-weight-bold">
{{fullname}}
</span>
<span class="w-100 pull-left text-truncate small" aria-hidden="true">
{{{matchingField}}}
</span>
<span class="sr-only" aria-label="{{#str}}usermatchedon, core_grades{{/str}}">{{matchingFieldName}}</span>
</div>
</a>
</li>
@@ -0,0 +1,71 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template gradereport_grader/search/resultset
Wrapping template for returned result items.
Context variables required for this template:
* users - Our returned users to render.
* found - Count of the found users.
* total - Total count of users within this report.
* selectall - The created link that allows users to select all of the results.
* searchterm - The entered text to find these results.
* hasusers - Allow the handling where no users exist for the returned search term.
Example context (json):
{
"users": [
{
"id": 2,
"fullname": "Foo bar",
"profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630",
"matchingField": "<span class=\"font-weight-bold\">Foo</span> bar",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
},
{
"id": 3,
"fullname": "Bar Foo",
"profileimageurl": "http://foo.bar/pluginfile.php/80/user/icon/boost/f1?rev=7631",
"matchingField": "Bar <span class=\"font-weight-bold\">Foo</span>",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
}
],
"found": 2,
"total": 20,
"selectall": "https://foo.bar/grade/report/grader/index.php?id=2&searchvalue=abe",
"searchterm": "Foo",
"hasusers": true
}
}}
<div class="d-flex flex-column mh-100 h-100">
{{#hasusers}}
<span class="small d-block px-4 my-2">{{#str}}showingxofy, gradereport_grader, {"found": "{{found}}", "total": "{{total}}"}{{/str}}</span>
<ul class="searchresultitemscontainer d-flex flex-column mw-100 position-relative py-2 list-group h-100 mh-100 mx-0" role="listbox" data-region="search-result-items-container" tabindex="-1">
{{#users}}
{{>gradereport_grader/search/resultitem}}
{{/users}}
</ul>
<div class="unsearchablecontentcontainer p-1">
<a role="button" id="select-all" href="{{{selectall}}}" class="small d-block p-4" tabindex="-1">
{{#str}}viewallresults, gradereport_grader, {{searchterm}}{{/str}}
</a>
</div>
{{/hasusers}}
{{^hasusers}}
<span class="small d-block px-4 my-4">{{#str}} noresultsfor, core_grades, {{searchterm}}{{/str}}</span>
{{/hasusers}}
</div>
@@ -0,0 +1,47 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template gradereport_grader/search/searchinput
Wrapping template for search input.
Context variables required for this template:
* currentvalue - If the user has already searched, set the value to that.
* courseid - The id of the course to search within the report of.
Example context (json):
{
"currentvalue": "bar",
"courseid": 2
}
}}
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
{{#currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core_grades
{{/str}}{{/label}}
{{$value}}{{{currentvalue}}}{{/value}}
{{/ core/search_input_auto }}
{{/currentvalue}}
{{^currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core_grades
{{/str}}{{/label}}
{{$placeholder}}{{#str}}
searchusers, core_grades
{{/str}}{{/placeholder}}
{{/ core/search_input_auto }}
{{/currentvalue}}
@@ -0,0 +1,302 @@
@core @javascript @gradereport @gradereport_grader
Feature: Within the grader report, test that we can search for users
In order to find specific users in the course gradebook
As a teacher
I need to be able to see the search input and trigger the search somehow
Background:
Given the following "courses" exist:
| fullname | shortname | category | groupmode |
| Course 1 | C1 | 0 | 1 |
And the following "users" exist:
| username | firstname | lastname | email | idnumber | phone1 | phone2 | department | institution | city | country |
| teacher1 | Teacher | 1 | teacher1@example.com | t1 | 1234567892 | 1234567893 | ABC1 | ABCD | Perth | AU |
| student1 | Student | 1 | student1@example.com | s1 | 3213078612 | 8974325612 | ABC1 | ABCD | Hanoi | VN |
| student2 | Dummy | User | student2@example.com | s2 | 4365899871 | 7654789012 | ABC2 | ABCD | Tokyo | JP |
| student3 | User | Example | student3@example.com | s3 | 3243249087 | 0875421745 | ABC2 | ABCD | Olney | GB |
| student4 | User | Test | student4@example.com | s4 | 0987532523 | 2149871323 | ABC3 | ABCD | Tokyo | JP |
| student5 | Turtle | Manatee | student5@example.com | s5 | 1239087780 | 9873623589 | ABC3 | ABCD | Perth | AU |
# Note: Add groups etc so we can test that the search ignores those filters as well if we go down the filter dataset path.
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
| student3 | C1 | student |
| student4 | C1 | student |
| student5 | C1 | student |
And the following "activities" exist:
| activity | course | idnumber | name |
| assign | C1 | a1 | Test assignment one |
And the following config values are set as admin:
| showuseridentity | idnumber,email,city,country,phone1,phone2,department,institution |
And I am on the "Course 1" "Course" page logged in as "teacher1"
And I change window size to "large"
And I navigate to "View > Grader report" in the course gradebook
Scenario: A teacher can view and trigger the user search
# Check the placeholder text
Given I should see "Search users"
# Confirm the search is currently inactive and results are unfiltered.
And the following should exist in the "user-grades" table:
| -1- |
| Turtle Manatee |
| Student 1 |
| User Example |
| User Test |
| Dummy User |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
When I set the field "Search users" to "Turtle"
And I wait until "View all results for \"Turtle\"" "link" exists
And "Turtle Manatee" "list_item" should exist in the ".user-search" "css_element"
And "User Example" "list_item" should not exist in the ".user-search" "css_element"
And I click on "Turtle Manatee" "list_item"
# Business case: This will trigger a page reload and can not dynamically update the table.
And I wait until the page is ready
Then the following should exist in the "user-grades" table:
| -1- |
| Turtle Manatee |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| User Example |
| User Test |
| Dummy User |
And I set the field "Search users" to "Turt"
And "Clear search input" "button" should exist in the ".user-search" "css_element"
And I click on "Clear search input" "button" in the ".user-search" "css_element"
And "View all results for \"Turt\"" "link" should not be visible
Scenario: A teacher can search the grader report to find specified users
# Case: Standard search.
Given I set the field "Search users" to "Dummy"
And I click on "Dummy User" "option_role"
And I wait until the page is ready
And the following should exist in the "user-grades" table:
| -1- |
| Turtle Manatee |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| User Example |
| User Test |
| Dummy User |
# Case: No users found.
When I set the field "Search users" to "Plagiarism"
And I should see "No results for \"Plagiarism\""
# Table remains unchanged as the user had no results to select from the dropdown.
And the following should exist in the "user-grades" table:
| -1- |
| Turtle Manatee |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| User Example |
| User Test |
| Dummy User |
# Case: Multiple users found and select only one result.
Then I set the field "Search users" to "User"
And "Dummy User" "list_item" should exist in the ".user-search" "css_element"
And "User Example" "list_item" should exist in the ".user-search" "css_element"
And "User Test" "list_item" should exist in the ".user-search" "css_element"
And "Turtle Manatee" "list_item" should not exist in the ".user-search" "css_element"
And I click on "Dummy User" "list_item"
And I wait until the page is ready
And the following should exist in the "user-grades" table:
| -1- |
| Dummy User |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| User Example |
| User Test |
| Turtle Manatee |
# Business case: When searching with multiple partial matches, show the matches in the dropdown + a "View all results for (Bob)"
# Business case cont. When pressing enter with multiple partial matches, behave like when you select the "View all results for (Bob)"
# Case: Multiple users found and select all partial matches.
And I set the field "Search users" to "User"
# Dont need to check if all users are in the dropdown, we checked that earlier in this test.
And "View all results for \"User\"" "link" should exist
And I click on "View all results for \"User\"" "link"
And I wait until the page is ready
And the following should exist in the "user-grades" table:
| -1- |
| Dummy User |
| User Example |
| User Test |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| Turtle Manatee |
Scenario: A teacher can quickly tell that a search is active on the current table
Given I set the field "Search users" to "Turtle"
And I click on "Turtle Manatee" "list_item"
And I wait until the page is ready
# The search input remains in the field on reload this is in keeping with other search implementations.
When the field "Search users" matches value "Turtle"
And "View all results for \"Turtle\"" "link" should not exist
# Test if we can then further retain the turtle result set and further filter from there.
Then I set the field "Search users" to "Turtle plagiarism"
And "Turtle Manatee" "list_item" should not exist
And I should see "No results for \"Turtle plagiarism\""
Scenario: A teacher can search for values besides the users' name
Given I set the field "Search users" to "student5@example.com"
And "Turtle Manatee" "list_item" should exist
And I set the field "Search users" to "@example.com"
# Note: All learners match this email & showing emails is current default.
And "Dummy User" "list_item" should exist in the ".user-search" "css_element"
And "User Example" "list_item" should exist in the ".user-search" "css_element"
And "User Test" "list_item" should exist in the ".user-search" "css_element"
And "Student 1" "list_item" should exist in the ".user-search" "css_element"
And "Turtle Manatee" "list_item" should exist in the ".user-search" "css_element"
# Search on the country field.
When I set the field "Search users" to "JP"
And I wait until "Turtle Manatee" "list_item" does not exist
And "Dummy User" "list_item" should exist in the ".user-search" "css_element"
And "User Test" "list_item" should exist in the ".user-search" "css_element"
# Search on the city field.
And I set the field "Search users" to "Hanoi"
And I wait until "User Test" "list_item" does not exist
Then "Student 1" "list_item" should exist in the ".user-search" "css_element"
# Search on the institution field.
And I set the field "Search users" to "ABCD"
And I wait until "Dummy User" "list_item" exists
And "User Example" "list_item" should exist in the ".user-search" "css_element"
And "User Test" "list_item" should exist in the ".user-search" "css_element"
And "Student 1" "list_item" should exist in the ".user-search" "css_element"
And "Turtle Manatee" "list_item" should exist in the ".user-search" "css_element"
# Search on the department field.
And I set the field "Search users" to "ABC3"
And I wait until "User Example" "list_item" does not exist
And "User Test" "list_item" should exist in the ".user-search" "css_element"
And "Turtle Manatee" "list_item" should exist in the ".user-search" "css_element"
# Search on the phone1 field.
And I set the field "Search users" to "4365899871"
And I wait until "User Test" "list_item" does not exist
And "Dummy User" "list_item" should exist in the ".user-search" "css_element"
# Search on the phone2 field.
And I set the field "Search users" to "2149871323"
And I wait until "Dummy User" "list_item" does not exist
And "User Test" "list_item" should exist in the ".user-search" "css_element"
# Search on the institution field then press enter to show the record set.
And I set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "list_item" exists
And "Dummy User" "list_item" should exist in the ".user-search" "css_element"
And "User Example" "list_item" should exist in the ".user-search" "css_element"
And "User Test" "list_item" should exist in the ".user-search" "css_element"
And "Student 1" "list_item" should exist in the ".user-search" "css_element"
And I press the enter key
And I wait until the page is ready
And the following should exist in the "user-grades" table:
| -1- |
| Student 1 |
| User Example |
| User Test |
| Dummy User |
| Turtle Manatee |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
@accessibility
Scenario: A teacher can set focus and search using the input are with a keyboard
# Basic tests for the page.
Given the page should meet accessibility standards
And the page should meet "wcag131, wcag141, wcag412" accessibility standards
And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
# Move onto general keyboard navigation testing.
And I press the tab key
And the focused element is "Search users" "field"
When I set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists
And I press the down key
And the focused element is "Student 1" "option_role"
And I press the end key
And the focused element is "Turtle Manatee" "option_role"
And I press the home key
And the focused element is "Student 1" "option_role"
And I press the up key
And the focused element is "Turtle Manatee" "option_role"
And I press the down key
And the focused element is "Student 1" "option_role"
And I press the escape key
And the focused element is "Search users" "field"
Then I set the field "Search users" to "Goodmeme"
And I press the down key
And the focused element is "Search users" "field"
# Lets check the tabbing order.
And I set the field "Search users" to "ABC"
And I wait "3" seconds
And I wait until "View all results for \"ABC\"" "button" exists
And I press the tab key
And the focused element is "Clear search input" "button"
And I press the tab key
And the focused element is "View all results for \"ABC\"" "button"
And I press the tab key
And the focused element is ".search-widget[data-searchtype='group'] [data-toggle='dropdown']" "css_element"
# Ensure we can interact with the input & clear search options with the keyboard.
# Space & Enter have the same handling for triggering the two functionalities.
And I set the field "Search users" to "User"
And I click on ".usersearchwidget [data-action=search]" "css_element"
And I press the enter key
And I wait to be redirected
And the following should exist in the "user-grades" table:
| -1- |
| Dummy User |
| User Example |
| User Test |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| Turtle Manatee |
# Sometimes with behat we get unattached nodes causing spurious failures.
And I wait "1" seconds
And I set the field "Search users" to "ABC"
And I wait until "Turtle Manatee" "option_role" exists
And I press the tab key
And the focused element is "Clear search input" "button"
And I press the enter key
And I wait until the page is ready
And I should not see "Turtle Manatee"
Scenario: Once a teacher searches, it'll apply the currently set filters and inform the teacher as such
# Set up a basic filtering case.
Given I press "Filter by name"
And I select "U" in the "First name" "core_grades > initials bar"
And I select "E" in the "Last name" "core_grades > initials bar"
And I press "Apply"
And I wait until the page is ready
And the following should exist in the "user-grades" table:
| -1- |
| User Example |
And the following should not exist in the "user-grades" table:
| -1- |
| Teacher 1 |
| Student 1 |
| User Test |
| Dummy User |
| Turtle Manatee |
# Begin the search checking if we are adhering the filters.
When I set the field "Search users" to "Turtle"
Then "Turtle Manatee" "list_item" should not exist in the ".user-search" "css_element"
+1 -1
View File
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2023032700; // The current plugin version (Date: YYYYMMDDXX).
$plugin->version = 2023032702; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2022111800; // Requires this Moodle version.
$plugin->component = 'gradereport_grader'; // Full name of the plugin (used for diagnostics)
+128
View File
@@ -22,6 +22,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_user\fields;
require_once($CFG->libdir.'/gradelib.php');
/**
@@ -154,6 +156,18 @@ abstract class grade_report {
*/
protected $userwheresql_params = array();
/**
* If the user is wanting to search for a particular user within searchable fields their needle will be placed here.
* @var string $usersearch
*/
protected string $usersearch = '';
/**
* If the user is wanting to show only one particular user their id will be placed here.
* @var int $userid
*/
protected int $userid = -1;
/**
* Constructor. Sets local copies of user preferences and initialises grade_tree.
* @param int $courseid
@@ -186,6 +200,10 @@ abstract class grade_report {
$this->preferences_page = $CFG->wwwroot.'/grade/report/grader/preferences.php?id='.$courseid;
// init gtree in child class
// Set any url params.
$this->usersearch = optional_param('searchvalue', '', PARAM_NOTAGS);
$this->userid = optional_param('userid', -1, PARAM_INT);
}
/**
@@ -431,6 +449,116 @@ abstract class grade_report {
$this->userwheresql .= ' AND '.$DB->sql_like('u.lastname', ':lastname', false, false);
$this->userwheresql_params['lastname'] = $SESSION->gradereport[$filtersurnamekey] . '%';
}
// When a user wants to view a particular user rather than a set of users.
// By omission when selecting one user, also allow passing the search value around.
if ($this->userid !== -1) {
$this->userwheresql .= ' AND '.$DB->sql_equal('u.id', ':uid');
$this->userwheresql_params['uid'] = $this->userid;
}
// A user wants to return a subset of learners that match their search criteria.
if ($this->usersearch !== '' && $this->userid === -1) {
// Get the fields for all contexts because there is a special case later where it allows
// matches of fields you can't access if they are on your own account.
$userfields = fields::for_identity(null)->with_userpic();
['mappings' => $mappings] = (array)$userfields->get_sql('u', true);
[
'where' => $keywordswhere,
'params' => $keywordsparams,
] = $this->get_users_search_sql($mappings, (array)$userfields);
$this->userwheresql = " AND $keywordswhere";
$this->userwheresql_params = array_merge($this->userwheresql_params, $keywordsparams);
}
}
/**
* Prepare SQL where clause and associated parameters for any user searching being performed.
* This mostly came from core_user\table\participants_search with some slight modifications four our use case.
*
* @param array $mappings Array of field mappings (fieldname => SQL code for the value)
* @param array $userfields An array that we cast from user profile fields to search within.
* @return array SQL query data in the format ['where' => '', 'params' => []].
*/
protected function get_users_search_sql(array $mappings, array $userfields): array {
global $DB, $USER;
$canviewfullnames = has_capability('moodle/site:viewfullnames', $this->context);
$params = [];
$searchkey1 = 'search01';
$searchkey2 = 'search02';
$searchkey3 = 'search03';
$conditions = [];
// Search by fullname.
[$fullname, $fullnameparams] = fields::get_sql_fullname('u', $canviewfullnames);
$conditions[] = $DB->sql_like($fullname, ':' . $searchkey1, false, false);
$params = array_merge($params, $fullnameparams);
// Search by email.
$email = $DB->sql_like('email', ':' . $searchkey2, false, false);
if (!in_array('email', $userfields)) {
$maildisplay = 'maildisplay0';
$userid1 = 'userid01';
// Prevent users who hide their email address from being found by others
// who aren't allowed to see hidden email addresses.
$email = "(". $email ." AND (" .
"u.maildisplay <> :$maildisplay " .
"OR u.id = :$userid1". // Users can always find themselves.
"))";
$params[$maildisplay] = core_user::MAILDISPLAY_HIDE;
$params[$userid1] = $USER->id;
}
$conditions[] = $email;
// Search by idnumber.
$idnumber = $DB->sql_like('idnumber', ':' . $searchkey3, false, false);
if (!in_array('idnumber', $userfields)) {
$userid2 = 'userid02';
// Users who aren't allowed to see idnumbers should at most find themselves
// when searching for an idnumber.
$idnumber = "(". $idnumber . " AND u.id = :$userid2)";
$params[$userid2] = $USER->id;
}
$conditions[] = $idnumber;
// Search all user identify fields.
$extrasearchfields = fields::get_identity_fields(null);
foreach ($extrasearchfields as $fieldindex => $extrasearchfield) {
if (in_array($extrasearchfield, ['email', 'idnumber', 'country'])) {
// Already covered above.
continue;
}
// The param must be short (max 32 characters) so don't include field name.
$param = $searchkey3 . '_ident' . $fieldindex;
$fieldsql = $mappings[$extrasearchfield];
$condition = $DB->sql_like($fieldsql, ':' . $param, false, false);
$params[$param] = "%$this->usersearch%";
if (!in_array($extrasearchfield, $userfields)) {
// User cannot see this field, but allow match if their own account.
$userid3 = 'userid03_ident' . $fieldindex;
$condition = "(". $condition . " AND u.id = :$userid3)";
$params[$userid3] = $USER->id;
}
$conditions[] = $condition;
}
$where = "(". implode(" OR ", $conditions) .") ";
$params[$searchkey1] = "%$this->usersearch%";
$params[$searchkey2] = "%$this->usersearch%";
$params[$searchkey3] = "%$this->usersearch%";
return [
'where' => $where,
'params' => $params,
];
}
/**
@@ -39,7 +39,7 @@
<small>{{.}}</small>
{{/buttonheader}}
<div class="{{#parentclasses}}{{.}}{{/parentclasses}}">
<div class="{{#buttonclasses}}{{.}}{{/buttonclasses}} btn dropdown-toggle keep-open d-flex text-left align-items-center p-0 font-weight-bold" data-toggle="dropdown" tabindex="0" aria-haspopup="true" role="button" aria-label="{{#str}} aria-toggledropdown, core_grades {{/str}}" aria-expanded="false">
<div class="{{#buttonclasses}}{{.}}{{/buttonclasses}} btn dropdown-toggle keep-open d-flex text-left align-items-center p-0 font-weight-bold" data-toggle="dropdown" aria-haspopup="true" {{#usebutton}}role="button" aria-expanded="false" tabindex="0"{{/usebutton}} {{^usebutton}}tabindex="-1"{{/usebutton}} aria-label="{{#str}} aria-toggledropdown, core_grades {{/str}}" >
{{{buttoncontent}}}
</div>
<div class="{{#dropdownclasses}}{{.}}{{/dropdownclasses}} dropdown-menu {{#rtl}}dropdown-menu-right{{/rtl}}">
+2
View File
@@ -574,6 +574,7 @@ $string['nooutcome'] = 'No outcome';
$string['nooutcomes'] = 'Outcome items must be linked to a course outcome, but there are no outcomes for this course. Would you like to add one?';
$string['nopermissiontoresetweights'] = 'No permission to reset the weights';
$string['nopublish'] = 'Do not publish';
$string['noresultsfor'] = 'No results for "{$a}"';
$string['noreports'] = 'You are not enrolled in, nor teaching any courses on this site.';
$string['norolesdefined'] = 'No roles defined in Administration > Grades > General settings > Graded roles';
$string['noscales'] = 'Outcomes must be linked to a course scale or global scale, but there are none. Would you like to add one?';
@@ -852,6 +853,7 @@ $string['userfields_show_help'] = 'Show additional user fields like email addres
$string['usergrade'] = 'User {$a->fullname} ({$a->useridnumber}) on item {$a->gradeidnumber}';
$string['userid'] = 'User ID';
$string['useridnumberwarning'] = 'Users without an ID number are excluded from the export as they cannot be imported';
$string['usermatchedon'] = 'This user matches the search via the following field';
$string['usermappingerror'] = 'User mapping error: Could not find user with {$a->field} of "{$a->value}".';
$string['usermappingerrormultipleusersfound'] = 'User mapping error: Multiple users found with {$a->field} of "{$a->value}". Please use a more unique mapping field.';
$string['usermappingerrorusernotfound'] = 'User mapping error. Could not find user.';
@@ -112,6 +112,7 @@ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSele
'menuitem' => 'menuitem',
'optgroup' => 'optgroup',
'option' => 'option',
'option_role' => 'option_role',
'question' => 'question',
'radio' => 'radio',
'region' => 'region',
@@ -217,6 +218,9 @@ XPATH
XPATH
, 'menuitem' => <<<XPATH
.//*[@role='menuitem'][%titleMatch% or %ariaLabelMatch% or text()[contains(., %locator%)]]
XPATH
, 'option_role' => <<<XPATH
.//*[@role='option'][%titleMatch% or %ariaLabelMatch% or text()[contains(., %locator%)]]
XPATH
, 'question' => <<<XPATH
.//div[contains(concat(' ', normalize-space(@class), ' '), ' que ')]