Merge branch 'MDL-85069_500_STABLE' of https://github.com/marxjohnson/moodle into MOODLE_500_STABLE
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
issueNumber: MDL-85069
|
||||
notes:
|
||||
core_question:
|
||||
- message: >
|
||||
`core_question_search_shared_banks` will now search all question banks,
|
||||
not just those outside the current course.
|
||||
|
||||
This makes the service usable in cases outside of the current "Switch
|
||||
banks" UI, which require searching all banks on the site.
|
||||
|
||||
It also makes the autocomplete in the "Switch banks" UI more consistent,
|
||||
as it was previously excluding some of the banks listed in the UI
|
||||
(Question banks in this course), but not others (Recently viewed
|
||||
question banks).
|
||||
|
||||
This change has also adds a 'requiredcapabilties' parameter to the
|
||||
function, which accepts an list of abbreviated capabilities for
|
||||
checking access against question banks before they are returned.
|
||||
type: changed
|
||||
@@ -267,7 +267,7 @@ $string['novirtualquestiontype'] = 'No virtual question type for question type {
|
||||
$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['otherquestionbank'] = 'All question banks';
|
||||
$string['otherquestionbankstoomany'] = 'More than {$a} results. You need to refine your search.';
|
||||
$string['page-question-x'] = 'Any question page';
|
||||
$string['page-question-edit'] = 'Question editing page';
|
||||
|
||||
+1
-1
@@ -1278,7 +1278,7 @@ function question_default_export_filename($course, $category): string {
|
||||
*
|
||||
* @param int|stdClass|question_definition $questionorid object or id.
|
||||
* If an object is passed, it should include ->contextid and ->createdby.
|
||||
* @param string $cap 'add', 'edit', 'view', 'use', 'move' or 'tag'.
|
||||
* @param string $cap 'add', 'edit', 'view', 'use', 'move', or 'tag'.
|
||||
* @param int $notused no longer used.
|
||||
* @return bool this user has the capability $cap for this question $question?
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
$string['addanotherbank'] = 'Add question bank';
|
||||
$string['coursecategory'] = 'Shared teaching resources for category: {$a}';
|
||||
$string['courserestore'] = 'Course restore';
|
||||
$string['createqbank'] = 'Create question bank {$a}';
|
||||
$string['modulename'] = 'Question bank';
|
||||
$string['modulename_help'] = 'This activity allows a teacher to create, preview, and edit questions in a database of question categories.
|
||||
|
||||
@@ -37,6 +38,7 @@ $string['noqbankinstances'] = 'There are no question banks in this course.';
|
||||
$string['pluginadministration'] = 'Question bank administration';
|
||||
$string['pluginname'] = 'Question bank';
|
||||
$string['privacy:metadata'] = 'The Question bank plugin does not store any personal data, core_question automatically tracks all sorts of data for questions.';
|
||||
$string['progress_createqbank'] = 'Creating question banks';
|
||||
$string['qbank:addinstance'] = 'Add a new question bank';
|
||||
$string['qbank:view'] = 'View question bank';
|
||||
$string['qbankname'] = 'Question bank name';
|
||||
|
||||
@@ -108,3 +108,40 @@ function qbank_delete_instance(int $id): bool {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for tool_generator so we can add qbanks to generated courses.
|
||||
*
|
||||
* @param tool_generator_course_backend $backend
|
||||
* @param testing_data_generator $generator
|
||||
* @param int $courseid
|
||||
* @param int $number
|
||||
* @return void
|
||||
* @throws coding_exception
|
||||
*/
|
||||
function qbank_course_backend_generator_create_activity(
|
||||
tool_generator_course_backend $backend,
|
||||
testing_data_generator $generator,
|
||||
int $courseid,
|
||||
int $number,
|
||||
) {
|
||||
// Set up generator.
|
||||
$generator = $generator->get_plugin_generator('mod_qbank');
|
||||
|
||||
// Create assignments.
|
||||
$backend->log('createqbank', $number, true, 'mod_qbank');
|
||||
for ($i = 1; $i <= $number; $i++) {
|
||||
$qbank = $generator->create_instance(
|
||||
[
|
||||
'course' => $courseid,
|
||||
'name' => "Question bank course {$courseid} bank {$i}",
|
||||
],
|
||||
[
|
||||
'section' => 0,
|
||||
],
|
||||
);
|
||||
question_get_default_category(\core\context\module::instance($qbank->cmid)->id, true);
|
||||
$backend->dot($i, $number);
|
||||
}
|
||||
$backend->end_log();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ class mod_qbank_generator extends testing_module_generator {
|
||||
if (empty($record->type)) {
|
||||
$record->type = core_question\local\bank\question_bank_helper::TYPE_STANDARD;
|
||||
}
|
||||
if (isset($options['section']) && (int) $options['section'] !== 0) {
|
||||
throw new \core\exception\coding_exception(
|
||||
"Attempted to create a mod_qbank instance in section {$options['section']}. " .
|
||||
"Question banks can only be created in section 0.",
|
||||
);
|
||||
}
|
||||
return parent::create_instance($record, $options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ Feature: Moving a question to another category should not affect random question
|
||||
And I click on "With selected" "button"
|
||||
And I click on question bulk action "move"
|
||||
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
|
||||
And I click on "Subcategory" item in the autocomplete list
|
||||
And I click on "Subcategory" "list_item" in the "Move the selected questions to..." "dialogue"
|
||||
And I click on "Move questions" "button"
|
||||
And I should see "Are you sure you want to move these questions?"
|
||||
And I click on "Confirm" "button"
|
||||
|
||||
+1
-1
@@ -6,6 +6,6 @@ define("core_question/question_banks_datasource",["exports","core/ajax","core/no
|
||||
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
|
||||
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
|
||||
* @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}));
|
||||
*/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 element=document.querySelector(selector),contextId=element.dataset.contextid;let requiredcapabilities=["use"];if(element.dataset.requiredcapabilities&&(requiredcapabilities=JSON.parse(element.dataset.requiredcapabilities)),!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,requiredcapabilities:requiredcapabilities}}])[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
|
||||
@@ -1 +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 <http://www.gnu.org/licenses/>.\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 <[email protected]>\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"}
|
||||
{"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 <http://www.gnu.org/licenses/>.\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 <[email protected]>\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 let requiredcapabilities = ['use'];\n if (element.dataset.requiredcapabilities) {\n requiredcapabilities = JSON.parse(element.dataset.requiredcapabilities);\n }\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 requiredcapabilities: requiredcapabilities,\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","element","document","querySelector","contextId","dataset","contextid","requiredcapabilities","JSON","parse","Error","methodname","args","search","then","catch","Notification","exception","processResults","results","sharedbanks"],"mappings":";;;;;;;;sKA2Be,CAEXA,UAAW,SAASC,SAAUC,MAAOC,gBAC3BC,QAAUC,SAASC,cAAcL,UACjCM,UAAYH,QAAQI,QAAQC,cAC9BC,qBAAuB,CAAC,UACxBN,QAAQI,QAAQE,uBAChBA,qBAAuBC,KAAKC,MAAMR,QAAQI,QAAQE,wBAGjDH,gBACK,IAAIM,MAAM,+CAAiDZ,yBAG3D,CAAC,CACPa,WAAY,oCACZC,KAAM,CACFN,UAAWF,UACXS,OAAQd,MACRQ,qBAAsBA,yBAE1B,GACHO,KAAKd,UACLe,MAAMC,sBAAaC,YAGxBC,eAAgB,CAACpB,SAAUqB,UAChBA,QAAQC"}
|
||||
@@ -30,6 +30,10 @@ export default {
|
||||
transport: function(selector, query, callback) {
|
||||
const element = document.querySelector(selector);
|
||||
const contextId = element.dataset.contextid;
|
||||
let requiredcapabilities = ['use'];
|
||||
if (element.dataset.requiredcapabilities) {
|
||||
requiredcapabilities = JSON.parse(element.dataset.requiredcapabilities);
|
||||
}
|
||||
|
||||
if (!contextId) {
|
||||
throw new Error('The attribute data-contextid is required on ' + selector);
|
||||
@@ -40,6 +44,7 @@ export default {
|
||||
args: {
|
||||
contextid: contextId,
|
||||
search: query,
|
||||
requiredcapabilities: requiredcapabilities,
|
||||
},
|
||||
}])[0]
|
||||
.then(callback)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -29,6 +29,7 @@ import AutoComplete from 'core/form-autocomplete';
|
||||
import {moveQuestions} from 'core_question/repository';
|
||||
import Templates from 'core/templates';
|
||||
import Notification from 'core/notification';
|
||||
import Pending from 'core/pending';
|
||||
|
||||
|
||||
export default class ModalQuestionBankBulkmove extends Modal {
|
||||
@@ -38,13 +39,15 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
SAVE_BUTTON: '[data-action="bulkmovesave"]',
|
||||
SELECTED_QUESTIONS: 'table#categoryquestions input[id^="checkq"]',
|
||||
SEARCH_BANK: '#searchbanks',
|
||||
SEARCH_CATEGORY: '#searchcategories',
|
||||
CATEGORY_OPTIONS: '#searchcategories option',
|
||||
SEARCH_CATEGORY: '.selectcategory',
|
||||
QUESTION_CATEGORY_SELECTOR: '.question_category_selector',
|
||||
CATEGORY_OPTIONS: '.selectcategory option',
|
||||
BANK_OPTIONS: '#searchbanks option',
|
||||
CATEGORY_ENHANCED_INPUT: '.search-categories input',
|
||||
ORIGINAL_SELECTS: 'select.bulk-move',
|
||||
CATEGORY_WARNING: '#searchcatwarning',
|
||||
CATEGORY_SUGGESTION: '.search-categories span.form-autocomplete-downarrow',
|
||||
CATEGORY_SELECTION: '.search-categories span[role="option"][data-active-selection="true"]',
|
||||
CONFIRM_BUTTON: '.bulk-move-footer button[data-action="save"]',
|
||||
CANCEL_BUTTON: '.bulk-move-footer button[data-action="cancel"]'
|
||||
};
|
||||
@@ -112,6 +115,7 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
* @param {integer} currentCategoryId
|
||||
*/
|
||||
async display(currentBankContextId, currentCategoryId) {
|
||||
const displayPending = new Pending('qbank_bulkmove/bulk_move_modal');
|
||||
this.bodyPromise = await Fragment.loadFragment(
|
||||
'qbank_bulkmove',
|
||||
'bulk_move',
|
||||
@@ -122,27 +126,23 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
);
|
||||
|
||||
await this.setBody(this.bodyPromise);
|
||||
await this.enhanceSelects(document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.ORIGINAL_SELECTS));
|
||||
await this.enhanceSelects();
|
||||
this.registerEnhancedEventListeners();
|
||||
this.mapCategoryContextIds();
|
||||
this.updateSaveButtonState();
|
||||
displayPending.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event listeners on the enhanced selects. Must be done after they have been enhanced.
|
||||
*/
|
||||
registerEnhancedEventListeners() {
|
||||
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change", (e) => {
|
||||
const targetCategoryId = e.currentTarget.value;
|
||||
this.targetCategoryId = targetCategoryId;
|
||||
this.rebuildOptions(this.targetBankContextId, targetCategoryId);
|
||||
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change", () => {
|
||||
this.updateSaveButtonState();
|
||||
});
|
||||
|
||||
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener("change", (e) => {
|
||||
const selectedBankContextId = e.currentTarget.value;
|
||||
this.targetBankContextId = selectedBankContextId;
|
||||
this.rebuildOptions(selectedBankContextId, this.targetCategoryId);
|
||||
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener("change", async(e) => {
|
||||
await this.updateCategorySelector(e.currentTarget.value);
|
||||
this.updateSaveButtonState();
|
||||
});
|
||||
|
||||
this.getModal().on("click", ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON, (e) => {
|
||||
@@ -151,25 +151,6 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a map, so we can determine which bank belongs to which category.
|
||||
*/
|
||||
mapCategoryContextIds() {
|
||||
const customSelectCategoryOptions = document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_OPTIONS);
|
||||
|
||||
if (customSelectCategoryOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryContextIds = [];
|
||||
|
||||
customSelectCategoryOptions.forEach((option) => {
|
||||
categoryContextIds[option.value] = option.dataset.bankContextid;
|
||||
});
|
||||
|
||||
this.categoryContextIds = categoryContextIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the body with a confirmation prompt and set confirm cancel buttons in the footer.
|
||||
* @return {Promise<void>}
|
||||
@@ -201,37 +182,33 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically update all enhanced selects options based on what is selected.
|
||||
* Update the category selector based on the selected question bank.
|
||||
*
|
||||
* @param {integer} selectedBankContextId
|
||||
* @param {integer} selectedCategoryId
|
||||
* @param {Number} selectedBankCmId
|
||||
* @return {Promise} Resolved when the update is complete.
|
||||
*/
|
||||
rebuildOptions(selectedBankContextId, selectedCategoryId) {
|
||||
const categoryContextIds = this.categoryContextIds;
|
||||
const customSelectCategoryOptions = document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_OPTIONS);
|
||||
|
||||
// Disable the category selector if no bank selected.
|
||||
if (!selectedBankContextId) {
|
||||
updateCategorySelector(selectedBankCmId) {
|
||||
if (!selectedBankCmId) {
|
||||
this.updateCategorySelectorState(false);
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
// Mark to be disabled all the categories not belonging to the selected bank.
|
||||
// This will then be handled by the enhanced selects event handlers.
|
||||
customSelectCategoryOptions.forEach((option) => {
|
||||
if (option.dataset.bankContextid != selectedBankContextId) {
|
||||
option.dataset.enabled = 'disabled';
|
||||
} else {
|
||||
option.dataset.enabled = 'enabled';
|
||||
return Fragment.loadFragment(
|
||||
'core_question',
|
||||
'category_selector',
|
||||
this.contextId,
|
||||
{
|
||||
'bankcmid': selectedBankCmId,
|
||||
}
|
||||
});
|
||||
this.updateCategorySelectorState(true);
|
||||
}
|
||||
|
||||
// De-select the selected category if it does not belong to the selected bank.
|
||||
if (selectedCategoryId && selectedBankContextId && categoryContextIds[selectedCategoryId] != selectedBankContextId) {
|
||||
const selectedCategoryElement = document.querySelector(
|
||||
'.search-categories span[role="option"][data-value="' + selectedCategoryId + '"]'
|
||||
);
|
||||
selectedCategoryElement.click();
|
||||
)
|
||||
.then((html, js) => {
|
||||
const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.QUESTION_CATEGORY_SELECTOR);
|
||||
return Templates.replaceNode(categorySelector, html, js);
|
||||
})
|
||||
.then(() => {
|
||||
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING).classList.add('d-none');
|
||||
return this.enhanceSelects();
|
||||
})
|
||||
.catch(Notification.exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +220,7 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
const warning = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING);
|
||||
const enhancedInput = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_ENHANCED_INPUT);
|
||||
const suggestionButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SUGGESTION);
|
||||
const selection = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SELECTION);
|
||||
|
||||
if (toEnable) {
|
||||
warning.classList.add('d-none');
|
||||
@@ -252,6 +230,7 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
warning.classList.remove('d-none');
|
||||
enhancedInput.setAttribute('disabled', 'disabled');
|
||||
suggestionButton.classList.add('d-none');
|
||||
selection.click(); // Clear selected category.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,9 +239,10 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
*/
|
||||
updateSaveButtonState() {
|
||||
const saveButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON);
|
||||
const targetCategoryId = this.targetCategoryId;
|
||||
const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY);
|
||||
[this.targetCategoryId, this.targetBankContextId] = categorySelector.value.split(',');
|
||||
|
||||
if (targetCategoryId && targetCategoryId != this.currentCategoryId) {
|
||||
if (this.targetCategoryId && this.targetCategoryId !== this.currentCategoryId) {
|
||||
saveButton.removeAttribute('disabled');
|
||||
} else {
|
||||
saveButton.setAttribute('disabled', 'disabled');
|
||||
@@ -303,31 +283,32 @@ export default class ModalQuestionBankBulkmove extends Modal {
|
||||
|
||||
/**
|
||||
* Take the provided select options and enhance them into auto-complete fields.
|
||||
* @param {NodeList} selects Custom select elements to enhance.
|
||||
*
|
||||
* @return {Promise<Promise[]>}
|
||||
*/
|
||||
async enhanceSelects(selects) {
|
||||
async enhanceSelects() {
|
||||
const placeholder = await getString('searchbyname', 'mod_quiz');
|
||||
const enhanced = [];
|
||||
|
||||
if (selects.length > 0) {
|
||||
for (let i = 0; i < selects.length; i++) {
|
||||
enhanced.push(AutoComplete.enhance(
|
||||
selects.item(i),
|
||||
false,
|
||||
'',
|
||||
placeholder,
|
||||
false,
|
||||
true,
|
||||
'',
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
await AutoComplete.enhance(
|
||||
ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK,
|
||||
false,
|
||||
'core_question/question_banks_datasource',
|
||||
placeholder,
|
||||
false,
|
||||
true,
|
||||
'',
|
||||
true,
|
||||
);
|
||||
|
||||
return Promise.all(enhanced);
|
||||
}
|
||||
|
||||
return Promise.reject('No selects to enhance');
|
||||
await AutoComplete.enhance(
|
||||
ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY,
|
||||
false,
|
||||
null,
|
||||
placeholder,
|
||||
false,
|
||||
true,
|
||||
'',
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace qbank_bulkmove\output;
|
||||
|
||||
use cm_info;
|
||||
use core_question\local\bank\question_bank_helper;
|
||||
use core_question\output\question_category_selector;
|
||||
use moodle_url;
|
||||
use renderer_base;
|
||||
use single_button;
|
||||
@@ -62,44 +63,26 @@ class bulk_move implements \renderable, \templatable {
|
||||
public function export_for_template(renderer_base $output) {
|
||||
|
||||
[, $cmrec] = get_module_from_cmid($this->currentbankid);
|
||||
$currentbank = cm_info::create($cmrec);
|
||||
$currentbankcm = cm_info::create($cmrec);
|
||||
|
||||
// Get all shared banks and categories and make the current bank/category pre-selected, i.e. ordered first in the list.
|
||||
$bankstorender = question_bank_helper::get_activity_instances_with_shareable_questions(
|
||||
[],
|
||||
[],
|
||||
['moodle/question:add'],
|
||||
true,
|
||||
$this->currentbankid
|
||||
);
|
||||
|
||||
$allcategories = array_map(function($bank) {
|
||||
if ($bank->modid == $this->currentbankid) {
|
||||
// If this is the current bank then sort the categories so that our current categoryid is first in the list.
|
||||
$this->sort_categories($bank->questioncategories, $this->currentcategoryid);
|
||||
}
|
||||
return $bank->questioncategories;
|
||||
}, $bankstorender);
|
||||
|
||||
// The current bank is not a shared bank, but grab the category records anyway so that we can at least allow them
|
||||
// to be moved to another local category in the bank.
|
||||
if (!plugin_supports('mod', $currentbank->modname, FEATURE_PUBLISHES_QUESTIONS, false)) {
|
||||
$currentbank = question_bank_helper::get_activity_instances_with_private_questions(
|
||||
incourseids: [$currentbank->course],
|
||||
getcategories: true,
|
||||
// Get the current bank and its categories. All other banks and categories will be loaded dynamically.
|
||||
if (plugin_supports('mod', $currentbankcm->modname, FEATURE_PUBLISHES_QUESTIONS, false)) {
|
||||
$banktorender = question_bank_helper::get_activity_instances_with_shareable_questions(
|
||||
havingcap: ['moodle/question:add'],
|
||||
currentbankid: $this->currentbankid,
|
||||
filtercontext: $currentbankcm->context,
|
||||
limit: 1,
|
||||
)[0];
|
||||
} 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];
|
||||
$currentbankcats = $currentbank->questioncategories;
|
||||
// Move the current category to the top of the list.
|
||||
$this->sort_categories($currentbankcats, $this->currentcategoryid);
|
||||
// Add the current bank categories to the front of the categories list.
|
||||
array_unshift($allcategories, $currentbankcats);
|
||||
// Add the current bank to the front of the banks list.
|
||||
array_unshift($bankstorender, $currentbank);
|
||||
}
|
||||
|
||||
// Flatten all the categories into a 2D array.
|
||||
$allcategories = array_merge(...array_values($allcategories));
|
||||
$categoryselector = new question_category_selector([$currentbankcm->context], autocomplete: true);
|
||||
|
||||
$savebutton = new single_button(
|
||||
new moodle_url('#'),
|
||||
@@ -113,29 +96,10 @@ class bulk_move implements \renderable, \templatable {
|
||||
);
|
||||
|
||||
return [
|
||||
'allsharedbanks' => $bankstorender,
|
||||
'allcategories' => $allcategories,
|
||||
'bank' => $banktorender,
|
||||
'categories' => $categoryselector->export_for_template($output),
|
||||
'save' => $savebutton->export_for_template($output),
|
||||
'contextid' => $currentbankcm->context->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapped usort to move the currentcategoryid to the top of the list of question categories.
|
||||
*
|
||||
* @param array $categories categories to sort
|
||||
* @param int $currentcategoryid the category to be sorted to the top of the list
|
||||
* @return void
|
||||
*/
|
||||
protected function sort_categories(array &$categories, int $currentcategoryid): void {
|
||||
usort($categories, static function($categorya, $categoryb) use ($currentcategoryid) {
|
||||
if ($categorya->id != $currentcategoryid && $categoryb->id == $currentcategoryid) {
|
||||
return 1;
|
||||
}
|
||||
if ($categorya->id == $currentcategoryid && $categoryb->id != $currentcategoryid) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $categoryb->id <=> $categorya->id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,7 @@
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"allsharedbanks": [
|
||||
{
|
||||
"name": "Quiz 1",
|
||||
"contextid": 1,
|
||||
"coursenamebankname": "c1 - Quiz 1"
|
||||
},
|
||||
"bank": [
|
||||
{
|
||||
"name": "Question bank 1",
|
||||
"modid": "2",
|
||||
@@ -34,55 +29,27 @@
|
||||
"coursenamebankname": "c1 - Question bank 1",
|
||||
"cminfo": {},
|
||||
"questioncategories": [
|
||||
{
|
||||
"id": "22",
|
||||
"name": "Default for Question bank 1",
|
||||
"contextid": "22",
|
||||
"enabled": "disabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Question bank 2",
|
||||
"modid": "3",
|
||||
"contextid": 33,
|
||||
"coursenamebankname": "c2 - Question bank 2",
|
||||
"cminfo": {},
|
||||
"questioncategories": [
|
||||
{
|
||||
"id": "23",
|
||||
"name": "Default for Question bank 2",
|
||||
"contextid": "23",
|
||||
"enabled": "disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"allcategories": [
|
||||
{
|
||||
"id": "22",
|
||||
"name": "Default for Question bank 1",
|
||||
"contextid": "22"
|
||||
},
|
||||
{
|
||||
"id": "23",
|
||||
"name": "Default for Question bank 2",
|
||||
"contextid": "23",
|
||||
"enabled": "disabled"
|
||||
},
|
||||
{
|
||||
"id": "24",
|
||||
"name": "Default for Question bank 3",
|
||||
"contextid": "24",
|
||||
"enabled": "disabled"
|
||||
},
|
||||
{
|
||||
"id": "25",
|
||||
"name": "Default for Question bank 4",
|
||||
"contextid": "25",
|
||||
"enabled": "disabled"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"categories": [
|
||||
{
|
||||
"value": "0",
|
||||
"disabled": true,
|
||||
"label": "Bank name 1"
|
||||
},
|
||||
{
|
||||
"value": "123,456",
|
||||
"selected": true,
|
||||
"label": "Category 1"
|
||||
},
|
||||
{
|
||||
"value": "124,456",
|
||||
"label": "Category 2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"save": {
|
||||
"id": "single_button669e368d496707",
|
||||
"formid": null,
|
||||
@@ -111,25 +78,30 @@
|
||||
],
|
||||
"actions": [],
|
||||
"hasactions": false
|
||||
}
|
||||
},
|
||||
"contextid": 1000
|
||||
}
|
||||
}}
|
||||
|
||||
<div class="search-banks">
|
||||
<h5>{{#str}}questionbank, question{{/str}}</h5>
|
||||
<select class="form-select bulk-move d-none" id="searchbanks">
|
||||
{{#allsharedbanks}}
|
||||
<option value="{{contextid}}">{{{coursenamebankname}}}</option>
|
||||
{{/allsharedbanks}}
|
||||
<label for="searchbanks" class="visually-hidden">{{#str}}questionbank, question{{/str}}</label>
|
||||
<select
|
||||
class="form-select bulk-move d-none"
|
||||
id="searchbanks"
|
||||
data-contextid="{{contextid}}"
|
||||
data-requiredcapabilities='["add"]'
|
||||
>
|
||||
{{#bank}}
|
||||
<option value="{{modid}}">{{{coursenamebankname}}}</option>
|
||||
{{/bank}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-categories mt-3">
|
||||
<h5>{{#str}}questioncategories, question{{/str}}</h5>
|
||||
<select class="form-select bulk-move d-none" id="searchcategories">
|
||||
{{#allcategories}}
|
||||
<option value="{{id}}" data-bank-contextid="{{contextid}}" data-enabled="{{enabled}}">{{{name}}}</option>
|
||||
{{/allcategories}}
|
||||
</select>
|
||||
{{#categories}}
|
||||
{{> core_question/question_category_selector }}
|
||||
{{/categories}}
|
||||
<div id="searchcatwarning" class="d-none">{{#str}}warning, qbank_bulkmove{{/str}}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ Feature: Use the qbank plugin manager page for bulkmove
|
||||
Then I should not see "C3 - Question bank 3" in the ".search-banks" "css_element"
|
||||
And I click on "C1 - Question bank 1" item in the autocomplete list
|
||||
Then I should not see "Test questions 1" in the ".search-categories .form-autocomplete-selection" "css_element"
|
||||
And the field "selectcategory" matches value "Test questions 2 (1)"
|
||||
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
|
||||
And "Test questions 2" "autocomplete_suggestions" should exist
|
||||
And "Test questions 3" "autocomplete_suggestions" should not exist
|
||||
And "Test questions 4" "autocomplete_suggestions" should not exist
|
||||
And "Test questions 5" "autocomplete_suggestions" should exist
|
||||
@@ -92,7 +92,7 @@ Feature: Use the qbank plugin manager page for bulkmove
|
||||
And I click on "With selected" "button"
|
||||
And I click on "move" "button"
|
||||
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
|
||||
And I click on "Test questions 6" item in the autocomplete list
|
||||
And I click on "Test questions 6 (1)" item in the autocomplete list
|
||||
And I click on "Move questions" "button"
|
||||
Then I should see "Are you sure you want to move these questions?"
|
||||
And I click on "Confirm" "button"
|
||||
@@ -122,6 +122,9 @@ Feature: Use the qbank plugin manager page for bulkmove
|
||||
And I should see "Question 1"
|
||||
And I should see "Question 2"
|
||||
And I should not see "Question 3"
|
||||
# No questions are highlighted when bulk-moved.
|
||||
And the "class" attribute of "Question 1" "table_row" should not contain "highlight"
|
||||
And the "class" attribute of "Question 2" "table_row" should not contain "highlight"
|
||||
|
||||
@javascript
|
||||
Scenario: Unable to bulk move questions from history page
|
||||
@@ -131,3 +134,63 @@ Feature: Use the qbank plugin manager page for bulkmove
|
||||
And I click on "With selected" "button"
|
||||
Then I should see question bulk action "deleteselected"
|
||||
And I should not see question bulk action "move"
|
||||
|
||||
@javascript
|
||||
Scenario: Questions can be moved to a different bank, if the user has the correct capability
|
||||
Given the following "role" exists:
|
||||
| name | Question adder |
|
||||
| shortname | adder |
|
||||
And the following "role capability" exists:
|
||||
| role | adder |
|
||||
| moodle/question:add | allow |
|
||||
And the following "course" exists:
|
||||
| fullname | Course 4 |
|
||||
| shortname | C4 |
|
||||
| category | 0 |
|
||||
And the following "course enrolment" exists:
|
||||
| course | C4 |
|
||||
| user | teacher1 |
|
||||
| role | adder |
|
||||
And the following "activity" exists:
|
||||
| activity | qbank |
|
||||
| course | C4 |
|
||||
| name | Question bank 4 |
|
||||
| idnumber | qbank4 |
|
||||
And the following "question category" exists:
|
||||
| contextlevel | Activity module |
|
||||
| reference | qbank4 |
|
||||
| name | Test questions 7 |
|
||||
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
|
||||
And I press "Create a new question ..."
|
||||
And I set the field "item_qtype_truefalse" to "1"
|
||||
# Manually create a new question so additional parameters are included in the URL, and we can test they are handled correctly
|
||||
# during the move operation.
|
||||
And I click on "Add" "button" in the "Choose a question type to add" "dialogue"
|
||||
And I set the following fields to these values:
|
||||
| Question name | Seventh question |
|
||||
| Question text | test |
|
||||
And I press "id_submitbutton"
|
||||
And I click on "Seventh question" "checkbox"
|
||||
And I click on "With selected" "button"
|
||||
And I click on "move" "button"
|
||||
And the field "searchbanks" matches value "C1 - Test quiz"
|
||||
And the field "selectcategory" matches value "Test questions 1 (2)"
|
||||
And I open the autocomplete suggestions list in the ".search-banks" "css_element"
|
||||
And I should see "C1 - Question bank 1" in the ".search-banks .form-autocomplete-suggestions" "css_element"
|
||||
And I should see "C2 - Question bank 2" in the ".search-banks .form-autocomplete-suggestions" "css_element"
|
||||
And I should see "C4 - Question bank 4" in the ".search-banks .form-autocomplete-suggestions" "css_element"
|
||||
And I should not see "C3 - Question bank 3" in the ".search-banks .form-autocomplete-suggestions" "css_element"
|
||||
And I click on "C1 - Question bank 1" item in the autocomplete list
|
||||
And the field "selectcategory" matches value "Test questions 2 (1)"
|
||||
And I click on "Move questions" "button"
|
||||
And I should see "Are you sure you want to move these questions?"
|
||||
When I click on "Confirm" "button"
|
||||
Then I should see "Questions successfully moved"
|
||||
# The move dialogue should default to the new bank and category.
|
||||
And I click on "Seventh question" "checkbox"
|
||||
And I click on "With selected" "button"
|
||||
And I click on "move" "button"
|
||||
And the field "searchbanks" matches value "C1 - Question bank 1"
|
||||
And the field "selectcategory" matches value "Test questions 2 (2)"
|
||||
# The moved question should be highlighted
|
||||
And the "class" attribute of "Seventh question" "table_row" should contain "highlight"
|
||||
|
||||
@@ -23,6 +23,7 @@ require_once($CFG->libdir . "/questionlib.php");
|
||||
use context;
|
||||
use core_question\category_manager;
|
||||
use core_question\local\bank\question_version_status;
|
||||
use core_question\output\question_category_selector;
|
||||
use moodle_exception;
|
||||
use html_writer;
|
||||
|
||||
@@ -304,46 +305,7 @@ class helper {
|
||||
bool $top = false,
|
||||
int $showallversions = 0,
|
||||
): array {
|
||||
global $DB;
|
||||
|
||||
$contextids = explode(',', $contexts);
|
||||
foreach ($contextids as $contextid) {
|
||||
$context = context::instance_by_id($contextid);
|
||||
if ($context->contextlevel === CONTEXT_MODULE) {
|
||||
$validcontexts[] = $contextid;
|
||||
}
|
||||
}
|
||||
if (empty($validcontexts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$insql, $inparams] = $DB->get_in_or_equal($validcontexts);
|
||||
|
||||
$topwhere = $top ? '' : 'AND c.parent <> 0';
|
||||
$statuscondition = "AND (qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " .
|
||||
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
|
||||
$substatuscondition = "AND v.status <> '" . question_version_status::QUESTION_STATUS_HIDDEN . "' ";
|
||||
$sql = "SELECT c.*,
|
||||
(SELECT COUNT(1)
|
||||
FROM {question} q
|
||||
JOIN {question_versions} qv ON qv.questionid = q.id
|
||||
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
|
||||
WHERE q.parent = '0'
|
||||
$statuscondition
|
||||
AND c.id = qbe.questioncategoryid
|
||||
AND ({$showallversions} = 1
|
||||
OR (qv.version = (SELECT MAX(v.version)
|
||||
FROM {question_versions} v
|
||||
JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
|
||||
WHERE be.id = qbe.id $substatuscondition)
|
||||
)
|
||||
)
|
||||
) AS questioncount
|
||||
FROM {question_categories} c
|
||||
WHERE c.contextid {$insql} {$topwhere}
|
||||
ORDER BY {$sortorder}";
|
||||
|
||||
return $DB->get_records_sql($sql, $inparams);
|
||||
return (new question_category_selector())->get_categories_for_contexts($contexts, $sortorder, $top, $showallversions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,74 +328,14 @@ class helper {
|
||||
int $nochildrenof = -1,
|
||||
bool $escapecontextnames = true,
|
||||
): array {
|
||||
global $CFG;
|
||||
$pcontexts = [];
|
||||
foreach ($contexts as $context) {
|
||||
if ($context->contextlevel !== CONTEXT_MODULE) {
|
||||
continue;
|
||||
}
|
||||
$pcontexts[] = $context->id;
|
||||
}
|
||||
$contextslist = join(', ', $pcontexts);
|
||||
|
||||
$categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
|
||||
|
||||
if ($top) {
|
||||
$categories = self::question_fix_top_names($categories);
|
||||
}
|
||||
|
||||
$categories = self::question_add_context_in_key($categories);
|
||||
$categories = self::add_indented_names($categories, $nochildrenof);
|
||||
|
||||
// Sort cats out into different contexts.
|
||||
$categoriesarray = [];
|
||||
foreach ($pcontexts as $contextid) {
|
||||
$context = \context::instance_by_id($contextid);
|
||||
$contextstring = $context->get_context_name(true, true, $escapecontextnames);
|
||||
foreach ($categories as $category) {
|
||||
if ($category->contextid == $contextid) {
|
||||
$cid = $category->id;
|
||||
if ("{$currentcat},{$contextid}" != $cid || $currentcat == 0) {
|
||||
$a = new \stdClass();
|
||||
$a->name = format_string(
|
||||
$category->indentedname,
|
||||
true,
|
||||
['context' => $context]
|
||||
);
|
||||
if ($category->idnumber !== null && $category->idnumber !== '') {
|
||||
$a->idnumber = s($category->idnumber);
|
||||
}
|
||||
if (!empty($category->questioncount)) {
|
||||
$a->questioncount = $category->questioncount;
|
||||
}
|
||||
if (isset($a->idnumber) && isset($a->questioncount)) {
|
||||
$formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
|
||||
} else if (isset($a->idnumber)) {
|
||||
$formattedname = get_string('categorynamewithidnumber', 'question', $a);
|
||||
} else if (isset($a->questioncount)) {
|
||||
$formattedname = get_string('categorynamewithcount', 'question', $a);
|
||||
} else {
|
||||
$formattedname = $a->name;
|
||||
}
|
||||
$categoriesarray[$contextstring][$cid] = $formattedname;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($popupform) {
|
||||
$popupcats = [];
|
||||
foreach ($categoriesarray as $contextstring => $optgroup) {
|
||||
$group = [];
|
||||
foreach ($optgroup as $key => $value) {
|
||||
$key = str_replace($CFG->wwwroot, '', $key);
|
||||
$group[$key] = $value;
|
||||
}
|
||||
$popupcats[] = [$contextstring => $group];
|
||||
}
|
||||
return $popupcats;
|
||||
} else {
|
||||
return $categoriesarray;
|
||||
}
|
||||
return (new question_category_selector())->question_category_options(
|
||||
$contexts,
|
||||
$top,
|
||||
$currentcat,
|
||||
$popupform,
|
||||
$nochildrenof,
|
||||
$escapecontextnames,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,13 +345,7 @@ class helper {
|
||||
* @return array
|
||||
*/
|
||||
public static function question_add_context_in_key(array $categories): array {
|
||||
$newcatarray = [];
|
||||
foreach ($categories as $id => $category) {
|
||||
$category->parent = "$category->parent,$category->contextid";
|
||||
$category->id = "$category->id,$category->contextid";
|
||||
$newcatarray["$id,$category->contextid"] = $category;
|
||||
}
|
||||
return $newcatarray;
|
||||
return (new question_category_selector())->question_add_context_in_key($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,15 +357,7 @@ class helper {
|
||||
* @throws \coding_exception
|
||||
*/
|
||||
public static function question_fix_top_names(array $categories, bool $escape = true): array {
|
||||
|
||||
foreach ($categories as $id => $category) {
|
||||
if ($category->parent == 0) {
|
||||
$context = \context::instance_by_id($category->contextid);
|
||||
$categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape));
|
||||
}
|
||||
}
|
||||
|
||||
return $categories;
|
||||
return (new question_category_selector())->question_fix_top_names($categories, $escape);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-1
@@ -47,7 +47,7 @@ class move_questions extends external_api {
|
||||
'newcontextid' => new external_value(PARAM_INT, 'Contextid of the target question bank'),
|
||||
'newcategoryid' => new external_value(PARAM_INT, 'ID of the target question category'),
|
||||
'questionids' => new external_value(PARAM_SEQUENCE, 'Comma separated list of question ids to move'),
|
||||
'returnurl' => new external_value(PARAM_URL,
|
||||
'returnurl' => new external_value(PARAM_LOCALURL,
|
||||
desc: 'A URL to add/update the filter param with the new category',
|
||||
default: ''
|
||||
),
|
||||
@@ -112,6 +112,15 @@ class move_questions extends external_api {
|
||||
if ($returnurlstring) {
|
||||
$returnurl = new moodle_url($returnurlstring);
|
||||
$returnurl->param('cmid', $newcontext->instanceid);
|
||||
$returnurl->param('cat', "{$newcategoryid},{$newcontextid}");
|
||||
$returnurl->remove_params('category');
|
||||
// We can only highlight 1 question, so only highlight if we're moving a single question.
|
||||
$qids = explode(',', $questionids);
|
||||
if (count($qids) === 1) {
|
||||
$returnurl->param('lastchanged', reset($qids));
|
||||
} else {
|
||||
$returnurl->remove_params('lastchanged');
|
||||
};
|
||||
$filter = $returnurl->param('filter');
|
||||
if ($filter) {
|
||||
$returnfilters = filter_condition_manager::update_filter_param_to_category(
|
||||
|
||||
+58
-6
@@ -16,6 +16,7 @@
|
||||
|
||||
namespace core_question\external;
|
||||
|
||||
use core\exception\coding_exception;
|
||||
use core_external\external_api;
|
||||
use core_external\external_function_parameters;
|
||||
use core_external\external_single_structure;
|
||||
@@ -49,34 +50,85 @@ class search_shared_banks extends external_api {
|
||||
public static function execute_parameters(): external_function_parameters {
|
||||
return new external_function_parameters(
|
||||
[
|
||||
'contextid' => new external_value(PARAM_INT, 'The current context ID.'),
|
||||
'contextid' => new external_value(PARAM_INT, 'The current context ID for applying text filters to bank names.'),
|
||||
'search' => new external_value(PARAM_TEXT, 'Search terms by which to filter the shared banks.', default: ''),
|
||||
'requiredcapabilities' => new external_multiple_structure(
|
||||
new external_value(PARAM_TEXT, 'Capability'),
|
||||
'Array of abbreviated "moodle/question:" capabilities that the user must have at least one of in the context ' .
|
||||
'of each matching question bank. Valid options are "add", "managecategory", "flag", "config", "edit", ' .
|
||||
'"view", "use", "move" or "tag". For "edit", "view", "use", "move" and "tag", the -all and -mine ' .
|
||||
"suffixed versions of the capabilty will both be checked.",
|
||||
VALUE_DEFAULT,
|
||||
['use'],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a list of abbreviated capaibilities into an array of full capability strings.
|
||||
*
|
||||
* Each abbreviation must match a capability with the 'moodle/question:' prefix. Capabilities that have an "all" and "mine"
|
||||
* variant will have both variants included in the returned array.
|
||||
*
|
||||
* These abbreviations are copied from {@see question_has_capability_on()}
|
||||
*
|
||||
* @param array $abbreviations Abbreviated capabilities. Must match capabilities with the 'moodle/question:' prefix.
|
||||
* @return array The expanded capabilities
|
||||
*/
|
||||
protected static function expand_capabilities(array $abbreviations): array {
|
||||
$capabilitieswithallandmine = ['edit', 'view', 'use', 'move', 'tag'];
|
||||
$prefix = 'moodle/question:';
|
||||
$suffixes = ['all', 'mine'];
|
||||
$capabilities = [];
|
||||
foreach ($abbreviations as $abbreviation) {
|
||||
if (in_array($abbreviation, $capabilitieswithallandmine)) {
|
||||
foreach ($suffixes as $suffix) {
|
||||
$capability = $prefix . $abbreviation . $suffix;
|
||||
if (is_null(get_capability_info($capability))) {
|
||||
throw new coding_exception("Capability {$capability} does not exist.");
|
||||
}
|
||||
$capabilities[] = $capability;
|
||||
}
|
||||
} else {
|
||||
$capability = $prefix . $abbreviation;
|
||||
if (is_null(get_capability_info($capability))) {
|
||||
throw new coding_exception("Capability {$capability} does not exist.");
|
||||
}
|
||||
$capabilities[] = $capability;
|
||||
}
|
||||
}
|
||||
return $capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array $requiredcapabilities List of abbreviated capabilities to check, {@see self::expand_capabilities()}
|
||||
* @return array
|
||||
*/
|
||||
public static function execute(int $contextid, string $search = ''): array {
|
||||
public static function execute(
|
||||
int $contextid,
|
||||
string $search = '',
|
||||
array $requiredcapabilities = ['use'],
|
||||
): array {
|
||||
[
|
||||
'contextid' => $contextid,
|
||||
'search' => $search,
|
||||
'requiredcapabilities' => $requiredcapabilities,
|
||||
] = self::validate_parameters(self::execute_parameters(), [
|
||||
'contextid' => $contextid,
|
||||
'search' => $search,
|
||||
'requiredcapabilities' => $requiredcapabilities,
|
||||
]);
|
||||
|
||||
$modulecontext = context::instance_by_id($contextid);
|
||||
$courseid = $modulecontext->get_parent_context()->instanceid;
|
||||
self::validate_context($modulecontext);
|
||||
|
||||
$sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(
|
||||
notincourseids: [$courseid],
|
||||
havingcap: ['moodle/question:useall', 'moodle/question:usemine'],
|
||||
havingcap: self::expand_capabilities($requiredcapabilities),
|
||||
filtercontext: $modulecontext,
|
||||
search: $search,
|
||||
limit: self::MAX_RESULTS + 1, // Return up to 1 extra result, so we know there are more.
|
||||
@@ -112,7 +164,7 @@ class search_shared_banks extends external_api {
|
||||
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.'),
|
||||
'value' => new external_value(PARAM_INT, 'Course Module ID of the shared bank.'),
|
||||
'label' => new external_value(PARAM_TEXT, 'Formatted bank name'),
|
||||
]),
|
||||
'List of shared banks',
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<?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\output;
|
||||
|
||||
use core\context;
|
||||
use core\output\renderable;
|
||||
use core\output\renderer_base;
|
||||
use core\output\templatable;
|
||||
use core_question\local\bank\question_version_status;
|
||||
|
||||
/**
|
||||
* A select menu of question categories.
|
||||
*
|
||||
*
|
||||
* @package core_question
|
||||
* @copyright 2025 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 question_category_selector implements renderable, templatable {
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $contexts
|
||||
* @param bool $top
|
||||
* @param string $currentcat
|
||||
* @param string $selected
|
||||
* @param int $nochildrenof
|
||||
* @param bool $autocomplete
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var array The module contexts for the question banks to show category options for. */
|
||||
protected array $contexts = [],
|
||||
/** @var bool If true, include top categories for each context in the options. */
|
||||
protected bool $top = false,
|
||||
/** @var string The current category, to exclude from the list. */
|
||||
protected $currentcat = 0,
|
||||
/** @var string The value of the initially selected option. */
|
||||
protected string $selected = "",
|
||||
/** @var int If this matches the ID of a category in the list, don't include its children. */
|
||||
protected int $nochildrenof = -1,
|
||||
/** @var bool If true, return options as a flattened array suitable for a list of autocomplete suggestions. */
|
||||
protected bool $autocomplete = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the category objects, including a count of the number of questions in that category,
|
||||
* for all the categories in the lists $contexts.
|
||||
*
|
||||
* @param string $contexts comma separated list of contextids
|
||||
* @param string $sortorder used as the ORDER BY clause in the select statement.
|
||||
* @param bool $top Whether to return the top categories or not.
|
||||
* @param int $showallversions 1 to show all versions not only the latest.
|
||||
* @return array of category objects.
|
||||
* @throws \dml_exception
|
||||
*/
|
||||
public function get_categories_for_contexts(
|
||||
string $contexts,
|
||||
string $sortorder = 'parent, sortorder, name ASC',
|
||||
bool $top = false,
|
||||
int $showallversions = 0,
|
||||
): array {
|
||||
global $DB;
|
||||
|
||||
$contextids = explode(',', $contexts);
|
||||
foreach ($contextids as $contextid) {
|
||||
$context = context::instance_by_id($contextid);
|
||||
if ($context->contextlevel === CONTEXT_MODULE) {
|
||||
$validcontexts[] = $contextid;
|
||||
}
|
||||
}
|
||||
if (empty($validcontexts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$insql, $inparams] = $DB->get_in_or_equal($validcontexts);
|
||||
|
||||
$topwhere = $top ? '' : 'AND c.parent <> 0';
|
||||
$statuscondition = "AND (qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " .
|
||||
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
|
||||
$substatuscondition = "AND v.status <> '" . question_version_status::QUESTION_STATUS_HIDDEN . "' ";
|
||||
$sql = "SELECT c.*,
|
||||
(SELECT COUNT(1)
|
||||
FROM {question} q
|
||||
JOIN {question_versions} qv ON qv.questionid = q.id
|
||||
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
|
||||
WHERE q.parent = '0'
|
||||
$statuscondition
|
||||
AND c.id = qbe.questioncategoryid
|
||||
AND ({$showallversions} = 1
|
||||
OR (qv.version = (SELECT MAX(v.version)
|
||||
FROM {question_versions} v
|
||||
JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
|
||||
WHERE be.id = qbe.id $substatuscondition)
|
||||
)
|
||||
)
|
||||
) AS questioncount
|
||||
FROM {question_categories} c
|
||||
WHERE c.contextid {$insql} {$topwhere}
|
||||
ORDER BY {$sortorder}";
|
||||
|
||||
return $DB->get_records_sql($sql, $inparams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output an array of question categories.
|
||||
*
|
||||
* @param array $contexts The list of contexts.
|
||||
* @param bool $top Whether to return the top categories or not.
|
||||
* @param int $currentcat The current category, to exclude from the list.
|
||||
* @param bool $popupform Return each question bank's group in an additional nested array.
|
||||
* @param int $nochildrenof Don't include children of this category
|
||||
* @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not.
|
||||
* @return array
|
||||
* @throws \coding_exception|\dml_exception
|
||||
*/
|
||||
public function question_category_options(
|
||||
array $contexts,
|
||||
bool $top = false,
|
||||
int $currentcat = 0,
|
||||
bool $popupform = false,
|
||||
int $nochildrenof = -1,
|
||||
bool $escapecontextnames = true,
|
||||
): array {
|
||||
global $CFG;
|
||||
$pcontexts = [];
|
||||
foreach ($contexts as $context) {
|
||||
if ($context->contextlevel !== CONTEXT_MODULE) {
|
||||
continue;
|
||||
}
|
||||
$pcontexts[] = $context->id;
|
||||
}
|
||||
$contextslist = join(', ', $pcontexts);
|
||||
|
||||
$categories = $this->get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
|
||||
|
||||
if ($top) {
|
||||
$categories = $this->question_fix_top_names($categories);
|
||||
}
|
||||
|
||||
$categories = $this->question_add_context_in_key($categories);
|
||||
$categories = $this->add_indented_names($categories, $nochildrenof);
|
||||
|
||||
// Sort cats out into different contexts.
|
||||
$categoriesarray = [];
|
||||
foreach ($pcontexts as $contextid) {
|
||||
$context = context::instance_by_id($contextid);
|
||||
$contextstring = $context->get_context_name(true, true, $escapecontextnames);
|
||||
foreach ($categories as $category) {
|
||||
if ($category->contextid == $contextid) {
|
||||
$cid = $category->id;
|
||||
if ("{$currentcat},{$contextid}" != $cid || $currentcat == 0) {
|
||||
$a = new \stdClass();
|
||||
$a->name = format_string(
|
||||
$category->indentedname,
|
||||
true,
|
||||
['context' => $context]
|
||||
);
|
||||
if ($category->idnumber !== null && $category->idnumber !== '') {
|
||||
$a->idnumber = s($category->idnumber);
|
||||
}
|
||||
if (!empty($category->questioncount)) {
|
||||
$a->questioncount = $category->questioncount;
|
||||
}
|
||||
if (isset($a->idnumber) && isset($a->questioncount)) {
|
||||
$formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
|
||||
} else if (isset($a->idnumber)) {
|
||||
$formattedname = get_string('categorynamewithidnumber', 'question', $a);
|
||||
} else if (isset($a->questioncount)) {
|
||||
$formattedname = get_string('categorynamewithcount', 'question', $a);
|
||||
} else {
|
||||
$formattedname = $a->name;
|
||||
}
|
||||
$categoriesarray[$contextstring][$cid] = $formattedname;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($popupform) {
|
||||
$popupcats = [];
|
||||
foreach ($categoriesarray as $contextstring => $optgroup) {
|
||||
$group = [];
|
||||
foreach ($optgroup as $key => $value) {
|
||||
$key = str_replace($CFG->wwwroot, '', $key);
|
||||
$group[$key] = $value;
|
||||
}
|
||||
$popupcats[] = [$contextstring => $group];
|
||||
}
|
||||
return $popupcats;
|
||||
} else {
|
||||
return $categoriesarray;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add context in categories key.
|
||||
*
|
||||
* @param array $categories The list of categories, keyed by ID.
|
||||
* @return array The list with the context id added to each key, id, and parent attribute.
|
||||
*/
|
||||
public function question_add_context_in_key(array $categories): array {
|
||||
$newcatarray = [];
|
||||
foreach ($categories as $id => $category) {
|
||||
$category->parent = "$category->parent,$category->contextid";
|
||||
$category->id = "$category->id,$category->contextid";
|
||||
$newcatarray["$id,$category->contextid"] = $category;
|
||||
}
|
||||
return $newcatarray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds top categories in the given categories hierarchy and replace their name with a proper localised string.
|
||||
*
|
||||
* @param array $categories An array of question categories.
|
||||
* @param bool $escape Whether the returned name of the thing is to be HTML escaped or not.
|
||||
* @return array The same question category list given to the function, with the top category names being translated.
|
||||
* @throws \coding_exception
|
||||
*/
|
||||
public function question_fix_top_names(array $categories, bool $escape = true): array {
|
||||
|
||||
foreach ($categories as $id => $category) {
|
||||
if ($category->parent == 0) {
|
||||
$context = context::instance_by_id($category->contextid);
|
||||
$categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape));
|
||||
}
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format categories into an indented list reflecting the tree structure.
|
||||
*
|
||||
* @param array $categories An array of category objects, keyed by ID.
|
||||
* @param int $nochildrenof If the category with this ID is in the list, don't include its children.
|
||||
* @return array The formatted list of categories.
|
||||
*/
|
||||
public function add_indented_names(array $categories, int $nochildrenof = -1): array {
|
||||
|
||||
// Add an array to each category to hold the child category ids. This array
|
||||
// will be removed again by flatten_category_tree(). It should not be used
|
||||
// outside these two functions.
|
||||
foreach (array_keys($categories) as $id) {
|
||||
$categories[$id]->childids = [];
|
||||
}
|
||||
|
||||
// Build the tree structure, and record which categories are top-level.
|
||||
// We have to be careful, because the categories array may include published
|
||||
// categories from other courses, but not their parents.
|
||||
$toplevelcategoryids = [];
|
||||
foreach (array_keys($categories) as $id) {
|
||||
if (
|
||||
!empty($categories[$id]->parent) &&
|
||||
array_key_exists($categories[$id]->parent, $categories)
|
||||
) {
|
||||
$categories[$categories[$id]->parent]->childids[] = $id;
|
||||
} else {
|
||||
$toplevelcategoryids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the tree to and add the indents.
|
||||
$newcategories = [];
|
||||
foreach ($toplevelcategoryids as $id) {
|
||||
$newcategories = $newcategories + $this->flatten_category_tree(
|
||||
$categories,
|
||||
$id,
|
||||
0,
|
||||
$nochildrenof,
|
||||
);
|
||||
}
|
||||
|
||||
return $newcategories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for the use of add_indented_names().
|
||||
*
|
||||
* Recursively adds an indentedname field to each category, starting with the category
|
||||
* with id $id, and dealing with that category and all its children, and
|
||||
* return a new array, with those categories in the right order.
|
||||
*
|
||||
* @param array $categories an array of categories which has had childids
|
||||
* fields added by flatten_category_tree(). Passed by reference for
|
||||
* performance only. It is not modfied.
|
||||
* @param int $id the category to start the indenting process from.
|
||||
* @param int $depth the indent depth. Used in recursive calls.
|
||||
* @param int $nochildrenof If the category with this ID is in the list, don't recur to its children.
|
||||
* @return array a new array of categories, in the right order for the tree.
|
||||
*/
|
||||
public function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array {
|
||||
|
||||
// Indent the name of this category.
|
||||
$newcategories = [];
|
||||
$newcategories[$id] = $categories[$id];
|
||||
$newcategories[$id]->indentedname = str_repeat(' ', $depth) .
|
||||
$categories[$id]->name;
|
||||
|
||||
// Recursively indent the children.
|
||||
foreach ($categories[$id]->childids as $childid) {
|
||||
if ($childid != $nochildrenof) {
|
||||
$newcategories = $newcategories + self::flatten_category_tree(
|
||||
$categories,
|
||||
$childid,
|
||||
$depth + 1,
|
||||
$nochildrenof,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the childids array that were temporarily added.
|
||||
unset($newcategories[$id]->childids);
|
||||
|
||||
return $newcategories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for the question_category_selector.mustache template.
|
||||
*
|
||||
* @param renderer_base $output
|
||||
* @return array[] [
|
||||
* 'banks' => a 2-D array of question banks and categories, for a plain select list with optgroups.
|
||||
* 'categories' => A flat list of categories, with bank names and disabled entries, for enhancing with an Autocomplete.
|
||||
* ]
|
||||
*/
|
||||
public function export_for_template(renderer_base $output): array {
|
||||
$categoriesarray = $this->question_category_options(
|
||||
$this->contexts,
|
||||
$this->top,
|
||||
$this->currentcat,
|
||||
false,
|
||||
$this->nochildrenof,
|
||||
false,
|
||||
);
|
||||
|
||||
$bankoptgroups = [];
|
||||
$categoryoptions = [];
|
||||
if ($this->autocomplete) {
|
||||
foreach ($categoriesarray as $bankname => $categories) {
|
||||
$categoryoptions[] = [
|
||||
'label' => $bankname,
|
||||
'value' => 0,
|
||||
'disabled' => true,
|
||||
];
|
||||
foreach ($categories as $idcontext => $category) {
|
||||
$categoryoptions[] = [
|
||||
'label' => $category,
|
||||
'value' => $idcontext,
|
||||
'selected' => $this->selected == $idcontext,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (empty($selected) && isset($categoryoptions[1])) {
|
||||
// Default to selecting the first category option.
|
||||
$categoryoptions[1]['selected'] = 1;
|
||||
}
|
||||
} else {
|
||||
foreach ($categoriesarray as $bankname => $categories) {
|
||||
$bankoptgroups[] = [
|
||||
'bankname' => $bankname,
|
||||
'categories' => array_map(
|
||||
fn($idcontext, $category) => [
|
||||
'idcontext' => $idcontext,
|
||||
'category' => $category,
|
||||
'selected' => $this->selected == $idcontext,
|
||||
],
|
||||
array_keys($categories),
|
||||
$categories,
|
||||
),
|
||||
];
|
||||
}
|
||||
if (empty($selected) && isset($bankoptgroups[0]['categories'][0])) {
|
||||
// Default to selecting the first category option.
|
||||
$bankoptgroups[0]['categories'][0]['selected'] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'banks' => $bankoptgroups,
|
||||
'categories' => $categoryoptions,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -86,3 +86,16 @@ function core_question_output_fragment_question_data(array $args): string {
|
||||
$questionbank->display_question_list();
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render and return a category selector for the categories in a given question bank.
|
||||
*
|
||||
* @param array $args ['bankcmid' => Course module ID of the question bank]
|
||||
* @return string The rendered selector.
|
||||
*/
|
||||
function core_question_output_fragment_category_selector(array $args): string {
|
||||
global $OUTPUT;
|
||||
$context = \core\context\module::instance($args['bankcmid']);
|
||||
$selector = new \core_question\output\question_category_selector([$context], autocomplete: true);
|
||||
return $OUTPUT->render($selector);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{{!
|
||||
This file is part of Moodle - http://moodle.org/
|
||||
|
||||
Moodle is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Moodle is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
}}
|
||||
{{!
|
||||
@template core_question/question_category_selector
|
||||
|
||||
Template for core_question\output\question_category_selector
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"value": "0",
|
||||
"disabled": true,
|
||||
"label": "Bank name 1"
|
||||
},
|
||||
{
|
||||
"value": "123,456",
|
||||
"selected": true,
|
||||
"label": "Category 1"
|
||||
},
|
||||
{
|
||||
"value": "124,456",
|
||||
"label": "Category 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
<div class="question_category_selector">
|
||||
<label for="selectcategory{{uniqid}}" class="visually-hidden">{{#str}} questioncategory, core_question {{/str}}</label>
|
||||
<select id="selectcategory{{uniqid}}" class="selectcategory" name="selectcategory">
|
||||
{{#banks}}
|
||||
<optgroup label="{{bankname}}">
|
||||
{{#categories}}
|
||||
<option value="{{idcontext}}"{{#selected}} selected{{/selected}}>{{{category}}}</option>
|
||||
{{/categories}}
|
||||
</optgroup>
|
||||
{{/banks}}
|
||||
{{#categories}}
|
||||
<option {{!
|
||||
}}value="{{value}}"{{!
|
||||
}}{{#disabled}} disabled class="suggestions-heading"{{/disabled}}{{!
|
||||
}}{{#selected}} selected{{/selected}}>
|
||||
{{{label}}}
|
||||
</option>
|
||||
{{/categories}}
|
||||
</select>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@ Feature: A teacher can put questions with idnumbers in categories in the questio
|
||||
And I click on "With selected" "button"
|
||||
And I click on question bulk action "move"
|
||||
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
|
||||
And I click on "Used category" item in the autocomplete list
|
||||
And I click on "Used category" "list_item" in the "Move the selected questions to..." "dialogue"
|
||||
And I click on "Move questions" "button"
|
||||
And I should see "Are you sure you want to move these questions?"
|
||||
And I click on "Confirm" "button"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<?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\external;
|
||||
|
||||
use core\context\module;
|
||||
|
||||
/**
|
||||
* Unit tests for core_question\external\search_shared_banks
|
||||
*
|
||||
* @package core_question
|
||||
* @copyright 2025 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
|
||||
* @covers \core_question\external\search_shared_banks
|
||||
*/
|
||||
final class search_shared_banks_test extends \advanced_testcase {
|
||||
|
||||
/**
|
||||
* Create a set of question banks across 3 courses.
|
||||
*
|
||||
* One bank belongs to a quiz, which is not searchable as it is not shared.
|
||||
* One bank does not include the word "test" in its name, so will not match that search.
|
||||
* One bank is on a course where the user has different permissions.
|
||||
* One bank is on the same course as the quiz, so will not be returned unless banks on the same course are included.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function create_banks(): array {
|
||||
$generator = $this->getDataGenerator();
|
||||
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
|
||||
$qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank');
|
||||
|
||||
$course1 = $generator->create_course();
|
||||
$course2 = $generator->create_course();
|
||||
$course3 = $generator->create_course();
|
||||
$teacher = $generator->create_user();
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'teacher');
|
||||
$generator->enrol_user($teacher->id, $course2->id, 'teacher');
|
||||
$generator->enrol_user($teacher->id, $course3->id, 'editingteacher');
|
||||
|
||||
$quiz = $quizgenerator->create_instance(['name' => 'Test quiz', 'course' => $course1->id]);
|
||||
$qbank1 = $qbankgenerator->create_instance(['name' => 'Test qbank 1', 'course' => $course2->id]);
|
||||
$qbank2 = $qbankgenerator->create_instance(['name' => 'Test qbank 2', 'course' => $course2->id]);
|
||||
$qbank3 = $qbankgenerator->create_instance(['name' => 'Different name', 'course' => $course2->id]);
|
||||
$qbank4 = $qbankgenerator->create_instance(['name' => 'Test qbank with different permissions', 'course' => $course3->id]);
|
||||
|
||||
return [
|
||||
$course1,
|
||||
$course2,
|
||||
$course3,
|
||||
$teacher,
|
||||
module::instance($quiz->cmid),
|
||||
$qbank1,
|
||||
$qbank2,
|
||||
$qbank3,
|
||||
$qbank4,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the function with no search string. All banks the user has permission to should be returned.
|
||||
*/
|
||||
public function test_empty_search(): void {
|
||||
$this->resetAfterTest();
|
||||
[
|
||||
,
|
||||
$course2,
|
||||
$course3,
|
||||
$teacher,
|
||||
$quizcontext,
|
||||
$qbank1,
|
||||
$qbank2,
|
||||
$qbank3,
|
||||
$qbank4,
|
||||
] = $this->create_banks();
|
||||
|
||||
$this->setUser($teacher);
|
||||
$result = search_shared_banks::execute($quizcontext->id);
|
||||
|
||||
$this->assertEquals(
|
||||
[
|
||||
[
|
||||
'label' => "{$course2->shortname} - {$qbank1->name}",
|
||||
'value' => $qbank1->cmid,
|
||||
],
|
||||
[
|
||||
'label' => "{$course2->shortname} - {$qbank2->name}",
|
||||
'value' => $qbank2->cmid,
|
||||
],
|
||||
[
|
||||
'label' => "{$course2->shortname} - {$qbank3->name}",
|
||||
'value' => $qbank3->cmid,
|
||||
],
|
||||
[
|
||||
'label' => "{$course3->shortname} - {$qbank4->name}",
|
||||
'value' => $qbank4->cmid,
|
||||
],
|
||||
],
|
||||
$result['sharedbanks'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the function with a search string matching a subset of available banks. Only those matching should be returned.
|
||||
*/
|
||||
public function test_search(): void {
|
||||
$this->resetAfterTest();
|
||||
[
|
||||
,
|
||||
$course2,
|
||||
$course3,
|
||||
$teacher,
|
||||
$quizcontext,
|
||||
$qbank1,
|
||||
$qbank2,
|
||||
,
|
||||
$qbank4,
|
||||
] = $this->create_banks();
|
||||
|
||||
$this->setUser($teacher);
|
||||
$result = search_shared_banks::execute($quizcontext->id, 'Test');
|
||||
|
||||
$this->assertEquals(
|
||||
[
|
||||
[
|
||||
'label' => "{$course2->shortname} - {$qbank1->name}",
|
||||
'value' => $qbank1->cmid,
|
||||
],
|
||||
[
|
||||
'label' => "{$course2->shortname} - {$qbank2->name}",
|
||||
'value' => $qbank2->cmid,
|
||||
],
|
||||
[
|
||||
'label' => "{$course3->shortname} - {$qbank4->name}",
|
||||
'value' => $qbank4->cmid,
|
||||
],
|
||||
],
|
||||
$result['sharedbanks'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the function with a different capability. Only results where the user has that capability should be returned.
|
||||
*/
|
||||
public function test_search_different_capability(): void {
|
||||
$this->resetAfterTest();
|
||||
[
|
||||
,
|
||||
,
|
||||
$course3,
|
||||
$teacher,
|
||||
$quizcontext,
|
||||
,
|
||||
,
|
||||
,
|
||||
$qbank4,
|
||||
] = $this->create_banks();
|
||||
|
||||
$this->setUser($teacher);
|
||||
$result = search_shared_banks::execute($quizcontext->id, 'Test', ['edit']);
|
||||
|
||||
$this->assertEquals(
|
||||
[
|
||||
[
|
||||
'label' => "{$course3->shortname} - {$qbank4->name}",
|
||||
'value' => $qbank4->cmid,
|
||||
],
|
||||
],
|
||||
$result['sharedbanks'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If there are more than the max number of results, a placeholder is returned at the end.
|
||||
*/
|
||||
public function test_search_max_results(): void {
|
||||
$this->resetAfterTest();
|
||||
[
|
||||
,
|
||||
$course2,
|
||||
,
|
||||
$teacher,
|
||||
$quizcontext,
|
||||
] = $this->create_banks();
|
||||
|
||||
$qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank');
|
||||
for ($i = 1; $i <= search_shared_banks::MAX_RESULTS + 2; $i++) {
|
||||
$qbankgenerator->create_instance(['name' => "Extra qbank {$i}", 'course' => $course2->id]);
|
||||
}
|
||||
|
||||
$this->setUser($teacher);
|
||||
$result = search_shared_banks::execute($quizcontext->id, 'Extra');
|
||||
$this->assertCount(search_shared_banks::MAX_RESULTS + 1, $result['sharedbanks']);
|
||||
$lastresult = end($result['sharedbanks']);
|
||||
$this->assertEquals(
|
||||
[
|
||||
'label' => get_string('otherquestionbankstoomany', 'question', search_shared_banks::MAX_RESULTS),
|
||||
'value' => 0,
|
||||
],
|
||||
$lastresult
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user