From 09d0a20d459040b36d00a130e2d7aec94ef96c7a Mon Sep 17 00:00:00 2001 From: Mihail Geshoski Date: Wed, 19 Oct 2022 22:42:52 +0800 Subject: [PATCH] MDL-75837 core_grades: Adapt the base search widget to support dropdowns --- .../amd/build/searchwidget/basewidget.min.js | 6 +- .../build/searchwidget/basewidget.min.js.map | 2 +- grade/amd/src/searchwidget/basewidget.js | 124 ++++++++---------- grade/templates/searchwidget/loading.mustache | 27 ++++ .../searchwidget/searchitem.mustache | 36 +++-- theme/boost/scss/moodle/grade.scss | 55 ++++++-- theme/boost/style/moodle.css | 34 +++-- theme/classic/style/moodle.css | 34 +++-- 8 files changed, 186 insertions(+), 132 deletions(-) create mode 100644 grade/templates/searchwidget/loading.mustache diff --git a/grade/amd/build/searchwidget/basewidget.min.js b/grade/amd/build/searchwidget/basewidget.min.js index 038154053e9..bdeff4f616a 100644 --- a/grade/amd/build/searchwidget/basewidget.min.js +++ b/grade/amd/build/searchwidget/basewidget.min.js @@ -1,10 +1,10 @@ -define("core_grades/searchwidget/basewidget",["exports","core/modal_factory","core/modal_events","core/utils","core/templates","core/aria"],(function(_exports,ModalFactory,ModalEvents,_utils,Templates,_aria){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 _interopRequireWildcard(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]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} +define("core_grades/searchwidget/basewidget",["exports","core/utils","core/templates","core/aria","core_grades/searchwidget/selectors","core/notification"],(function(_exports,_utils,Templates,_aria,Selectors,_notification){var obj;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 _interopRequireWildcard(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]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} /** - * A small modal to search users or grade items within the gradebook. + * A widget to search users or grade items within the gradebook. * * @module core_grades/searchwidget/basewidget * @copyright 2022 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.promisesAndResolvers=_exports.init=void 0,ModalFactory=_interopRequireWildcard(ModalFactory),ModalEvents=_interopRequireWildcard(ModalEvents),Templates=_interopRequireWildcard(Templates);_exports.init=function(bodyPromise,data,searchFunc,modalTitle){let unsearchableContent=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;const modal=buildModal(bodyPromise,modalTitle);registerListenerEvents(modal,data,searchFunc,unsearchableContent)};const registerListenerEvents=(modal,data,searchFunc,unsearchableContent)=>{modal.then((modal=>{modal.getRoot().on(ModalEvents.hidden,(()=>{modal.destroy()})),modal.getBodyPromise().then((body=>body[0])).then((body=>{const searchInput=body.querySelector('input[data-action="search"]'),searchResultsContainer=body.querySelector('[data-region="search-results-container-widget"]');if(renderSearchResults(searchResultsContainer,data),unsearchableContent){body.querySelector('[data-region="unsearchable-content-container-widget"]').innerHTML+=unsearchableContent}return searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{await renderSearchResults(searchResultsContainer,debounceCallee(searchInput.value,data,searchFunc()))}),300)),(0,_aria.comboBox)(searchInput),body})).catch()})).catch()},buildModal=(bodyPromise,modalTitle)=>ModalFactory.create({type:ModalFactory.types.DEFAULT,title:modalTitle,body:bodyPromise,small:!0,scrollable:!1,templateContext:{classes:"reportdatasearch modal-sm"}}).then((modal=>(modal.show(),modal))),debounceCallee=(searchValue,data,searchFunction)=>searchValue.length>0?searchFunction(data,searchValue):data,renderSearchResults=async(searchResultsContainer,searchResultsData)=>{const templateData={searchresults:searchResultsData},{html:html,js:js}=await Templates.renderForPromise("core_grades/searchwidget/searchresults",templateData);await Templates.replaceNodeContents(searchResultsContainer,html,js)};_exports.promisesAndResolvers=()=>{let bodyPromiseResolver;const bodyPromise=new Promise((resolve=>{bodyPromiseResolver=resolve}));return{bodyPromiseResolver:bodyPromiseResolver,bodyPromise:bodyPromise}}})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.showLoader=_exports.registerListenerEvents=_exports.promisesAndResolvers=_exports.init=void 0,Templates=_interopRequireWildcard(Templates),Selectors=_interopRequireWildcard(Selectors),_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};_exports.init=async function(widgetContentContainer,bodyPromise,data,searchFunc){let unsearchableContent=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;bodyPromise.then((async bodyContent=>{if(widgetContentContainer.innerHTML=bodyContent,unsearchableContent){widgetContentContainer.querySelector(Selectors.regions.unsearchableContent).innerHTML+=unsearchableContent}const searchResultsContainer=widgetContentContainer.querySelector(Selectors.regions.searchResults);await showLoader(searchResultsContainer),await renderSearchResults(searchResultsContainer,data),registerListenerEvents(widgetContentContainer,data,searchFunc)})).catch(_notification.default.exception)};const registerListenerEvents=(widgetContentContainer,data,searchFunc)=>{const searchResultsContainer=widgetContentContainer.querySelector(Selectors.regions.searchResults),searchInput=widgetContentContainer.querySelector(Selectors.actions.search);searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{await renderSearchResults(searchResultsContainer,debounceCallee(searchInput.value,data,searchFunc()))}),300)),(0,_aria.comboBox)(searchInput)};_exports.registerListenerEvents=registerListenerEvents;const showLoader=async container=>{const{html:html,js:js}=await Templates.renderForPromise("core_grades/searchwidget/loading",{});Templates.replaceNodeContents(container,html,js)};_exports.showLoader=showLoader;const debounceCallee=(searchValue,data,searchFunction)=>searchValue.length>0?searchFunction(data,searchValue):data,renderSearchResults=async(searchResultsContainer,searchResultsData)=>{const templateData={searchresults:searchResultsData},{html:html,js:js}=await Templates.renderForPromise("core_grades/searchwidget/searchresults",templateData);await Templates.replaceNodeContents(searchResultsContainer,html,js)};_exports.promisesAndResolvers=()=>{let bodyPromiseResolver;const bodyPromise=new Promise((resolve=>{bodyPromiseResolver=resolve}));return{bodyPromiseResolver:bodyPromiseResolver,bodyPromise:bodyPromise}}})); //# sourceMappingURL=basewidget.min.js.map \ No newline at end of file diff --git a/grade/amd/build/searchwidget/basewidget.min.js.map b/grade/amd/build/searchwidget/basewidget.min.js.map index 60cc764b89e..336ad75119c 100644 --- a/grade/amd/build/searchwidget/basewidget.min.js.map +++ b/grade/amd/build/searchwidget/basewidget.min.js.map @@ -1 +1 @@ -{"version":3,"file":"basewidget.min.js","sources":["../../src/searchwidget/basewidget.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 modal to search users or grade items within the gradebook.\n *\n * @module core_grades/searchwidget/basewidget\n * @copyright 2022 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport * as ModalFactory from 'core/modal_factory';\nimport * as ModalEvents from 'core/modal_events';\nimport {debounce} from 'core/utils';\nimport * as Templates from 'core/templates';\nimport {comboBox} from 'core/aria';\n\n/**\n * Build the base searching widget.\n *\n * @method init\n * @param {Promise} bodyPromise The promise from the callee of the contents to place in the modal body.\n * @param {Array} data An array of all the data generated by the callee.\n * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.\n * @param {string} modalTitle The name of the search widget.\n * @param {string|null} unsearchableContent The content rendered in a non-searchable area.\n */\nexport const init = (bodyPromise, data, searchFunc, modalTitle, unsearchableContent = null) => {\n const modal = buildModal(bodyPromise, modalTitle);\n registerListenerEvents(modal, data, searchFunc, unsearchableContent);\n};\n\n/**\n * Register chooser related event listeners.\n *\n * @method registerListenerEvents\n * @param {Promise} modal Our modal that we are working with.\n * @param {Array} data An array of all the data generated by the callee.\n * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.\n * @param {string|null} unsearchableContent The content rendered in a non-searchable area.\n */\nconst registerListenerEvents = (modal, data, searchFunc, unsearchableContent) => {\n modal.then(modal => {\n // We want to destroy this when the dialog is closed.\n modal.getRoot().on(ModalEvents.hidden, () => {\n modal.destroy();\n });\n // Once the body of the modal has been resolved, add more features.\n modal.getBodyPromise()\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(body => body[0])\n .then(body => {\n const searchInput = body.querySelector('input[data-action=\"search\"]');\n const searchResultsContainer = body.querySelector('[data-region=\"search-results-container-widget\"]');\n\n renderSearchResults(searchResultsContainer, data);\n\n if (unsearchableContent) {\n const unsearchableContentContainer = body.querySelector(\n '[data-region=\"unsearchable-content-container-widget\"]');\n unsearchableContentContainer.innerHTML += unsearchableContent;\n }\n\n // The search input is triggered.\n searchInput.addEventListener('input', debounce(async() => {\n // Display the search results.\n await renderSearchResults(\n searchResultsContainer,\n debounceCallee(\n searchInput.value,\n data,\n searchFunc()\n )\n );\n }, 300));\n // Trigger event handling for the results in line with aria guidelines.\n comboBox(searchInput);\n return body;\n }).catch();\n }).catch();\n};\n\n/**\n * Given an object we want to build a modal ready to show.\n *\n * @method buildModal\n * @param {Promise} bodyPromise Body promise that the caller should resolve.\n * @param {string} modalTitle The name of the search widget.\n * @return {Object} The modal ready to display immediately and render body in later.\n */\nconst buildModal = (bodyPromise, modalTitle) => {\n return ModalFactory.create({\n type: ModalFactory.types.DEFAULT,\n title: modalTitle,\n body: bodyPromise,\n small: true,\n scrollable: false,\n templateContext: {\n classes: 'reportdatasearch modal-sm'\n }\n }).then(modal => {\n modal.show();\n return modal;\n });\n};\n\n/**\n * We have a small helper that'll call the curried search function allowing callers to filter\n * the data set however we want rather than defining how data must be filtered.\n *\n * @method debounceCallee\n * @param {String} searchValue The input from the user that we'll search against.\n * @param {Array} data An array of all the data generated by the callee.\n * @param {Function} searchFunction Partially applied function we need to manage search the passed dataset.\n * @return {Array} The filtered subset of the provided data that we'll then render into the results.\n */\nconst debounceCallee = (searchValue, data, searchFunction) => {\n if (searchValue.length > 0) { // Search query is present.\n return searchFunction(data, searchValue);\n }\n return data;\n};\n\n/**\n * Given the output of the callers' search function, render out the results into the modal.\n *\n * @method renderSearchResults\n * @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results.\n * @param {Array} searchResultsData The filtered subset of the provided data that we'll then render into the results.\n */\nconst renderSearchResults = async(searchResultsContainer, searchResultsData) => {\n const templateData = {\n 'searchresults': searchResultsData,\n };\n // Build up the html & js ready to place into the help section.\n const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/searchresults', templateData);\n await Templates.replaceNodeContents(searchResultsContainer, html, js);\n};\n\n/**\n * We want to create the basic promises and hooks that the caller will implement, so we can build modals\n * ahead of time and allow the caller to resolve their promises once complete.\n *\n * @method promisesAndResolvers\n * @returns {{bodyPromise: Promise, bodyPromiseResolver}}\n */\nexport const promisesAndResolvers = () => {\n // We want to show the modal instantly but loading whilst waiting for our data.\n let bodyPromiseResolver;\n const bodyPromise = new Promise(resolve => {\n bodyPromiseResolver = resolve;\n });\n\n return {bodyPromiseResolver, bodyPromise};\n};\n"],"names":["bodyPromise","data","searchFunc","modalTitle","unsearchableContent","modal","buildModal","registerListenerEvents","then","getRoot","on","ModalEvents","hidden","destroy","getBodyPromise","body","searchInput","querySelector","searchResultsContainer","renderSearchResults","innerHTML","addEventListener","async","debounceCallee","value","catch","ModalFactory","create","type","types","DEFAULT","title","small","scrollable","templateContext","classes","show","searchValue","searchFunction","length","searchResultsData","templateData","html","js","Templates","renderForPromise","replaceNodeContents","bodyPromiseResolver","Promise","resolve"],"mappings":";;;;;;;+QAsCoB,SAACA,YAAaC,KAAMC,WAAYC,gBAAYC,2EAAsB,WAC5EC,MAAQC,WAAWN,YAAaG,YACtCI,uBAAuBF,MAAOJ,KAAMC,WAAYE,4BAY9CG,uBAAyB,CAACF,MAAOJ,KAAMC,WAAYE,uBACrDC,MAAMG,MAAKH,QAEPA,MAAMI,UAAUC,GAAGC,YAAYC,QAAQ,KACnCP,MAAMQ,aAGVR,MAAMS,iBAEDN,MAAKO,MAAQA,KAAK,KAClBP,MAAKO,aACIC,YAAcD,KAAKE,cAAc,+BACjCC,uBAAyBH,KAAKE,cAAc,sDAElDE,oBAAoBD,uBAAwBjB,MAExCG,oBAAqB,CACgBW,KAAKE,cACtC,yDACyBG,WAAahB,2BAI9CY,YAAYK,iBAAiB,SAAS,oBAASC,gBAErCH,oBACFD,uBACAK,eACIP,YAAYQ,MACZvB,KACAC,iBAGT,yBAEMc,aACFD,QACRU,WACRA,SAWDnB,WAAa,CAACN,YAAaG,aACtBuB,aAAaC,OAAO,CACvBC,KAAMF,aAAaG,MAAMC,QACzBC,MAAO5B,WACPY,KAAMf,YACNgC,OAAO,EACPC,YAAY,EACZC,gBAAiB,CACbC,QAAS,+BAEd3B,MAAKH,QACJA,MAAM+B,OACC/B,SAcTkB,eAAiB,CAACc,YAAapC,KAAMqC,iBACnCD,YAAYE,OAAS,EACdD,eAAerC,KAAMoC,aAEzBpC,KAULkB,oBAAsBG,MAAMJ,uBAAwBsB,2BAChDC,aAAe,eACAD,oBAGfE,KAACA,KAADC,GAAOA,UAAYC,UAAUC,iBAAiB,yCAA0CJ,oBACxFG,UAAUE,oBAAoB5B,uBAAwBwB,KAAMC,mCAUlC,SAE5BI,0BACE/C,YAAc,IAAIgD,SAAQC,UAC5BF,oBAAsBE,iBAGnB,CAACF,oBAAAA,oBAAqB/C,YAAAA"} \ No newline at end of file +{"version":3,"file":"basewidget.min.js","sources":["../../src/searchwidget/basewidget.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 widget to search users or grade items within the gradebook.\n *\n * @module core_grades/searchwidget/basewidget\n * @copyright 2022 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {debounce} from 'core/utils';\nimport * as Templates from 'core/templates';\nimport {comboBox} from 'core/aria';\nimport * as Selectors from 'core_grades/searchwidget/selectors';\nimport Notification from 'core/notification';\n\n/**\n * Build the base searching widget.\n *\n * @method init\n * @param {HTMLElement} widgetContentContainer The selector for the widget container element.\n * @param {Promise} bodyPromise The promise from the callee of the contents to place in the widget container.\n * @param {Array} data An array of all the data generated by the callee.\n * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.\n * @param {string|null} unsearchableContent The content rendered in a non-searchable area.\n */\nexport const init = async(widgetContentContainer, bodyPromise, data, searchFunc, unsearchableContent = null) => {\n bodyPromise.then(async(bodyContent) => {\n // Render the body content.\n widgetContentContainer.innerHTML = bodyContent;\n\n // Render the unsearchable content if defined.\n if (unsearchableContent) {\n const unsearchableContentContainer = widgetContentContainer.querySelector(Selectors.regions.unsearchableContent);\n unsearchableContentContainer.innerHTML += unsearchableContent;\n }\n\n const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);\n // Display a loader until the search results are rendered.\n await showLoader(searchResultsContainer);\n // Render the search results.\n await renderSearchResults(searchResultsContainer, data);\n\n registerListenerEvents(widgetContentContainer, data, searchFunc);\n\n }).catch(Notification.exception);\n};\n\n/**\n * Register the event listeners for the search widget.\n *\n * @method registerListenerEvents\n * @param {HTMLElement} widgetContentContainer The selector for the widget container element.\n * @param {Array} data An array of all the data generated by the callee.\n * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.\n */\nexport const registerListenerEvents = (widgetContentContainer, data, searchFunc) => {\n const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);\n const searchInput = widgetContentContainer.querySelector(Selectors.actions.search);\n\n // The search input is triggered.\n searchInput.addEventListener('input', debounce(async() => {\n // Display the search results.\n await renderSearchResults(\n searchResultsContainer,\n debounceCallee(\n searchInput.value,\n data,\n searchFunc()\n )\n );\n }, 300));\n // Trigger event handling for the results in line with aria guidelines.\n comboBox(searchInput);\n};\n\n/**\n * Renders the loading placeholder for the search widget.\n *\n * @method showLoader\n * @param {HTMLElement} container The DOM node where we'll render the loading placeholder.\n */\nexport const showLoader = async(container) => {\n const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/loading', {});\n Templates.replaceNodeContents(container, html, js);\n};\n\n/**\n * We have a small helper that'll call the curried search function allowing callers to filter\n * the data set however we want rather than defining how data must be filtered.\n *\n * @method debounceCallee\n * @param {String} searchValue The input from the user that we'll search against.\n * @param {Array} data An array of all the data generated by the callee.\n * @param {Function} searchFunction Partially applied function we need to manage search the passed dataset.\n * @return {Array} The filtered subset of the provided data that we'll then render into the results.\n */\nconst debounceCallee = (searchValue, data, searchFunction) => {\n if (searchValue.length > 0) { // Search query is present.\n return searchFunction(data, searchValue);\n }\n return data;\n};\n\n/**\n * Given the output of the callers' search function, render out the results into the search results container.\n *\n * @method renderSearchResults\n * @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results.\n * @param {Array} searchResultsData The filtered subset of the provided data that we'll then render into the results.\n */\nconst renderSearchResults = async(searchResultsContainer, searchResultsData) => {\n const templateData = {\n 'searchresults': searchResultsData,\n };\n // Build up the html & js ready to place into the help section.\n const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/searchresults', templateData);\n await Templates.replaceNodeContents(searchResultsContainer, html, js);\n};\n\n/**\n * We want to create the basic promises and hooks that the caller will implement, so we can build the search widget\n * ahead of time and allow the caller to resolve their promises once complete.\n *\n * @method promisesAndResolvers\n * @returns {{bodyPromise: Promise, bodyPromiseResolver}}\n */\nexport const promisesAndResolvers = () => {\n // We want to show the widget instantly but loading whilst waiting for our data.\n let bodyPromiseResolver;\n const bodyPromise = new Promise(resolve => {\n bodyPromiseResolver = resolve;\n });\n\n return {bodyPromiseResolver, bodyPromise};\n};\n"],"names":["async","widgetContentContainer","bodyPromise","data","searchFunc","unsearchableContent","then","innerHTML","bodyContent","querySelector","Selectors","regions","searchResultsContainer","searchResults","showLoader","renderSearchResults","registerListenerEvents","catch","Notification","exception","searchInput","actions","search","addEventListener","debounceCallee","value","html","js","Templates","renderForPromise","replaceNodeContents","container","searchValue","searchFunction","length","searchResultsData","templateData","bodyPromiseResolver","Promise","resolve"],"mappings":";;;;;;;gVAsCoBA,eAAMC,uBAAwBC,YAAaC,KAAMC,gBAAYC,2EAAsB,KACnGH,YAAYI,MAAKN,MAAAA,iBAEbC,uBAAuBM,UAAYC,YAG/BH,oBAAqB,CACgBJ,uBAAuBQ,cAAcC,UAAUC,QAAQN,qBAC/DE,WAAaF,0BAGxCO,uBAAyBX,uBAAuBQ,cAAcC,UAAUC,QAAQE,qBAEhFC,WAAWF,8BAEXG,oBAAoBH,uBAAwBT,MAElDa,uBAAuBf,uBAAwBE,KAAMC,eAEtDa,MAAMC,sBAAaC,kBAWbH,uBAAyB,CAACf,uBAAwBE,KAAMC,oBAC3DQ,uBAAyBX,uBAAuBQ,cAAcC,UAAUC,QAAQE,eAChFO,YAAcnB,uBAAuBQ,cAAcC,UAAUW,QAAQC,QAG3EF,YAAYG,iBAAiB,SAAS,oBAASvB,gBAErCe,oBACFH,uBACAY,eACIJ,YAAYK,MACZtB,KACAC,iBAGT,yBAEMgB,2EASAN,WAAad,MAAAA,kBAChB0B,KAACA,KAADC,GAAOA,UAAYC,UAAUC,iBAAiB,mCAAoC,IACxFD,UAAUE,oBAAoBC,UAAWL,KAAMC,0CAa7CH,eAAiB,CAACQ,YAAa7B,KAAM8B,iBACnCD,YAAYE,OAAS,EACdD,eAAe9B,KAAM6B,aAEzB7B,KAULY,oBAAsBf,MAAMY,uBAAwBuB,2BAChDC,aAAe,eACAD,oBAGfT,KAACA,KAADC,GAAOA,UAAYC,UAAUC,iBAAiB,yCAA0CO,oBACxFR,UAAUE,oBAAoBlB,uBAAwBc,KAAMC,mCAUlC,SAE5BU,0BACEnC,YAAc,IAAIoC,SAAQC,UAC5BF,oBAAsBE,iBAGnB,CAACF,oBAAAA,oBAAqBnC,YAAAA"} \ No newline at end of file diff --git a/grade/amd/src/searchwidget/basewidget.js b/grade/amd/src/searchwidget/basewidget.js index d208cc7a490..cb593bc349b 100644 --- a/grade/amd/src/searchwidget/basewidget.js +++ b/grade/amd/src/searchwidget/basewidget.js @@ -14,105 +14,87 @@ // along with Moodle. If not, see . /** - * A small modal to search users or grade items within the gradebook. + * A widget to search users or grade items within the gradebook. * * @module core_grades/searchwidget/basewidget * @copyright 2022 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -import * as ModalFactory from 'core/modal_factory'; -import * as ModalEvents from 'core/modal_events'; import {debounce} from 'core/utils'; import * as Templates from 'core/templates'; import {comboBox} from 'core/aria'; +import * as Selectors from 'core_grades/searchwidget/selectors'; +import Notification from 'core/notification'; /** * Build the base searching widget. * * @method init - * @param {Promise} bodyPromise The promise from the callee of the contents to place in the modal body. + * @param {HTMLElement} widgetContentContainer The selector for the widget container element. + * @param {Promise} bodyPromise The promise from the callee of the contents to place in the widget container. * @param {Array} data An array of all the data generated by the callee. * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset. - * @param {string} modalTitle The name of the search widget. * @param {string|null} unsearchableContent The content rendered in a non-searchable area. */ -export const init = (bodyPromise, data, searchFunc, modalTitle, unsearchableContent = null) => { - const modal = buildModal(bodyPromise, modalTitle); - registerListenerEvents(modal, data, searchFunc, unsearchableContent); +export const init = async(widgetContentContainer, bodyPromise, data, searchFunc, unsearchableContent = null) => { + bodyPromise.then(async(bodyContent) => { + // Render the body content. + widgetContentContainer.innerHTML = bodyContent; + + // Render the unsearchable content if defined. + if (unsearchableContent) { + const unsearchableContentContainer = widgetContentContainer.querySelector(Selectors.regions.unsearchableContent); + unsearchableContentContainer.innerHTML += unsearchableContent; + } + + const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults); + // Display a loader until the search results are rendered. + await showLoader(searchResultsContainer); + // Render the search results. + await renderSearchResults(searchResultsContainer, data); + + registerListenerEvents(widgetContentContainer, data, searchFunc); + + }).catch(Notification.exception); }; /** - * Register chooser related event listeners. + * Register the event listeners for the search widget. * * @method registerListenerEvents - * @param {Promise} modal Our modal that we are working with. + * @param {HTMLElement} widgetContentContainer The selector for the widget container element. * @param {Array} data An array of all the data generated by the callee. * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset. - * @param {string|null} unsearchableContent The content rendered in a non-searchable area. */ -const registerListenerEvents = (modal, data, searchFunc, unsearchableContent) => { - modal.then(modal => { - // We want to destroy this when the dialog is closed. - modal.getRoot().on(ModalEvents.hidden, () => { - modal.destroy(); - }); - // Once the body of the modal has been resolved, add more features. - modal.getBodyPromise() - // The return value of getBodyPromise is a jquery object containing the body NodeElement. - .then(body => body[0]) - .then(body => { - const searchInput = body.querySelector('input[data-action="search"]'); - const searchResultsContainer = body.querySelector('[data-region="search-results-container-widget"]'); +export const registerListenerEvents = (widgetContentContainer, data, searchFunc) => { + const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults); + const searchInput = widgetContentContainer.querySelector(Selectors.actions.search); - renderSearchResults(searchResultsContainer, data); - - if (unsearchableContent) { - const unsearchableContentContainer = body.querySelector( - '[data-region="unsearchable-content-container-widget"]'); - unsearchableContentContainer.innerHTML += unsearchableContent; - } - - // The search input is triggered. - searchInput.addEventListener('input', debounce(async() => { - // Display the search results. - await renderSearchResults( - searchResultsContainer, - debounceCallee( - searchInput.value, - data, - searchFunc() - ) - ); - }, 300)); - // Trigger event handling for the results in line with aria guidelines. - comboBox(searchInput); - return body; - }).catch(); - }).catch(); + // The search input is triggered. + searchInput.addEventListener('input', debounce(async() => { + // Display the search results. + await renderSearchResults( + searchResultsContainer, + debounceCallee( + searchInput.value, + data, + searchFunc() + ) + ); + }, 300)); + // Trigger event handling for the results in line with aria guidelines. + comboBox(searchInput); }; /** - * Given an object we want to build a modal ready to show. + * Renders the loading placeholder for the search widget. * - * @method buildModal - * @param {Promise} bodyPromise Body promise that the caller should resolve. - * @param {string} modalTitle The name of the search widget. - * @return {Object} The modal ready to display immediately and render body in later. + * @method showLoader + * @param {HTMLElement} container The DOM node where we'll render the loading placeholder. */ -const buildModal = (bodyPromise, modalTitle) => { - return ModalFactory.create({ - type: ModalFactory.types.DEFAULT, - title: modalTitle, - body: bodyPromise, - small: true, - scrollable: false, - templateContext: { - classes: 'reportdatasearch modal-sm' - } - }).then(modal => { - modal.show(); - return modal; - }); +export const showLoader = async(container) => { + const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/loading', {}); + Templates.replaceNodeContents(container, html, js); }; /** @@ -133,7 +115,7 @@ const debounceCallee = (searchValue, data, searchFunction) => { }; /** - * Given the output of the callers' search function, render out the results into the modal. + * Given the output of the callers' search function, render out the results into the search results container. * * @method renderSearchResults * @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results. @@ -149,14 +131,14 @@ const renderSearchResults = async(searchResultsContainer, searchResultsData) => }; /** - * We want to create the basic promises and hooks that the caller will implement, so we can build modals + * We want to create the basic promises and hooks that the caller will implement, so we can build the search widget * ahead of time and allow the caller to resolve their promises once complete. * * @method promisesAndResolvers * @returns {{bodyPromise: Promise, bodyPromiseResolver}} */ export const promisesAndResolvers = () => { - // We want to show the modal instantly but loading whilst waiting for our data. + // We want to show the widget instantly but loading whilst waiting for our data. let bodyPromiseResolver; const bodyPromise = new Promise(resolve => { bodyPromiseResolver = resolve; diff --git a/grade/templates/searchwidget/loading.mustache b/grade/templates/searchwidget/loading.mustache new file mode 100644 index 00000000000..7625998f023 --- /dev/null +++ b/grade/templates/searchwidget/loading.mustache @@ -0,0 +1,27 @@ +{{! + 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 . +}} +{{! + @template core_grades/searchwidget/loading + + This template renders the loading placeholder for the search widget. + + Example context (json): + {} +}} +
+ {{> core/loading }} +
diff --git a/grade/templates/searchwidget/searchitem.mustache b/grade/templates/searchwidget/searchitem.mustache index de0e4db5450..b90203ace5a 100644 --- a/grade/templates/searchwidget/searchitem.mustache +++ b/grade/templates/searchwidget/searchitem.mustache @@ -27,22 +27,20 @@ "fullname": "Cameron Greeve" } }} -
- {{#name}} - - - {{name}} - - - {{/name}} - {{^name}} - - - {{fullname}} - - - {{email}} - - - {{/name}} -
+{{#name}} + + + {{name}} + + +{{/name}} +{{^name}} + + + {{fullname}} + + + +{{/name}} diff --git a/theme/boost/scss/moodle/grade.scss b/theme/boost/scss/moodle/grade.scss index 6204aaf48ee..65caaf4ad6f 100644 --- a/theme/boost/scss/moodle/grade.scss +++ b/theme/boost/scss/moodle/grade.scss @@ -530,21 +530,48 @@ } } -.searchresultitemscontainer { - max-height: 150px; - overflow: auto; +.search-widget { + .dropdown-menu { + padding: 0.8rem 1.2rem; - a:hover { - background-color: $gray-100; - } + &.wide { + width: 350px; + } - .focused-cell { - outline-style: dotted; - outline-color: green; + &.narrow { + width: 250px; + } + + .dropdown-item { + span { + &.email { + color: $text-muted; + } + } + } + + .dropdown-item:hover, + .dropdown-item:active { + span { + color: #fff; + } + } + + .searchresultscontainer { + height: 178px; + font-size: 90%; + + .searchresultitemscontainer { + height: 178px; + max-height: 178px; + overflow: auto; + } + } + + .unsearchablecontentcontainer { + border-top: 1px solid #dee2e6; + padding-top: 10px; + font-size: 90%; + } } } - -.unsearchablecontentcontainer { - border-top: 1px solid #dee2e6; - padding-top: 10px; -} diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 86f69448deb..686c2756f3f 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -19747,18 +19747,28 @@ p.arrow_button { .path-grade-report-singleview .reporttable .dropdown-toggle::after { display: none; } -.searchresultitemscontainer { - max-height: 150px; - overflow: auto; } - .searchresultitemscontainer a:hover { - background-color: #f8f9fa; } - .searchresultitemscontainer .focused-cell { - outline-style: dotted; - outline-color: green; } - -.unsearchablecontentcontainer { - border-top: 1px solid #dee2e6; - padding-top: 10px; } +.search-widget .dropdown-menu { + padding: 0.8rem 1.2rem; } + .search-widget .dropdown-menu.wide { + width: 350px; } + .search-widget .dropdown-menu.narrow { + width: 250px; } + .search-widget .dropdown-menu .dropdown-item span.email { + color: #6a737b; } + .search-widget .dropdown-menu .dropdown-item:hover span, + .search-widget .dropdown-menu .dropdown-item:active span { + color: #fff; } + .search-widget .dropdown-menu .searchresultscontainer { + height: 178px; + font-size: 90%; } + .search-widget .dropdown-menu .searchresultscontainer .searchresultitemscontainer { + height: 178px; + max-height: 178px; + overflow: auto; } + .search-widget .dropdown-menu .unsearchablecontentcontainer { + border-top: 1px solid #dee2e6; + padding-top: 10px; + font-size: 90%; } .columns-autoflow-1to1to1 { column-count: 3; } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 8186e7b861d..baa860f975a 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -19747,18 +19747,28 @@ p.arrow_button { .path-grade-report-singleview .reporttable .dropdown-toggle::after { display: none; } -.searchresultitemscontainer { - max-height: 150px; - overflow: auto; } - .searchresultitemscontainer a:hover { - background-color: #f8f9fa; } - .searchresultitemscontainer .focused-cell { - outline-style: dotted; - outline-color: green; } - -.unsearchablecontentcontainer { - border-top: 1px solid #dee2e6; - padding-top: 10px; } +.search-widget .dropdown-menu { + padding: 0.8rem 1.2rem; } + .search-widget .dropdown-menu.wide { + width: 350px; } + .search-widget .dropdown-menu.narrow { + width: 250px; } + .search-widget .dropdown-menu .dropdown-item span.email { + color: #6a737b; } + .search-widget .dropdown-menu .dropdown-item:hover span, + .search-widget .dropdown-menu .dropdown-item:active span { + color: #fff; } + .search-widget .dropdown-menu .searchresultscontainer { + height: 178px; + font-size: 90%; } + .search-widget .dropdown-menu .searchresultscontainer .searchresultitemscontainer { + height: 178px; + max-height: 178px; + overflow: auto; } + .search-widget .dropdown-menu .unsearchablecontentcontainer { + border-top: 1px solid #dee2e6; + padding-top: 10px; + font-size: 90%; } .columns-autoflow-1to1to1 { column-count: 3; }