MDL-76143 gradebook: Add search into graderreport
This commit is contained in:
+1
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
];
|
||||
@@ -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.';
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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 ')]
|
||||
|
||||
Reference in New Issue
Block a user