MDL-87264 questions: Make bank switcher more generic

Currently, the question bank switcher assumes its being displayed
inside a quiz. This means it can't easily be reused in other
activities, or outside activity contexts.

This change makes the inclusion and display of an activity's
question bank on the list of available banks optional, and
removes any references to the quiz.
This commit is contained in:
Mark Johnson
2026-02-27 14:43:56 +00:00
parent e89205eca2
commit c6cf0cb951
28 changed files with 697 additions and 300 deletions
@@ -0,0 +1,26 @@
issueNumber: MDL-87264
notes:
mod_quiz:
- message: >
The `mod_quiz_output_fragment_switch_question_bank()` Fragment API
callback is deprecated in favour of
`core_question\route\api\bank::banks()`, available via the route
`/api/rest/v2/core/question/banks?courseid=X`.
type: deprecated
- message: >
The "gobacktoquiz" and "selectquestionbank" lang strings have been deprecated.
These are only used by the question bank switching UI, so have been replaced
with the "switchergoback" and "switcherselectbank" strings in the core_question
component.
type: deprecated
core_question:
- message: >
The `\core_question\output\switch_question_bank` renderable is now deprecated,
as rendering of the switch_question_bank template is now all handled client-side
by the `core_question/bank_switcher` Javascript module.
type: deprecated
- message: >
The UI for switching question banks is now encapsulated in the
`core_question/bank_switcher` Javascript module. This takes an existing modal
and replaces the content with the switcher. It will then emit a custom
`bankSwitched` event on the modal's DOM element when a new bank is selected.
+4
View File
@@ -82,6 +82,7 @@ $string['clicktounflag'] = 'Remove flag';
$string['clickunflag'] = 'Remove flag';
$string['contexterror'] = 'You shouldn\'t have got here if you\'re not moving a category to another context.';
$string['copy'] = 'Copy from {$a} and change links.';
$string['coursenamebankname'] = '{$a->coursename} - {$a->bankname}';
$string['created'] = 'Created';
$string['createdefault'] = 'Create default question bank';
$string['createdby'] = 'Created by';
@@ -325,6 +326,9 @@ $string['shareincontext'] = 'Share in context for {$a}';
$string['stoponerror'] = 'Stop on error';
$string['stoponerror_help'] = 'This setting determines whether the import process stops when an error is detected, resulting in no questions being imported, or whether any questions containing errors are ignored and any valid questions are imported.';
$string['switchbank'] = 'Switch bank';
$string['switchergoback'] = 'Go back';
$string['switchersearchbyname'] = 'Search by name...';
$string['switcherselectbank'] = 'Select question bank';
$string['tofilecategory'] = 'Write category to file';
$string['tofilecontext'] = 'Write context to file';
$string['topfor'] = 'Top for {$a}';
+2 -2
View File
@@ -1,10 +1,10 @@
define("mod_quiz/add_question_modal",["exports","core/modal","core/fragment","core/str","core/form-autocomplete"],(function(_exports,_modal,Fragment,_str,_formAutocomplete){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}}
define("mod_quiz/add_question_modal",["exports","core/modal"],(function(_exports,_modal){var obj;
/**
* Contain the logic for the add random question modal.
*
* @module mod_quiz/add_question_modal
* @copyright 2023 Andrew Lyons <andrew@nicols.co.uk>
* @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,"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}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=(obj=_modal)&&obj.__esModule?obj:{default:obj};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.courseId=modalConfig.courseId,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}}return _exports.default=AddQuestionModal,_exports.default}));
//# sourceMappingURL=add_question_modal.min.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -49
View File
@@ -22,9 +22,6 @@
*/
import Modal from 'core/modal';
import * as Fragment from 'core/fragment';
import {getString} from 'core/str';
import AutoComplete from 'core/form-autocomplete';
export default class AddQuestionModal extends Modal {
configure(modalConfig) {
@@ -43,6 +40,7 @@ export default class AddQuestionModal extends Modal {
// This is because the URL cmid param will change during filter operations as we will be in another bank context.
this.quizCmId = modalConfig.quizCmId;
this.bankCmId = modalConfig.bankCmId;
this.courseId = modalConfig.courseId;
// Store the original title of the modal, so we can revert back to it once we have switched to another bank.
this.originalTitle = modalConfig.title;
@@ -99,50 +97,4 @@ export default class AddQuestionModal extends Modal {
getAddOnPageId() {
return this.addOnPageId;
}
/**
* Update the modal with a list of banks to switch to and enhance the standard selects to Autocomplete fields.
*
* @param {String} Selector for the original select element.
* @return {Promise} Modal.
*/
async handleSwitchBankContentReload(Selector) {
this.setTitle(getString('selectquestionbank', 'mod_quiz'));
// Create a 'Go back' button and set it in the footer.
const el = document.createElement('button');
el.classList.add('btn', 'btn-primary');
el.textContent = await 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 getString('searchbyname', 'mod_quiz');
await this.getBodyPromise();
await AutoComplete.enhance(
Selector,
false,
'core_question/question_banks_datasource',
placeholder,
false,
true,
'',
true
);
// Hide the selection element as we don't need it.
document.querySelector('.search-banks .form-autocomplete-selection')?.classList.add('d-none');
return this;
}
}
@@ -29,6 +29,7 @@ import * as Templates from 'core/templates';
import * as FormChangeChecker from 'core_form/changechecker';
import {call as fetchMany} from 'core/ajax';
import Pending from 'core/pending';
import BankSwitcher from 'core_question/bank_switcher';
const SELECTORS = {
ANCHOR: 'a[href]',
@@ -70,6 +71,7 @@ export default class ModalAddRandomQuestion extends Modal {
* @param {string} returnUrl URL to return to after form submission.
* @param {Number} quizCmId Current quiz course module id.
* @param {boolean} showNewCategory Display the New category tab when selecting random questions.
* @param {Number} courseId Current course id, required for switching banks.
*/
static init(
contextId,
@@ -77,7 +79,8 @@ export default class ModalAddRandomQuestion extends Modal {
category,
returnUrl,
quizCmId,
showNewCategory = true
showNewCategory = true,
courseId,
) {
const selector = '.menu [data-action="addarandomquestion"], [data-action="editrandomquestion"]';
document.addEventListener('click', (e) => {
@@ -104,6 +107,7 @@ export default class ModalAddRandomQuestion extends Modal {
templateContext: {
hidden: showNewCategory,
},
courseId,
});
});
}
@@ -131,6 +135,7 @@ export default class ModalAddRandomQuestion extends Modal {
this.showNewCategory = modalConfig.showNewCategory;
this.setSlotId(modalConfig.slotId ?? 0);
this.setSavedFilterCondition(modalConfig.savedFilterCondition ?? null);
this.setCourseId(modalConfig.courseId);
super.configure(modalConfig);
}
@@ -206,6 +211,24 @@ export default class ModalAddRandomQuestion extends Modal {
return this.slotId;
}
/**
* Store the ID of the current course
*
* @param {Number} courseId
*/
setCourseId(courseId) {
this.courseId = courseId;
}
/**
* Get the current course ID.
*
* @return {Number}
*/
getCourseId() {
return this.courseId;
}
/**
* Store the current filterCondition JSON string.
*
@@ -366,61 +389,22 @@ export default class ModalAddRandomQuestion extends Modal {
}
});
const switcher = new BankSwitcher();
this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => {
this.setSavedFilterCondition(
document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition
);
this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH)
.then(function(ModalQuizQuestionBank) {
$(SELECTORS.BANK_SEARCH)?.on('change', (e) => {
const bankCmId = $(e.currentTarget).val();
// Have to recreate the modal as we have already used the body for the switch bank content.
if (bankCmId > 0) {
ModalAddRandomQuestion.create({
'contextId': ModalQuizQuestionBank.getContextId(),
'bankCmId': bankCmId,
'category': ModalQuizQuestionBank.getCategory(),
'returnUrl': ModalQuizQuestionBank.getReturnUrl(),
'quizCmId': ModalQuizQuestionBank.quizCmId,
'title': ModalQuizQuestionBank.originalTitle,
'addOnPage': ModalQuizQuestionBank.getAddOnPageId(),
'templateContext': {hidden: ModalQuizQuestionBank.showNewCategory},
'showNewCategory': ModalQuizQuestionBank.showNewCategory,
'slotId': ModalQuizQuestionBank.getSlotId(),
})
.then(ModalQuizQuestionBank.destroy())
.catch(Notification.exception);
}
});
return ModalQuizQuestionBank;
});
try {
switcher.show(this, this.courseId, this.getContextId(), this.bankCmId, this.quizCmId);
} catch (ex) {
Notification.exception(ex);
}
});
this.getModal().on('click', SELECTORS.GO_BACK_BUTTON, (e) => {
const anchorElement = $(e.currentTarget);
// Have to recreate the modal as we have already used the body for the switch bank content.
ModalAddRandomQuestion.create({
'contextId': this.getContextId(),
'bankCmId': anchorElement.attr('value'),
'category': this.getCategory(),
'returnUrl': this.getReturnUrl(),
'quizCmId': this.quizCmId,
'title': this.originalTitle,
'addOnPage': this.getAddOnPageId(),
'templateContext': {hidden: this.showNewCategory},
'showNewCategory': this.showNewCategory,
'savedFilterCondition': this.getSavedFilterCondition(),
'slotId': this.getSlotId(),
}).then(this.destroy()).catch(Notification.exception);
});
this.getModal().on('click', SELECTORS.ANCHOR, (e) => {
const anchorElement = $(e.currentTarget);
// Have to recreate the modal as we have already used the body for the switch bank content.
if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']').length) {
ModalAddRandomQuestion.create({
this.getModal().get(0).addEventListener('bankSwitched', async(e) => {
if (e.detail.cmid > 0) {
const modalConfig = {
'contextId': this.getContextId(),
'bankCmId': anchorElement.attr(SELECTORS.NEW_BANKMOD_ID),
'bankCmId': e.detail.cmid,
'category': this.getCategory(),
'returnUrl': this.getReturnUrl(),
'quizCmId': this.quizCmId,
@@ -429,7 +413,18 @@ export default class ModalAddRandomQuestion extends Modal {
'templateContext': {hidden: this.showNewCategory},
'showNewCategory': this.showNewCategory,
'slotId': this.getSlotId(),
}).then(this.destroy()).catch(Notification.exception);
'courseId': this.getCourseId(),
};
if (e.detail.cmid === this.bankCmId) {
// We're displaying the same bank as before, keep the current filters.
modalConfig.savedFilterCondition = this.getSavedFilterCondition();
}
try {
await ModalAddRandomQuestion.create(modalConfig);
this.destroy();
} catch (ex) {
await Notification.exception(ex);
}
}
});
})
@@ -26,6 +26,7 @@ import * as Fragment from 'core/fragment';
import * as FormChangeChecker from 'core_form/changechecker';
import * as ModalEvents from 'core/modal_events';
import * as Notification from 'core/notification';
import BankSwitcher from 'core_question/bank_switcher';
const SELECTORS = {
ADD_TO_QUIZ_CONTAINER: 'td.addtoquizaction',
@@ -50,8 +51,9 @@ export default class ModalQuizQuestionBank extends Modal {
* @param {Number} contextId Current module context id.
* @param {Number} bankCmId Current question bank course module id.
* @param {Number} quizCmId Current quiz course module id.
* @param {Number} courseId Current course id, required for switching banks.
*/
static init(contextId, bankCmId, quizCmId) {
static init(contextId, bankCmId, quizCmId, courseId) {
const selector = '.menu [data-action="questionbank"]';
document.addEventListener('click', (e) => {
const trigger = e.target.closest(selector);
@@ -70,6 +72,7 @@ export default class ModalQuizQuestionBank extends Modal {
hidden: true,
},
large: true,
courseId,
});
});
}
@@ -157,23 +160,27 @@ export default class ModalQuizQuestionBank extends Modal {
formElement.setAttribute('action', actionUrl.toString());
});
const switcher = new BankSwitcher();
this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => {
this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH)
.then(function(ModalQuizQuestionBank) {
document.querySelector(SELECTORS.BANK_SEARCH)?.addEventListener('change', (e) => {
const bankCmId = e.currentTarget.value;
if (bankCmId > 0) {
ModalQuizQuestionBank.bankCmId = bankCmId;
ModalQuizQuestionBank.reloadBodyContent(window.location.search);
}
});
document.querySelector(SELECTORS.GO_BACK_BUTTON).addEventListener('click', (e) => {
ModalQuizQuestionBank.bankCmId = e.currentTarget.value;
ModalQuizQuestionBank.reloadBodyContent(window.location.search);
});
}
)
.catch(Notification.exception);
try {
switcher.show(this, this.courseId, this.getContextId(), this.bankCmId, this.quizCmId);
} catch (ex) {
Notification.exception(ex);
}
});
this.getModal().get(0).addEventListener('bankSwitched', (e) => {
const bankCmId = e.detail.cmid;
if (bankCmId > 0) {
// We need to clear the filter as we are about to reload the content.
const url = new URL(location.href);
url.searchParams.delete('filter');
history.pushState({}, '', url);
this.bankCmId = bankCmId;
this.reloadBodyContent(window.location.search);
}
});
this.getModal().on('click', SELECTORS.ANCHOR, (e) => {
@@ -185,28 +192,7 @@ export default class ModalQuizQuestionBank extends Modal {
return;
}
// If the anchor element was a preview question link.
if (anchorElement.closest(SELECTORS.PREVIEW_CONTAINER)) {
return;
}
// Sorting links have their own handler.
if (anchorElement.closest(SELECTORS.SORTERS)) {
return;
}
if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']')) {
this.bankCmId = anchorElement.getAttribute(SELECTORS.NEW_BANKMOD_ID);
// We need to clear the filter as we are about to reload the content.
const url = new URL(location.href);
url.searchParams.delete('filter');
history.pushState({}, '', url);
}
// Anything else means reload the pop-up contents.
e.preventDefault();
this.reloadBodyContent(anchorElement.search);
});
// Disable the form change checker when the body is rendered.
@@ -122,6 +122,7 @@ class edit_renderer extends \plugin_renderer_base {
$thiscontext->id,
$quizobj->get_cm()->id,
$quizobj->get_cm()->id,
$quizobj->get_courseid(),
]);
$this->page->requires->js_call_amd('mod_quiz/modal_add_random_question', 'init', [
@@ -131,6 +132,7 @@ class edit_renderer extends \plugin_renderer_base {
$pageurl->out_as_local_url(true),
$pageurl->param('cmid'),
\core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME),
$quizobj->get_courseid(),
]);
// Include the question chooser.
+2
View File
@@ -5,3 +5,5 @@ groupoverridesdeleted,mod_quiz
randomcoursecatwithsubcat,mod_quiz
randomcoursewithsubcat,mod_quiz
randomsystemwithsubcat,mod_quiz
selectquestionbank,mod_quiz
gobacktoquiz,mod_quiz
+4 -2
View File
@@ -1012,7 +1012,6 @@ $string['selectedattempts'] = 'Selected attempts...';
$string['selectmultipleitems'] = 'Select multiple items';
$string['selectmultipletoolbar'] = 'Select multiple toolbar';
$string['selectnone'] = 'Deselect all';
$string['selectquestionbank'] = 'Select question bank';
$string['selectquestionslot'] = 'Select question {$a}';
$string['selectquestiontype'] = '-- Select question type --';
$string['sendnotificationopendatesoon'] = 'Notify user of an approaching quiz open date';
@@ -1155,7 +1154,6 @@ $string['yourfinalgradeis'] = 'Your final grade for this quiz is {$a}.';
$string['questionversion'] = 'v{$a}';
$string['questionversionlatest'] = 'v{$a} (latest)';
$string['alwayslatest'] = 'Always latest';
$string['gobacktoquiz'] = 'Go back';
// Deprecated since Moodle 4.5.
$string['attemptsdeleted'] = 'Quiz attempts deleted';
@@ -1167,3 +1165,7 @@ $string['groupoverridesdeleted'] = 'Group overrides deleted';
$string['randomcoursecatwithsubcat'] = 'Any category inside course category {$a}';
$string['randomcoursewithsubcat'] = 'Any category in this course';
$string['randomsystemwithsubcat'] = 'Any system-level category';
// Deprecated since Moodle 5.2.
$string['gobacktoquiz'] = 'Go back';
$string['selectquestionbank'] = 'Select question bank';
+7
View File
@@ -2490,8 +2490,15 @@ function mod_quiz_output_fragment_quiz_question_bank($args): string {
* @param array $args provided by the AJAX request.
* @return string html to render to the modal.
*/
#[\core\attribute\deprecated(
replacement: 'core_question\route\api\bank::switcher',
since: '5.2',
reason: 'Replaced with a non-quiz-specific API endpoint',
mdl: 'MDL-87264',
)]
function mod_quiz_output_fragment_switch_question_bank($args): string {
global $USER, $COURSE, $OUTPUT;
\core\deprecation::emit_deprecation(__FUNCTION__);
$quizcmid = clean_param($args['quizcmid'], PARAM_INT);
+21
View File
@@ -0,0 +1,21 @@
define("core_question/bank_switcher",["exports","core/str","core/fetch","core/templates","core/form-autocomplete"],(function(_exports,_str,_fetch,_templates,_formAutocomplete){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Bank switcher module for switching between question banks.
*
* The BankSwitcher class is used to display the bank switching UI in an existing Modal. This can either be a new Modal
* created for the purpose, or an existing modal, in which case it will replace the existing title, body and footer.
*
* When a new bank is selected, it will emit a custom `bankSwitched` event on the modal's DOM element,
* with the cmid of the selected bank in `event.details.cmid`. For example:
*
* const modal = Modal.create(...);
* const switcher = new BankSwitcher();
* switcher.show(modal, courseId, cmId);
* modal.getModal().get(0).addEventListener('bankSwitched', (e) => window.console.log(e.details.cmid));
*
* @module core_question/bank_switcher
* @copyright 2025 Catalyst IT Europe Ltd.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_fetch=_interopRequireDefault(_fetch),_templates=_interopRequireDefault(_templates),_formAutocomplete=_interopRequireDefault(_formAutocomplete);const SELECTORS_ANCHOR="a[data-newmodid]",SELECTORS_AUTOCOMPLETE_SELECTION=".search-banks .form-autocomplete-selection",SELECTORS_BANK_SEARCH="#searchbanks",SELECTORS_BANK_SWITCHER=".bank-switcher",SELECTORS_GO_BACK_BUTTON='button[data-action="go-back"]',SELECTORS_MODAL='div[data-region="modal"]';return _exports.default=class{async show(modal,courseId,contextId){var _modalBody$querySelec;let currentCmId=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,activityCmId=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;if(!courseId)throw new Error("courseId is required by the bank switcher.");if(modal.setTitle((0,_str.get_string)("switcherselectbank","core_question")),currentCmId){const el=document.createElement("button");el.classList.add("btn","btn-primary"),el.textContent=await(0,_str.get_string)("switchergoback","core_question"),el.setAttribute("data-action","go-back"),el.setAttribute("value",currentCmId),el.addEventListener("click",this.clickListener),modal.setFooter(el)}const params={course:courseId,includeshared:!0,includerecent:!0};activityCmId&&(params.currentmodule=activityCmId);const banksResponse=await _fetch.default.performGet("question","banks",{params:params}),{banks:banks}=await banksResponse.json(),templateContext={contextid:contextId,hasactivitybank:!1,hascoursesharedbanks:!1,coursesharedbanks:[],hasrecentlyviewedbanks:!1,recentlyviewedbanks:[]};banks.forEach((bank=>bank.current?(templateContext.hasactivitybank=!0,void(templateContext.activitybank={name:bank.name,cmid:bank.modid})):bank.recent?(templateContext.hasrecentlyviewedbanks=!0,void templateContext.recentlyviewedbanks.push({coursenamebankname:bank.coursenamebankname,modid:bank.modid})):(templateContext.hascoursesharedbanks=!0,void templateContext.coursesharedbanks.push({name:bank.name,modid:bank.modid})))),modal.setBody(_templates.default.render("core_question/switch_question_bank",templateContext));const placeholder=await(0,_str.get_string)("switchersearchbyname","core_question");await modal.getBodyPromise(),await _formAutocomplete.default.enhance(SELECTORS_BANK_SEARCH,!1,"core_question/question_banks_datasource",placeholder,!1,!0,"",!0);const modalBody=modal.getBody()[0];null===(_modalBody$querySelec=modalBody.querySelector(SELECTORS_AUTOCOMPLETE_SELECTION))||void 0===_modalBody$querySelec||_modalBody$querySelec.classList.add("d-none");const switcherElement=modalBody.querySelector(SELECTORS_BANK_SWITCHER);switcherElement.addEventListener("click",this.clickListener),switcherElement.addEventListener("change",this.changeListener)}clickListener(e){const goBack=e.target.closest(SELECTORS_GO_BACK_BUTTON);let cmid;if(goBack)cmid=goBack.value;else{const anchor=e.target.closest(SELECTORS_ANCHOR);if(!anchor)return;cmid=anchor.dataset.newmodid}e.preventDefault();const modalElement=e.target.closest(SELECTORS_MODAL),bankSwitched=new CustomEvent("bankSwitched",{detail:{cmid:cmid}});modalElement.dispatchEvent(bankSwitched)}changeListener(e){const search=e.target.closest(SELECTORS_BANK_SEARCH);if(!search)return;const bankCmId=search.value;if(bankCmId>0){const modalElement=e.target.closest(SELECTORS_MODAL),bankSwitched=new CustomEvent("bankSwitched",{detail:{cmid:bankCmId}});modalElement.dispatchEvent(bankSwitched)}}},_exports.default}));
//# sourceMappingURL=bank_switcher.min.js.map
File diff suppressed because one or more lines are too long
+177
View File
@@ -0,0 +1,177 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import {get_string as getString} from 'core/str';
import Fetch from 'core/fetch';
import Templates from 'core/templates';
import AutoComplete from 'core/form-autocomplete';
/**
* Bank switcher module for switching between question banks.
*
* The BankSwitcher class is used to display the bank switching UI in an existing Modal. This can either be a new Modal
* created for the purpose, or an existing modal, in which case it will replace the existing title, body and footer.
*
* When a new bank is selected, it will emit a custom `bankSwitched` event on the modal's DOM element,
* with the cmid of the selected bank in `event.details.cmid`. For example:
*
* const modal = Modal.create(...);
* const switcher = new BankSwitcher();
* switcher.show(modal, courseId, cmId);
* modal.getModal().get(0).addEventListener('bankSwitched', (e) => window.console.log(e.details.cmid));
*
* @module core_question/bank_switcher
* @copyright 2025 Catalyst IT Europe Ltd.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const SELECTORS = {
ANCHOR: 'a[data-newmodid]',
AUTOCOMPLETE_SELECTION: '.search-banks .form-autocomplete-selection',
BANK_SEARCH: '#searchbanks',
BANK_SWITCHER: '.bank-switcher',
GO_BACK_BUTTON: 'button[data-action="go-back"]',
MODAL: 'div[data-region="modal"]',
};
export default class BankSwitcher {
/**
* Show the bank switcher.
*
* This will replace the content of the given modal with the bank switcher, and emit a bankChanged event on the modal
* when a bank is selected (either a new bank, or the current one again).
*
* @param {Modal} modal The modal to display the switcher within.
* @param {Number} courseId The course ID to display banks from.
* @param {Number} contextId The context the bank switcher is being displayed in, for text filters.
* @param {Number} currentCmId The cmid of the currently selected question bank.
* @param {Number} activityCmId The cmid of activity we are currently in (if any).
* @return {Promise<void>} Resolves once the switcher has finished displaying.
*/
async show(modal, courseId, contextId, currentCmId = null, activityCmId = null) {
if (!courseId) {
throw new Error('courseId is required by the bank switcher.');
}
modal.setTitle(getString('switcherselectbank', 'core_question'));
if (currentCmId) {
// Create a 'Go back' button and set it in the footer.
const el = document.createElement('button');
el.classList.add('btn', 'btn-primary');
el.textContent = await getString('switchergoback', 'core_question');
el.setAttribute('data-action', 'go-back');
el.setAttribute('value', currentCmId);
el.addEventListener('click', this.clickListener);
modal.setFooter(el);
}
const params = {
course: courseId,
includeshared: true,
includerecent: true,
};
if (activityCmId) {
params.currentmodule = activityCmId;
}
const banksResponse = await Fetch.performGet('question', 'banks', {params});
const {banks} = await banksResponse.json();
const templateContext = {
contextid: contextId,
hasactivitybank: false,
hascoursesharedbanks: false,
coursesharedbanks: [],
hasrecentlyviewedbanks: false,
recentlyviewedbanks: [],
};
banks.forEach((bank) => {
if (bank.current) {
templateContext.hasactivitybank = true;
templateContext.activitybank = {
name: bank.name,
cmid: bank.modid,
};
return;
}
if (bank.recent) {
templateContext.hasrecentlyviewedbanks = true;
templateContext.recentlyviewedbanks.push({
coursenamebankname: bank.coursenamebankname,
modid: bank.modid,
});
return;
}
templateContext.hascoursesharedbanks = true;
templateContext.coursesharedbanks.push({
name: bank.name,
modid: bank.modid,
});
});
modal.setBody(
Templates.render('core_question/switch_question_bank', templateContext)
);
const placeholder = await getString('switchersearchbyname', 'core_question');
await modal.getBodyPromise();
await AutoComplete.enhance(
SELECTORS.BANK_SEARCH,
false,
'core_question/question_banks_datasource',
placeholder,
false,
true,
'',
true
);
const modalBody = modal.getBody()[0];
// Hide the selection element as we don't need it.
modalBody.querySelector(SELECTORS.AUTOCOMPLETE_SELECTION)?.classList.add('d-none');
const switcherElement = modalBody.querySelector(SELECTORS.BANK_SWITCHER);
switcherElement.addEventListener('click', this.clickListener);
switcherElement.addEventListener('change', this.changeListener);
}
clickListener(e) {
const goBack = e.target.closest(SELECTORS.GO_BACK_BUTTON);
let cmid;
if (goBack) {
cmid = goBack.value;
} else {
const anchor = e.target.closest(SELECTORS.ANCHOR);
if (!anchor) {
return;
}
cmid = anchor.dataset.newmodid;
}
e.preventDefault();
const modalElement = e.target.closest(SELECTORS.MODAL);
const bankSwitched = new CustomEvent('bankSwitched', {detail: {cmid}});
modalElement.dispatchEvent(bankSwitched);
}
changeListener(e) {
const search = e.target.closest(SELECTORS.BANK_SEARCH);
if (!search) {
return;
}
const bankCmId = search.value;
if (bankCmId > 0) {
const modalElement = e.target.closest(SELECTORS.MODAL);
const bankSwitched = new CustomEvent('bankSwitched', {detail: {cmid: bankCmId}});
modalElement.dispatchEvent(bankSwitched);
}
}
}
@@ -72,14 +72,14 @@ class bulk_move implements \renderable, \templatable {
currentbankid: $this->currentbankid,
filtercontext: $currentbankcm->context,
limit: 1,
)[0];
)[0]->get_formatted();
} else {
$banktorender = question_bank_helper::get_activity_instances_with_private_questions(
incourseids: [$currentbankcm->course],
havingcap: ['moodle/question:add'],
currentbankid: $this->currentbankid,
filtercontext: $currentbankcm->context,
)[0];
)[0]->get_formatted();
}
$categoryselector = new question_category_selector(
+8 -4
View File
@@ -22,6 +22,7 @@ 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\formatted_bank;
use core_question\local\bank\question_bank_helper;
use core\context;
@@ -135,10 +136,13 @@ class search_shared_banks extends external_api {
);
$suggestions = array_map(
fn($sharedbank) => [
'value' => $sharedbank->modid,
'label' => $sharedbank->coursenamebankname,
],
function (formatted_bank $sharedbank): array {
$sharedbank = $sharedbank->get_formatted();
return [
'value' => $sharedbank->modid,
'label' => $sharedbank->coursenamebankname,
];
},
$sharedbanks,
);
@@ -0,0 +1,109 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question\local\bank;
use core\context;
use core_course\cm_info;
use JsonSerializable;
/**
* Data class to hold bank info and categories, and return them with formatted names for output.
*
* @package core_question
* @copyright 2026 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class formatted_bank implements JsonSerializable {
/**
* @var bool True if the bank is the module currently being viewed.
*/
public bool $current;
/**
* Constructor
*
* @param cm_info $cminfo The course module info for the bank's course module
* @param context $filtercontext The context used for applying filters when formatting strings.
* @param array $questioncategories Array of categories belonging to the bank.
* @param bool $shared True if the bank contains shared questions, false if it contains private questions.
* @param bool $recent True if the bank was recently viewed by the user.
*/
public function __construct(
/** @var cm_info $cminfo Course module info. */
public cm_info $cminfo,
/** @var context $filtercontext The context used for applying filters when formatting strings. */
public context $filtercontext,
/** @var array $questioncategories Array of categories belonging to the bank. */
public array $questioncategories,
/** @var bool True if the bank contains shared questions, false if it contains private questions. */
public bool $shared,
/** @var bool True if the bank was recently viewed by the user. */
public bool $recent,
) {
}
/**
* Return the question bank formatted for output.
*
* @return \stdClass
*/
public function get_formatted(): \stdClass {
$filteroptions = [
'escape' => false,
'context' => $this->filtercontext,
];
$formattedname = $this->cminfo->get_formatted_name($filteroptions);
return (object) [
'name' => $formattedname,
'modid' => $this->cminfo->id,
'contextid' => $this->cminfo->context->id,
'coursenamebankname' => get_string(
'coursenamebankname',
'question',
(object) [
'coursename' => format_string($this->cminfo->get_course()->shortname, true, $filteroptions),
'bankname' => $formattedname,
],
),
'cminfo' => $this->cminfo,
'questioncategories' => $this->questioncategories,
'shared' => $this->shared,
'recent' => $this->recent,
];
}
/**
* Return the formatted object for encoding as JSON, for example by web service routes.
*/
public function jsonSerialize(): \stdClass {
return $this->get_formatted();
}
/**
* Format multiple banks.
*
* @param formatted_bank[] $banks The formatted_bank objects to return formatted.
* @return array The formatted bank data.
*/
public static function format_banks(array $banks): array {
return array_map(
fn($bank) => $bank->get_formatted(),
$banks,
);
}
}
@@ -19,6 +19,8 @@ namespace core_question\local\bank;
use cm_info;
use context;
use context_course;
use core\context\course;
use core\context\module;
use core\context_helper;
use core\di;
use core\task\manager;
@@ -366,7 +368,7 @@ class question_bank_helper {
* @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[]
* @return formatted_bank[]
*/
public static function get_recently_used_open_banks(
int $userid,
@@ -397,7 +399,7 @@ class question_bank_helper {
if (!empty($havingcap) && !(new question_edit_contexts($context))->have_one_cap($havingcap)) {
continue;
}
$record = self::get_formatted_bank($cm, filtercontext: $filtercontext);
$record = self::get_formatted_bank($cm, filtercontext: $filtercontext, isrecent: true);
$banks[] = $record;
}
@@ -445,9 +447,15 @@ class question_bank_helper {
* Used in qbank_bulkmove/bulk_move.mustache
* @param ?context $filtercontext Optional context in which to apply filters.
*
* @return stdClass
* @return formatted_bank
*/
private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass {
private static function get_formatted_bank(
stdClass $cm,
int $currentbankid = 0,
?context $filtercontext = null,
bool $isshared = true,
bool $isrecent = false,
): formatted_bank {
$cminfo = cm_info::create($cm);
$concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : [];
@@ -461,21 +469,14 @@ class question_bank_helper {
return $cat;
}, $concatedcats);
$bank = new stdClass();
$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;
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;
$filtercontext ??= context_course::instance($cminfo->get_course()->id);
return new formatted_bank(
$cminfo,
$filtercontext,
$categories,
$isshared,
$isrecent,
);
}
/**
@@ -737,4 +738,51 @@ class question_bank_helper {
// As a failsafe, limit the length of the final string in case the lang string is too long.
return shorten_text($bankname, self::BANK_NAME_MAX_LENGTH);
}
/**
* Return a list of question banks.
*
* By default, this will just return a list of shared question banks (activities with FEATURE_PUBLISHES_QUESTIONS) in the given
* course context.
* Other options allow you to include private question banks and banks recently accessed by the user
* (which may be in other courses).
*
* @param course $coursecontext The course context to find shared and private banks within.
* @param module|null $modulecontext If set, use this instead of $coursecontext when applying text filters on bank names.
* @param bool $includeshared Include banks with shared questions.
* @param bool $includerecent Include banks recently viewed by the user.
* @return formatted_bank[]
*/
public static function get_banks_for_course(
course $coursecontext,
?module $modulecontext = null,
bool $includeshared = true,
bool $includerecent = false,
): array {
global $USER;
if ($modulecontext) {
$context = $modulecontext;
} else {
$context = $coursecontext;
}
$capabilities = ['moodle/question:useall', 'moodle/question:usemine'];
$banks = [];
if ($includeshared) {
$sharedbanks = self::get_activity_instances_with_shareable_questions(
incourseids: [$coursecontext->instanceid],
havingcap: $capabilities,
filtercontext: $context,
);
$banks = array_merge($banks, $sharedbanks);
}
if ($includerecent) {
$recentbanks = self::get_recently_used_open_banks(
$USER->id,
filtercontext: $context,
havingcap: $capabilities,
);
$banks = array_merge($banks, $recentbanks);
}
return $banks;
}
}
@@ -18,6 +18,7 @@ namespace core_question\output;
use action_link;
use core\output\pix_icon;
use core_question\local\bank\formatted_bank;
use renderer_base;
use core_courseformat\output\local\content\cm\controlmenu;
use core_question\local\bank\question_bank_helper;
@@ -34,10 +35,10 @@ class question_bank_list implements \renderable, \templatable {
/**
* Instantiate the output class.
*
* @param array $bankinstances {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
* @param formatted_bank[] $bankinstances {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
*/
public function __construct(
/** @var array $bankinstances */
/** @var formatted_bank[] $bankinstances */
protected readonly array $bankinstances
) {
}
@@ -51,6 +52,7 @@ class question_bank_list implements \renderable, \templatable {
public function export_for_template(renderer_base $output): array {
$banks = [];
foreach ($this->bankinstances as $instance) {
$instance = $instance->get_formatted();
if (plugin_supports('mod', $instance->cminfo->modname, FEATURE_PUBLISHES_QUESTIONS)) {
$returnurl = question_bank_helper::get_url_for_qbank_list($instance->cminfo->course);
$format = course_get_format($instance->cminfo->course);
@@ -26,12 +26,21 @@
namespace core_question\output;
use cm_info;
use core\attribute\deprecated;
use core\deprecation;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use renderer_base;
/**
* Get the switch question bank rendered content. Displays lists of shared banks the viewing user has access to.
*/
#[deprecated(
replacement: 'core_question/bank_switcher Javascript module',
since: 5.2,
reason: 'Bank switching interface is now rendered client-side.',
mdl: 'MDL-87264',
)]
class switch_question_bank implements \renderable, \templatable {
/**
@@ -41,6 +50,12 @@ class switch_question_bank implements \renderable, \templatable {
* @param int $courseid of the current course.
* @param int $userid of the user viewing the page.
*/
#[deprecated(
replacement: 'core_question/bank_switcher Javascript module',
since: 5.2,
reason: 'Bank switching interface is now rendered client-side.',
mdl: 'MDL-87264',
)]
public function __construct(
/** @var int quiz course module id */
private readonly int $quizcmid,
@@ -49,6 +64,7 @@ class switch_question_bank implements \renderable, \templatable {
/** @var int id of the user viewing the page */
private readonly int $userid
) {
deprecation::emit_deprecation([$this, __FUNCTION__]);
}
/**
@@ -57,18 +73,28 @@ class switch_question_bank implements \renderable, \templatable {
* @param renderer_base $output
* @return array
*/
#[deprecated(
replacement: 'core_question/bank_switcher Javascript module',
since: 5.2,
reason: 'Bank switching interface is now rendered client-side.',
mdl: 'MDL-87264',
)]
public function export_for_template(renderer_base $output) {
deprecation::emit_deprecation([$this, __FUNCTION__]);
[, $cm] = get_module_from_cmid($this->quizcmid);
$cminfo = cm_info::create($cm);
$capabilities = ['moodle/question:useall', 'moodle/question:usemine'];
$coursesharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(
incourseids: [$this->courseid],
havingcap: $capabilities,
filtercontext: $cminfo->context,
$coursesharedbanks = formatted_bank::format_banks(
question_bank_helper::get_activity_instances_with_shareable_questions(
incourseids: [$this->courseid],
havingcap: $capabilities,
filtercontext: $cminfo->context,
),
);
$recentlyviewedbanks = formatted_bank::format_banks(
question_bank_helper::get_recently_used_open_banks($this->userid, havingcap: $capabilities),
);
$recentlyviewedbanks = question_bank_helper::get_recently_used_open_banks($this->userid, havingcap: $capabilities);
return [
'quizname' => $cminfo->get_formatted_name(),
@@ -17,6 +17,7 @@
namespace core_question\output;
use context_course;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use renderer_base;
use single_button;
@@ -35,14 +36,14 @@ class view_banks implements \templatable, \renderable {
/**
* Create a new view_banks instance.
*
* @param array $sharedbanks {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
* @param array $privatebanks {@see question_bank_helper::get_activity_instances_with_private_questions()}
* @param formatted_bank[] $sharedbanks {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
* @param formatted_bank[] $privatebanks {@see question_bank_helper::get_activity_instances_with_private_questions()}
* @param stdClass $course the viewing course.
*/
public function __construct(
/** @var array Banks that can be shared */
/** @var formatted_bank[] Banks that can be shared */
protected readonly array $sharedbanks,
/** @var array Banks that cannot be shared */
/** @var formatted_bank[] Banks that cannot be shared */
protected readonly array $privatebanks,
/** @var stdClass current course object */
protected readonly stdClass $course,
+67 -3
View File
@@ -20,12 +20,19 @@ use core\context\course;
use core\context\module;
use core\exception\required_capability_exception;
use core\param;
use core\router\parameters\query_course;
use core\router\parameters\query_coursemodule;
use core\router\require_login;
use core\router\route;
use core\router\schema\parameters\path_parameter;
use core\router\schema\example;
use core\router\schema\objects\array_of_things;
use core\router\schema\objects\schema_object;
use core\router\schema\parameters\query_parameter;
use core\router\schema\response\content\json_media_type;
use core\router\schema\response\payload_response;
use core_question\local\bank\question_edit_contexts;
use core_question\local\bank\question_version_status;
use core\router\schema\response\response;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -115,4 +122,61 @@ class bank {
response: $response,
);
}
/**
* Return a list of formatted question banks matching the parameters.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param course $coursecontext The course context.
* @param module $currentmodulecontext The module context.
* @param question_bank_helper $helper Injected dependency.
* @return payload_response A list of question banks with formatted names, and whether they are shared and recently used.
*/
#[route(
path: '/banks', // Resolves to /api/rest/v2/question/banks.
queryparams: [
new query_course(required: true),
new query_coursemodule('currentmodule'),
new query_parameter(name: 'includeshared', type: param::BOOL, default: true),
new query_parameter(name: 'includerecent', type: param::BOOL, default: false),
],
responses: [
new response(
statuscode: 200,
description: 'OK',
content: [
new json_media_type(
schema: new schema_object(
content: [
'banks' => new array_of_things(thingtype: formatted_bank::class),
],
),
),
],
),
],
requirelogin: new require_login(true, courseattributename: 'course'),
)]
public function banks(
ServerRequestInterface $request,
ResponseInterface $response,
course $coursecontext,
module $currentmodulecontext,
question_bank_helper $helper,
): payload_response {
$params = $request->getQueryParams();
$banks = $helper::get_banks_for_course(
$coursecontext,
$currentmodulecontext,
$params['includeshared'],
$params['includerecent'],
);
return new payload_response(
request: $request,
response: $response,
payload: ['banks' => $banks],
);
}
}
@@ -19,122 +19,77 @@
Example context (json):
{
"quizname": "Quiz 1",
"quizcmid": 1,
"quizcontextid": 1,
"contextid": 1,
"hasactivitybank": true,
"activitybank": {
"name": "Quiz 1",
"cmid": 1
},
"hascoursesharedbanks": true,
"coursesharedbanks": [
{
"name": "Question bank 1",
"modid": "2",
"contextid": 2,
"coursenamebankname": "c1 - Question bank 1",
"cminfo": {},
"questioncategories": []
"modid": "2"
},
{
"name": "Question bank 2",
"modid": "3",
"contextid": 3,
"coursenamebankname": "c1 - Question bank 2",
"cminfo": {},
"questioncategories": []
"modid": "3"
}
],
"hasrecentlyviewedbanks": true,
"recentlyviewedbanks": [
{
"name": "Question bank 3",
"modid": "4",
"contextid": 4,
"coursenamebankname": "c2 - Question bank 4",
"cminfo": {},
"questioncategories": []
"coursenamebankname": "c2 - Question bank 4"
},
{
"name": "Question bank 4",
"modid": "6",
"contextid": 6,
"coursenamebankname": "c3 - Question bank 5",
"cminfo": {},
"questioncategories": []
}
],
"hassharedbanks": true,
"sharedbanks": [
{
"name": "Question bank 1",
"modid": "2",
"contextid": 2,
"coursenamebankname": "c1 - Question bank 1",
"cminfo": {},
"questioncategories": []
},
{
"name": "Question bank 2",
"modid": "3",
"contextid": 3,
"coursenamebankname": "c1 - Question bank 2",
"cminfo": {},
"questioncategories": []
},
{
"name": "Question bank 3",
"modid": "4",
"contextid": 4,
"coursenamebankname": "c2 - Question bank 4",
"cminfo": {},
"questioncategories": []
},
{
"name": "Question bank 4",
"modid": "6",
"contextid": 6,
"coursenamebankname": "c3 - Question bank 5",
"cminfo": {},
"questioncategories": []
"coursenamebankname": "c3 - Question bank 5"
}
]
}
}}
<div class="quiz-bank">
<h5>{{#str}}quizquestionbank, core_question{{/str}}</h5>
<a href="#" class="ms-3" data-newmodid="{{quizcmid}}">{{quizname}}</a>
</div>
<div class="bank-switcher">
{{#hasactivitybank}}
<div class="quiz-bank">
<h5>{{#str}}quizquestionbank, core_question{{/str}}</h5>
<a href="#" class="ms-3" data-newmodid="{{activitybank.cmid}}">{{activitybank.name}}</a>
</div>
<hr class="w-75">
{{/hasactivitybank}}
<hr class="w-75">
{{#hascoursesharedbanks}}
<div class="course-shared-banks">
<h5>{{#str}}banksincourse, core_question{{/str}}</h5>
{{#coursesharedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{name}}</a>
</li>
</ul>
{{/coursesharedbanks}}
</div>
<hr class="w-75">
{{/hascoursesharedbanks}}
{{#hascoursesharedbanks}}
<div class="course-shared-banks">
<h5>{{#str}}banksincourse, core_question{{/str}}</h5>
{{#coursesharedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{name}}</a>
</li>
</ul>
{{/coursesharedbanks}}
{{#hasrecentlyviewedbanks}}
<div class="recently-viewed-banks">
<h5>{{#str}}recentlyviewedquestionbanks, core_question{{/str}}</h5>
{{#recentlyviewedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{coursenamebankname}}</a>
</li>
</ul>
{{/recentlyviewedbanks}}
</div>
<hr class="w-75">
{{/hasrecentlyviewedbanks}}
<div class="search-banks">
<h5>{{#str}}otherquestionbank, core_question{{/str}}</h5>
<select class="form-select" id="searchbanks" data-contextid="{{contextid}}">
</select>
</div>
<hr class="w-75">
{{/hascoursesharedbanks}}
{{#hasrecentlyviewedbanks}}
<div class="recently-viewed-banks">
<h5>{{#str}}recentlyviewedquestionbanks, core_question{{/str}}</h5>
{{#recentlyviewedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{coursenamebankname}}</a>
</li>
</ul>
{{/recentlyviewedbanks}}
</div>
<hr class="w-75">
{{/hasrecentlyviewedbanks}}
<div class="search-banks">
<h5>{{#str}}otherquestionbank, core_question{{/str}}</h5>
<select class="form-select" id="searchbanks" data-contextid="{{quizcontextid}}">
</select>
</div>
@@ -17,6 +17,7 @@
namespace core_question;
use core\exception\coding_exception;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
/**
@@ -127,7 +128,8 @@ final class question_bank_helper_test extends \advanced_testcase {
);
$count = 0;
foreach ($sharedbanks as $courseinstance) {
foreach ($sharedbanks as $sharedbank) {
$courseinstance = $sharedbank->get_formatted();
// Must all be mod_qbanks.
$this->assertEquals('qbank', $courseinstance->cminfo->modname);
// Must have 2 categories each bank.
@@ -148,7 +150,8 @@ final class question_bank_helper_test extends \advanced_testcase {
);
$count = 0;
foreach ($privatebanks as $courseinstance) {
foreach ($privatebanks as $privatebank) {
$courseinstance = $privatebank->get_formatted();
// Must all be mod_quiz.
$this->assertEquals('quiz', $courseinstance->cminfo->modname);
// Must have 1 category in each bank.
@@ -239,7 +242,9 @@ final class question_bank_helper_test extends \advanced_testcase {
$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');
$twobanks = formatted_bank::format_banks(
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],
@@ -247,12 +252,16 @@ final class question_bank_helper_test extends \advanced_testcase {
);
// 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');
$sharedbanks = formatted_bank::format_banks(
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);
$limitedbanks = formatted_bank::format_banks(
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'])));
}
@@ -413,14 +422,18 @@ final class question_bank_helper_test extends \advanced_testcase {
$viewedorder = array_reverse($banks);
// Check that the courseid filter works.
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, $course1->id);
$recentlyviewed = formatted_bank::format_banks(
question_bank_helper::get_recently_used_open_banks($user->id, $course1->id),
);
$this->assertCount(3, $recentlyviewed);
// We should have the viewed banks in course 2.
$courseviewed = array_slice($banks, 3, 3);
$this->assertEqualsCanonicalizing(array_column($recentlyviewed, 'modid'), array_column($courseviewed, 'cmid'));
// Check that the capability filter works.
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, havingcap: ['moodle/question:useall']);
$recentlyviewed = formatted_bank::format_banks(
question_bank_helper::get_recently_used_open_banks($user->id, havingcap: ['moodle/question:useall']),
);
$this->assertCount(2, $recentlyviewed);
// We should have the 2 most recently viewed banks in course 1.
$capabilityviewed = array_slice($banks, 1, 2);
@@ -431,7 +444,7 @@ final class question_bank_helper_test extends \advanced_testcase {
// We only keep a record of 5 maximum.
$this->assertCount(5, $recentlyviewed);
foreach ($recentlyviewed as $order => $record) {
$this->assertEquals($viewedorder[$order]->cmid, $record->modid);
$this->assertEquals($viewedorder[$order]->cmid, $record->cminfo->id);
}
// Now if we view one of those again it should get bumped to the front of the list.
@@ -444,12 +457,12 @@ final class question_bank_helper_test extends \advanced_testcase {
// We should still have 5 maximum.
$this->assertCount(5, $recentlyviewed);
// The recently viewed on got bumped to the front.
$this->assertEquals($banks[2]->cmid, $recentlyviewed[0]->modid);
$this->assertEquals($banks[2]->cmid, $recentlyviewed[0]->cminfo->id);
// The others got sorted accordingly behind it.
$this->assertEquals($banks[5]->cmid, $recentlyviewed[1]->modid);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[2]->modid);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[3]->modid);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[4]->modid);
$this->assertEquals($banks[5]->cmid, $recentlyviewed[1]->cminfo->id);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[2]->cminfo->id);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[3]->cminfo->id);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[4]->cminfo->id);
// Now create a quiz and trigger the bank view of it.
$quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(['course' => $course1]);
@@ -463,7 +476,7 @@ final class question_bank_helper_test extends \advanced_testcase {
// Make sure that we only store bank views for plugins that support FEATURE_PUBLISHES_QUESTIONS.
foreach ($recentlyviewed as $record) {
$this->assertNotEquals($quiz->cmid, $record->modid);
$this->assertNotEquals($quiz->cmid, $record->cminfo->id);
}
// Now delete one of the viewed bank modules and get the records again.
@@ -472,10 +485,10 @@ final class question_bank_helper_test extends \advanced_testcase {
$this->assertCount(4, $recentlyviewed);
// Check the order was retained.
$this->assertEquals($banks[5]->cmid, $recentlyviewed[0]->modid);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[1]->modid);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[2]->modid);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[3]->modid);
$this->assertEquals($banks[5]->cmid, $recentlyviewed[0]->cminfo->id);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[1]->cminfo->id);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[2]->cminfo->id);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[3]->cminfo->id);
}
/**