diff --git a/lang/en/question.php b/lang/en/question.php index 4bd854c6102..9307a30b086 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -268,6 +268,7 @@ $string['numqas'] = 'No. question attempts'; $string['numquestions'] = 'No. questions'; $string['numquestionsandhidden'] = '{$a->numquestions} (+{$a->numhidden} hidden +{$a->numdraft} draft)'; $string['otherquestionbank'] = 'Other question banks'; +$string['otherquestionbankstoomany'] = '> {$a} results, please refine your search.'; $string['page-question-x'] = 'Any question page'; $string['page-question-edit'] = 'Question editing page'; $string['page-question-category'] = 'Question category page'; diff --git a/lib/db/services.php b/lib/db/services.php index c29cf1098c2..0655a9a2ecb 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -3294,6 +3294,12 @@ $functions = array( 'type' => 'write', 'ajax' => true, ], + 'core_question_search_shared_banks' => [ + 'classname' => '\core_question\external\search_shared_banks', + 'description' => 'Get a list of shared question banks filtered by a search term.', + 'type' => 'read', + 'ajax' => true, + ], 'core_message_set_unsent_message' => [ 'classname' => 'core_message\external\set_unsent_message', 'description' => 'Store an unsent message string', diff --git a/mod/quiz/amd/build/add_question_modal.min.js b/mod/quiz/amd/build/add_question_modal.min.js index 361e833c17e..8c2fee6443c 100644 --- a/mod/quiz/amd/build/add_question_modal.min.js +++ b/mod/quiz/amd/build/add_question_modal.min.js @@ -5,6 +5,6 @@ define("mod_quiz/add_question_modal",["exports","core/modal","core/fragment","co * @module mod_quiz/add_question_modal * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=_interopRequireDefault(_modal),Fragment=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}(Fragment),_formAutocomplete=_interopRequireDefault(_formAutocomplete);class AddQuestionModal extends _modal.default{configure(modalConfig){modalConfig.large=!0,modalConfig.show=!0,modalConfig.removeOnClose=!0,this.setContextId(modalConfig.contextId),this.setAddOnPageId(modalConfig.addOnPage),this.quizCmId=modalConfig.quizCmId,this.bankCmId=modalConfig.bankCmId,this.originalTitle=modalConfig.title,super.configure(modalConfig)}constructor(root){super(root),this.contextId=null,this.addOnPageId=null}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setAddOnPageId(id){this.addOnPageId=id}getAddOnPageId(){return this.addOnPageId}async handleSwitchBankContentReload(Selector){var _document$querySelect;this.setTitle((0,_str.getString)("selectquestionbank","mod_quiz"));const el=document.createElement("button");el.classList.add("btn","btn-primary"),el.textContent=await(0,_str.getString)("gobacktoquiz","mod_quiz"),el.setAttribute("data-action","go-back"),el.setAttribute("value",this.bankCmId),this.setFooter(el),this.setBody(Fragment.loadFragment("mod_quiz","switch_question_bank",this.getContextId(),{quizcmid:this.quizCmId,bankcmid:this.bankCmId}));const placeholder=await(0,_str.getString)("searchbyname","mod_quiz");return await this.getBodyPromise(),await _formAutocomplete.default.enhance(Selector,!1,"",placeholder,!1,!0,"",!0),null===(_document$querySelect=document.querySelector(".search-banks .form-autocomplete-selection"))||void 0===_document$querySelect||_document$querySelect.classList.add("d-none"),this}}return _exports.default=AddQuestionModal,_exports.default})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=_interopRequireDefault(_modal),Fragment=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}(Fragment),_formAutocomplete=_interopRequireDefault(_formAutocomplete);class AddQuestionModal extends _modal.default{configure(modalConfig){modalConfig.large=!0,modalConfig.show=!0,modalConfig.removeOnClose=!0,this.setContextId(modalConfig.contextId),this.setAddOnPageId(modalConfig.addOnPage),this.quizCmId=modalConfig.quizCmId,this.bankCmId=modalConfig.bankCmId,this.originalTitle=modalConfig.title,super.configure(modalConfig)}constructor(root){super(root),this.contextId=null,this.addOnPageId=null}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setAddOnPageId(id){this.addOnPageId=id}getAddOnPageId(){return this.addOnPageId}async handleSwitchBankContentReload(Selector){var _document$querySelect;this.setTitle((0,_str.getString)("selectquestionbank","mod_quiz"));const el=document.createElement("button");el.classList.add("btn","btn-primary"),el.textContent=await(0,_str.getString)("gobacktoquiz","mod_quiz"),el.setAttribute("data-action","go-back"),el.setAttribute("value",this.bankCmId),this.setFooter(el),this.setBody(Fragment.loadFragment("mod_quiz","switch_question_bank",this.getContextId(),{quizcmid:this.quizCmId,bankcmid:this.bankCmId}));const placeholder=await(0,_str.getString)("searchbyname","mod_quiz");return await this.getBodyPromise(),await _formAutocomplete.default.enhance(Selector,!1,"core_question/question_banks_datasource",placeholder,!1,!0,"",!0),null===(_document$querySelect=document.querySelector(".search-banks .form-autocomplete-selection"))||void 0===_document$querySelect||_document$querySelect.classList.add("d-none"),this}}return _exports.default=AddQuestionModal,_exports.default})); //# sourceMappingURL=add_question_modal.min.js.map \ No newline at end of file diff --git a/mod/quiz/amd/build/add_question_modal.min.js.map b/mod/quiz/amd/build/add_question_modal.min.js.map index bf318909ddd..fc4b9eebcf0 100644 --- a/mod/quiz/amd/build/add_question_modal.min.js.map +++ b/mod/quiz/amd/build/add_question_modal.min.js.map @@ -1 +1 @@ -{"version":3,"file":"add_question_modal.min.js","sources":["../src/add_question_modal.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 * Contain the logic for the add random question modal.\n *\n * @module mod_quiz/add_question_modal\n * @copyright 2023 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Modal from 'core/modal';\nimport * as Fragment from 'core/fragment';\nimport {getString} from 'core/str';\nimport AutoComplete from 'core/form-autocomplete';\n\nexport default class AddQuestionModal extends Modal {\n configure(modalConfig) {\n // Add question modals are always large.\n modalConfig.large = true;\n\n // Always show on creation.\n modalConfig.show = true;\n modalConfig.removeOnClose = true;\n\n // Apply question modal configuration.\n this.setContextId(modalConfig.contextId);\n this.setAddOnPageId(modalConfig.addOnPage);\n\n // Store the quiz module id for when we need to POST to the quiz.\n // This is because the URL cmid param will change during filter operations as we will be in another bank context.\n this.quizCmId = modalConfig.quizCmId;\n this.bankCmId = modalConfig.bankCmId;\n\n // Store the original title of the modal, so we can revert back to it once we have switched to another bank.\n this.originalTitle = modalConfig.title;\n\n // Apply standard configuration.\n super.configure(modalConfig);\n }\n\n constructor(root) {\n super(root);\n\n this.contextId = null;\n this.addOnPageId = null;\n }\n\n /**\n * Save the Moodle context id that the question bank is being\n * rendered in.\n *\n * @method setContextId\n * @param {Number} id\n */\n setContextId(id) {\n this.contextId = id;\n }\n\n /**\n * Retrieve the saved Moodle context id.\n *\n * @method getContextId\n * @return {Number}\n */\n getContextId() {\n return this.contextId;\n }\n\n /**\n * Set the id of the page that the question should be added to\n * when the user clicks the add to quiz link.\n *\n * @method setAddOnPageId\n * @param {Number} id\n */\n setAddOnPageId(id) {\n this.addOnPageId = id;\n }\n\n /**\n * Returns the saved page id for the question to be added to.\n *\n * @method getAddOnPageId\n * @return {Number}\n */\n getAddOnPageId() {\n return this.addOnPageId;\n }\n\n /**\n * Update the modal with a list of banks to switch to and enhance the standard selects to Autocomplete fields.\n *\n * @param {String} Selector for the original select element.\n * @return {Promise} Modal.\n */\n async handleSwitchBankContentReload(Selector) {\n this.setTitle(getString('selectquestionbank', 'mod_quiz'));\n\n // Create a 'Go back' button and set it in the footer.\n const el = document.createElement('button');\n el.classList.add('btn', 'btn-primary');\n el.textContent = await getString('gobacktoquiz', 'mod_quiz');\n el.setAttribute('data-action', 'go-back');\n el.setAttribute('value', this.bankCmId);\n this.setFooter(el);\n\n this.setBody(\n Fragment.loadFragment(\n 'mod_quiz',\n 'switch_question_bank',\n this.getContextId(),\n {\n 'quizcmid': this.quizCmId,\n 'bankcmid': this.bankCmId,\n })\n );\n const placeholder = await getString('searchbyname', 'mod_quiz');\n await this.getBodyPromise();\n await AutoComplete.enhance(\n Selector,\n false,\n '',\n placeholder,\n false,\n true,\n '',\n true\n );\n\n // Hide the selection element as we don't need it.\n document.querySelector('.search-banks .form-autocomplete-selection')?.classList.add('d-none');\n\n return this;\n }\n}\n"],"names":["AddQuestionModal","Modal","configure","modalConfig","large","show","removeOnClose","setContextId","contextId","setAddOnPageId","addOnPage","quizCmId","bankCmId","originalTitle","title","constructor","root","addOnPageId","id","getContextId","this","getAddOnPageId","Selector","setTitle","el","document","createElement","classList","add","textContent","setAttribute","setFooter","setBody","Fragment","loadFragment","placeholder","getBodyPromise","AutoComplete","enhance","querySelector"],"mappings":";;;;;;;61BA4BqBA,yBAAyBC,eAC1CC,UAAUC,aAENA,YAAYC,OAAQ,EAGpBD,YAAYE,MAAO,EACnBF,YAAYG,eAAgB,OAGvBC,aAAaJ,YAAYK,gBACzBC,eAAeN,YAAYO,gBAI3BC,SAAWR,YAAYQ,cACvBC,SAAWT,YAAYS,cAGvBC,cAAgBV,YAAYW,YAG3BZ,UAAUC,aAGpBY,YAAYC,YACFA,WAEDR,UAAY,UACZS,YAAc,KAUvBV,aAAaW,SACJV,UAAYU,GASrBC,sBACWC,KAAKZ,UAUhBC,eAAeS,SACND,YAAcC,GASvBG,wBACWD,KAAKH,gDASoBK,yCAC3BC,UAAS,kBAAU,qBAAsB,mBAGxCC,GAAKC,SAASC,cAAc,UAClCF,GAAGG,UAAUC,IAAI,MAAO,eACxBJ,GAAGK,kBAAoB,kBAAU,eAAgB,YACjDL,GAAGM,aAAa,cAAe,WAC/BN,GAAGM,aAAa,QAASV,KAAKR,eACzBmB,UAAUP,SAEVQ,QACDC,SAASC,aACL,WACA,uBACAd,KAAKD,eACL,UACgBC,KAAKT,kBACLS,KAAKR,kBAGvBuB,kBAAoB,kBAAU,eAAgB,yBAC9Cf,KAAKgB,uBACLC,0BAAaC,QACfhB,UACA,EACA,GACAa,aACA,GACA,EACA,IACA,iCAIJV,SAASc,cAAc,sGAA+CZ,UAAUC,IAAI,UAE7ER"} \ No newline at end of file +{"version":3,"file":"add_question_modal.min.js","sources":["../src/add_question_modal.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 * Contain the logic for the add random question modal.\n *\n * @module mod_quiz/add_question_modal\n * @copyright 2023 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Modal from 'core/modal';\nimport * as Fragment from 'core/fragment';\nimport {getString} from 'core/str';\nimport AutoComplete from 'core/form-autocomplete';\n\nexport default class AddQuestionModal extends Modal {\n configure(modalConfig) {\n // Add question modals are always large.\n modalConfig.large = true;\n\n // Always show on creation.\n modalConfig.show = true;\n modalConfig.removeOnClose = true;\n\n // Apply question modal configuration.\n this.setContextId(modalConfig.contextId);\n this.setAddOnPageId(modalConfig.addOnPage);\n\n // Store the quiz module id for when we need to POST to the quiz.\n // This is because the URL cmid param will change during filter operations as we will be in another bank context.\n this.quizCmId = modalConfig.quizCmId;\n this.bankCmId = modalConfig.bankCmId;\n\n // Store the original title of the modal, so we can revert back to it once we have switched to another bank.\n this.originalTitle = modalConfig.title;\n\n // Apply standard configuration.\n super.configure(modalConfig);\n }\n\n constructor(root) {\n super(root);\n\n this.contextId = null;\n this.addOnPageId = null;\n }\n\n /**\n * Save the Moodle context id that the question bank is being\n * rendered in.\n *\n * @method setContextId\n * @param {Number} id\n */\n setContextId(id) {\n this.contextId = id;\n }\n\n /**\n * Retrieve the saved Moodle context id.\n *\n * @method getContextId\n * @return {Number}\n */\n getContextId() {\n return this.contextId;\n }\n\n /**\n * Set the id of the page that the question should be added to\n * when the user clicks the add to quiz link.\n *\n * @method setAddOnPageId\n * @param {Number} id\n */\n setAddOnPageId(id) {\n this.addOnPageId = id;\n }\n\n /**\n * Returns the saved page id for the question to be added to.\n *\n * @method getAddOnPageId\n * @return {Number}\n */\n getAddOnPageId() {\n return this.addOnPageId;\n }\n\n /**\n * Update the modal with a list of banks to switch to and enhance the standard selects to Autocomplete fields.\n *\n * @param {String} Selector for the original select element.\n * @return {Promise} Modal.\n */\n async handleSwitchBankContentReload(Selector) {\n this.setTitle(getString('selectquestionbank', 'mod_quiz'));\n\n // Create a 'Go back' button and set it in the footer.\n const el = document.createElement('button');\n el.classList.add('btn', 'btn-primary');\n el.textContent = await getString('gobacktoquiz', 'mod_quiz');\n el.setAttribute('data-action', 'go-back');\n el.setAttribute('value', this.bankCmId);\n this.setFooter(el);\n\n this.setBody(\n Fragment.loadFragment(\n 'mod_quiz',\n 'switch_question_bank',\n this.getContextId(),\n {\n 'quizcmid': this.quizCmId,\n 'bankcmid': this.bankCmId,\n })\n );\n const placeholder = await getString('searchbyname', 'mod_quiz');\n await this.getBodyPromise();\n await AutoComplete.enhance(\n Selector,\n false,\n 'core_question/question_banks_datasource',\n placeholder,\n false,\n true,\n '',\n true\n );\n\n // Hide the selection element as we don't need it.\n document.querySelector('.search-banks .form-autocomplete-selection')?.classList.add('d-none');\n\n return this;\n }\n}\n"],"names":["AddQuestionModal","Modal","configure","modalConfig","large","show","removeOnClose","setContextId","contextId","setAddOnPageId","addOnPage","quizCmId","bankCmId","originalTitle","title","constructor","root","addOnPageId","id","getContextId","this","getAddOnPageId","Selector","setTitle","el","document","createElement","classList","add","textContent","setAttribute","setFooter","setBody","Fragment","loadFragment","placeholder","getBodyPromise","AutoComplete","enhance","querySelector"],"mappings":";;;;;;;61BA4BqBA,yBAAyBC,eAC1CC,UAAUC,aAENA,YAAYC,OAAQ,EAGpBD,YAAYE,MAAO,EACnBF,YAAYG,eAAgB,OAGvBC,aAAaJ,YAAYK,gBACzBC,eAAeN,YAAYO,gBAI3BC,SAAWR,YAAYQ,cACvBC,SAAWT,YAAYS,cAGvBC,cAAgBV,YAAYW,YAG3BZ,UAAUC,aAGpBY,YAAYC,YACFA,WAEDR,UAAY,UACZS,YAAc,KAUvBV,aAAaW,SACJV,UAAYU,GASrBC,sBACWC,KAAKZ,UAUhBC,eAAeS,SACND,YAAcC,GASvBG,wBACWD,KAAKH,gDASoBK,yCAC3BC,UAAS,kBAAU,qBAAsB,mBAGxCC,GAAKC,SAASC,cAAc,UAClCF,GAAGG,UAAUC,IAAI,MAAO,eACxBJ,GAAGK,kBAAoB,kBAAU,eAAgB,YACjDL,GAAGM,aAAa,cAAe,WAC/BN,GAAGM,aAAa,QAASV,KAAKR,eACzBmB,UAAUP,SAEVQ,QACDC,SAASC,aACL,WACA,uBACAd,KAAKD,eACL,UACgBC,KAAKT,kBACLS,KAAKR,kBAGvBuB,kBAAoB,kBAAU,eAAgB,yBAC9Cf,KAAKgB,uBACLC,0BAAaC,QACfhB,UACA,EACA,0CACAa,aACA,GACA,EACA,IACA,iCAIJV,SAASc,cAAc,sGAA+CZ,UAAUC,IAAI,UAE7ER"} \ No newline at end of file diff --git a/mod/quiz/amd/src/add_question_modal.js b/mod/quiz/amd/src/add_question_modal.js index 2f04ac455ec..e9f9cf44993 100644 --- a/mod/quiz/amd/src/add_question_modal.js +++ b/mod/quiz/amd/src/add_question_modal.js @@ -132,7 +132,7 @@ export default class AddQuestionModal extends Modal { await AutoComplete.enhance( Selector, false, - '', + 'core_question/question_banks_datasource', placeholder, false, true, diff --git a/question/amd/build/question_banks_datasource.min.js b/question/amd/build/question_banks_datasource.min.js new file mode 100644 index 00000000000..2ac75a6f2fa --- /dev/null +++ b/question/amd/build/question_banks_datasource.min.js @@ -0,0 +1,11 @@ +define("core_question/question_banks_datasource",["exports","core/ajax","core/notification"],(function(_exports,_ajax,_notification){var obj; +/** + * Autocomplete data source for shared question banks. + * + * @module core_question/question_banks_datasource + * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};var _default={transport:function(selector,query,callback){const contextId=document.querySelector(selector).dataset.contextid;if(!contextId)throw new Error("The attribute data-contextid is required on "+selector);(0,_ajax.call)([{methodname:"core_question_search_shared_banks",args:{contextid:contextId,search:query}}])[0].then(callback).catch(_notification.default.exception)},processResults:(selector,results)=>results.sharedbanks};return _exports.default=_default,_exports.default})); + +//# sourceMappingURL=question_banks_datasource.min.js.map \ No newline at end of file diff --git a/question/amd/build/question_banks_datasource.min.js.map b/question/amd/build/question_banks_datasource.min.js.map new file mode 100644 index 00000000000..3b9762a2c27 --- /dev/null +++ b/question/amd/build/question_banks_datasource.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"question_banks_datasource.min.js","sources":["../src/question_banks_datasource.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 * Autocomplete data source for shared question banks.\n *\n * @module core_question/question_banks_datasource\n * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}\n * @author Mark Johnson \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\n\nexport default {\n\n transport: function(selector, query, callback) {\n const element = document.querySelector(selector);\n const contextId = element.dataset.contextid;\n\n if (!contextId) {\n throw new Error('The attribute data-contextid is required on ' + selector);\n }\n\n fetchMany([{\n methodname: 'core_question_search_shared_banks',\n args: {\n contextid: contextId,\n search: query,\n },\n }])[0]\n .then(callback)\n .catch(Notification.exception);\n },\n\n processResults: (selector, results) => {\n return results.sharedbanks;\n },\n};\n"],"names":["transport","selector","query","callback","contextId","document","querySelector","dataset","contextid","Error","methodname","args","search","then","catch","Notification","exception","processResults","results","sharedbanks"],"mappings":";;;;;;;;sKA2Be,CAEXA,UAAW,SAASC,SAAUC,MAAOC,gBAE3BC,UADUC,SAASC,cAAcL,UACbM,QAAQC,cAE7BJ,gBACK,IAAIK,MAAM,+CAAiDR,yBAG3D,CAAC,CACPS,WAAY,oCACZC,KAAM,CACFH,UAAWJ,UACXQ,OAAQV,UAEZ,GACHW,KAAKV,UACLW,MAAMC,sBAAaC,YAGxBC,eAAgB,CAAChB,SAAUiB,UAChBA,QAAQC"} \ No newline at end of file diff --git a/question/amd/src/question_banks_datasource.js b/question/amd/src/question_banks_datasource.js new file mode 100644 index 00000000000..fc47d89b802 --- /dev/null +++ b/question/amd/src/question_banks_datasource.js @@ -0,0 +1,52 @@ +// 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 . + +/** + * Autocomplete data source for shared question banks. + * + * @module core_question/question_banks_datasource + * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {call as fetchMany} from 'core/ajax'; +import Notification from 'core/notification'; + +export default { + + transport: function(selector, query, callback) { + const element = document.querySelector(selector); + const contextId = element.dataset.contextid; + + if (!contextId) { + throw new Error('The attribute data-contextid is required on ' + selector); + } + + fetchMany([{ + methodname: 'core_question_search_shared_banks', + args: { + contextid: contextId, + search: query, + }, + }])[0] + .then(callback) + .catch(Notification.exception); + }, + + processResults: (selector, results) => { + return results.sharedbanks; + }, +}; diff --git a/question/classes/external/search_shared_banks.php b/question/classes/external/search_shared_banks.php new file mode 100644 index 00000000000..0a6eea33566 --- /dev/null +++ b/question/classes/external/search_shared_banks.php @@ -0,0 +1,122 @@ +. + +namespace core_question\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_multiple_structure; +use core_external\external_value; +use core_question\local\bank\question_bank_helper; +use core\context; + +/** + * Return a filtered of the user's shared question banks + * + * For use with core_question/question_banks_datasource as a source for autocomplete suggestions. + * + * @package core_question + * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class search_shared_banks extends external_api { + + /** + * @var int The maximum number of banks to return. + */ + const MAX_RESULTS = 20; + + /** + * Define parameters for external function. + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters( + [ + 'contextid' => new external_value(PARAM_INT, 'The current context ID.'), + 'search' => new external_value(PARAM_TEXT, 'Search terms by which to filter the shared banks.', default: ''), + ] + ); + } + + /** + * Return ID and formatted name of question banks accessible by the user, in courses other than the one $contextid is in. + * + * @param int $contextid Context ID of the current activity + * @param string $search String to filter results by question bank name + * @return array + */ + public static function execute(int $contextid, string $search = ''): array { + [ + 'contextid' => $contextid, + 'search' => $search, + ] = self::validate_parameters(self::execute_parameters(), [ + 'contextid' => $contextid, + 'search' => $search, + ]); + + $modulecontext = context::instance_by_id($contextid); + $courseid = $modulecontext->get_parent_context()->instanceid; + + $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( + notincourseids: [$courseid], + havingcap: ['moodle/question:managecategory'], + filtercontext: $modulecontext, + search: $search, + limit: self::MAX_RESULTS + 1, // Return up to 1 extra result, so we know there are more. + ); + + $suggestions = array_map( + fn($sharedbank) => [ + 'value' => $sharedbank->modid, + 'label' => $sharedbank->coursenamebankname, + ], + $sharedbanks, + ); + + if (count($suggestions) > self::MAX_RESULTS) { + // If there are too many results, replace the last one with a placeholder. + $suggestions[array_key_last($suggestions)] = [ + 'value' => 0, + 'label' => get_string('otherquestionbankstoomany', 'question', self::MAX_RESULTS), + ]; + } + + return [ + 'sharedbanks' => $suggestions, + ]; + } + + /** + * Define return values. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'sharedbanks' => new external_multiple_structure( + new external_single_structure([ + 'value' => new external_value(PARAM_INT, 'Module ID of the shared bank.'), + 'label' => new external_value(PARAM_TEXT, 'Formatted bank name'), + ]), + 'List of shared banks', + ), + ]); + } +} diff --git a/question/classes/local/bank/question_bank_helper.php b/question/classes/local/bank/question_bank_helper.php index a7a69251589..172028cfd55 100644 --- a/question/classes/local/bank/question_bank_helper.php +++ b/question/classes/local/bank/question_bank_helper.php @@ -134,6 +134,10 @@ class question_bank_helper { * @param bool $getcategories optionally return the categories belonging to these banks. * @param int $currentbankid optionally include the bank id you want included as the first result from the method return. * it will only be included if the other parameters allow it. + * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with + * parameters that will get banks across multiple contexts. + * @param string $search Optional term to search question bank instances by name + * @param int $limit The number of results to return (default 0 = no limit) * @return stdClass[] */ public static function get_activity_instances_with_shareable_questions( @@ -142,13 +146,19 @@ class question_bank_helper { array $havingcap = [], bool $getcategories = false, int $currentbankid = 0, + ?context $filtercontext = null, + string $search = '', + int $limit = 0, ): array { return self::get_bank_instances(true, $incourseids, $notincourseids, $getcategories, $currentbankid, - $havingcap + $havingcap, + $filtercontext, + $search, + $limit, ); } @@ -161,6 +171,8 @@ class question_bank_helper { * @param bool $getcategories optionally return the categories belonging to these banks. * @param int $currentbankid optionally include the bank id you want included as the first result from the method return. * it will only be included if the other parameters allow it. + * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with + * parameters that will get banks across multiple contexts. * @return stdClass[] */ public static function get_activity_instances_with_private_questions( @@ -169,13 +181,15 @@ class question_bank_helper { array $havingcap = [], bool $getcategories = false, int $currentbankid = 0, + ?context $filtercontext = null, ): array { return self::get_bank_instances(false, $incourseids, $notincourseids, $getcategories, $currentbankid, - $havingcap + $havingcap, + $filtercontext, ); } @@ -191,6 +205,10 @@ class question_bank_helper { * @param int $currentbankid optionally include the bank id you want included as the first result from the method return. * it will only be included if the other parameters allow it. * @param array $havingcap current user must have these capabilities on each bank context. + * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with + * parameters that will get banks across multiple contexts. + * @param string $search Optional term to search question bank instances by name + * @param int $limit The number of results to return (default 0 = no limit) * @return stdClass[] */ private static function get_bank_instances( @@ -200,6 +218,9 @@ class question_bank_helper { bool $getcategories = false, int $currentbankid = 0, array $havingcap = [], + ?context $filtercontext = null, + string $search = '', + int $limit = 0, ): array { global $DB; @@ -241,13 +262,17 @@ class question_bank_helper { if ($plugin === self::get_default_question_bank_activity_name()) { $sql .= " AND p{$key}.type <> '" . self::TYPE_PREVIEW . "'"; } + if (!empty($search)) { + $sql .= " AND " . $DB->sql_like("p{$key}.name", ":search{$key}", false); + $params["search{$key}"] = "%{$search}%"; + } $pluginssql[] = $sql; } $pluginssql = implode(' ', $pluginssql); // Build the SQL to filter out any requested course ids. if (!empty($notincourseids)) { - [$notincoursesql, $notincourseparams] = $DB->get_in_or_equal($notincourseids, SQL_PARAMS_QM, 'param', false); + [$notincoursesql, $notincourseparams] = $DB->get_in_or_equal($notincourseids, SQL_PARAMS_NAMED, 'param', false); $notincoursesql = "AND cm.course {$notincoursesql}"; $params = array_merge($params, $notincourseparams); } else { @@ -256,7 +281,7 @@ class question_bank_helper { // Build the SQL to include ONLY records belonging to the requested courses. if (!empty($incourseids)) { - [$incoursesql, $incourseparams] = $DB->get_in_or_equal($incourseids); + [$incoursesql, $incourseparams] = $DB->get_in_or_equal($incourseids, SQL_PARAMS_NAMED); $incoursesql = " AND cm.course {$incoursesql}"; $params = array_merge($params, $incourseparams); } else { @@ -265,8 +290,8 @@ class question_bank_helper { // Optionally order the results by the requested bank id. if (!empty($currentbankid)) { - $orderbysql = " ORDER BY CASE WHEN cm.id = ? THEN 0 ELSE 1 END ASC, cm.id DESC "; - $params[] = $currentbankid; + $orderbysql = " ORDER BY CASE WHEN cm.id = :currentbankid THEN 0 ELSE 1 END ASC, cm.id DESC "; + $params['currentbankid'] = $currentbankid; } else { $orderbysql = ''; } @@ -280,7 +305,7 @@ class question_bank_helper { GROUP BY cm.id, cm.course {$orderbysql}"; - $rs = $DB->get_recordset_sql($sql, $params); + $rs = $DB->get_recordset_sql($sql, $params, limitnum: $limit); $banks = []; foreach ($rs as $cm) { @@ -293,8 +318,9 @@ class question_bank_helper { } } // Populate the raw record. - $banks[] = self::get_formatted_bank($cm, $currentbankid); + $banks[] = self::get_formatted_bank($cm, $currentbankid, filtercontext: $filtercontext); } + $rs->close(); return $banks; } @@ -305,9 +331,15 @@ class question_bank_helper { * * @param int $userid of the user to get recently viewed banks for. * @param int $notincourseid if supplied don't return any in this course id + * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with + * parameters that will get banks across multiple contexts. * @return cm_info[] */ - public static function get_recently_used_open_banks(int $userid, int $notincourseid = 0): array { + public static function get_recently_used_open_banks( + int $userid, + int $notincourseid = 0, + ?context $filtercontext = null, + ): array { $prefs = get_user_preferences(self::RECENTLY_VIEWED, null, $userid); $contextids = !empty($prefs) ? explode(',', $prefs) : []; if (empty($contextids)) { @@ -328,7 +360,7 @@ class question_bank_helper { if (!empty($notincourseid) && $notincourseid == $cm->course) { continue; } - $record = self::get_formatted_bank($cm); + $record = self::get_formatted_bank($cm, filtercontext: $filtercontext); $banks[] = $record; } @@ -370,12 +402,15 @@ class question_bank_helper { /** * Populate the raw record with data for use in rendering. + * * @param stdClass $cm raw course_modules record to populate data from. * @param int $currentbankid set an 'enabled' flag on the instance that matched this id. - * Used in qbank_bulkmove/bulk_move.mustache + * Used in qbank_bulkmove/bulk_move.mustache + * @param ?context $filtercontext Optional context in which to apply filters. + * * @return stdClass */ - private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0): stdClass { + private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass { $cminfo = cm_info::create($cm); $concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : []; @@ -390,14 +425,19 @@ class question_bank_helper { }, $concatedcats); $bank = new stdClass(); - $bank->name = $cminfo->get_formatted_name(['escape' => false]); + $filteroptions = ['escape' => false]; + if (!is_null($filtercontext)) { + $filteroptions['context'] = $filtercontext; + } + $bank->name = $cminfo->get_formatted_name($filteroptions); $bank->modid = $cminfo->id; $bank->contextid = $cminfo->context->id; - $bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, - ['context' => $cminfo->context, 'escape' => false]) . " - {$bank->name}"; + if (!isset($filteroptions['context'])) { + $filteroptions['context'] = context_course::instance($cminfo->get_course()->id); + } + $bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, $filteroptions) . " - {$bank->name}"; $bank->cminfo = $cminfo; $bank->questioncategories = $categories; - return $bank; } diff --git a/question/classes/output/switch_question_bank.php b/question/classes/output/switch_question_bank.php index 9619cfc51dc..ab64b51994a 100644 --- a/question/classes/output/switch_question_bank.php +++ b/question/classes/output/switch_question_bank.php @@ -62,25 +62,21 @@ class switch_question_bank implements \renderable, \templatable { [, $cm] = get_module_from_cmid($this->quizcmid); $cminfo = cm_info::create($cm); - $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( - notincourseids: [$this->courseid], - havingcap: ['moodle/question:managecategory'] - ); $coursesharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions( incourseids: [$this->courseid], - havingcap: ['moodle/question:managecategory'] + havingcap: ['moodle/question:managecategory'], + filtercontext: $cminfo->context, ); $recentlyviewedbanks = question_bank_helper::get_recently_used_open_banks($this->userid); return [ 'quizname' => $cminfo->get_formatted_name(), 'quizcmid' => $this->quizcmid, + 'quizcontextid' => $cminfo->context->id, 'hascoursesharedbanks' => !empty($coursesharedbanks), 'coursesharedbanks' => $coursesharedbanks, 'hasrecentlyviewedbanks' => !empty($recentlyviewedbanks), 'recentlyviewedbanks' => $recentlyviewedbanks, - 'hassharedbanks' => !empty($sharedbanks), - 'sharedbanks' => $sharedbanks, ]; } } diff --git a/question/templates/switch_question_bank.mustache b/question/templates/switch_question_bank.mustache index 14cc8770cb9..725b28ff155 100644 --- a/question/templates/switch_question_bank.mustache +++ b/question/templates/switch_question_bank.mustache @@ -21,6 +21,7 @@ { "quizname": "Quiz 1", "quizcmid": 1, + "quizcontextid": 1, "hascoursesharedbanks": true, "coursesharedbanks": [ { @@ -131,14 +132,9 @@
{{/hasrecentlyviewedbanks}} -{{#hassharedbanks}} -
-
{{#str}}otherquestionbank, core_question{{/str}}
- -
-{{/hassharedbanks}} \ No newline at end of file + +
+
{{#str}}otherquestionbank, core_question{{/str}}
+ +
diff --git a/question/tests/local/bank/question_bank_helper_test.php b/question/tests/local/bank/question_bank_helper_test.php index 76011ba5058..36a4ec9d88c 100644 --- a/question/tests/local/bank/question_bank_helper_test.php +++ b/question/tests/local/bank/question_bank_helper_test.php @@ -160,6 +160,54 @@ final class question_bank_helper_test extends \advanced_testcase { $this->assertEquals(1, $count); } + /** + * We should be able to filter sharable question bank instances by name. + * + * @covers ::get_activity_instances_with_shareable_questions + * @return void + */ + public function test_get_instances_by_name(): void { + global $DB; + + $this->resetAfterTest(); + $user = self::getDataGenerator()->create_user(); + $roles = $DB->get_records('role', [], '', 'shortname, id'); + self::setUser($user); + + $sharedmodgen = self::getDataGenerator()->get_plugin_generator('mod_qbank'); + $category1 = self::getDataGenerator()->create_category(); + $course1 = self::getDataGenerator()->create_course(['category' => $category1->id]); + role_assign($roles['editingteacher']->id, $user->id, \core\context\course::instance($course1->id)); + + $sharedmods = []; + for ($i = 1; $i <= 21; $i++) { + $sharedmods[$i] = $sharedmodgen->create_instance(['course' => $course1, 'name' => "Shared bank {$i}"]); + } + $sharedmods[22] = $sharedmodgen->create_instance(['course' => $course1, 'name' => "Another bank"]); + + // We get all banks with no parameters. + $allsharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(); + $this->assertCount(22, $allsharedbanks); + + // Searching for "2", we get the 4 banks with "2" in the name. + $twobanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: '2'); + $this->assertCount(4, $twobanks); + $this->assertEquals( + [$sharedmods[2]->cmid, $sharedmods[12]->cmid, $sharedmods[20]->cmid, $sharedmods[21]->cmid], + array_map(fn($bank) => $bank->modid, $twobanks), + ); + + // Searching for "Shared bank" with no limit, we should get all 21, but not "Another bank". + $sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank'); + $this->assertCount(21, $sharedbanks); + $this->assertEmpty(array_filter($sharedbanks, fn($bank) => in_array($bank->name, ['Another bank']))); + + // Searching for "Shared bank" with a limit of 20, we should get all except number 21 and "Another bank". + $limitedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank', limit: 20); + $this->assertCount(20, $limitedbanks); + $this->assertEmpty(array_filter($limitedbanks, fn($bank) => in_array($bank->name, ['Shared bank 21', 'Another bank']))); + } + /** * Assert creating a default mod_qbank instance on a course provides the expected boilerplate settings. * diff --git a/version.php b/version.php index 5f9fc7df173..1637fcb9829 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025040100.01; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025040100.02; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.0beta (Build: 20250401)'; // Human-friendly version name