diff --git a/grade/amd/build/searchwidget/initials.min.js b/grade/amd/build/searchwidget/initials.min.js index 0d68127de2ed4..b22b67b1fb470 100644 --- a/grade/amd/build/searchwidget/initials.min.js +++ b/grade/amd/build/searchwidget/initials.min.js @@ -5,6 +5,6 @@ define("core_grades/searchwidget/initials",["exports","core/pending","core/url", * @module core_grades/searchwidget/initials * @copyright 2022 Mathew May * @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 \ No newline at end of file diff --git a/grade/amd/build/searchwidget/initials.min.js.map b/grade/amd/build/searchwidget/initials.min.js.map index 5ef01f43f747c..a9773d7547567 100644 --- a/grade/amd/build/searchwidget/initials.min.js.map +++ b/grade/amd/build/searchwidget/initials.min.js.map @@ -1 +1 @@ -{"version":3,"file":"initials.min.js","sources":["../../src/searchwidget/initials.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 .\n\n/**\n * A small dropdown to filter users within the gradebook.\n *\n * @module core_grades/searchwidget/initials\n * @copyright 2022 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Pending from 'core/pending';\nimport * as Url from 'core/url';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport $ from 'jquery';\n\n/**\n * Whether the event listener has already been registered for this module.\n *\n * @type {boolean}\n */\nlet registered = false;\n\n// Contain our selectors within this file until they could be of use elsewhere.\nconst selectors = {\n pageListItem: 'page-item',\n pageClickableItem: '.page-link',\n activeItem: 'active',\n formDropdown: '.initialsdropdownform',\n parentDomNode: '.initials-selector',\n firstInitial: 'firstinitial',\n lastInitial: 'lastinitial',\n initialBars: '.initialbar', // Both first and last name use this class.\n targetButton: 'initialswidget',\n formItems: {\n type: 'submit',\n save: 'save',\n cancel: 'cancel'\n }\n};\n\n/**\n * Our initial hook into the module which will eventually allow us to handle the dropdown initials bar form.\n *\n * @param {String} callingLink The link to redirect upon form submission.\n */\nexport const init = (callingLink) => {\n if (registered) {\n return;\n }\n const pendingPromise = new Pending();\n registerListenerEvents(callingLink);\n // BS events always bubble so, we need to listen for the event higher up the chain.\n $(selectors.parentDomNode).on('shown.bs.dropdown', () => {\n document.querySelector(selectors.pageClickableItem).focus({preventScroll: true});\n });\n pendingPromise.resolve();\n registered = true;\n};\n\n/**\n * Register event listeners.\n *\n * @param {String} callingLink The link to redirect upon form submission.\n */\nconst registerListenerEvents = (callingLink) => {\n const events = [\n 'click',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n ];\n CustomEvents.define(document, events);\n\n // Register events.\n events.forEach((event) => {\n document.addEventListener(event, (e) => {\n // Always fetch the latest information when we click as state is a fickle thing.\n let {firstActive, lastActive, sifirst, silast} = onClickVariables();\n let itemToReset = '';\n\n // Prevent the usual form behaviour.\n if (e.target.closest(selectors.formDropdown)) {\n e.preventDefault();\n }\n\n // Handle the state of active initials before form submission.\n if (e.target.closest(`${selectors.formDropdown} .${selectors.pageListItem}`)) {\n // Ensure the li items don't cause weird clicking emptying out the form.\n if (e.target.classList.contains(selectors.pageListItem)) {\n return;\n }\n\n const initialsBar = e.target.closest(selectors.initialBars); // Find out which initial bar we are in.\n\n // We want to find the current active item in the menu area the user selected.\n // We also want to fetch the raw item out of the array for instant manipulation.\n if (initialsBar.classList.contains(selectors.firstInitial)) {\n sifirst = e.target;\n itemToReset = firstActive;\n } else {\n silast = e.target;\n itemToReset = lastActive;\n }\n swapActiveItems(itemToReset, e);\n }\n\n // Handle form submissions.\n if (e.target.type === selectors.formItems.type) {\n if (e.target.dataset.action === selectors.formItems.save) {\n // Ensure we strip out the value (All) as it messes with the PHP side of the initials bar.\n // Then we will redirect the user back onto the page with new filters applied.\n window.location = Url.relativeUrl(callingLink, {\n 'id': e.target.closest(selectors.formDropdown).dataset.courseid,\n 'sifirst': sifirst.parentElement.classList.contains('initialbarall') ? '' : sifirst.value,\n 'silast': silast.parentElement.classList.contains('initialbarall') ? '' : silast.value,\n });\n }\n if (e.target.dataset.action === selectors.formItems.cancel) {\n $(`.${selectors.targetButton}`).dropdown('toggle');\n }\n }\n });\n });\n};\n\n/**\n * A small abstracted helper function which allows us to ensure we have up-to-date lists of nodes.\n *\n * @returns {{firstActive: HTMLElement, lastActive: HTMLElement, sifirst: ?String, silast: ?String}}\n */\nconst onClickVariables = () => {\n // Ensure we have an up-to-date initials bar.\n const firstItems = [...document.querySelectorAll(`.${selectors.firstInitial} li`)];\n const lastItems = [...document.querySelectorAll(`.${selectors.lastInitial} li`)];\n const firstActive = firstItems.filter((item) => item.classList.contains(selectors.activeItem))[0];\n const lastActive = lastItems.filter((item) => item.classList.contains(selectors.activeItem))[0];\n // Ensure we retain both of the selections from a previous instance.\n let sifirst = firstActive.querySelector(selectors.pageClickableItem);\n let silast = lastActive.querySelector(selectors.pageClickableItem);\n return {firstActive, lastActive, sifirst, silast};\n};\n\n/**\n * Given we are provided the old li and current click event, swap around the active properties.\n *\n * @param {HTMLElement} itemToReset\n * @param {Event} e\n */\nconst swapActiveItems = (itemToReset, e) => {\n itemToReset.classList.remove(selectors.activeItem);\n itemToReset.querySelector(selectors.pageClickableItem).ariaCurrent = false;\n\n // Set the select item as the current item.\n const itemToSetActive = e.target.parentElement;\n itemToSetActive.classList.add(selectors.activeItem);\n e.target.ariaCurrent = true;\n};\n"],"names":["registered","selectors","type","save","cancel","callingLink","pendingPromise","Pending","registerListenerEvents","on","document","querySelector","focus","preventScroll","resolve","events","CustomEvents","activate","keyboardActivate","define","forEach","event","addEventListener","e","firstActive","lastActive","sifirst","silast","onClickVariables","itemToReset","target","closest","preventDefault","classList","contains","swapActiveItems","dataset","action","window","location","Url","relativeUrl","courseid","parentElement","value","dropdown","firstItems","querySelectorAll","lastItems","filter","item","remove","ariaCurrent","add"],"mappings":";;;;;;;44BAiCIA,YAAa,QAGXC,uBACY,YADZA,4BAEiB,aAFjBA,qBAGU,SAHVA,uBAIY,wBAJZA,wBAKa,qBALbA,uBAMY,eANZA,sBAOW,cAPXA,sBAQW,cARXA,uBASY,iBATZA,oBAUS,CACPC,KAAM,SACNC,KAAM,OACNC,OAAQ,wBASKC,iBACbL,wBAGEM,eAAiB,IAAIC,iBAC3BC,uBAAuBH,iCAErBJ,yBAAyBQ,GAAG,qBAAqB,KAC/CC,SAASC,cAAcV,6BAA6BW,MAAM,CAACC,eAAe,OAE9EP,eAAeQ,UACfd,YAAa,SAQXQ,uBAA0BH,oBACtBU,OAAS,CACX,QACAC,mCAAaD,OAAOE,SACpBD,mCAAaD,OAAOG,qDAEXC,OAAOT,SAAUK,QAG9BA,OAAOK,SAASC,QACZX,SAASY,iBAAiBD,OAAQE,QAE1BC,YAACA,YAADC,WAAcA,WAAdC,QAA0BA,QAA1BC,OAAmCA,QAAUC,mBAC7CC,YAAc,MAGdN,EAAEO,OAAOC,QAAQ9B,yBACjBsB,EAAES,iBAIFT,EAAEO,OAAOC,kBAAW9B,oCAA2BA,yBAA2B,IAEtEsB,EAAEO,OAAOG,UAAUC,SAASjC,+BAIZsB,EAAEO,OAAOC,QAAQ9B,uBAIrBgC,UAAUC,SAASjC,yBAC/ByB,QAAUH,EAAEO,OACZD,YAAcL,cAEdG,OAASJ,EAAEO,OACXD,YAAcJ,YAElBU,gBAAgBN,YAAaN,GAI7BA,EAAEO,OAAO5B,OAASD,oBAAoBC,OAClCqB,EAAEO,OAAOM,QAAQC,SAAWpC,oBAAoBE,OAGhDmC,OAAOC,SAAWC,IAAIC,YAAYpC,YAAa,IACrCkB,EAAEO,OAAOC,QAAQ9B,wBAAwBmC,QAAQM,iBAC5ChB,QAAQiB,cAAcV,UAAUC,SAAS,iBAAmB,GAAKR,QAAQkB,aAC1EjB,OAAOgB,cAAcV,UAAUC,SAAS,iBAAmB,GAAKP,OAAOiB,SAGrFrB,EAAEO,OAAOM,QAAQC,SAAWpC,oBAAoBG,uCAC1CH,yBAA0B4C,SAAS,kBAYvDjB,iBAAmB,WAEfkB,WAAa,IAAIpC,SAASqC,4BAAqB9C,gCAC/C+C,UAAY,IAAItC,SAASqC,4BAAqB9C,+BAC9CuB,YAAcsB,WAAWG,QAAQC,MAASA,KAAKjB,UAAUC,SAASjC,wBAAuB,GACzFwB,WAAauB,UAAUC,QAAQC,MAASA,KAAKjB,UAAUC,SAASjC,wBAAuB,OAEzFyB,QAAUF,YAAYb,cAAcV,6BACpC0B,OAASF,WAAWd,cAAcV,mCAC/B,CAACuB,YAAAA,YAAaC,WAAAA,WAAYC,QAAAA,QAASC,OAAAA,SASxCQ,gBAAkB,CAACN,YAAaN,KAClCM,YAAYI,UAAUkB,OAAOlD,sBAC7B4B,YAAYlB,cAAcV,6BAA6BmD,aAAc,EAG7C7B,EAAEO,OAAOa,cACjBV,UAAUoB,IAAIpD,sBAC9BsB,EAAEO,OAAOsB,aAAc"} \ No newline at end of file +{"version":3,"file":"initials.min.js","sources":["../../src/searchwidget/initials.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 .\n\n/**\n * A small dropdown to filter users within the gradebook.\n *\n * @module core_grades/searchwidget/initials\n * @copyright 2022 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Pending from 'core/pending';\nimport * as Url from 'core/url';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport $ from 'jquery';\n\n/**\n * Whether the event listener has already been registered for this module.\n *\n * @type {boolean}\n */\nlet registered = false;\n\n// Contain our selectors within this file until they could be of use elsewhere.\nconst selectors = {\n pageListItem: 'page-item',\n pageClickableItem: '.page-link',\n activeItem: 'active',\n formDropdown: '.initialsdropdownform',\n parentDomNode: '.initials-selector',\n firstInitial: 'firstinitial',\n lastInitial: 'lastinitial',\n initialBars: '.initialbar', // Both first and last name use this class.\n targetButton: 'initialswidget',\n formItems: {\n type: 'submit',\n save: 'save',\n cancel: 'cancel'\n }\n};\n\n/**\n * Our initial hook into the module which will eventually allow us to handle the dropdown initials bar form.\n *\n * @param {String} callingLink The link to redirect upon form submission.\n */\nexport const init = (callingLink) => {\n if (registered) {\n return;\n }\n const pendingPromise = new Pending();\n registerListenerEvents(callingLink);\n // BS events always bubble so, we need to listen for the event higher up the chain.\n $(selectors.parentDomNode).on('shown.bs.dropdown', () => {\n document.querySelector(selectors.pageClickableItem).focus({preventScroll: true});\n });\n pendingPromise.resolve();\n registered = true;\n};\n\n/**\n * Register event listeners.\n *\n * @param {String} callingLink The link to redirect upon form submission.\n */\nconst registerListenerEvents = (callingLink) => {\n const events = [\n 'click',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n ];\n CustomEvents.define(document, events);\n\n // Register events.\n events.forEach((event) => {\n document.addEventListener(event, (e) => {\n // Always fetch the latest information when we click as state is a fickle thing.\n let {firstActive, lastActive, sifirst, silast} = onClickVariables();\n let itemToReset = '';\n\n // Prevent the usual form behaviour.\n if (e.target.closest(selectors.formDropdown)) {\n e.preventDefault();\n }\n\n // Handle the state of active initials before form submission.\n if (e.target.closest(`${selectors.formDropdown} .${selectors.pageListItem}`)) {\n // Ensure the li items don't cause weird clicking emptying out the form.\n if (e.target.classList.contains(selectors.pageListItem)) {\n return;\n }\n\n const initialsBar = e.target.closest(selectors.initialBars); // Find out which initial bar we are in.\n\n // We want to find the current active item in the menu area the user selected.\n // We also want to fetch the raw item out of the array for instant manipulation.\n if (initialsBar.classList.contains(selectors.firstInitial)) {\n sifirst = e.target;\n itemToReset = firstActive;\n } else {\n silast = e.target;\n itemToReset = lastActive;\n }\n swapActiveItems(itemToReset, e);\n }\n\n // Handle form submissions.\n if (e.target.closest(`${selectors.formDropdown}`) && e.target.type === selectors.formItems.type) {\n if (e.target.dataset.action === selectors.formItems.save) {\n // Ensure we strip out the value (All) as it messes with the PHP side of the initials bar.\n // Then we will redirect the user back onto the page with new filters applied.\n window.location = Url.relativeUrl(callingLink, {\n 'id': e.target.closest(selectors.formDropdown).dataset.courseid,\n 'sifirst': sifirst.parentElement.classList.contains('initialbarall') ? '' : sifirst.value,\n 'silast': silast.parentElement.classList.contains('initialbarall') ? '' : silast.value,\n });\n }\n if (e.target.dataset.action === selectors.formItems.cancel) {\n $(`.${selectors.targetButton}`).dropdown('toggle');\n }\n }\n });\n });\n};\n\n/**\n * A small abstracted helper function which allows us to ensure we have up-to-date lists of nodes.\n *\n * @returns {{firstActive: HTMLElement, lastActive: HTMLElement, sifirst: ?String, silast: ?String}}\n */\nconst onClickVariables = () => {\n // Ensure we have an up-to-date initials bar.\n const firstItems = [...document.querySelectorAll(`.${selectors.firstInitial} li`)];\n const lastItems = [...document.querySelectorAll(`.${selectors.lastInitial} li`)];\n const firstActive = firstItems.filter((item) => item.classList.contains(selectors.activeItem))[0];\n const lastActive = lastItems.filter((item) => item.classList.contains(selectors.activeItem))[0];\n // Ensure we retain both of the selections from a previous instance.\n let sifirst = firstActive.querySelector(selectors.pageClickableItem);\n let silast = lastActive.querySelector(selectors.pageClickableItem);\n return {firstActive, lastActive, sifirst, silast};\n};\n\n/**\n * Given we are provided the old li and current click event, swap around the active properties.\n *\n * @param {HTMLElement} itemToReset\n * @param {Event} e\n */\nconst swapActiveItems = (itemToReset, e) => {\n itemToReset.classList.remove(selectors.activeItem);\n itemToReset.querySelector(selectors.pageClickableItem).ariaCurrent = false;\n\n // Set the select item as the current item.\n const itemToSetActive = e.target.parentElement;\n itemToSetActive.classList.add(selectors.activeItem);\n e.target.ariaCurrent = true;\n};\n"],"names":["registered","selectors","type","save","cancel","callingLink","pendingPromise","Pending","registerListenerEvents","on","document","querySelector","focus","preventScroll","resolve","events","CustomEvents","activate","keyboardActivate","define","forEach","event","addEventListener","e","firstActive","lastActive","sifirst","silast","onClickVariables","itemToReset","target","closest","preventDefault","classList","contains","swapActiveItems","dataset","action","window","location","Url","relativeUrl","courseid","parentElement","value","dropdown","firstItems","querySelectorAll","lastItems","filter","item","remove","ariaCurrent","add"],"mappings":";;;;;;;44BAiCIA,YAAa,QAGXC,uBACY,YADZA,4BAEiB,aAFjBA,qBAGU,SAHVA,uBAIY,wBAJZA,wBAKa,qBALbA,uBAMY,eANZA,sBAOW,cAPXA,sBAQW,cARXA,uBASY,iBATZA,oBAUS,CACPC,KAAM,SACNC,KAAM,OACNC,OAAQ,wBASKC,iBACbL,wBAGEM,eAAiB,IAAIC,iBAC3BC,uBAAuBH,iCAErBJ,yBAAyBQ,GAAG,qBAAqB,KAC/CC,SAASC,cAAcV,6BAA6BW,MAAM,CAACC,eAAe,OAE9EP,eAAeQ,UACfd,YAAa,SAQXQ,uBAA0BH,oBACtBU,OAAS,CACX,QACAC,mCAAaD,OAAOE,SACpBD,mCAAaD,OAAOG,qDAEXC,OAAOT,SAAUK,QAG9BA,OAAOK,SAASC,QACZX,SAASY,iBAAiBD,OAAQE,QAE1BC,YAACA,YAADC,WAAcA,WAAdC,QAA0BA,QAA1BC,OAAmCA,QAAUC,mBAC7CC,YAAc,MAGdN,EAAEO,OAAOC,QAAQ9B,yBACjBsB,EAAES,iBAIFT,EAAEO,OAAOC,kBAAW9B,oCAA2BA,yBAA2B,IAEtEsB,EAAEO,OAAOG,UAAUC,SAASjC,+BAIZsB,EAAEO,OAAOC,QAAQ9B,uBAIrBgC,UAAUC,SAASjC,yBAC/ByB,QAAUH,EAAEO,OACZD,YAAcL,cAEdG,OAASJ,EAAEO,OACXD,YAAcJ,YAElBU,gBAAgBN,YAAaN,GAI7BA,EAAEO,OAAOC,kBAAW9B,0BAA6BsB,EAAEO,OAAO5B,OAASD,oBAAoBC,OACnFqB,EAAEO,OAAOM,QAAQC,SAAWpC,oBAAoBE,OAGhDmC,OAAOC,SAAWC,IAAIC,YAAYpC,YAAa,IACrCkB,EAAEO,OAAOC,QAAQ9B,wBAAwBmC,QAAQM,iBAC5ChB,QAAQiB,cAAcV,UAAUC,SAAS,iBAAmB,GAAKR,QAAQkB,aAC1EjB,OAAOgB,cAAcV,UAAUC,SAAS,iBAAmB,GAAKP,OAAOiB,SAGrFrB,EAAEO,OAAOM,QAAQC,SAAWpC,oBAAoBG,uCAC1CH,yBAA0B4C,SAAS,kBAYvDjB,iBAAmB,WAEfkB,WAAa,IAAIpC,SAASqC,4BAAqB9C,gCAC/C+C,UAAY,IAAItC,SAASqC,4BAAqB9C,+BAC9CuB,YAAcsB,WAAWG,QAAQC,MAASA,KAAKjB,UAAUC,SAASjC,wBAAuB,GACzFwB,WAAauB,UAAUC,QAAQC,MAASA,KAAKjB,UAAUC,SAASjC,wBAAuB,OAEzFyB,QAAUF,YAAYb,cAAcV,6BACpC0B,OAASF,WAAWd,cAAcV,mCAC/B,CAACuB,YAAAA,YAAaC,WAAAA,WAAYC,QAAAA,QAASC,OAAAA,SASxCQ,gBAAkB,CAACN,YAAaN,KAClCM,YAAYI,UAAUkB,OAAOlD,sBAC7B4B,YAAYlB,cAAcV,6BAA6BmD,aAAc,EAG7C7B,EAAEO,OAAOa,cACjBV,UAAUoB,IAAIpD,sBAC9BsB,EAAEO,OAAOsB,aAAc"} \ No newline at end of file diff --git a/grade/amd/src/searchwidget/initials.js b/grade/amd/src/searchwidget/initials.js index bc7ed37246b36..19fa2a830c4fa 100644 --- a/grade/amd/src/searchwidget/initials.js +++ b/grade/amd/src/searchwidget/initials.js @@ -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. diff --git a/grade/classes/output/gradebook_dropdown.php b/grade/classes/output/gradebook_dropdown.php index e30c3736d5840..cf3da41e3926c 100644 --- a/grade/classes/output/gradebook_dropdown.php +++ b/grade/classes/output/gradebook_dropdown.php @@ -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 @@ public function __construct( ?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 @@ public function __construct( $this->buttonclasses = $buttonclasses; $this->dropdownclasses = $dropdownclasses; $this->buttonheader = $buttonheader; + $this->usesbutton = $usebutton; } /** @@ -112,6 +118,7 @@ public function export_for_template(renderer_base $output): array { 'buttonclasses' => $this->buttonclasses, 'dropdownclasses' => $this->dropdownclasses, 'buttonheader' => $this->buttonheader, + 'usebutton' => $this->usesbutton, ]; } diff --git a/grade/report/grader/amd/build/search.min.js b/grade/report/grader/amd/build/search.min.js new file mode 100644 index 0000000000000..4ffcb9153052e --- /dev/null +++ b/grade/report/grader/amd/build/search.min.js @@ -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(),''.concat(this.getSearchTerm(),"")),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 \ No newline at end of file diff --git a/grade/report/grader/amd/build/search.min.js.map b/grade/report/grader/amd/build/search.min.js.map new file mode 100644 index 0000000000000..22c3cd557f0f1 --- /dev/null +++ b/grade/report/grader/amd/build/search.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"search.min.js","sources":["../src/search.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 .\n\n/**\n * Allow the user to search for learners within the grader report.\n * Have to basically search twice on the dataset to avoid passing around massive csv params whilst allowing debouncing.\n *\n * @module gradereport_grader/search\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GradebookSearchClass from 'gradereport_grader/search/search_class';\nimport * as Repository from 'gradereport_grader/search/repository';\nimport {get_strings as getStrings} from 'core/str';\nimport Url from 'core/url';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\n\n// Define our standard lookups.\nconst selectors = {\n component: '.user-search',\n courseid: '[data-region=\"courseid\"]',\n};\nconst component = document.querySelector(selectors.component);\nconst courseID = component.querySelector(selectors.courseid).dataset.courseid;\nconst bannedFilterFields = ['profileimageurlsmall', 'profileimageurl', 'id', 'link', 'matchingField', 'matchingFieldName'];\n\nexport default class UserSearch extends GradebookSearchClass {\n\n // A map of user profile field names that is human-readable.\n profilestringmap = null;\n\n constructor() {\n super();\n }\n\n static init() {\n return new UserSearch();\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n setComponentSelector() {\n return '.user-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n setDropdownSelector() {\n return '.usersearchdropdown';\n }\n\n /**\n * The triggering div that contains the searching widget.\n *\n * @returns {string}\n */\n setTriggerSelector() {\n return '.usersearchwidget';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('gradereport_grader/search/resultset', {\n users: this.getMatchedResults(),\n hasusers: this.getMatchedResults().length > 0,\n total: this.getDatasetSize(),\n found: this.getMatchedResults().length,\n searchterm: this.getSearchTerm(),\n selectall: this.selectAllResultsLink(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n return Repository.userFetch(courseID).then((r) => r.users);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n return filterableData.filter((user) => Object.keys(user).some((key) => {\n if (user[key] === \"\" || bannedFilterFields.includes(key)) {\n return false;\n }\n return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n *\n * @returns {Array} The results with the matched fields inserted.\n */\n async filterMatchDataset() {\n const stringMap = await this.getStringMap();\n this.setMatchedResults(\n this.getMatchedResults().map((user) => {\n for (const [key, value] of Object.entries(user)) {\n const valueString = value.toString().toLowerCase();\n if (!valueString.includes(this.getPreppedSearchTerm())) {\n continue;\n }\n // Ensure we have a good string, otherwise fallback to the key.\n user.matchingFieldName = stringMap.get(key) ?? key;\n user.matchingField = valueString.replace(\n this.getPreppedSearchTerm(),\n `${this.getSearchTerm()}`\n );\n user.link = this.selectOneLink(user.id);\n break;\n }\n return user;\n })\n );\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n clickHandler(e) {\n super.clickHandler(e);\n if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) {\n window.location = this.selectAllResultsLink();\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n super.keyHandler(e);\n\n if (e.target === this.getHTMLElements().currentViewAll && (e.key === 'Enter' || e.key === 'Space')) {\n window.location = this.selectAllResultsLink();\n }\n\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'Enter':\n case ' ':\n if (document.activeElement === this.getHTMLElements().searchInput) {\n if (e.key === ' ') {\n break;\n } else {\n window.location = this.selectAllResultsLink();\n break;\n }\n }\n if (document.activeElement === this.getHTMLElements().clearSearchButton) {\n this.closeSearch();\n break;\n }\n e.preventDefault();\n window.location = e.target.closest('.dropdown-item').href;\n break;\n }\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n searchvalue: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n searchvalue: this.getSearchTerm(),\n userid: userID,\n }, false);\n }\n\n /**\n * Given the set of profile fields we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n getStringMap() {\n if (!this.profilestringmap) {\n const requiredStrings = [\n 'username',\n 'firstname',\n 'lastname',\n 'email',\n 'city',\n 'country',\n 'department',\n 'institution',\n 'idnumber',\n 'phone1',\n 'phone2',\n ];\n this.profilestringmap = getStrings(requiredStrings.map((key) => ({key})))\n .then((stringArray) => new Map(\n requiredStrings.map((key, index) => ([key, stringArray[index]]))\n ));\n }\n return this.profilestringmap;\n }\n}\n"],"names":["selectors","courseID","document","querySelector","dataset","courseid","bannedFilterFields","UserSearch","GradebookSearchClass","constructor","setComponentSelector","setDropdownSelector","setTriggerSelector","html","js","users","this","getMatchedResults","hasusers","length","total","getDatasetSize","found","searchterm","getSearchTerm","selectall","selectAllResultsLink","getHTMLElements","searchDropdown","fetchDataset","Repository","userFetch","then","r","filterableData","filter","user","Object","keys","some","key","includes","toString","toLowerCase","getPreppedSearchTerm","stringMap","getStringMap","setMatchedResults","map","value","entries","valueString","matchingFieldName","get","matchingField","replace","link","selectOneLink","id","clickHandler","e","target","currentViewAll","button","window","location","keyHandler","activeElement","searchInput","clearSearchButton","closeSearch","preventDefault","closest","href","Url","relativeUrl","searchvalue","userID","userid","profilestringmap","requiredStrings","stringArray","Map","index"],"mappings":"65CA8BMA,oBACS,eADTA,mBAEQ,2BAGRC,SADYC,SAASC,cAAcH,qBACdG,cAAcH,oBAAoBI,QAAQC,SAC/DC,mBAAqB,CAAC,uBAAwB,kBAAmB,KAAM,OAAQ,gBAAiB,2BAEjFC,mBAAmBC,sBAKpCC,8CAFmB,qKAOR,IAAIF,WAQfG,6BACW,eAQXC,4BACW,sBAQXC,2BACW,iDAODC,KAACA,KAADC,GAAOA,UAAY,+BAAiB,sCAAuC,CAC7EC,MAAOC,KAAKC,oBACZC,SAAUF,KAAKC,oBAAoBE,OAAS,EAC5CC,MAAOJ,KAAKK,iBACZC,MAAON,KAAKC,oBAAoBE,OAChCI,WAAYP,KAAKQ,gBACjBC,UAAWT,KAAKU,4DAEAV,KAAKW,kBAAkBC,eAAgBf,KAAMC,IAQrEe,sBACWC,WAAWC,UAAU9B,UAAU+B,MAAMC,GAAMA,EAAElB,4BASpCmB,uBACTA,eAAeC,QAAQC,MAASC,OAAOC,KAAKF,MAAMG,MAAMC,KACzC,KAAdJ,KAAKI,OAAelC,mBAAmBmC,SAASD,MAG7CJ,KAAKI,KAAKE,WAAWC,cAAcF,SAASzB,KAAK4B,6DAUtDC,gBAAkB7B,KAAK8B,oBACxBC,kBACD/B,KAAKC,oBAAoB+B,KAAKZ,WACrB,MAAOI,IAAKS,SAAUZ,OAAOa,QAAQd,MAAO,0BACvCe,YAAcF,MAAMP,WAAWC,iBAChCQ,YAAYV,SAASzB,KAAK4B,yBAI/BR,KAAKgB,yCAAoBP,UAAUQ,IAAIb,8CAAQA,IAC/CJ,KAAKkB,cAAgBH,YAAYI,QAC7BvC,KAAK4B,gEAC6B5B,KAAKQ,4BAE3CY,KAAKoB,KAAOxC,KAAKyC,cAAcrB,KAAKsB,kBAGjCtB,SAUnBuB,aAAaC,SACHD,aAAaC,GACfA,EAAEC,SAAW7C,KAAKW,kBAAkBmC,gBAA+B,IAAbF,EAAEG,SACxDC,OAAOC,SAAWjD,KAAKU,wBAS/BwC,WAAWN,gBACDM,WAAWN,GAEbA,EAAEC,SAAW7C,KAAKW,kBAAkBmC,gBAA6B,UAAVF,EAAEpB,KAA6B,UAAVoB,EAAEpB,MAC9EwB,OAAOC,SAAWjD,KAAKU,wBAInBkC,EAAEpB,SACD,YACA,OACGtC,SAASiE,gBAAkBnD,KAAKW,kBAAkByC,YAAa,IACjD,MAAVR,EAAEpB,UAGFwB,OAAOC,SAAWjD,KAAKU,gCAI3BxB,SAASiE,gBAAkBnD,KAAKW,kBAAkB0C,kBAAmB,MAChEC,oBAGTV,EAAEW,iBACFP,OAAOC,SAAWL,EAAEC,OAAOW,QAAQ,kBAAkBC,MAUjE/C,8BACWgD,aAAIC,YAAY,iCAAkC,CACrDjB,GAAIzD,SACJ2E,YAAa5D,KAAKQ,kBACnB,GASPiC,cAAcoB,eACHH,aAAIC,YAAY,iCAAkC,CACrDjB,GAAIzD,SACJ2E,YAAa5D,KAAKQ,gBAClBsD,OAAQD,SACL,GASX/B,mBACS9B,KAAK+D,iBAAkB,OAClBC,gBAAkB,CACpB,WACA,YACA,WACA,QACA,OACA,UACA,aACA,cACA,WACA,SACA,eAECD,kBAAmB,oBAAWC,gBAAgBhC,KAAKR,OAAUA,IAAAA,SAC7DR,MAAMiD,aAAgB,IAAIC,IACvBF,gBAAgBhC,KAAI,CAACR,IAAK2C,QAAW,CAAC3C,IAAKyC,YAAYE,oBAG5DnE,KAAK+D"} \ No newline at end of file diff --git a/grade/report/grader/amd/build/search/repository.min.js b/grade/report/grader/amd/build/search/repository.min.js new file mode 100644 index 0000000000000..494236169e990 --- /dev/null +++ b/grade/report/grader/amd/build/search/repository.min.js @@ -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 + * @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 \ No newline at end of file diff --git a/grade/report/grader/amd/build/search/repository.min.js.map b/grade/report/grader/amd/build/search/repository.min.js.map new file mode 100644 index 0000000000000..6a9300d8dc1ff --- /dev/null +++ b/grade/report/grader/amd/build/search/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 .\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 \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"} \ No newline at end of file diff --git a/grade/report/grader/amd/build/search/search_class.min.js b/grade/report/grader/amd/build/search/search_class.min.js new file mode 100644 index 0000000000000..d33a1664e2644 --- /dev/null +++ b/grade/report/grader/amd/build/search/search_class.min.js @@ -0,0 +1,10 @@ +define("gradereport_grader/search/search_class",["exports","jquery","core/custom_interaction_events","core/utils"],(function(_exports,_jquery,_custom_interaction_events,_utils){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj} +/** + * The class that manages the state of the search. + * + * @module gradereport_grader/search/search_class + * @copyright 2023 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery);const events=["keydown",(_custom_interaction_events=_interopRequireDefault(_custom_interaction_events)).default.events.activate,_custom_interaction_events.default.events.keyboardActivate];return _exports.default=class{constructor(){var _this$searchInput$val;_defineProperty(this,"selectors",{component:this.setComponentSelector(),trigger:this.setTriggerSelector(),input:'[data-action="search"]',clearSearch:'[data-action="clearsearch"]',dropdown:this.setDropdownSelector(),resultitems:'[role="option"]',viewall:"#select-all"}),_defineProperty(this,"matchedResults",[]),_defineProperty(this,"searchTerm",""),_defineProperty(this,"preppedSearchTerm",null),_defineProperty(this,"resultNodes",[]),_defineProperty(this,"currentNode",null),_defineProperty(this,"currentViewAll",null),_defineProperty(this,"dataset",null),_defineProperty(this,"datasetSize",0),_defineProperty(this,"component",document.querySelector(this.selectors.component)),_defineProperty(this,"searchInput",this.component.querySelector(this.selectors.input)),_defineProperty(this,"searchDropdown",this.component.querySelector(this.selectors.dropdown)),_defineProperty(this,"$searchButton",(0,_jquery.default)(this.selectors.trigger)),_defineProperty(this,"clearSearchButton",this.component.querySelector(this.selectors.clearSearch)),_defineProperty(this,"$component",(0,_jquery.default)(this.component)),_defineProperty(this,"selectNode",(node=>{node.focus({preventScroll:!0}),this.searchDropdown.scrollTop=node.offsetTop-node.clientHeight/2})),_defineProperty(this,"moveToFirstNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[0])})),_defineProperty(this,"moveToLastNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[this.resultNodes.length-1])})),_defineProperty(this,"moveToNode",(index=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[index])})),this.setSearchTerms(null!==(_this$searchInput$val=this.searchInput.value)&&void 0!==_this$searchInput$val?_this$searchInput$val:""),this.registerClickHandlers(),this.registerKeyHandlers(),this.registerInputHandlers()}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}filterDataset(dataset){throw new Error("filterDataset(".concat(dataset,") must be implemented in ").concat(this.constructor.name))}filterMatchDataset(){throw new Error("filterMatchDataset() must be implemented in ".concat(this.constructor.name))}renderDropdown(){throw new Error("renderDropdown() must be implemented in ".concat(this.constructor.name))}setComponentSelector(){throw new Error("setComponentSelector() must be implemented in ".concat(this.constructor.name))}setDropdownSelector(){throw new Error("setDropdownSelector() must be implemented in ".concat(this.constructor.name))}setTriggerSelector(){throw new Error("setTriggerSelector() must be implemented in ".concat(this.constructor.name))}async getDataset(){return this.dataset||(this.dataset=await this.fetchDataset()),this.datasetSize=this.dataset.length,this.dataset}getDatasetSize(){return this.datasetSize}getMatchedResults(){return this.matchedResults}setMatchedResults(result){this.matchedResults=result}getSearchTerm(){return this.searchTerm}getPreppedSearchTerm(){return this.preppedSearchTerm}setSearchTerms(result){this.searchTerm=result,this.preppedSearchTerm=result.toLowerCase()}getHTMLElements(){return this.updateNodes(),{searchDropdown:this.searchDropdown,currentViewAll:this.currentViewAll,searchInput:this.searchInput,clearSearchButton:this.clearSearchButton}}closeSearch(){this.toggleDropdown(),this.clearSearchButton.classList.add("d-none"),this.setSearchTerms(""),this.searchInput.value=""}toggleDropdown(){let on=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.$component.dropdown("toggle"),this.$searchButton.attr("aria-expanded",on),on?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show()):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide())}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)}registerClickHandlers(){this.$searchButton.on("click",(()=>{this.toggleDropdown()})),this.component.addEventListener("click",this.clickHandler.bind(this)),document.addEventListener("click",(e=>{!e.target.closest(this.selectors.component)&&this.searchDropdown.classList.contains("show")&&this.toggleDropdown()}))}registerKeyHandlers(){_custom_interaction_events.default.define(document,events),events.forEach((event=>{this.component.addEventListener(event,this.keyHandler.bind(this))}))}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?(this.toggleDropdown(),this.clearSearchButton.classList.add("d-none")):(this.clearSearchButton.classList.remove("d-none"),await this.renderAndShow())}),300))}async renderAndShow(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),await this.filterMatchDataset(),await this.renderDropdown(),this.toggleDropdown(!0)}keyUpDown(direction,e){e.preventDefault(),e.stopPropagation(),document.activeElement===this.searchInput&&this.resultNodes.length>0&&(-1===direction?this.moveToLastNode():this.moveToFirstNode());const index=this.resultNodes.indexOf(this.currentNode);this.currentNode&&(-1===direction?0===index?this.moveToLastNode():this.moveToNode(index-1):index+1>=this.resultNodes.length?this.moveToFirstNode():this.moveToNode(index+1))}async clickHandler(e){this.updateNodes(),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href),e.target.closest(this.selectors.clearSearch)&&0===e.button&&(this.closeSearch(),this.searchInput.focus({preventScroll:!0})),e.target.closest(this.selectors.input)&&""!==this.getSearchTerm()&&0===e.button&&await this.renderAndShow()}keyHandler(e){switch(this.updateNodes(),e.key){case"ArrowUp":this.keyUpDown(-1,e);break;case"ArrowDown":this.keyUpDown(1,e);break;case"Home":e.preventDefault(),this.moveToFirstNode();break;case"End":e.preventDefault(),this.moveToLastNode();break;case"Escape":this.toggleDropdown(),this.searchInput.focus({preventScroll:!0});break;case"Tab":e.target.closest(this.selectors.clearSearch)&&(this.currentViewAll?(e.preventDefault(),this.currentViewAll.focus({preventScroll:!0})):this.closeSearch()),e.target.closest(this.selectors.viewall)&&this.closeSearch()}}},_exports.default})); + +//# sourceMappingURL=search_class.min.js.map \ No newline at end of file diff --git a/grade/report/grader/amd/build/search/search_class.min.js.map b/grade/report/grader/amd/build/search/search_class.min.js.map new file mode 100644 index 0000000000000..03492144f0970 --- /dev/null +++ b/grade/report/grader/amd/build/search/search_class.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"search_class.min.js","sources":["../../src/search/search_class.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 .\n\nimport $ from 'jquery';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport {debounce} from 'core/utils';\n\n/**\n * The class that manages the state of the search.\n *\n * @module gradereport_grader/search/search_class\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n// Reused variables for the class.\nconst events = [\n 'keydown',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n];\nconst UP = -1;\nconst DOWN = 1;\n\nexport default class {\n // Define our standard lookups.\n selectors = {\n component: this.setComponentSelector(),\n trigger: this.setTriggerSelector(),\n input: '[data-action=\"search\"]',\n clearSearch: '[data-action=\"clearsearch\"]',\n dropdown: this.setDropdownSelector(),\n resultitems: '[role=\"option\"]',\n viewall: '#select-all',\n };\n\n // The results from the called filter function.\n matchedResults = [];\n\n // What did the user search for?\n searchTerm = '';\n\n // What the user searched for as a lowercase.\n preppedSearchTerm = null;\n\n // The DOM nodes after the dropdown render.\n resultNodes = [];\n\n // Where does the user currently have focus?\n currentNode = null;\n\n // The current node for the view all link.\n currentViewAll = null;\n\n dataset = null;\n\n datasetSize = 0;\n\n // DOM nodes that persist.\n component = document.querySelector(this.selectors.component);\n searchInput = this.component.querySelector(this.selectors.input);\n searchDropdown = this.component.querySelector(this.selectors.dropdown);\n $searchButton = $(this.selectors.trigger);\n clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n $component = $(this.component);\n\n constructor() {\n this.setSearchTerms(this.searchInput.value ?? '');\n // Begin handling the base search component.\n this.registerClickHandlers();\n this.registerKeyHandlers();\n this.registerInputHandlers();\n }\n\n /**\n * Stub out a required function.\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n * @param {Array} dataset\n */\n filterDataset(dataset) {\n throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n filterMatchDataset() {\n throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n renderDropdown() {\n throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n setComponentSelector() {\n throw new Error(`setComponentSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n setDropdownSelector() {\n throw new Error(`setDropdownSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n setTriggerSelector() {\n throw new Error(`setTriggerSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Promise}\n */\n async getDataset() {\n if (!this.dataset) {\n this.dataset = await this.fetchDataset();\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Return the size of the dataset.\n *\n * @returns {number}\n */\n getDatasetSize() {\n return this.datasetSize;\n }\n\n /**\n * Return the results of the filter upon the dataset.\n *\n * @returns {Array}\n */\n getMatchedResults() {\n return this.matchedResults;\n }\n\n /**\n * Given a filter has been run across the dataset, store the matched results.\n *\n * @param {Array} result\n */\n setMatchedResults(result) {\n this.matchedResults = result;\n }\n\n /**\n * Get the value that the user entered.\n *\n * @returns {string}\n */\n getSearchTerm() {\n return this.searchTerm;\n }\n\n /**\n * Get the transformed search value.\n *\n * @returns {string}\n */\n getPreppedSearchTerm() {\n return this.preppedSearchTerm;\n }\n\n /**\n * When a user searches for something, set our variable to manage it.\n *\n * @param {string} result\n */\n setSearchTerms(result) {\n this.searchTerm = result;\n this.preppedSearchTerm = result.toLowerCase();\n }\n\n /**\n * Return an object containing a handfull of dom nodes that we sometimes need the value of.\n *\n * @returns {object}\n */\n getHTMLElements() {\n this.updateNodes();\n return {\n searchDropdown: this.searchDropdown,\n currentViewAll: this.currentViewAll,\n searchInput: this.searchInput,\n clearSearchButton: this.clearSearchButton\n };\n }\n\n /**\n * When called, close the dropdown and reset the input field attributes.\n */\n closeSearch() {\n this.toggleDropdown();\n // Hide the \"clear\" search button search bar.\n this.clearSearchButton.classList.add('d-none');\n // Clear the entered search query in the search bar and hide the search results container.\n this.setSearchTerms('');\n this.searchInput.value = \"\";\n }\n\n /**\n * When called, update the dropdown fields.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n this.$component.dropdown('toggle');\n this.$searchButton.attr('aria-expanded', on);\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n }\n }\n\n /**\n * These class members change when a new result set is rendered. So update for fresh data.\n */\n updateNodes() {\n this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];\n this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);\n this.currentViewAll = this.component.querySelector(this.selectors.viewall);\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Prevent the click triggering the dropdown.\n this.$searchButton.on('click', () => {\n this.toggleDropdown();\n });\n\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n\n // Register a small click event onto the document since we need to check if they are clicking off the component.\n document.addEventListener('click', (e) => {\n // Since we are handling dropdowns manually, ensure we can close it when clicking off.\n if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) {\n this.toggleDropdown();\n }\n });\n }\n\n /**\n * Register key event listeners.\n */\n registerKeyHandlers() {\n CustomEvents.define(document, events);\n\n // Register click events.\n events.forEach((event) => {\n this.component.addEventListener(event, this.keyHandler.bind(this));\n });\n }\n\n /**\n * Register input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n this.toggleDropdown();\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n await this.renderAndShow();\n }\n }, 300));\n }\n\n /**\n * A combo method to take the matching fields and render out the results.\n *\n * @returns {Promise}\n */\n async renderAndShow() {\n // User has given something for us to filter against.\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n await this.filterMatchDataset();\n // Replace the dropdown node contents and show the results.\n await this.renderDropdown();\n // Set the dropdown to open.\n this.toggleDropdown(true);\n }\n\n /**\n * Set the current focus either on the preceding or next result item.\n *\n * @param {Number} direction Is the user moving up or down the resultset?\n * @param {KeyboardEvent} e The JS event from the event handler.\n */\n keyUpDown(direction, e) {\n e.preventDefault();\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n // Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.\n if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n const index = this.resultNodes.indexOf(this.currentNode);\n if (this.currentNode) {\n if (direction === UP) {\n if (index === 0) {\n this.moveToLastNode();\n } else {\n this.moveToNode(index - 1);\n }\n } else {\n if (index + 1 >= this.resultNodes.length) {\n this.moveToFirstNode();\n } else {\n this.moveToNode(index + 1);\n }\n }\n }\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n this.updateNodes();\n\n // Prevent normal key presses activating this.\n if (e.target.closest('.dropdown-item') && e.button === 0) {\n window.location = e.target.closest('.dropdown-item').href;\n }\n // The \"clear search\" button is triggered.\n if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {\n this.closeSearch();\n this.searchInput.focus({preventScroll: true});\n }\n // User may have accidentally clicked off the dropdown and wants to reopen it.\n if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {\n await this.renderAndShow();\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n this.updateNodes();\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'ArrowUp':\n this.keyUpDown(UP, e);\n break;\n case 'ArrowDown':\n this.keyUpDown(DOWN, e);\n break;\n case 'Home':\n e.preventDefault();\n this.moveToFirstNode();\n break;\n case 'End':\n e.preventDefault();\n this.moveToLastNode();\n break;\n case 'Escape':\n this.toggleDropdown();\n this.searchInput.focus({preventScroll: true});\n break;\n case 'Tab':\n // If the current focus is on clear search, then check if viewall exists then around tab to it.\n if (e.target.closest(this.selectors.clearSearch)) {\n if (this.currentViewAll) {\n e.preventDefault();\n this.currentViewAll.focus({preventScroll: true});\n } else {\n this.closeSearch();\n }\n }\n // If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.\n if (e.target.closest(this.selectors.viewall)) {\n this.closeSearch();\n }\n break;\n }\n }\n\n /**\n * Set focus on a given node after parsed through the calling functions.\n *\n * @param {HTMLElement} node The node to set focus upon.\n */\n selectNode = (node) => {\n node.focus({preventScroll: true});\n this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);\n };\n\n /**\n * Set the focus on the first node within the array.\n */\n moveToFirstNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[0]);\n }\n };\n\n /**\n * Set the focus to the final node within the array.\n */\n moveToLastNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[this.resultNodes.length - 1]);\n }\n };\n\n /**\n * Set focus on any given specified node within the node array.\n *\n * @param {Number} index Which item within the array to set focus upon.\n */\n moveToNode = (index) => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[index]);\n }\n };\n}\n"],"names":["events","activate","CustomEvents","keyboardActivate","constructor","component","this","setComponentSelector","trigger","setTriggerSelector","input","clearSearch","dropdown","setDropdownSelector","resultitems","viewall","document","querySelector","selectors","node","focus","preventScroll","searchDropdown","scrollTop","offsetTop","clientHeight","resultNodes","length","selectNode","index","setSearchTerms","searchInput","value","registerClickHandlers","registerKeyHandlers","registerInputHandlers","fetchDataset","Error","name","filterDataset","dataset","filterMatchDataset","renderDropdown","datasetSize","getDatasetSize","getMatchedResults","matchedResults","setMatchedResults","result","getSearchTerm","searchTerm","getPreppedSearchTerm","preppedSearchTerm","toLowerCase","getHTMLElements","updateNodes","currentViewAll","clearSearchButton","closeSearch","toggleDropdown","classList","add","on","$component","$searchButton","attr","show","remove","hide","querySelectorAll","currentNode","find","r","id","activeElement","addEventListener","clickHandler","bind","e","target","closest","contains","define","forEach","event","keyHandler","async","renderAndShow","getDataset","keyUpDown","direction","preventDefault","stopPropagation","moveToLastNode","moveToFirstNode","indexOf","moveToNode","button","window","location","href","key"],"mappings":";;;;;;;mIA2BMA,OAAS,CACX,kGACaA,OAAOC,SACpBC,mCAAaF,OAAOG,gDA+CpBC,yEAxCY,CACRC,UAAWC,KAAKC,uBAChBC,QAASF,KAAKG,qBACdC,MAAO,yBACPC,YAAa,8BACbC,SAAUN,KAAKO,sBACfC,YAAa,kBACbC,QAAS,sDAII,sCAGJ,6CAGO,yCAGN,uCAGA,4CAGG,qCAEP,yCAEI,oCAGFC,SAASC,cAAcX,KAAKY,UAAUb,+CACpCC,KAAKD,UAAUY,cAAcX,KAAKY,UAAUR,8CACzCJ,KAAKD,UAAUY,cAAcX,KAAKY,UAAUN,iDAC7C,mBAAEN,KAAKY,UAAUV,mDACbF,KAAKD,UAAUY,cAAcX,KAAKY,UAAUP,iDACnD,mBAAEL,KAAKD,+CAsWNc,OACVA,KAAKC,MAAM,CAACC,eAAe,SACtBC,eAAeC,UAAYJ,KAAKK,UAAaL,KAAKM,aAAe,6CAMxD,KACVnB,KAAKoB,YAAYC,OAAS,QACrBC,WAAWtB,KAAKoB,YAAY,8CAOxB,KACTpB,KAAKoB,YAAYC,OAAS,QACrBC,WAAWtB,KAAKoB,YAAYpB,KAAKoB,YAAYC,OAAS,0CASrDE,QACNvB,KAAKoB,YAAYC,OAAS,QACrBC,WAAWtB,KAAKoB,YAAYG,gBAjYhCC,6CAAexB,KAAKyB,YAAYC,6DAAS,SAEzCC,6BACAC,2BACAC,wBAMTC,qBACU,IAAIC,sDAA+C/B,KAAKF,YAAYkC,OAO9EC,cAAcC,eACJ,IAAIH,8BAAuBG,4CAAmClC,KAAKF,YAAYkC,OAMzFG,2BACU,IAAIJ,4DAAqD/B,KAAKF,YAAYkC,OAMpFI,uBACU,IAAIL,wDAAiD/B,KAAKF,YAAYkC,OAMhF/B,6BACU,IAAI8B,8DAAuD/B,KAAKF,YAAYkC,OAMtFzB,4BACU,IAAIwB,6DAAsD/B,KAAKF,YAAYkC,OAMrF7B,2BACU,IAAI4B,4DAAqD/B,KAAKF,YAAYkC,iCAS3EhC,KAAKkC,eACDA,cAAgBlC,KAAK8B,qBAEzBO,YAAcrC,KAAKkC,QAAQb,OACzBrB,KAAKkC,QAQhBI,wBACWtC,KAAKqC,YAQhBE,2BACWvC,KAAKwC,eAQhBC,kBAAkBC,aACTF,eAAiBE,OAQ1BC,uBACW3C,KAAK4C,WAQhBC,8BACW7C,KAAK8C,kBAQhBtB,eAAekB,aACNE,WAAaF,YACbI,kBAAoBJ,OAAOK,cAQpCC,8BACSC,cACE,CACHjC,eAAgBhB,KAAKgB,eACrBkC,eAAgBlD,KAAKkD,eACrBzB,YAAazB,KAAKyB,YAClB0B,kBAAmBnD,KAAKmD,mBAOhCC,mBACSC,sBAEAF,kBAAkBG,UAAUC,IAAI,eAEhC/B,eAAe,SACfC,YAAYC,MAAQ,GAQ7B2B,qBAAeG,gEACNC,WAAWnD,SAAS,eACpBoD,cAAcC,KAAK,gBAAiBH,IACrCA,SACKxC,eAAesC,UAAUC,IAAI,4BAChCvD,KAAKgB,gBAAgB4C,cAElB5C,eAAesC,UAAUO,OAAO,4BACnC7D,KAAKgB,gBAAgB8C,QAO/Bb,mBACS7B,YAAc,IAAIpB,KAAKD,UAAUgE,iBAAiB/D,KAAKY,UAAUJ,mBACjEwD,YAAchE,KAAKoB,YAAY6C,MAAKC,GAAKA,EAAEC,KAAOzD,SAAS0D,cAAcD,UACzEjB,eAAiBlD,KAAKD,UAAUY,cAAcX,KAAKY,UAAUH,SAMtEkB,6BAES+B,cAAcF,GAAG,SAAS,UACtBH,yBAIJtD,UAAUsE,iBAAiB,QAASrE,KAAKsE,aAAaC,KAAKvE,OAGhEU,SAAS2D,iBAAiB,SAAUG,KAE3BA,EAAEC,OAAOC,QAAQ1E,KAAKY,UAAUb,YAAcC,KAAKgB,eAAesC,UAAUqB,SAAS,cACjFtB,oBAQjBzB,yDACiBgD,OAAOlE,SAAUhB,QAG9BA,OAAOmF,SAASC,aACP/E,UAAUsE,iBAAiBS,MAAO9E,KAAK+E,WAAWR,KAAKvE,UAOpE6B,6BAESJ,YAAY4C,iBAAiB,SAAS,oBAASW,eAC3CxD,eAAexB,KAAKyB,YAAYC,OAER,KAAzB1B,KAAK2C,sBACAU,sBAEAF,kBAAkBG,UAAUC,IAAI,iBAGhCJ,kBAAkBG,UAAUO,OAAO,gBAClC7D,KAAKiF,mBAEhB,iCAUExC,wBAAwBzC,KAAKiC,oBAAoBjC,KAAKkF,qBACrDlF,KAAKmC,2BAELnC,KAAKoC,sBAENiB,gBAAe,GASxB8B,UAAUC,UAAWZ,GACjBA,EAAEa,iBAEFb,EAAEc,kBAEE5E,SAAS0D,gBAAkBpE,KAAKyB,aAAezB,KAAKoB,YAAYC,OAAS,KA/S1E,IAgTK+D,eACKG,sBAEAC,yBAGPjE,MAAQvB,KAAKoB,YAAYqE,QAAQzF,KAAKgE,aACxChE,KAAKgE,eAvTN,IAwTKoB,UACc,IAAV7D,WACKgE,sBAEAG,WAAWnE,MAAQ,GAGxBA,MAAQ,GAAKvB,KAAKoB,YAAYC,YACzBmE,uBAEAE,WAAWnE,MAAQ,uBAWrBiD,QACVvB,cAGDuB,EAAEC,OAAOC,QAAQ,mBAAkC,IAAbF,EAAEmB,SACxCC,OAAOC,SAAWrB,EAAEC,OAAOC,QAAQ,kBAAkBoB,MAGrDtB,EAAEC,OAAOC,QAAQ1E,KAAKY,UAAUP,cAA6B,IAAbmE,EAAEmB,cAC7CvC,mBACA3B,YAAYX,MAAM,CAACC,eAAe,KAGvCyD,EAAEC,OAAOC,QAAQ1E,KAAKY,UAAUR,QAAmC,KAAzBJ,KAAK2C,iBAAuC,IAAb6B,EAAEmB,cACrE3F,KAAKiF,gBASnBF,WAAWP,eACFvB,cAEGuB,EAAEuB,SACD,eACIZ,WAzWV,EAyWwBX,aAElB,iBACIW,UA3WR,EA2WwBX,aAEpB,OACDA,EAAEa,sBACGG,4BAEJ,MACDhB,EAAEa,sBACGE,2BAEJ,cACIlC,sBACA5B,YAAYX,MAAM,CAACC,eAAe,cAEtC,MAEGyD,EAAEC,OAAOC,QAAQ1E,KAAKY,UAAUP,eAC5BL,KAAKkD,gBACLsB,EAAEa,sBACGnC,eAAepC,MAAM,CAACC,eAAe,UAErCqC,eAIToB,EAAEC,OAAOC,QAAQ1E,KAAKY,UAAUH,eAC3B2C"} \ No newline at end of file diff --git a/grade/report/grader/amd/src/search.js b/grade/report/grader/amd/src/search.js new file mode 100644 index 0000000000000..9cc7146cb0d10 --- /dev/null +++ b/grade/report/grader/amd/src/search.js @@ -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 . + +/** + * 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 + * @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(), + `${this.getSearchTerm()}` + ); + 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} + */ + 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; + } +} diff --git a/grade/report/grader/amd/src/search/repository.js b/grade/report/grader/amd/src/search/repository.js new file mode 100644 index 0000000000000..2236d33a6c255 --- /dev/null +++ b/grade/report/grader/amd/src/search/repository.js @@ -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 . + +/** + * A repo for the search partial in the grader report. + * + * @module gradereport_grader/search/repository + * @copyright 2022 Mathew May + * @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]; +}; diff --git a/grade/report/grader/amd/src/search/search_class.js b/grade/report/grader/amd/src/search/search_class.js new file mode 100644 index 0000000000000..f1dd47679966b --- /dev/null +++ b/grade/report/grader/amd/src/search/search_class.js @@ -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 . + +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 + * @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} + */ + 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} + */ + 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]); + } + }; +} diff --git a/grade/report/grader/classes/external/get_users_in_report.php b/grade/report/grader/classes/external/get_users_in_report.php new file mode 100644 index 0000000000000..4114ebe7bddc6 --- /dev/null +++ b/grade/report/grader/classes/external/get_users_in_report.php @@ -0,0 +1,124 @@ +. + +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 + * @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(), + ]); + } +} diff --git a/grade/report/grader/classes/output/action_bar.php b/grade/report/grader/classes/output/action_bar.php index b7da5f7ec9dde..a83cd11ec2cec 100644 --- a/grade/report/grader/classes/output/action_bar.php +++ b/grade/report/grader/classes/output/action_bar.php @@ -29,6 +29,9 @@ */ class action_bar extends \core_grades\output\action_bar { + /** @var string $usersearch The content that the current user is looking for. */ + protected string $usersearch = ''; + /** * 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 @@ public function get_template(): string { * * @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 @@ public function export_for_template(\renderer_base $output): array { ); $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; diff --git a/grade/report/grader/db/services.php b/grade/report/grader/db/services.php new file mode 100644 index 0000000000000..5904fa35e1212 --- /dev/null +++ b/grade/report/grader/db/services.php @@ -0,0 +1,37 @@ +. + +/** + * Grader grade report external functions and service definitions. + * + * @package gradereport_grader + * @copyright 2022 Mathew May + * @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], + ], +]; diff --git a/grade/report/grader/index.php b/grade/report/grader/index.php index e58041b60af31..28dfcc12c6220 100644 --- a/grade/report/grader/index.php +++ b/grade/report/grader/index.php @@ -49,6 +49,7 @@ $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))) { diff --git a/grade/report/grader/lang/en/gradereport_grader.php b/grade/report/grader/lang/en/gradereport_grader.php index 21cbf3f44fdf8..cf8a12c492d15 100644 --- a/grade/report/grader/lang/en/gradereport_grader.php +++ b/grade/report/grader/lang/en/gradereport_grader.php @@ -44,10 +44,13 @@ $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.'; diff --git a/grade/report/grader/styles.css b/grade/report/grader/styles.css index a68953d2a799c..36f025ba798ee 100644 --- a/grade/report/grader/styles.css +++ b/grade/report/grader/styles.css @@ -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%; +} diff --git a/grade/report/grader/templates/action_bar.mustache b/grade/report/grader/templates/action_bar.mustache index 9942108edef26..96514c985d28c 100644 --- a/grade/report/grader/templates/action_bar.mustache +++ b/grade/report/grader/templates/action_bar.mustache @@ -71,6 +71,12 @@ {{/generalnavselector}} + {{#searchdropdown}} + + + {{/searchdropdown}} {{#groupselector}}