Merge branch 'MDL-72321_master-squashed' of https://github.com/marxjohnson/moodle

This commit is contained in:
Andrew Nicols
2023-08-31 23:02:35 +08:00
175 changed files with 5139 additions and 2026 deletions
+19 -4
View File
@@ -6218,15 +6218,30 @@ trait restore_question_set_reference_data_trait {
$data = (object) $data;
$data->usingcontextid = $this->get_mappingid('context', $data->usingcontextid);
$data->itemid = $this->get_new_parentid('quiz_question_instance');
$filtercondition = json_decode($data->filtercondition);
if ($category = $this->get_mappingid('question_category', $filtercondition->questioncategoryid)) {
$filtercondition->questioncategoryid = $category;
$filtercondition = json_decode($data->filtercondition, true);
if (!isset($filtercondition['filter'])) {
// Pre-4.3, convert the old filtercondition format to the new format.
$filtercondition = \core_question\question_reference_manager::convert_legacy_set_reference_filter_condition(
$filtercondition);
}
$data->filtercondition = json_encode($filtercondition);
// Map category id used for category filter condition and corresponding context id.
$oldcategoryid = $filtercondition['filter']['category']['values'][0];
$newcategoryid = $this->get_mappingid('question_category', $oldcategoryid);
$filtercondition['filter']['category']['values'][0] = $newcategoryid;
if ($context = $this->get_mappingid('context', $data->questionscontextid)) {
$data->questionscontextid = $context;
}
$filtercondition['cat'] = implode(',', [
$filtercondition['filter']['category']['values'][0],
$data->questionscontextid,
]);
$data->filtercondition = json_encode($filtercondition);
$DB->insert_record('question_set_references', $data);
}
}
+3
View File
@@ -506,6 +506,9 @@ class comment {
$html .= html_writer::start_tag('div', array('class' => 'comment-area'));
$html .= html_writer::start_tag('div', array('class' => 'db'));
$html .= html_writer::tag('label',
get_string('comment', 'comment'),
['for' => 'dlg-content-'.$this->cid, 'class' => 'sr-only']);
$html .= html_writer::tag('textarea', '', $textareaattrs);
$html .= html_writer::end_tag('div'); // .db
+2 -1
View File
@@ -398,6 +398,7 @@ $string['markedoutof'] = 'Marked out of';
$string['markedoutofmax'] = 'Marked out of {$a}';
$string['markoutofmax'] = 'Mark {$a->mark} out of {$a->max}';
$string['marks'] = 'Marks';
$string['noconditionspecified'] = 'Please specify a condition';
$string['noresponse'] = '[No response]';
$string['notanswered'] = 'Not answered';
$string['notgraded'] = 'Not graded';
@@ -465,9 +466,9 @@ $string['rightanswer'] = 'Right answer';
$string['rightanswer_help'] = 'An automatically generated summary of the correct response. This can be limited, so you may wish to consider explaining the correct solution in the general feedback for the question, and turning this option off.';
$string['saved'] = 'Saved: {$a}';
$string['settingsformultipletries'] = 'Multiple tries';
$string['showhidden'] = 'Show hidden questions';
$string['shortversioninfo'] = 'v{$a->version} (of {$a->latestversion})';
$string['shortversioninfolatest'] = 'v{$a->version} (latest)';
$string['showhidden'] = 'Also show old questions';
$string['showmarkandmax'] = 'Show mark and max';
$string['showmaxmarkonly'] = 'Show max mark only';
$string['showquestiontext'] = 'Show question text in the question list?';
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,10 +1,10 @@
define("core/datafilter/filtertype",["exports","core/form-autocomplete","core/datafilter/selectors","core/str"],(function(_exports,_formAutocomplete,_selectors,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
define("core/datafilter/filtertype",["exports","core/form-autocomplete","core/datafilter/selectors","core/str","core/notification"],(function(_exports,_formAutocomplete,_selectors,_str,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Base Filter class for a filter type in the filter UI.
*
* @module core/datafilter/filtertype
* @copyright 2020 Andrew Nicols <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,_formAutocomplete=_interopRequireDefault(_formAutocomplete),_selectors=_interopRequireDefault(_selectors);return _exports.default=class{constructor(filterType,rootNode,initialValues){this.filterType=filterType,this.rootNode=rootNode,this.addValueSelector(initialValues)}tearDown(){}get placeholder(){return(0,_str.getString)("placeholdertypeorselect","core")}get showSuggestions(){return!0}async addValueSelector(){let initialValues=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];const filterValueNode=this.getFilterValueNode(),sourceDataNode=this.getSourceDataForFilter();if(!sourceDataNode)return;filterValueNode.innerHTML=sourceDataNode.outerHTML;const dataSource=filterValueNode.querySelector("select");dataSource.id="filter-value-"+dataSource.getAttribute("data-field-name");const filterValueLabel=document.createElement("label");filterValueLabel.setAttribute("for",dataSource.id),filterValueLabel.classList.add("sr-only"),filterValueLabel.innerText=dataSource.getAttribute("data-field-title"),filterValueNode.appendChild(filterValueLabel),initialValues.forEach((filterValue=>{let selectedOption=dataSource.querySelector('option[value="'.concat(filterValue,'"]'));selectedOption?selectedOption.selected=!0:this.showSuggestions||(selectedOption=document.createElement("option"),selectedOption.value=filterValue,selectedOption.innerHTML=filterValue,selectedOption.selected=!0,dataSource.append(selectedOption))})),_formAutocomplete.default.enhance(dataSource,"1"==dataSource.dataset.allowCustom,null,await this.placeholder,!1,this.showSuggestions,null,!dataSource.multiple,{items:"core/datafilter/autocomplete_selection_items",layout:"core/datafilter/autocomplete_layout",selection:"core/datafilter/autocomplete_selection"})}get filterRoot(){return this.rootNode.querySelector(_selectors.default.filter.byName(this.filterType))}getSourceDataForFilter(){return this.rootNode.querySelector(_selectors.default.filterset.regions.datasource).querySelector(_selectors.default.data.fields.byName(this.filterType))}getFilterValueNode(){return this.filterRoot.querySelector(_selectors.default.filter.regions.values)}get name(){return this.filterType}get jointype(){return parseInt(this.filterRoot.querySelector(_selectors.default.filter.fields.join).value,10)}get rawValues(){const filterValueSelect=this.getFilterValueNode().querySelector("select");return Object.values((select=filterValueSelect,select.querySelectorAll(":checked"))).map((option=>option.value));var select}get values(){return this.rawValues.map((option=>parseInt(option,10)))}get filterValue(){return{name:this.name,jointype:this.jointype,values:this.values}}},_exports.default}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_formAutocomplete=_interopRequireDefault(_formAutocomplete),_selectors=_interopRequireDefault(_selectors),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(filterType,rootNode,initialValues){this.filterType=filterType,this.rootNode=rootNode,this.addValueSelector(initialValues).then((()=>{const filterRoot=this.filterRoot;return filterRoot&&filterRoot.querySelector(_selectors.default.data.required)&&filterRoot.querySelector(_selectors.default.filter.actions.remove).remove(),filterRoot})).catch(_notification.default.exception)}tearDown(){}get placeholder(){return(0,_str.getString)("placeholdertypeorselect","core")}get showSuggestions(){return!0}async addValueSelector(){let initialValues=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];const filterValueNode=this.getFilterValueNode(),sourceDataNode=this.getSourceDataForFilter();if(!sourceDataNode)throw new Error("No source data for filter.");filterValueNode.innerHTML=sourceDataNode.outerHTML;const dataSource=filterValueNode.querySelector("select");dataSource.id="filter-value-"+dataSource.getAttribute("data-field-name");const filterValueLabel=document.createElement("label");return filterValueLabel.setAttribute("for",dataSource.id),filterValueLabel.classList.add("sr-only"),filterValueLabel.innerText=dataSource.getAttribute("data-field-title"),filterValueNode.appendChild(filterValueLabel),initialValues.forEach((filterValue=>{let selectedOption=dataSource.querySelector('option[value="'.concat(filterValue,'"]'));selectedOption?selectedOption.selected=!0:this.showSuggestions||(selectedOption=document.createElement("option"),selectedOption.value=filterValue,selectedOption.innerHTML=filterValue,selectedOption.selected=!0,dataSource.append(selectedOption))})),_formAutocomplete.default.enhance(dataSource,"1"==dataSource.dataset.allowCustom,null,await this.placeholder,!1,this.showSuggestions,null,!dataSource.multiple,{items:"core/datafilter/autocomplete_selection_items",layout:"core/datafilter/autocomplete_layout",selection:"core/datafilter/autocomplete_selection"})}get filterRoot(){return this.rootNode.querySelector(_selectors.default.filter.byName(this.filterType))}getSourceDataForFilter(){return this.rootNode.querySelector(_selectors.default.filterset.regions.datasource).querySelector(_selectors.default.data.fields.byName(this.filterType))}getFilterValueNode(){return this.filterRoot.querySelector(_selectors.default.filter.regions.values)}get name(){return this.filterType}get jointype(){return parseInt(this.filterRoot.querySelector(_selectors.default.filter.fields.join).value,10)}get rawValues(){const filterValueSelect=this.getFilterValueNode().querySelector("select");return Object.values((select=filterValueSelect,select.querySelectorAll(":checked"))).map((option=>option.value));var select}get values(){return this.rawValues.map((option=>parseInt(option,10)))}get filterOptions(){return[]}get filterValue(){return{name:this.name,jointype:this.jointype,values:this.values,filteroptions:this.filterOptions}}},_exports.default}));
//# sourceMappingURL=filtertype.min.js.map
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
define("core/datafilter/filtertypes/binary",["exports","core/datafilter/filtertype","core/datafilter/selectors","core/templates","core/str"],(function(_exports,_filtertype,_selectors,_templates,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_filtertype=_interopRequireDefault(_filtertype),_selectors=_interopRequireDefault(_selectors),_templates=_interopRequireDefault(_templates);class _default extends _filtertype.default{constructor(){super(...arguments),_defineProperty(this,"optionOne",void 0),_defineProperty(this,"optionTwo",void 0)}async addValueSelector(initialValues){return[this.optionOne,this.optionTwo]=await this.getTextValues(),this.displayBinarySelection(initialValues)}getTextValues(){return(0,_str.get_strings)([{key:"no"},{key:"yes"}])}async displayBinarySelection(){let initialValues=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];const specificFilterSet=this.rootNode.querySelector(_selectors.default.filter.byName(this.filterType)),sourceDataNode=this.getSourceDataForFilter(),context={filtertype:this.filterType,title:sourceDataNode.getAttribute("data-field-title"),required:sourceDataNode.dataset.required,options:[{text:this.optionOne,value:0,selected:0===initialValues[0]},{text:this.optionTwo,value:1,selected:1===initialValues[0]}]};return _templates.default.render("core/datafilter/filtertypes/binary_selector",context).then(((binaryUi,js)=>_templates.default.replaceNodeContents(specificFilterSet.querySelector(_selectors.default.filter.regions.values),binaryUi,js)))}get values(){return this.filterRoot.querySelector('[data-filterfield="'.concat(this.name,'"]')).value}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=binary.min.js.map
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,6 +6,6 @@ define("core/datafilter/selectors",["exports"],(function(_exports){Object.define
* @copyright 2020 Michael Hawkins <michaelh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const getFilterRegion=region=>'[data-filterregion="'.concat(region,'"]'),getFilterAction=action=>'[data-filteraction="'.concat(action,'"]'),getFilterField=field=>'[data-filterfield="'.concat(field,'"]');var _default={filter:{region:getFilterRegion("filter"),actions:{remove:getFilterAction("remove")},fields:{join:getFilterField("join"),type:getFilterField("type")},regions:{values:getFilterRegion("value")},byName:name=>"".concat(getFilterRegion("filter"),'[data-filter-type="').concat(name,'"]')},filterset:{region:getFilterRegion("actions"),actions:{addRow:getFilterAction("add"),applyFilters:getFilterAction("apply"),resetFilters:getFilterAction("reset")},regions:{filtermatch:getFilterRegion("filtermatch"),filterlist:getFilterRegion("filters"),datasource:getFilterRegion("filtertypedata")},fields:{join:"".concat(getFilterRegion("filtermatch")," ").concat(getFilterField("join"))}},data:{fields:{byName:name=>'[data-field-name="'.concat(name,'"]'),all:"".concat(getFilterRegion("filtertypedata")," [data-field-name]")},typeList:getFilterRegion("filtertypelist"),typeListSelect:"select".concat(getFilterRegion("filtertypelist"))}};return _exports.default=_default,_exports.default}));
const getFilterRegion=region=>'[data-filterregion="'.concat(region,'"]'),getFilterAction=action=>'[data-filteraction="'.concat(action,'"]'),getFilterField=field=>'[data-filterfield="'.concat(field,'"]');var _default={filter:{region:getFilterRegion("filter"),actions:{remove:getFilterAction("remove")},fields:{join:getFilterField("join"),type:getFilterField("type")},regions:{values:getFilterRegion("value")},byName:name=>"".concat(getFilterRegion("filter"),'[data-filter-type="').concat(name,'"]')},filterset:{region:getFilterRegion("actions"),actions:{addRow:getFilterAction("add"),applyFilters:getFilterAction("apply"),resetFilters:getFilterAction("reset")},regions:{filtermatch:getFilterRegion("filtermatch"),filterlist:getFilterRegion("filters"),datasource:getFilterRegion("filtertypedata"),emptyFilterRow:"".concat(getFilterRegion("filter"),'[data-filter-type=""]')},fields:{join:"".concat(getFilterRegion("filtermatch")," ").concat(getFilterField("join"))}},data:{fields:{byName:name=>'[data-field-name="'.concat(name,'"]'),all:"".concat(getFilterRegion("filtertypedata")," [data-field-name]")},typeList:getFilterRegion("filtertypelist"),typeListSelect:"select".concat(getFilterRegion("filtertypelist")),required:"".concat(getFilterRegion("value"),' > [data-required="1"]')}};return _exports.default=_default,_exports.default}));
//# sourceMappingURL=selectors.min.js.map
@@ -1 +1 @@
{"version":3,"file":"selectors.min.js","sources":["../../src/datafilter/selectors.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 * Module containing the selectors for user filters.\n *\n * @module core/datafilter/selectors\n * @copyright 2020 Michael Hawkins <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst getFilterRegion = region => `[data-filterregion=\"${region}\"]`;\nconst getFilterAction = action => `[data-filteraction=\"${action}\"]`;\nconst getFilterField = field => `[data-filterfield=\"${field}\"]`;\n\nexport default {\n filter: {\n region: getFilterRegion('filter'),\n actions: {\n remove: getFilterAction('remove'),\n },\n fields: {\n join: getFilterField('join'),\n type: getFilterField('type'),\n },\n regions: {\n values: getFilterRegion('value'),\n },\n byName: name => `${getFilterRegion('filter')}[data-filter-type=\"${name}\"]`,\n },\n filterset: {\n region: getFilterRegion('actions'),\n actions: {\n addRow: getFilterAction('add'),\n applyFilters: getFilterAction('apply'),\n resetFilters: getFilterAction('reset'),\n },\n regions: {\n filtermatch: getFilterRegion('filtermatch'),\n filterlist: getFilterRegion('filters'),\n datasource: getFilterRegion('filtertypedata'),\n },\n fields: {\n join: `${getFilterRegion('filtermatch')} ${getFilterField('join')}`,\n },\n },\n data: {\n fields: {\n byName: name => `[data-field-name=\"${name}\"]`,\n all: `${getFilterRegion('filtertypedata')} [data-field-name]`,\n },\n typeList: getFilterRegion('filtertypelist'),\n typeListSelect: `select${getFilterRegion('filtertypelist')}`,\n },\n};\n"],"names":["getFilterRegion","region","getFilterAction","action","getFilterField","field","filter","actions","remove","fields","join","type","regions","values","byName","name","filterset","addRow","applyFilters","resetFilters","filtermatch","filterlist","datasource","data","all","typeList","typeListSelect"],"mappings":";;;;;;;;MAuBMA,gBAAkBC,sCAAiCA,aACnDC,gBAAkBC,sCAAiCA,aACnDC,eAAiBC,oCAA+BA,yBAEvC,CACXC,OAAQ,CACJL,OAAQD,gBAAgB,UACxBO,QAAS,CACLC,OAAQN,gBAAgB,WAE5BO,OAAQ,CACJC,KAAMN,eAAe,QACrBO,KAAMP,eAAe,SAEzBQ,QAAS,CACLC,OAAQb,gBAAgB,UAE5Bc,OAAQC,gBAAWf,gBAAgB,wCAA+Be,YAEtEC,UAAW,CACPf,OAAQD,gBAAgB,WACxBO,QAAS,CACLU,OAAQf,gBAAgB,OACxBgB,aAAchB,gBAAgB,SAC9BiB,aAAcjB,gBAAgB,UAElCU,QAAS,CACLQ,YAAapB,gBAAgB,eAC7BqB,WAAYrB,gBAAgB,WAC5BsB,WAAYtB,gBAAgB,mBAEhCS,OAAQ,CACJC,eAASV,gBAAgB,2BAAkBI,eAAe,WAGlEmB,KAAM,CACFd,OAAQ,CACJK,OAAQC,kCAA6BA,WACrCS,cAAQxB,gBAAgB,yCAE5ByB,SAAUzB,gBAAgB,kBAC1B0B,+BAAyB1B,gBAAgB"}
{"version":3,"file":"selectors.min.js","sources":["../../src/datafilter/selectors.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 * Module containing the selectors for user filters.\n *\n * @module core/datafilter/selectors\n * @copyright 2020 Michael Hawkins <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst getFilterRegion = region => `[data-filterregion=\"${region}\"]`;\nconst getFilterAction = action => `[data-filteraction=\"${action}\"]`;\nconst getFilterField = field => `[data-filterfield=\"${field}\"]`;\n\nexport default {\n filter: {\n region: getFilterRegion('filter'),\n actions: {\n remove: getFilterAction('remove'),\n },\n fields: {\n join: getFilterField('join'),\n type: getFilterField('type'),\n },\n regions: {\n values: getFilterRegion('value'),\n },\n byName: name => `${getFilterRegion('filter')}[data-filter-type=\"${name}\"]`,\n },\n filterset: {\n region: getFilterRegion('actions'),\n actions: {\n addRow: getFilterAction('add'),\n applyFilters: getFilterAction('apply'),\n resetFilters: getFilterAction('reset'),\n },\n regions: {\n filtermatch: getFilterRegion('filtermatch'),\n filterlist: getFilterRegion('filters'),\n datasource: getFilterRegion('filtertypedata'),\n emptyFilterRow: `${getFilterRegion('filter')}[data-filter-type=\"\"]`,\n },\n fields: {\n join: `${getFilterRegion('filtermatch')} ${getFilterField('join')}`,\n },\n },\n data: {\n fields: {\n byName: name => `[data-field-name=\"${name}\"]`,\n all: `${getFilterRegion('filtertypedata')} [data-field-name]`,\n },\n typeList: getFilterRegion('filtertypelist'),\n typeListSelect: `select${getFilterRegion('filtertypelist')}`,\n required: `${getFilterRegion('value')} > [data-required=\"1\"]`,\n },\n};\n"],"names":["getFilterRegion","region","getFilterAction","action","getFilterField","field","filter","actions","remove","fields","join","type","regions","values","byName","name","filterset","addRow","applyFilters","resetFilters","filtermatch","filterlist","datasource","emptyFilterRow","data","all","typeList","typeListSelect","required"],"mappings":";;;;;;;;MAuBMA,gBAAkBC,sCAAiCA,aACnDC,gBAAkBC,sCAAiCA,aACnDC,eAAiBC,oCAA+BA,yBAEvC,CACXC,OAAQ,CACJL,OAAQD,gBAAgB,UACxBO,QAAS,CACLC,OAAQN,gBAAgB,WAE5BO,OAAQ,CACJC,KAAMN,eAAe,QACrBO,KAAMP,eAAe,SAEzBQ,QAAS,CACLC,OAAQb,gBAAgB,UAE5Bc,OAAQC,gBAAWf,gBAAgB,wCAA+Be,YAEtEC,UAAW,CACPf,OAAQD,gBAAgB,WACxBO,QAAS,CACLU,OAAQf,gBAAgB,OACxBgB,aAAchB,gBAAgB,SAC9BiB,aAAcjB,gBAAgB,UAElCU,QAAS,CACLQ,YAAapB,gBAAgB,eAC7BqB,WAAYrB,gBAAgB,WAC5BsB,WAAYtB,gBAAgB,kBAC5BuB,yBAAmBvB,gBAAgB,oCAEvCS,OAAQ,CACJC,eAASV,gBAAgB,2BAAkBI,eAAe,WAGlEoB,KAAM,CACFf,OAAQ,CACJK,OAAQC,kCAA6BA,WACrCU,cAAQzB,gBAAgB,yCAE5B0B,SAAU1B,gBAAgB,kBAC1B2B,+BAAyB3B,gBAAgB,mBACzC4B,mBAAa5B,gBAAgB"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+62 -6
View File
@@ -110,13 +110,14 @@ export default class {
}
/**
* Add an unselected filter row.
* Add a filter row.
*
* @param {Object} filterdata Optional, data for adding for row with an existing filter.
* @return {Promise}
*/
addFilterRow() {
addFilterRow(filterdata = {}) {
const pendingPromise = new Pending('core/datafilter:addFilterRow');
const rownum = 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
const rownum = filterdata.rownum ?? 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rownum})
.then(({html, js}) => {
const newContentNodes = Templates.appendNodeContents(this.getFilterRegion(), html, js);
@@ -148,6 +149,13 @@ export default class {
.then(result => {
pendingPromise.resolve();
// If an existing filter is passed in, add it. Otherwise, leave the row empty.
if (filterdata.filtertype) {
result.forEach(filter => {
this.addFilter(filter, filterdata.filtertype, filterdata.values,
filterdata.jointype, filterdata.filteroptions);
});
}
return result;
})
.catch(Notification.exception);
@@ -171,9 +179,11 @@ export default class {
* @param {HTMLElement} filterRow
* @param {String} filterType
* @param {Array} initialFilterValues The initially selected values for the filter
* @param {String} filterJoin
* @param {Object} filterOptions
* @returns {Filter}
*/
async addFilter(filterRow, filterType, initialFilterValues) {
async addFilter(filterRow, filterType, initialFilterValues, filterJoin, filterOptions) {
// Name the filter on the filter row.
filterRow.dataset.filterType = filterType;
@@ -184,13 +194,19 @@ export default class {
if (filterDataNode.dataset.filterTypeClass) {
Filter = await import(filterDataNode.dataset.filterTypeClass);
}
this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues);
this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues, filterOptions);
// Disable the select.
const typeField = filterRow.querySelector(Selectors.filter.fields.type);
typeField.value = filterType;
typeField.disabled = 'disabled';
// Update the join list.
this.updateJoinList(JSON.parse(filterDataNode.dataset.joinList), filterRow);
const joinField = filterRow.querySelector(Selectors.filter.fields.join);
joinField.disabled = false;
if (!isNaN(filterJoin)) {
joinField.value = filterJoin;
}
// Update the list of available filter types.
this.updateFiltersOptions();
@@ -230,6 +246,9 @@ export default class {
* @param {Bool} refreshContent Whether to refresh the table content when removing
*/
async removeFilterRow(filterRow, refreshContent = true) {
if (filterRow.querySelector(Selectors.data.required)) {
return;
}
const filterType = filterRow.querySelector(Selectors.filter.fields.type);
const hasFilterValue = !!filterType.value;
@@ -265,6 +284,9 @@ export default class {
* @return {Promise}
*/
replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) {
if (filterRow.querySelector(Selectors.data.required)) {
return;
}
// Remove the filter object.
this.removeFilterObject(filterRow.dataset.filterType);
@@ -433,4 +455,38 @@ export default class {
return legendStrings;
}
/**
* Update the list of join types for a filter.
*
* This will update the list of join types based on the allowed types defined for a filter.
* If only one type is allowed, the list will be hidden.
*
* @param {Array} filterJoinList Array of join types, a subset of the regularJoinList array in this function.
* @param {Element} filterRow The row being updated.
*/
updateJoinList(filterJoinList, filterRow) {
const regularJoinList = [0, 1, 2];
// If a join list was specified for this filter, find the default join list and disable the options that are not allowed
// for this filter.
if (filterJoinList.length !== 0) {
const joinField = filterRow.querySelector(Selectors.filter.fields.join);
// Check each option from the default list, and disable the option in this filter row if it is not allowed
// for this filter.
regularJoinList.forEach((join) => {
if (!filterJoinList.includes(join)) {
joinField.options[join].classList.add('hidden');
joinField.options[join].disabled = true;
}
});
// Now remove the disabled options, and hide the select list of there is only one option left.
joinField.options.forEach((element, index) => {
if (element.disabled) {
joinField.options[index] = null;
}
});
if (joinField.options.length === 1) {
joinField.hidden = true;
}
}
}
}
+22 -3
View File
@@ -23,6 +23,7 @@
import Autocomplete from 'core/form-autocomplete';
import Selectors from 'core/datafilter/selectors';
import {getString} from 'core/str';
import Notification from 'core/notification';
/**
* Fetch all checked options in the select.
@@ -49,7 +50,14 @@ export default class {
this.filterType = filterType;
this.rootNode = rootNode;
this.addValueSelector(initialValues);
this.addValueSelector(initialValues).then(() => {
const filterRoot = this.filterRoot;
if (filterRoot && filterRoot.querySelector(Selectors.data.required)) {
filterRoot.querySelector(Selectors.filter.actions.remove).remove();
}
return filterRoot;
}).catch(Notification.exception);
}
/**
@@ -81,6 +89,7 @@ export default class {
* Add the value selector to the filter row.
*
* @param {Array} initialValues
* @return {Promise}
*/
async addValueSelector(initialValues = []) {
const filterValueNode = this.getFilterValueNode();
@@ -88,7 +97,7 @@ export default class {
// Copy the data in place.
const sourceDataNode = this.getSourceDataForFilter();
if (!sourceDataNode) {
return;
throw new Error('No source data for filter.');
}
filterValueNode.innerHTML = sourceDataNode.outerHTML;
@@ -121,7 +130,7 @@ export default class {
}
});
Autocomplete.enhance(
return Autocomplete.enhance(
// The source select element.
dataSource,
@@ -223,6 +232,15 @@ export default class {
return this.rawValues.map(option => parseInt(option, 10));
}
/**
* Get options specific to this filter type.
*
* @returns {Array} of {name:, value:} objects
*/
get filterOptions() {
return [];
}
/**
* Get the composed value for this filter.
*
@@ -233,6 +251,7 @@ export default class {
name: this.name,
jointype: this.jointype,
values: this.values,
filteroptions: this.filterOptions,
};
}
}
@@ -0,0 +1,111 @@
// 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/>.
/**
* Base filter for binary selector ie: (Yes / No).
*
* @module core/datafilter/filtertypes/binary
* @author 2022 Ghaly Marc-Alexandre <[email protected]>
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Filter from 'core/datafilter/filtertype';
import Selectors from 'core/datafilter/selectors';
import Templates from 'core/templates';
import {get_strings as getStrings} from 'core/str';
export default class extends Filter {
/**
* Text string for the first binary option.
*
* This option (and {@see optionTwo}) are set by {@see getTextValues()}. The base class will set default values,
* a subclass can override the method to define its own option.
*
* @type {String}
*/
optionOne;
/**
* Text string for the second binary option.
*
* @type {String}
*/
optionTwo;
/**
* Add the value selector to the filter row.
*
* @param {Array} initialValues The default value for the filter.
*/
async addValueSelector(initialValues) {
[this.optionOne, this.optionTwo] = await this.getTextValues();
return this.displayBinarySelection(initialValues);
}
/**
* Fetch text values for select options.
*
* Subclasses should override this method to set their own options.
*
* @returns {Promise}
*/
getTextValues() {
return getStrings([{key: 'no'}, {key: 'yes'}]);
}
/**
* Renders yes/no select input with proper selection.
*
* @param {Array} initialValues The default value for the filter.
*/
async displayBinarySelection(initialValues = []) {
// We specify a specific filterset in case there are multiple filtering condition - avoiding glitches.
const specificFilterSet = this.rootNode.querySelector(Selectors.filter.byName(this.filterType));
const sourceDataNode = this.getSourceDataForFilter();
const context = {
filtertype: this.filterType,
title: sourceDataNode.getAttribute('data-field-title'),
required: sourceDataNode.dataset.required,
options: [
{
text: this.optionOne,
value: 0,
selected: initialValues[0] === 0,
},
{
text: this.optionTwo,
value: 1,
selected: initialValues[0] === 1,
},
]
};
return Templates.render('core/datafilter/filtertypes/binary_selector', context)
.then((binaryUi, js) => {
return Templates.replaceNodeContents(specificFilterSet.querySelector(Selectors.filter.regions.values), binaryUi, js);
});
}
/**
* Get the list of raw values for this filter type.
*
* @returns {Array}
*/
get values() {
return this.filterRoot.querySelector(`[data-filterfield="${this.name}"]`).value;
}
}
+2
View File
@@ -51,6 +51,7 @@ export default {
filtermatch: getFilterRegion('filtermatch'),
filterlist: getFilterRegion('filters'),
datasource: getFilterRegion('filtertypedata'),
emptyFilterRow: `${getFilterRegion('filter')}[data-filter-type=""]`,
},
fields: {
join: `${getFilterRegion('filtermatch')} ${getFilterField('join')}`,
@@ -63,5 +64,6 @@ export default {
},
typeList: getFilterRegion('filtertypelist'),
typeListSelect: `select${getFilterRegion('filtertypelist')}`,
required: `${getFilterRegion('value')} > [data-required="1"]`,
},
};
+95 -45
View File
@@ -68,13 +68,7 @@ define([
// Find the elements in the DOM.
var selectionElement = $(document.getElementById(state.selectionId));
// Count the visible items.
var length = selectionElement.children('[aria-selected=true]').length;
// Limit the index to the upper/lower bounds of the list (wrap in both directions).
index = index % length;
while (index < 0) {
index += length;
}
index = wrapListIndex(index, selectionElement.children('[aria-selected=true]').length);
// Find the specified element.
var element = $(selectionElement.children('[aria-selected=true]').get(index));
// Create an id we can assign to this element.
@@ -156,21 +150,8 @@ define([
M.util.js_pending(pendingKey);
// Build up a valid context to re-render the template.
var items = [];
var items = rebuildOptions(originalSelect.children('option:selected'), false);
var newSelection = $(document.getElementById(state.selectionId));
originalSelect.children('option').each(function(index, ele) {
if ($(ele).prop('selected')) {
var label;
if ($(ele).data('html')) {
label = $(ele).data('html');
} else {
label = $(ele).html();
}
if (label !== '') {
items.push({label: label, value: $(ele).attr('value')});
}
}
});
if (!hasItemListChanged(state, items)) {
M.util.js_complete(pendingKey);
@@ -307,6 +288,91 @@ define([
}, 100).promise();
};
/**
* Return the index of the currently selected item in the suggestions list.
*
* @param {jQuery} suggestionsElement
* @return {Integer}
*/
var getCurrentItem = function(suggestionsElement) {
// Find the active one.
var element = suggestionsElement.children('[aria-selected=true]');
// Find its index.
return suggestionsElement.children(':not([aria-hidden])').index(element);
};
/**
* Limit the index to the upper/lower bounds of the list (wrap in both directions).
*
* @param {Integer} index The target index.
* @param {Integer} length The length of the list of visible items.
* @return {Integer} The resulting index with necessary wrapping applied.
*/
var wrapListIndex = function(index, length) {
index = index % length;
while (index < 0) {
index += length;
}
return index;
};
/**
* Return the index of the next item in the list without aria-disabled=true.
*
* @param {Integer} current The index of the current item.
* @param {Array} suggestions The list of suggestions.
* @return {Integer}
*/
var getNextEnabledItem = function(current, suggestions) {
var nextIndex = wrapListIndex(current + 1, suggestions.length);
if (suggestions[nextIndex].getAttribute('aria-disabled')) {
return getNextEnabledItem(nextIndex, suggestions);
}
return nextIndex;
};
/**
* Return the index of the previous item in the list without aria-disabled=true.
*
* @param {Integer} current The index of the current item.
* @param {Array} suggestions The list of suggestions.
* @return {Integer}
*/
var getPreviousEnabledItem = function(current, suggestions) {
var previousIndex = wrapListIndex(current - 1, suggestions.length);
if (suggestions[previousIndex].getAttribute('aria-disabled')) {
return getPreviousEnabledItem(previousIndex, suggestions);
}
return previousIndex;
};
/**
* Build a list of renderable options based on a set of option elements from the original select list.
*
* @param {jQuery} originalOptions
* @param {Boolean} includeEmpty
* @return {Array}
*/
var rebuildOptions = function(originalOptions, includeEmpty) {
var options = [];
originalOptions.each(function(index, ele) {
var label;
if ($(ele).data('html')) {
label = $(ele).data('html');
} else {
label = $(ele).html();
}
if (includeEmpty || label !== '') {
options.push({
label: label,
value: $(ele).attr('value'),
disabled: ele.disabled,
});
}
});
return options;
};
/**
* Find the index of the current active suggestion, and activate the next one.
*
@@ -318,12 +384,10 @@ define([
var activateNextItem = function(state) {
// Find the list of suggestions.
var suggestionsElement = $(document.getElementById(state.suggestionsId));
// Find the active one.
var element = suggestionsElement.children('[aria-selected=true]');
// Find it's index.
var current = suggestionsElement.children(':not([aria-hidden])').index(element);
var suggestions = suggestionsElement.children(':not([aria-hidden])');
var current = getCurrentItem(suggestionsElement);
// Activate the next one.
return activateItem(current + 1, state);
return activateItem(getNextEnabledItem(current, suggestions), state);
};
/**
@@ -385,17 +449,11 @@ define([
* @return {Promise}
*/
var activatePreviousItem = function(state) {
// Find the list of suggestions.
var suggestionsElement = $(document.getElementById(state.suggestionsId));
// Find the active one.
var element = suggestionsElement.children('[aria-selected=true]');
// Find it's index.
var current = suggestionsElement.children(':not([aria-hidden])').index(element);
var suggestions = suggestionsElement.children(':not([aria-hidden])');
var current = getCurrentItem(suggestionsElement);
// Activate the previous one.
return activateItem(current - 1, state);
return activateItem(getPreviousEnabledItem(current, suggestions), state);
};
/**
@@ -447,12 +505,7 @@ define([
// Used to track if we found any visible suggestions.
var matchingElements = false;
// Options is used by the context when rendering the suggestions from a template.
var suggestions = [];
originalSelect.children('option').each(function(index, option) {
if ($(option).prop('selected') !== true) {
suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')};
}
});
var suggestions = rebuildOptions(originalSelect.children('option:not(:selected)'), true);
// Re-render the list of suggestions.
var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
@@ -1153,10 +1206,7 @@ define([
var originalLabel = $('[for=' + state.selectId + ']');
// Create the new markup and insert it after the select.
var suggestions = [];
originalSelect.children('option').each(function(index, option) {
suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')};
});
var suggestions = rebuildOptions(originalSelect.children('option'), true);
// Render all the parts of our UI.
var context = $.extend({}, options, state);
+16 -1
View File
@@ -31,6 +31,15 @@ use templatable;
*/
abstract class datafilter implements renderable, templatable {
/** @var int None of the following match */
public const JOINTYPE_NONE = 0;
/** @var int Any of the following match */
public const JOINTYPE_ANY = 1;
/** @var int All of the following match */
public const JOINTYPE_ALL = 2;
/** @var context $context The context where the filters are being rendered. */
protected $context;
@@ -81,7 +90,10 @@ abstract class datafilter implements renderable, templatable {
bool $multiple,
?string $filterclass,
array $values,
bool $allowempty = false
bool $allowempty = false,
?stdClass $filteroptions = null,
bool $required = false,
array $joinlist = [self::JOINTYPE_NONE, self::JOINTYPE_ANY, self::JOINTYPE_ALL]
): ?stdClass {
if (!$allowempty && empty($values)) {
@@ -96,6 +108,9 @@ abstract class datafilter implements renderable, templatable {
'allowmultiple' => $multiple,
'filtertypeclass' => $filterclass,
'values' => $values,
'filteroptions' => $filteroptions,
'required' => $required,
'joinlist' => json_encode($joinlist)
];
}
}
+4
View File
@@ -86,4 +86,8 @@ $renamedclasses = [
'core_block\\local\\views\\secondary' => 'core_block\\navigation\\views\\secondary',
// Since Moodle 4.2.
'Box\\Spout' => 'OpenSpout',
// Since Moodle 4.3.
'core_question\\bank\\search\\condition' => 'core_question\\local\\bank\\condition',
'core_question\\bank\\search\\category_condition' => 'qbank_managecategories\\category_condition',
'core_question\\bank\\search\\hidden_condition' => 'qbank_deletequestion\\hidden_condition',
];
+9 -1
View File
@@ -82,7 +82,15 @@ class get extends external_api {
new external_value(PARAM_RAW, 'Filter value'),
'The value to filter on',
VALUE_REQUIRED
)
),
'filteroptions' => new external_multiple_structure(
new external_single_structure([
'name' => new external_value(PARAM_ALPHANUM, 'Name of the filter option', VALUE_REQUIRED),
'value' => new external_value(PARAM_RAW, 'Value of the filter option', VALUE_REQUIRED),
]),
'Additional options for this filter',
VALUE_OPTIONAL,
),
]),
'The filters that will be applied in the request',
VALUE_OPTIONAL
+7 -7
View File
@@ -33,28 +33,28 @@
"rownumber": 1
}
}}
<div data-filterregion="filter">
<div data-filterregion="filter" data-filter-type="">
<fieldset>
<legend class="sr-only">{{#str}}filterrowlegend, core, {{rownumber}}{{/str}}</legend>
<div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-start">
<div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-stretch mr-0 ml-0 row">
<div class="d-flex flex-column flex-md-row align-items-md-center my-1">
<label for="core-filter_row-jointype-{{uniqid}}" class="mr-md-2 mb-md-0">{{#str}}match{{/str}}</label>
<select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core-filter_row-jointype-{{uniqid}}">
<select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core-filter_row-jointype-{{uniqid}}" disabled>
<option value="0">{{#str}}none{{/str}}</option>
<option selected=selected value="1">{{#str}}any{{/str}}</option>
<option value="2">{{#str}}all{{/str}}</option>
</select>
</div>
<label class="sr-only pt-2" for="core-filter_row-filtertype-{{uniqid}}">{{#str}}filtertype{{/str}}</label>
<select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="type" id="core-filter_row-filtertype-{{uniqid}}">
<label class="sr-only pt-2 my-1" for="core-filter_row-filtertype-{{uniqid}}">{{#str}}filtertype{{/str}}</label>
<select class="custom-select my-1 mb-md-0 mr-md-2" data-filterfield="type" id="core-filter_row-filtertype-{{uniqid}}">
<option value="">{{#str}}selectfiltertype{{/str}}</option>
{{#filtertypes}}
<option value="{{name}}">{{title}}</option>
{{/filtertypes}}
</select>
<div data-filterregion="value" class="d-md-block flex-column align-items-start flex-lg-row"></div>
<div data-filterregion="value" class="d-md-block flex-column align-items-start flex-lg-row my-1"></div>
<button data-filteraction="remove" class="ml-auto icon-no-margin icon-size-4 btn text-reset" aria-label="{{#str}}clearfilterrow{{/str}}">
{{#pix}}e/cancel_solid_circle{{/pix}}
@@ -52,10 +52,12 @@
}}data-field-name="{{name}}" {{!
}}data-field-title="{{title}}" {{!
}}data-allow-custom="{{allowcustom}}" {{!
}}data-required="{{required}}" {{!
}}data-join-list="{{joinlist}}" {{!
}}class="hidden" {{!
}}{{#filtertypeclass}}data-filter-type-class="{{filtertypeclass}}" {{/filtertypeclass}}{{!
}}>
{{#values}}
<option value="{{value}}">{{{title}}}</option>
<option value="{{value}}"{{#disabled}} disabled{{/disabled}}>{{{title}}}</option>
{{/values}}
</select>
@@ -0,0 +1,46 @@
{{!
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/datafilter/filtertypes/binary_selector
Moodle template for hidden, questiontext and subcategories filter.
Context variables required for this template:
* filtertype - filter type name for dataset filterfield.
* textvalueone - text value for first selection.
* textvaluetwo - text value for second selection.
* optionone - boolen for first option selection.
* optiontwo - boolean for second option selection.
Example context (json):
{
"title": "Show hidden questions",
"filtertype": "hidden",
"textvalueone": "yes",
"textvaluetwo": "no",
"optionone": false,
"optiontwo": true
}
}}
<div class="d-flex flex-column flex-md-row align-items-md-center" data-required="{{required}}">
<label for="core-filter_row-binary-{{uniqid}}" class="sr-only">{{title}}</label>
<select class="custom-select mb-1 mb-md-0 mr-md-2" name="{{filtertype}}" data-filterfield="{{filtertype}}" id="core-filter_row-binary-{{uniqid}}">
{{#options}}
<option{{#selected}} selected{{/selected}} value="{{value}}">{{text}}</option>
{{/options}}
</select>
</div>
@@ -32,11 +32,12 @@
Example context (json):
{ "suggestionsId": 1, "options": [
{ "label": "Item label with <strong>tags</strong>", "value": "5" },
{ "label": "Another item label with <strong>tags</strong>", "value": "4" }
{ "label": "Another item label with <strong>tags</strong>", "value": "4"},
{ "label": "A disabled item", "value": "", "disabled": true}
]}
}}
<ul class="form-autocomplete-suggestions" id="{{suggestionsId}}" role="listbox" aria-label="{{#str}}suggestions, form{{/str}}" aria-hidden="true" tabindex="-1">
{{#options}}
<li role="option" data-value="{{value}}">{{{label}}}</li>
<li role="option" data-value="{{value}}"{{#disabled}} aria-disabled="true"{{/disabled}}>{{{label}}}</li>
{{/options}}
</ul>
+13
View File
@@ -145,6 +145,19 @@ being forced open in all behat tests.
- `$fullformat` - Passing `true` will return date/time units in full format (e.g. `1 day` instead of `1d`).
* The print_object function (for temporary debugging use only) can now be used on objects that have recursive references,
and has an improved display and other changes. (New parameters are available; see function documentation.)
* New `filteroptions` field added to getFilterValue() in core/datafilter/filtertype. This is generated by `get filterOptions()`
and returns an arbitrary list of options specific to the filter type, in [{name:, value}] format. This allows filters to extend
the values returned with additional fields.
* New `required` field added to core\output\datafilter. This will be output via the data-required property in filter_type.mustache,
any additional filter types will need to output this property as well. Filters with data-required="1" will not be removable from
the list of filters.
* addFilterRow() in core/datafilter now accepts a filterdata object to add a row with a pre-defined filter.
* New "binary" datafilter type added for creating filters with a single yes/no option.
* New "joinlist" parameter added to core\output\datafilter::get_filter_object(). This takes an array of datafilter::JOINTYPE_*
constants to define which types of join the filter supports. By default this will include all three of "Any", "All" and "None",
but a subset can be specified instead.
* core/form-autocomplete now supports disabled options in the source select list. These will be displayed in the autocomplete
options with the aria-disabled attribute, and will not be selectable.
=== 4.2 ===
-141
View File
@@ -1,141 +0,0 @@
<?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/>.
/**
* Fallback page of /mod/quiz/edit.php add random question dialog,
* for users who do not use javascript.
*
* @package mod_quiz
* @copyright 2008 Olli Savolainen
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/question/editlib.php');
use mod_quiz\form\add_random_form;
use mod_quiz\quiz_settings;
use qbank_managecategories\question_category_object;
list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) =
question_edit_setup('editq', '/mod/quiz/addrandom.php', true);
// These params are only passed from page request to request while we stay on
// this page otherwise they would go in question_edit_setup.
$returnurl = optional_param('returnurl', '', PARAM_LOCALURL);
$addonpage = optional_param('addonpage', 0, PARAM_INT);
$category = optional_param('category', 0, PARAM_INT);
$mdlscrollto = optional_param('mdlscrollto', 0, PARAM_INT);
$quizobj = quiz_settings::create($quiz->id);
$course = $quizobj->get_course();
// You need mod/quiz:manage in addition to question capabilities to access this page.
// You also need the moodle/question:useall capability somewhere.
require_capability('mod/quiz:manage', $contexts->lowest());
if (!$contexts->having_cap('moodle/question:useall')) {
throw new \moodle_exception('nopermissions', '', '', 'use');
}
$PAGE->set_url($thispageurl);
if ($returnurl) {
$returnurl = new moodle_url($returnurl);
} else {
$returnurl = new moodle_url('/mod/quiz/edit.php', ['cmid' => $cmid]);
}
if ($mdlscrollto) {
$returnurl->param('mdlscrollto', $mdlscrollto);
}
$defaultcategoryobj = question_make_default_categories($contexts->all());
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
$qcobject = new question_category_object(
$pagevars['cpage'],
$thispageurl,
$contexts->having_one_edit_tab_cap('categories'),
$defaultcategoryobj->id,
$defaultcategory,
null,
$contexts->having_cap('moodle/question:add'));
$mform = new add_random_form(new moodle_url('/mod/quiz/addrandom.php'),
['contexts' => $contexts, 'cat' => $pagevars['cat']]);
if ($mform->is_cancelled()) {
redirect($returnurl);
}
if ($data = $mform->get_data()) {
if (!empty($data->existingcategory)) {
list($categoryid) = explode(',', $data->category);
$includesubcategories = !empty($data->includesubcategories);
if (!$includesubcategories) {
// If the chosen category is a top category.
$includesubcategories = $DB->record_exists('question_categories', ['id' => $categoryid, 'parent' => 0]);
}
$returnurl->param('cat', $data->category);
} else if (!empty($data->newcategory)) {
list($parentid, $contextid) = explode(',', $data->parent);
$categoryid = $qcobject->add_category($data->parent, $data->name, '', true);
$includesubcategories = 0;
$returnurl->param('cat', $categoryid . ',' . $contextid);
} else {
throw new coding_exception(
'It seems a form was submitted without any button being pressed???');
}
if (empty($data->fromtags)) {
$data->fromtags = [];
}
$tagids = array_map(function($tagstrings) {
return (int)explode(',', $tagstrings)[0];
}, $data->fromtags);
quiz_add_random_questions($quiz, $addonpage, $categoryid, $data->numbertoadd, $includesubcategories, $tagids);
quiz_delete_previews($quiz);
$quizobj->get_grade_calculator()->recompute_quiz_sumgrades();
redirect($returnurl);
}
$mform->set_data([
'addonpage' => $addonpage,
'returnurl' => $returnurl,
'cmid' => $cm->id,
'category' => $category,
]);
// Setup $PAGE.
$streditingquiz = get_string('editinga', 'moodle', get_string('modulename', 'quiz'));
$PAGE->navbar->add($streditingquiz);
$PAGE->set_title($streditingquiz);
$PAGE->set_heading($course->fullname);
echo $OUTPUT->header();
if (!$quizname = $DB->get_field($cm->modname, 'name', ['id' => $cm->instance])) {
throw new \moodle_exception('invalidcoursemodule');
}
echo $OUTPUT->heading(get_string('addrandomquestiontoquiz', 'quiz', $quizname), 2);
$mform->display();
echo $OUTPUT->footer();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
define("mod_quiz/modal_quiz_question_bank",["exports","jquery","core/modal","core/fragment","core_form/changechecker","core/modal_events"],(function(_exports,_jquery,_modal,Fragment,FormChangeChecker,ModalEvents){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_modal=_interopRequireDefault(_modal),Fragment=_interopRequireWildcard(Fragment),FormChangeChecker=_interopRequireWildcard(FormChangeChecker),ModalEvents=_interopRequireWildcard(ModalEvents);const SELECTORS_ADD_TO_QUIZ_CONTAINER="td.addtoquizaction",SELECTORS_ANCHOR="a[href]",SELECTORS_PREVIEW_CONTAINER="td.previewaction",SELECTORS_SEARCH_OPTIONS="#advancedsearch",SELECTORS_DISPLAY_OPTIONS="#displayoptions",SELECTORS_ADD_QUESTIONS_FORM="form#questionsubmit";class ModalQuizQuestionBank extends _modal.default{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}show(){return this.reloadBodyContent(window.location.search),super.show(this)}reloadBodyContent(querystring){this.setBody(Fragment.loadFragment("mod_quiz","quiz_question_bank",this.getContextId(),{querystring:querystring}))}handleAddToQuizEvent(e,anchorElement){const href=new URL(anchorElement.attr("href"));href.searchParams.set("addonpage",this.getAddOnPageId()),anchorElement.attr("href",href)}handlePreviewContainerEvent(e,anchorElement){window.openpopup(e,{url:anchorElement.attr("href"),name:"questionpreview",options:["height=600","width=800","top=0","left=0","menubar=0","location=0","scrollbars","resizable","toolbar","status","directories=0","fullscreen=0","dependent"].join(",")})}handleDisplayOptionFormEvent(e){e.stopPropagation(),e.preventDefault();const queryString="?"+(0,_jquery.default)(e.target).closest(SELECTORS_DISPLAY_OPTIONS).serialize();this.reloadBodyContent(queryString)}registerDisplayOptionListeners(){this.getModal().on("change",SELECTORS_DISPLAY_OPTIONS,(e=>{(0,_jquery.default)(e.target).attr("aria-autocomplete")||this.handleDisplayOptionFormEvent(e)})),this.getModal().on("submit",SELECTORS_DISPLAY_OPTIONS,(e=>{this.handleDisplayOptionFormEvent(e)}))}registerEventListeners(){super.registerEventListeners(this),this.registerDisplayOptionListeners(),this.getModal().on("submit",SELECTORS_ADD_QUESTIONS_FORM,(e=>{const formElement=(0,_jquery.default)(e.currentTarget);(0,_jquery.default)("<input />").attr("type","hidden").attr("name","addonpage").attr("value",this.getAddOnPageId()).appendTo(formElement)})),this.getModal().on("click",SELECTORS_ANCHOR,(e=>{const anchorElement=(0,_jquery.default)(e.currentTarget);anchorElement.closest(SELECTORS_ADD_TO_QUIZ_CONTAINER).length?this.handleAddToQuizEvent(e,anchorElement):anchorElement.closest(SELECTORS_PREVIEW_CONTAINER).length?this.handlePreviewContainerEvent(e,anchorElement):anchorElement.closest(SELECTORS_SEARCH_OPTIONS).length||(e.preventDefault(),this.reloadBodyContent(anchorElement.prop("search")))})),this.getRoot().on(ModalEvents.bodyRendered,(()=>{FormChangeChecker.disableAllChecks()}))}}var obj,key,value;return _exports.default=ModalQuizQuestionBank,value="mod_quiz-quiz-question-bank",(key="TYPE")in(obj=ModalQuizQuestionBank)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,ModalQuizQuestionBank.registerModalType(),_exports.default}));
define("mod_quiz/modal_quiz_question_bank",["exports","jquery","core/modal","core/fragment","core_form/changechecker","core/modal_events"],(function(_exports,_jquery,_modal,Fragment,FormChangeChecker,ModalEvents){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_modal=_interopRequireDefault(_modal),Fragment=_interopRequireWildcard(Fragment),FormChangeChecker=_interopRequireWildcard(FormChangeChecker),ModalEvents=_interopRequireWildcard(ModalEvents);const SELECTORS_ADD_TO_QUIZ_CONTAINER="td.addtoquizaction",SELECTORS_ANCHOR="a[href]",SELECTORS_PREVIEW_CONTAINER="td.previewaction",SELECTORS_ADD_QUESTIONS_FORM="form#questionsubmit",SELECTORS_SORTERS=".sorters";class ModalQuizQuestionBank extends _modal.default{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}show(){return this.reloadBodyContent(window.location.search),super.show(this)}reloadBodyContent(querystring){this.setBody(Fragment.loadFragment("mod_quiz","quiz_question_bank",this.getContextId(),{querystring:querystring}))}handleAddToQuizEvent(e,anchorElement){const href=new URL(anchorElement.attr("href"));href.searchParams.set("addonpage",this.getAddOnPageId()),anchorElement.attr("href",href)}handlePreviewContainerEvent(e,anchorElement){window.openpopup(e,{url:anchorElement.attr("href"),name:"questionpreview",options:["height=600","width=800","top=0","left=0","menubar=0","location=0","scrollbars","resizable","toolbar","status","directories=0","fullscreen=0","dependent"].join(",")})}registerEventListeners(){super.registerEventListeners(this),this.getModal().on("submit",SELECTORS_ADD_QUESTIONS_FORM,(e=>{const formElement=(0,_jquery.default)(e.currentTarget);(0,_jquery.default)("<input />").attr("type","hidden").attr("name","addonpage").attr("value",this.getAddOnPageId()).appendTo(formElement)})),this.getModal().on("click",SELECTORS_ANCHOR,(e=>{const anchorElement=(0,_jquery.default)(e.currentTarget);anchorElement.closest(SELECTORS_ADD_TO_QUIZ_CONTAINER).length?this.handleAddToQuizEvent(e,anchorElement):anchorElement.closest(SELECTORS_PREVIEW_CONTAINER).length?this.handlePreviewContainerEvent(e,anchorElement):anchorElement.closest(SELECTORS_SORTERS).length||(e.preventDefault(),this.reloadBodyContent(anchorElement.prop("search")))})),this.getRoot().on(ModalEvents.bodyRendered,(()=>{FormChangeChecker.disableAllChecks()}))}}var obj,key,value;return _exports.default=ModalQuizQuestionBank,value="mod_quiz-quiz-question-bank",(key="TYPE")in(obj=ModalQuizQuestionBank)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,ModalQuizQuestionBank.registerModalType(),_exports.default}));
//# sourceMappingURL=modal_quiz_question_bank.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
define("mod_quiz/update_random_question_filter_condition",["exports","core/ajax","core/notification"],(function(_exports,_ajax,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Event handling for the edit random question form.
*
* Dynamically saves the new filter condition before navigating back to the quiz question list.
*
* @module mod_quiz/update_random_question_filter_condition
* @author 2022 <nathannguyen@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification);_exports.init=()=>{const SELECTORS_QUESTION_BANK_CONTAINER="#questionbank_container",SELECTORS_FORM_ELEMENT="#update_filter_condition_form",SELECTORS_UPDATE_BUTTON='[name="update"]',SELECTORS_MESSAGE_INPUT='[name="message"]',SELECTORS_FILTER_CONDITION_ELEMENT="[data-filtercondition]",questionBank=document.querySelector(SELECTORS_QUESTION_BANK_CONTAINER),form=document.querySelector(SELECTORS_FORM_ELEMENT);form.querySelector(SELECTORS_UPDATE_BUTTON).addEventListener("click",(async e=>{var _form$dataset,_form$dataset2,_questionBank$querySe;e.preventDefault();const request={methodname:"mod_quiz_update_filter_condition",args:{cmid:null===(_form$dataset=form.dataset)||void 0===_form$dataset?void 0:_form$dataset.cmid,slotid:null===(_form$dataset2=form.dataset)||void 0===_form$dataset2?void 0:_form$dataset2.slotid,filtercondition:null===(_questionBank$querySe=questionBank.querySelector(SELECTORS_FILTER_CONDITION_ELEMENT).dataset)||void 0===_questionBank$querySe?void 0:_questionBank$querySe.filtercondition}};try{const response=await _ajax.default.call([request])[0];form.querySelector(SELECTORS_MESSAGE_INPUT).value=response.message,form.submit()}catch(e){_notification.default.exception(e)}}))}}));
//# sourceMappingURL=update_random_question_filter_condition.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"update_random_question_filter_condition.min.js","sources":["../src/update_random_question_filter_condition.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 * Event handling for the edit random question form.\n *\n * Dynamically saves the new filter condition before navigating back to the quiz question list.\n *\n * @module mod_quiz/update_random_question_filter_condition\n * @author 2022 <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport ajax from 'core/ajax';\nimport Notification from 'core/notification';\n\nexport const init = () => {\n const SELECTORS = {\n QUESTION_BANK_CONTAINER: '#questionbank_container',\n FORM_ELEMENT: '#update_filter_condition_form',\n UPDATE_BUTTON: '[name=\"update\"]',\n CANCEL_BUTTON: '[name=\"cancel\"]',\n MESSAGE_INPUT: '[name=\"message\"]',\n FILTER_CONDITION_ELEMENT: '[data-filtercondition]',\n };\n\n const questionBank = document.querySelector(SELECTORS.QUESTION_BANK_CONTAINER);\n const form = document.querySelector(SELECTORS.FORM_ELEMENT);\n const updateButton = form.querySelector(SELECTORS.UPDATE_BUTTON);\n\n updateButton.addEventListener(\"click\", async(e) => {\n e.preventDefault();\n const request = {\n methodname: 'mod_quiz_update_filter_condition',\n args: {\n cmid: form.dataset?.cmid,\n slotid: form.dataset?.slotid,\n filtercondition: questionBank.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition,\n }\n };\n try {\n const response = await ajax.call([request])[0];\n const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT);\n messageInput.value = response.message;\n form.submit();\n } catch (e) {\n Notification.exception(e);\n }\n });\n\n};\n"],"names":["SELECTORS","questionBank","document","querySelector","form","addEventListener","async","e","preventDefault","request","methodname","args","cmid","dataset","_form$dataset","slotid","_form$dataset2","filtercondition","_questionBank$querySe","response","ajax","call","value","message","submit","exception"],"mappings":";;;;;;;;;wLA4BoB,WACVA,kCACuB,0BADvBA,uBAEY,gCAFZA,wBAGa,kBAHbA,wBAKa,mBALbA,mCAMwB,yBAGxBC,aAAeC,SAASC,cAAcH,mCACtCI,KAAOF,SAASC,cAAcH,wBACfI,KAAKD,cAAcH,yBAE3BK,iBAAiB,SAASC,MAAAA,2DACnCC,EAAEC,uBACIC,QAAU,CACZC,WAAY,mCACZC,KAAM,CACFC,2BAAMR,KAAKS,wCAALC,cAAcF,KACpBG,8BAAQX,KAAKS,yCAALG,eAAcD,OACtBE,8CAAiBhB,aAAaE,cAAcH,oCAAoCa,gDAA/DK,sBAAwED,4BAIvFE,eAAiBC,cAAKC,KAAK,CAACZ,UAAU,GACvBL,KAAKD,cAAcH,yBAC3BsB,MAAQH,SAASI,QAC9BnB,KAAKoB,SACP,MAAOjB,yBACQkB,UAAUlB"}
+103 -29
View File
@@ -27,18 +27,25 @@ import * as Notification from 'core/notification';
import * as Fragment from 'core/fragment';
import * as Templates from 'core/templates';
import * as FormChangeChecker from 'core_form/changechecker';
import {call as fetchMany} from 'core/ajax';
const SELECTORS = {
EXISTING_CATEGORY_CONTAINER: '[data-region="existing-category-container"]',
EXISTING_CATEGORY_FORM_ELEMENT: '#id_existingcategoryheader',
EXISTING_CATEGORY_TAB: '#id_existingcategoryheader',
NEW_CATEGORY_CONTAINER: '[data-region="new-category-container"]',
NEW_CATEGORY_FORM_ELEMENT: '#id_newcategoryheader',
NEW_CATEGORY_TAB: '#id_newcategoryheader',
TAB_CONTENT: '[data-region="tab-content"]',
ADD_ON_PAGE_FORM_ELEMENT: '[name="addonpage"]',
SUBMIT_BUTTON_ELEMENT: 'input[type="submit"]',
CANCEL_BUTTON_ELEMENT: 'input[type="submit"][name="cancel"]',
ADD_RANDOM_BUTTON: 'input[type="submit"][name="addrandom"]',
ADD_NEW_CATEGORY_BUTTON: 'input[type="submit"][name="newcategory"]',
SUBMIT_BUTTON_ELEMENT: 'input[type="submit"][name="addrandom"], input[type="submit"][name="newcategory"]',
FORM_HEADER: 'legend',
BUTTON_CONTAINER: '.fitem',
SELECT_NUMBER_TO_ADD: '#menurandomcount',
NEW_CATEGORY_ELEMENT: '#categoryname',
PARENT_CATEGORY_ELEMENT: '#parentcategory',
FILTER_CONDITION_ELEMENT: '[data-filtercondition]',
FORM_ELEMENT: '#add_random_question_form',
MESSAGE_INPUT: '[name="message"]',
};
export default class ModalAddRandomQuestion extends Modal {
@@ -173,24 +180,16 @@ export default class ModalAddRandomQuestion extends Modal {
* Moves the submit button into a footer element at the bottom of the form
* element for styling purposes.
*
* @method moveFormElementIntoTab
* @param {jquery} formElement The form element to move into the tab.
* @method moveContentIntoTab
* @param {jquery} tabContent The form element to move into the tab.
* @param {jquey} tabElement The tab element for the form element to move into.
*/
moveFormElementIntoTab(formElement, tabElement) {
const submitButtons = formElement.find(SELECTORS.SUBMIT_BUTTON_ELEMENT);
const footer = $('<div class="modal-footer mt-1" data-region="footer"></div>');
moveContentIntoTab(tabContent, tabElement) {
// Hide the header because the tabs show us which part of the form we're
// looking at.
formElement.find(SELECTORS.FORM_HEADER).addClass('hidden');
tabContent.find(SELECTORS.FORM_HEADER).addClass('hidden');
// Move the element inside a tab.
formElement.wrap(tabElement);
// Remove the buttons container element.
submitButtons.closest(SELECTORS.BUTTON_CONTAINER).remove();
// Put the button inside a footer.
submitButtons.appendTo(footer);
// Add the footer to the end of the category form element.
footer.appendTo(formElement);
tabContent.wrap(tabElement);
}
/**
@@ -230,29 +229,33 @@ export default class ModalAddRandomQuestion extends Modal {
* @return {promise} Resolved with form HTML and JS.
*/
loadForm() {
const cmid = this.getCMID();
const cat = this.getCategory();
const addonpage = this.getAddOnPageId();
const returnurl = this.getReturnUrl();
return Fragment.loadFragment(
'mod_quiz',
'add_random_question_form',
this.getContextId(),
{
addonpage: this.getAddOnPageId(),
cat: this.getCategory(),
returnurl: this.getReturnUrl(),
cmid: this.getCMID()
addonpage,
cat,
returnurl,
cmid,
}
)
.then((html, js) =>{
const form = $(html);
const existingCategoryFormElement = form.find(SELECTORS.EXISTING_CATEGORY_FORM_ELEMENT);
const existingCategoryTabContent = form.find(SELECTORS.EXISTING_CATEGORY_TAB);
const existingCategoryTab = this.getBody().find(SELECTORS.EXISTING_CATEGORY_CONTAINER);
const newCategoryFormElement = form.find(SELECTORS.NEW_CATEGORY_FORM_ELEMENT);
const newCategoryTabContent = form.find(SELECTORS.NEW_CATEGORY_TAB);
const newCategoryTab = this.getBody().find(SELECTORS.NEW_CATEGORY_CONTAINER);
// Transform the form into tabs for better rendering in the modal.
this.moveFormElementIntoTab(existingCategoryFormElement, existingCategoryTab);
this.moveFormElementIntoTab(newCategoryFormElement, newCategoryTab);
this.moveContentIntoTab(existingCategoryTabContent, existingCategoryTab);
this.moveContentIntoTab(newCategoryTabContent, newCategoryTab);
this.moveTabsIntoTabContent(form);
this.moveCancelButtonToTabs(form);
Templates.replaceNode(this.getBody().find(SELECTORS.TAB_CONTENT), form, js);
return;
@@ -261,11 +264,82 @@ export default class ModalAddRandomQuestion extends Modal {
// Make sure the form change checker is disabled otherwise it'll stop the user from navigating away from the
// page once the modal is hidden.
FormChangeChecker.disableAllChecks();
return;
// Add question to quiz.
this.getBody()[0].addEventListener('click', (e) => {
const button = e.target.closest(SELECTORS.SUBMIT_BUTTON_ELEMENT);
if (!button) {
return;
}
e.preventDefault();
const randomcount = document.querySelector(SELECTORS.SELECT_NUMBER_TO_ADD).value;
const filtercondition = document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition;
// Add Random questions if the add random button was clicked.
const addRandomButton = e.target.closest(SELECTORS.ADD_RANDOM_BUTTON);
if (addRandomButton) {
this.addQuestions(cmid, addonpage, randomcount, filtercondition, '', '');
return;
}
// Add new category if the add category button was clicked.
const addCategoryButton = e.target.closest(SELECTORS.ADD_NEW_CATEGORY_BUTTON);
if (addCategoryButton) {
this.addQuestions(
cmid,
addonpage,
randomcount,
filtercondition,
document.querySelector(SELECTORS.NEW_CATEGORY_ELEMENT).value,
document.querySelector(SELECTORS.PARENT_CATEGORY_ELEMENT).value
);
return;
}
});
})
.catch(Notification.exception);
}
/**
* Call web service function to add random questions
*
* @param {number} cmid course module id
* @param {number} addonpage the page where random questions will be added to
* @param {number} randomcount Number of random questions
* @param {string} filtercondition Filter condition
* @param {string} newcategory add new category
* @param {string} parentcategory parent category of new category
*/
async addQuestions(
cmid,
addonpage,
randomcount,
filtercondition,
newcategory,
parentcategory
) {
const call = {
methodname: 'mod_quiz_add_random_questions',
args: {
cmid,
addonpage,
randomcount,
filtercondition,
newcategory,
parentcategory,
}
};
try {
const response = await fetchMany([call])[0];
const form = document.querySelector(SELECTORS.FORM_ELEMENT);
const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT);
messageInput.value = response.message;
form.submit();
} catch (e) {
Notification.exception(e);
}
}
/**
* Override the modal show function to load the form when this modal is first
* shown.
@@ -276,7 +350,7 @@ export default class ModalAddRandomQuestion extends Modal {
super.show(this);
if (!this.loadedForm) {
this.loadForm();
this.loadForm(window.location.search);
this.loadedForm = true;
}
}
+3 -61
View File
@@ -31,9 +31,8 @@ const SELECTORS = {
ADD_TO_QUIZ_CONTAINER: 'td.addtoquizaction',
ANCHOR: 'a[href]',
PREVIEW_CONTAINER: 'td.previewaction',
SEARCH_OPTIONS: '#advancedsearch',
DISPLAY_OPTIONS: '#displayoptions',
ADD_QUESTIONS_FORM: 'form#questionsubmit',
SORTERS: '.sorters',
};
export default class ModalQuizQuestionBank extends Modal {
@@ -178,59 +177,6 @@ export default class ModalQuizQuestionBank extends Modal {
});
}
/**
* Reload the modal body with the new display options the user has selected.
*
* A query string is built using the form elements to be used to generate the
* new body content.
*
* @method handleDisplayOptionFormEvent
* @param {event} e A JavaScript event
*/
handleDisplayOptionFormEvent(e) {
// Stop propagation to prevent other wild event handlers
// from submitting the form on change.
e.stopPropagation();
e.preventDefault();
const form = $(e.target).closest(SELECTORS.DISPLAY_OPTIONS);
const queryString = '?' + form.serialize();
this.reloadBodyContent(queryString);
}
/**
* Listen for changes to the display options form.
*
* This handles the user changing:
* - The quiz category select box
* - The tags to filter by
* - Show/hide questions from sub categories
* - Show/hide old questions
*
* @method registerDisplayOptionListeners
*/
registerDisplayOptionListeners() {
// Listen for changes to the display options form.
this.getModal().on('change', SELECTORS.DISPLAY_OPTIONS, (e) => {
// Get the element that was changed.
const modifiedElement = $(e.target);
if (modifiedElement.attr('aria-autocomplete')) {
// If the element that was change is the autocomplete
// input then we should ignore it because that is for
// display purposes only.
return;
}
this.handleDisplayOptionFormEvent(e);
});
// Listen for the display options form submission because the tags
// filter will submit the form when it is changed.
this.getModal().on('submit', SELECTORS.DISPLAY_OPTIONS, (e) => {
this.handleDisplayOptionFormEvent(e);
});
}
/**
* Set up all of the event handling for the modal.
*
@@ -240,9 +186,6 @@ export default class ModalQuizQuestionBank extends Modal {
// Apply parent event listeners.
super.registerEventListeners(this);
// Set up the event handlers for all of the display options.
this.registerDisplayOptionListeners();
this.getModal().on('submit', SELECTORS.ADD_QUESTIONS_FORM, (e) => {
// If the user clicks on the "Add selected questions to the quiz" button to add some questions to the page
// then we need to intercept the submit in order to include the correct "add on page id" before the form is
@@ -270,9 +213,8 @@ export default class ModalQuizQuestionBank extends Modal {
return;
}
// Click on expand/collaspse search-options. Has its own handler.
// We should not interfere.
if (anchorElement.closest(SELECTORS.SEARCH_OPTIONS).length) {
// Sorting links have their own handler.
if (anchorElement.closest(SELECTORS.SORTERS).length) {
return;
}
@@ -0,0 +1,63 @@
// 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/>.
/**
* Event handling for the edit random question form.
*
* Dynamically saves the new filter condition before navigating back to the quiz question list.
*
* @module mod_quiz/update_random_question_filter_condition
* @author 2022 <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import ajax from 'core/ajax';
import Notification from 'core/notification';
export const init = () => {
const SELECTORS = {
QUESTION_BANK_CONTAINER: '#questionbank_container',
FORM_ELEMENT: '#update_filter_condition_form',
UPDATE_BUTTON: '[name="update"]',
CANCEL_BUTTON: '[name="cancel"]',
MESSAGE_INPUT: '[name="message"]',
FILTER_CONDITION_ELEMENT: '[data-filtercondition]',
};
const questionBank = document.querySelector(SELECTORS.QUESTION_BANK_CONTAINER);
const form = document.querySelector(SELECTORS.FORM_ELEMENT);
const updateButton = form.querySelector(SELECTORS.UPDATE_BUTTON);
updateButton.addEventListener("click", async(e) => {
e.preventDefault();
const request = {
methodname: 'mod_quiz_update_filter_condition',
args: {
cmid: form.dataset?.cmid,
slotid: form.dataset?.slotid,
filtercondition: questionBank.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition,
}
};
try {
const response = await ajax.call([request])[0];
const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT);
messageInput.value = response.message;
form.submit();
} catch (e) {
Notification.exception(e);
}
});
};
+158
View File
@@ -0,0 +1,158 @@
<?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 mod_quiz\external;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/externallib.php');
require_once($CFG->dirroot . '/question/editlib.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
use external_function_parameters;
use external_single_structure;
use external_value;
use external_api;
use mod_quiz\question\bank\filter\custom_category_condition;
use mod_quiz\quiz_settings;
use mod_quiz\structure;
/**
* Add random questions to a quiz.
*
* @package mod_quiz
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author Nathan Nguyen <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class add_random_questions extends external_api {
/**
* Parameters for the web service function
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters (
[
'cmid' => new external_value(PARAM_INT, 'The cmid of the quiz'),
'addonpage' => new external_value(PARAM_INT, 'The page where random questions will be added to'),
'randomcount' => new external_value(PARAM_INT, 'Number of random questions'),
'filtercondition' => new external_value(PARAM_TEXT, 'Filter condition'),
'newcategory' => new external_value(
PARAM_TEXT,
'(Optional) The name of a new question category to create and use for the random questions.',
VALUE_DEFAULT,
'',
),
'parentcategory' => new external_value(
PARAM_TEXT,
'(Optional) The parent of the new question category, if creating one.',
VALUE_DEFAULT,
0,
),
]
);
}
/**
* Add random questions.
*
* @param int $cmid The cmid of the quiz
* @param int $addonpage The page where random questions will be added to
* @param int $randomcount Number of random questions
* @param string $filtercondition Filter condition
* @param string $newcategory add new category
* @param string $parentcategory parent category of new category
* @return array result
*/
public static function execute(
int $cmid,
int $addonpage,
int $randomcount,
string $filtercondition,
string $newcategory = '',
string $parentcategory = '',
): array {
[
'cmid' => $cmid,
'addonpage' => $addonpage,
'randomcount' => $randomcount,
'filtercondition' => $filtercondition,
'newcategory' => $newcategory,
'parentcategory' => $parentcategory,
] = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'addonpage' => $addonpage,
'randomcount' => $randomcount,
'filtercondition' => $filtercondition,
'newcategory' => $newcategory,
'parentcategory' => $parentcategory,
]);
// Validate context.
$thiscontext = \context_module::instance($cmid);
self::validate_context($thiscontext);
require_capability('mod/quiz:manage', $thiscontext);
$filtercondition = json_decode($filtercondition, true);
// Create new category.
if (!empty($newcategory)) {
$contexts = new \core_question\local\bank\question_edit_contexts($thiscontext);
$defaultcategoryobj = question_make_default_categories($contexts->all());
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
$qcobject = new \qbank_managecategories\question_category_object(
null,
null,
$contexts->having_one_edit_tab_cap('categories'),
$defaultcategoryobj->id,
$defaultcategory,
null,
$contexts->having_cap('moodle/question:add'));
$categoryid = $qcobject->add_category($parentcategory, $newcategory, '', true);
$filter = [
'category' => [
'jointype' => custom_category_condition::JOINTYPE_DEFAULT,
'values' => [$categoryid],
'filteroptions' => ['includesubcategories' => false],
]
];
$filtercondition['filter'] = $filter;
}
// Add random question to the quiz.
[$quiz, ] = get_module_from_cmid($cmid);
$settings = quiz_settings::create_for_cmid($cmid);
$structure = structure::create_for_quiz($settings);
$structure->add_random_questions($addonpage, $randomcount, $filtercondition);
quiz_delete_previews($quiz);
quiz_settings::create($quiz->id)->get_grade_calculator()->recompute_quiz_sumgrades();
return ['message' => get_string('addarandomquestion_success', 'mod_quiz')];
}
/**
* Returns description of method result value.
*
* @return external_value
*/
public static function execute_returns() {
return new external_single_structure([
'message' => new external_value(PARAM_TEXT, 'Message', VALUE_OPTIONAL)
]);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?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 mod_quiz\external;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/externallib.php');
require_once($CFG->dirroot . '/question/editlib.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
use external_function_parameters;
use external_single_structure;
use external_value;
use external_api;
/**
* Update the filter condition for a random question.
*
* @package mod_quiz
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author Nathan Nguyen <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class update_filter_condition extends external_api {
/**
* Parameters for the web service function
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters ([
'cmid' => new external_value(PARAM_INT, 'The cmid of the quiz'),
'slotid' => new external_value(PARAM_INT, 'The quiz slot ID for the random question.'),
'filtercondition' => new external_value(PARAM_TEXT, 'Filter condition'),
]);
}
/**
* Add random questions.
*
* @param int $cmid course module id
* @param int $slotid The quiz slot id
* @param string $filtercondition
* @return array result
*/
public static function execute(
int $cmid,
int $slotid,
string $filtercondition,
): array {
global $DB;
[
'cmid' => $cmid,
'slotid' => $slotid,
'filtercondition' => $filtercondition,
] = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'slotid' => $slotid,
'filtercondition' => $filtercondition,
]);
// Validate context.
$thiscontext = \context_module::instance($cmid);
self::validate_context($thiscontext);
require_capability('mod/quiz:manage', $thiscontext);
// Update filter condition.
$setparams = [
'itemid' => $slotid,
'questionarea' => 'slot',
'component' => 'mod_quiz',
];
$DB->set_field('question_set_references', 'filtercondition', $filtercondition, $setparams);
return ['message' => get_string('updatefilterconditon_success', 'mod_quiz')];
}
/**
* Returns description of method result value.
*
* @return external_value
*/
public static function execute_returns() {
return new external_single_structure([
'message' => new external_value(PARAM_TEXT, 'Message', VALUE_OPTIONAL)
]);
}
}
+27
View File
@@ -16,6 +16,7 @@
namespace mod_quiz\form;
use core\check\performance\debugging;
use core_tag_tag;
use moodleform;
@@ -30,10 +31,23 @@ require_once($CFG->libdir.'/formslib.php');
* @package mod_quiz
* @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated Moodle 4.3 MDL-72321. This form is new generated in a modal with mod_quiz/add_random_question_form.mustache
* @todo Final deprecation in Moodle 4.7 MDL-78091
*/
class add_random_form extends moodleform {
/**
* Deprecated.
*
* @return void
* @deprecated Moodle 4.3 MDL-72321
* @todo Final deprecation in Moodle 4.7 MDL-78091
*/
protected function definition() {
debugging(
'add_random_form is deprecated. Please use mod_quiz/add_random_question_form.mustache instead.',
DEBUG_DEVELOPER
);
global $OUTPUT, $PAGE, $CFG;
$mform = $this->_form;
@@ -119,7 +133,20 @@ class add_random_form extends moodleform {
]);
}
/**
* Deprecated.
*
* @param array $fromform
* @param array $files
* @return array
* @deprecated Moodle 4.3 MDL-72321
* @todo Final deprecation in Moodle 4.7 MDL-78091
*/
public function validation($fromform, $files) {
debugging(
'add_random_form is deprecated. Please use mod_quiz/add_random_question_form.mustache instead.',
DEBUG_DEVELOPER
);
$errors = parent::validation($fromform, $files);
if (!empty($fromform['newcategory']) && trim($fromform['name']) == '') {
@@ -112,8 +112,13 @@ class slot_random {
* Set some tags for this quiz slot.
*
* @param \core_tag_tag[] $tags
*
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function set_tags($tags) {
debugging('Method set_tags() is deprecated, ' .
'please do not use this function.', DEBUG_DEVELOPER);
$this->tags = [];
foreach ($tags as $tag) {
// We use $tag->id as the key for the array so not only it handles duplicates of the same tag being given,
@@ -126,22 +131,24 @@ class slot_random {
* Set some tags for this quiz slot. This function uses tag ids to find tags.
*
* @param int[] $tagids
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function set_tags_by_id($tagids) {
debugging(
'Method set_tags_by_id() is deprecated, please do not use this function.',
DEBUG_DEVELOPER
);
$this->tags = \core_tag_tag::get_bulk($tagids, 'id, name');
}
/**
* Set filter condition.
*
* @param \stdClass $filters
* @param \string $filters
*/
public function set_filter_condition($filters) {
if (!empty($this->tags)) {
$filters->tags = $this->tags;
}
$this->filtercondition = json_encode($filters);
public function set_filter_condition(string $filters): void {
$this->filtercondition = $filters;
}
/**
+1 -2
View File
@@ -680,7 +680,7 @@ class edit_renderer extends \plugin_renderer_base {
if ($structure->can_add_random_questions()) {
$returnurl = new \moodle_url('/mod/quiz/edit.php', ['cmid' => $structure->get_cmid(), 'data-addonpage' => $page]);
$params = ['returnurl' => $returnurl, 'cmid' => $structure->get_cmid(), 'appendqnumstring' => 'addarandomquestion'];
$url = new \moodle_url('/mod/quiz/addrandom.php', $params);
$url = new \moodle_url('/mod/quiz/edit.php', $params);
$icon = new \pix_icon('t/add', $str->addarandomquestion, 'moodle', ['class' => 'iconsmall', 'title' => '']);
$attributes = ['class' => 'cm-edit-action addarandomquestion', 'data-action' => 'addarandomquestion'];
if ($page) {
@@ -1079,7 +1079,6 @@ class edit_renderer extends \plugin_renderer_base {
$qbankurlparams = [
'cmid' => $structure->get_cmid(),
'cat' => $slot->category . ',' . $slot->contextid,
'recurse' => $slot->randomrecurse,
];
$slottags = [];
+100 -118
View File
@@ -25,9 +25,15 @@
namespace mod_quiz\question\bank;
defined('MOODLE_INTERNAL') || die();
use core\output\datafilter;
use core_question\local\bank\condition;
use core_question\local\bank\question_version_status;
use mod_quiz\question\bank\filter\custom_category_condition;
use qbank_managecategories\category_condition;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
/**
* Subclass to customise the view of the question bank for the quiz editing screen.
*
@@ -45,8 +51,10 @@ class custom_view extends \core_question\local\bank\view {
/** @var \stdClass $quiz the quiz settings. */
protected $quiz = false;
/** @var int The maximum displayed length of the category info. */
const MAX_TEXT_LENGTH = 200;
/**
* @var string $component the component the api is used from.
*/
public $component = 'mod_quiz';
/**
* Constructor.
@@ -56,27 +64,55 @@ class custom_view extends \core_question\local\bank\view {
* @param \stdClass $cm activity settings.
* @param \stdClass $quiz quiz settings.
*/
public function __construct($contexts, $pageurl, $course, $cm, $quiz) {
parent::__construct($contexts, $pageurl, $course, $cm);
$this->quiz = $quiz;
public function __construct($contexts, $pageurl, $course, $cm, $params, $extraparams) {
// Default filter condition.
if (!isset($params['filter'])) {
$params['filter'] = [];
[$categoryid, $contextid] = custom_category_condition::validate_category_param($params['cat']);
if (!is_null($categoryid)) {
$category = custom_category_condition::get_category_record($categoryid, $contextid);
$params['filter']['category'] = [
'jointype' => custom_category_condition::JOINTYPE_DEFAULT,
'values' => [$category->id],
'filteroptions' => ['includesubcategories' => false],
];
}
}
$this->init_required_columns();
parent::__construct($contexts, $pageurl, $course, $cm, $params, $extraparams);
[$this->quiz, ] = get_module_from_cmid($cm->id);
$this->set_quiz_has_attempts(quiz_has_attempts($this->quiz->id));
$this->pagesize = self::DEFAULT_PAGE_SIZE;
}
protected function get_question_bank_plugins(): array {
$questionbankclasscolumns = [];
$corequestionbankcolumns = [
/**
* Init required columns.
*
* @return void
*/
protected function init_required_columns(): void {
// Override core columns.
$this->corequestionbankcolumns = [
'add_action_column',
'checkbox_column',
'question_type_column',
'question_name_text_column',
'preview_action_column'
];
}
/**
* Get class for each question bank columns.
*
* @return array
*/
protected function get_class_for_columns(): array {
$questionbankclasscolumns = [];
if (question_get_display_preference('qbshowtext', 0, PARAM_INT, new \moodle_url(''))) {
$corequestionbankcolumns[] = 'question_text_row';
$this->corequestionbankcolumns[] = 'question_text_row';
}
foreach ($corequestionbankcolumns as $fullname) {
foreach ($this->corequestionbankcolumns as $fullname) {
$shortname = $fullname;
if (class_exists('mod_quiz\\question\\bank\\' . $fullname)) {
$fullname = 'mod_quiz\\question\\bank\\' . $fullname;
@@ -88,40 +124,18 @@ class custom_view extends \core_question\local\bank\view {
$questionbankclasscolumns[$shortname] = '';
}
}
$plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
foreach ($plugins as $componentname => $plugin) {
$pluginentrypointobject = new $plugin();
$plugincolumnobjects = $pluginentrypointobject->get_question_columns($this);
// Don't need the plugins without column objects.
if (empty($plugincolumnobjects)) {
unset($plugins[$componentname]);
continue;
}
foreach ($plugincolumnobjects as $columnobject) {
$columnname = $columnobject->get_column_name();
foreach ($corequestionbankcolumns as $key => $corequestionbankcolumn) {
if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
unset($questionbankclasscolumns[$columnname]);
continue;
}
// Check if it has custom preference selector to view/hide.
if ($columnobject->has_preference() && !$columnobject->get_preference()) {
continue;
}
if ($corequestionbankcolumn === $columnname) {
$questionbankclasscolumns[$columnname] = $columnobject;
}
}
}
}
// Mitigate the error in case of any regression.
foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) {
if (empty($questionbankclasscolumn)) {
unset($questionbankclasscolumns[$shortname]);
}
}
return $questionbankclasscolumns;
}
/**
* Get the list of qbank plugins with available objects for features.
*
* @return array
*/
protected function get_question_bank_plugins(): array {
// The view is too crowded, exclude some columns.
$questionbankclasscolumns = parent::get_question_bank_plugins();
$questionbankclasscolumns = array_intersect_key($questionbankclasscolumns, array_flip($this->corequestionbankcolumns));
return $questionbankclasscolumns;
}
@@ -132,8 +146,8 @@ class custom_view extends \core_question\local\bank\view {
protected function default_sort(): array {
// Using the extended class for quiz specific sort.
return [
'qbank_viewquestiontype\\question_type_column' => 1,
'mod_quiz\\question\\bank\\question_name_text_column' => 1,
'qbank_viewquestiontype__question_type_column' => SORT_ASC,
'mod_quiz__question__bank__question_name_text_column' => SORT_ASC,
];
}
@@ -144,23 +158,13 @@ class custom_view extends \core_question\local\bank\view {
*
* @param bool $quizhasattempts whether the quiz has attempts.
*/
public function set_quiz_has_attempts($quizhasattempts): void {
private function set_quiz_has_attempts($quizhasattempts): void {
$this->quizhasattempts = $quizhasattempts;
if ($quizhasattempts && isset($this->visiblecolumns['addtoquizaction'])) {
unset($this->visiblecolumns['addtoquizaction']);
}
}
/**
* Question preview url.
*
* @param \stdClass $question
* @return \moodle_url
*/
public function preview_question_url($question) {
return quiz_question_preview_url($this->quiz, $question);
}
/**
* URL of add to quiz.
*
@@ -171,6 +175,7 @@ class custom_view extends \core_question\local\bank\view {
$params = $this->baseurl->params();
$params['addquestion'] = $questionid;
$params['sesskey'] = sesskey();
$params['cmid'] = $this->cm->id;
return new \moodle_url('/mod/quiz/edit.php', $params);
}
@@ -186,7 +191,7 @@ class custom_view extends \core_question\local\bank\view {
*/
public function render($pagevars, $tabname): string {
ob_start();
$this->display($pagevars, $tabname);
$this->display();
$out = ob_get_contents();
ob_end_clean();
return $out;
@@ -216,8 +221,15 @@ class custom_view extends \core_question\local\bank\view {
echo \html_writer::end_tag('div');
}
/**
* Override the base implementation in \core_question\local\bank\view
* because we don't want to print new question form in the fragment
* for the modal.
*
* @param false|mixed|\stdClass $category
* @param bool $canadd
*/
protected function create_new_question_form($category, $canadd): void {
// Don't display this.
}
/**
@@ -228,45 +240,15 @@ class custom_view extends \core_question\local\bank\view {
protected function display_question_bank_header(): void {
}
/**
* Override the base implementation in \core_question\bank\view
* because we don't want it to read from the $_POST global variables
* for the sort parameters since they are not present in a fragment.
*
* Unfortunately the best we can do is to look at the URL for
* those parameters (only marginally better really).
*/
protected function init_sort_from_params(): void {
$this->sort = [];
for ($i = 1; $i <= self::MAX_SORTS; $i++) {
if (!$sort = $this->baseurl->param('qbs' . $i)) {
break;
}
// Work out the appropriate order.
$order = 1;
if ($sort[0] == '-') {
$order = -1;
$sort = substr($sort, 1);
if (!$sort) {
break;
}
}
// Deal with subsorts.
list($colname) = $this->parse_subsort($sort);
$this->get_column_type($colname);
$this->sort[$sort] = $order;
}
}
protected function build_query(): void {
// Get the required tables and fields.
[$fields, $joins] = $this->get_component_requirements(array_merge($this->requiredcolumns, $this->questionactions));
// Build the order by clause.
$sorts = [];
foreach ($this->sort as $sort => $order) {
list($colname, $subsort) = $this->parse_subsort($sort);
$sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort);
foreach ($this->sort as $sortname => $sortorder) {
[$colname, $subsort] = $this->parse_subsort($sortname);
$sorts[] = $this->requiredcolumns[$colname]->sort_expression($sortorder == SORT_DESC, $subsort);
}
// Build the where clause.
@@ -275,50 +257,50 @@ class custom_view extends \core_question\local\bank\view {
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)';
$readyonly = "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' ";
$tests = ['q.parent = 0', $latestversion, $readyonly];
$onlyready = '((' . "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "'" .'))';
$this->sqlparams = [];
$conditions = [];
foreach ($this->searchconditions as $searchcondition) {
if ($searchcondition->where()) {
$tests[] = '((' . $searchcondition->where() .'))';
$conditions[] = '((' . $searchcondition->where() .'))';
}
if ($searchcondition->params()) {
$this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
}
}
$majorconditions = ['q.parent = 0', $latestversion, $onlyready];
// Get higher level filter condition.
$jointype = isset($this->pagevars['jointype']) ? (int)$this->pagevars['jointype'] : condition::JOINTYPE_DEFAULT;
$nonecondition = ($jointype === datafilter::JOINTYPE_NONE) ? ' NOT ' : '';
$separator = ($jointype === datafilter::JOINTYPE_ALL) ? ' AND ' : ' OR ';
// Build the SQL.
$sql = ' FROM {question} q ' . implode(' ', $joins);
$sql .= ' WHERE ' . implode(' AND ', $tests);
$sql .= ' WHERE ' . implode(' AND ', $majorconditions);
if (!empty($conditions)) {
$sql .= ' AND ' . $nonecondition . ' ( ';
$sql .= implode($separator, $conditions);
$sql .= ' ) ';
}
$this->countsql = 'SELECT count(1)' . $sql;
$this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
}
public function wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext): void {
global $CFG;
list(, $contextid) = explode(',', $cat);
$catcontext = \context::instance_by_id($contextid);
$thiscontext = $this->get_most_specific_context();
// Category selection form.
$this->display_question_bank_header();
// Display tag filter if usetags setting is enabled/enablefilters is true.
if ($this->enablefilters) {
if (is_array($this->customfilterobjects)) {
foreach ($this->customfilterobjects as $filterobjects) {
$this->searchconditions[] = $filterobjects;
public function add_standard_search_conditions(): void {
foreach ($this->plugins as $componentname => $plugin) {
if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
$pluginentrypointobject = new $plugin();
if ($componentname === 'qbank_managecategories') {
$pluginentrypointobject = new quiz_managecategories_feature();
}
} else {
if ($CFG->usetags) {
array_unshift($this->searchconditions,
new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids));
if ($componentname === 'qbank_viewquestiontext' || $componentname === 'qbank_deletequestion') {
continue;
}
$pluginobjects = $pluginentrypointobject->get_question_filters($this);
foreach ($pluginobjects as $pluginobject) {
$this->add_searchcondition($pluginobject, $pluginobject->get_condition_key());
}
array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
array_unshift($this->searchconditions, new custom_category_condition(
$cat, $recurse, $editcontexts, $this->baseurl, $this->course));
}
}
$this->display_options_form($showquestiontext);
}
/**
@@ -16,6 +16,8 @@
namespace mod_quiz\question\bank\filter;
use qbank_managecategories\helper;
/**
* A custom filter condition for quiz to select question categories.
*
@@ -27,19 +29,23 @@ namespace mod_quiz\question\bank\filter;
* @author Safat Shahin <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class custom_category_condition extends \core_question\bank\search\category_condition {
class custom_category_condition extends \qbank_managecategories\category_condition {
public function display_options() {
global $PAGE;
$displaydata = [];
$catmenu = custom_category_condition_helper::question_category_options($this->contexts, true, 0,
true, -1, false);
$displaydata['categoryselect'] = \html_writer::select($catmenu, 'category', $this->cat, [],
['class' => 'searchoptions custom-select', 'id' => 'id_selectacategory']);
$displaydata['categorydesc'] = '';
if ($this->category) {
$displaydata['categorydesc'] = $this->print_category_info($this->category);
public function get_initial_values() {
$catmenu = custom_category_condition_helper::question_category_options($this->contexts, true, 0, true, -1, false);
$values = [];
foreach ($catmenu as $menu) {
foreach ($menu as $catlist) {
foreach ($catlist as $key => $value) {
$values[] = (object) [
// Remove contextid from value.
'value' => strpos($key, ',') === false ? $key : substr($key, 0, strpos($key, ',')),
'title' => $value,
'selected' => ($key === $this->cat),
];
}
}
}
return $PAGE->get_renderer('core_question', 'bank')->render_category_condition($displaydata);
return $values;
}
}
+30 -13
View File
@@ -18,6 +18,8 @@ namespace mod_quiz\question\bank;
use core_question\local\bank\question_version_status;
use core_question\local\bank\random_question_loader;
use core_question\question_reference_manager;
use qbank_tagquestion\tag_condition;
use qubaid_condition;
defined('MOODLE_INTERNAL') || die();
@@ -110,6 +112,7 @@ class qbank_helper {
slot.displaynumber,
slot.requireprevious,
qsr.filtercondition,
qsr.usingcontextid,
qv.status,
qv.id AS versionid,
qv.version,
@@ -172,11 +175,12 @@ class qbank_helper {
if ($slot->filtercondition) {
// Unpack the information about a random question.
$filtercondition = json_decode($slot->filtercondition);
$slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
$slot->category = $filtercondition->questioncategoryid;
$slot->randomrecurse = (bool) $filtercondition->includingsubcategories;
$slot->randomtags = isset($filtercondition->tags) ? (array) $filtercondition->tags : [];
$filter = json_decode($slot->filtercondition, true);
$slot->filtercondition = question_reference_manager::convert_legacy_set_reference_filter_condition($filter);
$slot->category = $slot->filtercondition['filter']['category']['values'][0] ?? 0;
$slot->qtype = 'random';
$slot->name = get_string('random', 'quiz');
$slot->length = 1;
@@ -208,9 +212,12 @@ class qbank_helper {
*/
public static function get_tag_ids_for_slot(\stdClass $slotdata): array {
$tagids = [];
foreach ($slotdata->randomtags as $taginfo) {
[$id] = explode(',', $taginfo, 2);
$tagids[] = $id;
if (!isset($slotdata->filtercondition['filter'])) {
return $tagids;
}
$filter = $slotdata->filtercondition['filter'];
if (isset($filter['qtagids'])) {
$tagids = $filter['qtagids']['values'];
}
return $tagids;
}
@@ -222,10 +229,19 @@ class qbank_helper {
* @return string that can be used to display the random slot.
*/
public static function describe_random_question(\stdClass $slotdata): string {
global $DB;
$category = $DB->get_record('question_categories', ['id' => $slotdata->category]);
return \question_bank::get_qtype('random')->question_name(
$category, $slotdata->randomrecurse, $slotdata->randomtags);
$qtagids = self::get_tag_ids_for_slot($slotdata);
if ($qtagids) {
$tagnames = [];
$tags = \core_tag_tag::get_bulk($qtagids, 'id, name');
foreach ($tags as $tag) {
$tagnames[] = $tag->name;
}
$description = get_string('randomqnametags', 'mod_quiz', implode(",", $tagnames));
} else {
$description = get_string('randomqname', 'mod_quiz');
}
return shorten_text($description, 255);
}
/**
@@ -249,8 +265,9 @@ class qbank_helper {
// Random question.
$randomloader = new random_question_loader($qubaids, []);
$newqusetionid = $randomloader->get_next_question_id($slotdata->category,
$slotdata->randomrecurse, self::get_tag_ids_for_slot($slotdata));
$fitlercondition = $slotdata->filtercondition;
$filter = $fitlercondition['filter'] ?? [];
$newqusetionid = $randomloader->get_next_filtered_question_id($filter);
if ($newqusetionid === null) {
throw new \moodle_exception('notenoughrandomquestions', 'quiz');
@@ -0,0 +1,39 @@
<?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 mod_quiz\question\bank;
use core_question\local\bank\view;
use mod_quiz\question\bank\filter\custom_category_condition;
/**
* Class quiz_managecategories_feature
*
* Overrides the default categories feature with a custom category condition.
*
* @package mod_quiz
* @copyright 2022 Catalyst IT EU Ltd.
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_managecategories_feature extends \qbank_managecategories\plugin_feature {
public function get_question_filters(view $qbank = null): array {
return [
new custom_category_condition($qbank),
];
}
}
@@ -0,0 +1,48 @@
<?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 mod_quiz\question\bank;
/**
* Subclass to customise the view of the question bank for the quiz editing screen.
*
* @package mod_quiz
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author 2022 Nathan Nguyen <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class random_question_view extends custom_view {
/**
* Init required columns.
*
* @return void
*/
protected function init_required_columns(): void {
// Override core columns.
$this->corequestionbankcolumns = [
'question_type_column',
'question_name_text_column',
'preview_action_column'
];
}
/**
* Prints the table of questions in a category with interactions
*
* @param \context $catcontext*/
protected function display_bottom_controls(\context $catcontext): void {
}
}
+8 -6
View File
@@ -570,12 +570,14 @@ class quiz_settings {
continue;
}
if ($questiondata->qtype === 'random' && $includepotential) {
if (!isset($qcategories[$questiondata->category])) {
$qcategories[$questiondata->category] = false;
}
if (!empty($questiondata->filtercondition)) {
$filtercondition = json_decode($questiondata->filtercondition);
$qcategories[$questiondata->category] = !empty($filtercondition->includingsubcategories);
$filtercondition = $questiondata->filtercondition;
if (!empty($filtercondition)) {
$filter = $filtercondition['filter'];
if (isset($filter['category'])) {
foreach ($filter['category']['values'] as $catid) {
$qcategories[$catid] = $filter['category']['filteroptions']['includesubcategories'];
}
}
}
} else {
if (!in_array($questiondata->qtype, $questiontypes)) {
+39
View File
@@ -1389,4 +1389,43 @@ class structure {
// All the associated code for this method have been removed to get rid of accidental call or errors.
return [];
}
/**
* Add a random question to the quiz at a given point.
*
* @param int $addonpage the page on which to add the question.
* @param int $number the number of random questions to add.
* @param array $filtercondition the filter condition. Must contain at least a category filter.
*/
public function add_random_questions(int $addonpage, int $number, array $filtercondition): void {
global $DB;
if (!isset($filtercondition['filter']['category'])) {
throw new \invalid_parameter_exception('$filtercondition must contain at least a category filter.');
}
$categoryid = $filtercondition['filter']['category']['values'][0];
$category = $DB->get_record('question_categories', ['id' => $categoryid]);
if (!$category) {
new \moodle_exception('invalidcategoryid');
}
$catcontext = \context::instance_by_id($category->contextid);
require_capability('moodle/question:useall', $catcontext);
// Create the selected number of random questions.
for ($i = 0; $i < $number; $i++) {
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $this->get_quizid();
$randomslotdata->usingcontextid = context_module::instance($this->get_cmid())->id;
$randomslotdata->questionscontextid = $category->contextid;
$randomslotdata->maxmark = 1;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_quiz($this->get_quiz());
$randomslot->set_filter_condition(json_encode($filtercondition));
$randomslot->insert($addonpage);
}
}
}
+16
View File
@@ -215,4 +215,20 @@ $functions = [
'capabilities' => 'mod/quiz:reopenattempts',
'ajax' => true,
],
'mod_quiz_add_random_questions' => [
'classname' => 'mod_quiz\external\add_random_questions',
'description' => 'Add a number of random questions to a quiz.',
'type' => 'write',
'capabilities' => 'mod/quiz:manage',
'ajax' => true,
],
'mod_quiz_update_filter_condition' => [
'classname' => 'mod_quiz\external\update_filter_condition',
'description' => 'Update filter condition for a random question slot.',
'type' => 'write',
'capabilities' => 'mod/quiz:manage',
'ajax' => true,
],
];
+7 -6
View File
@@ -49,7 +49,7 @@ require_once($CFG->dirroot . '/question/editlib.php');
$mdlscrollto = optional_param('mdlscrollto', '', PARAM_INT);
list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) =
question_edit_setup('editq', '/mod/quiz/edit.php', true);
question_edit_setup('editq', '/mod/quiz/edit.php', true);
$PAGE->set_url($thispageurl);
$PAGE->set_secondary_active_tab("mod_quiz_edit");
@@ -166,10 +166,6 @@ $event = \mod_quiz\event\edit_page_viewed::create([
]);
$event->trigger();
// Get the question bank view.
$questionbank = new mod_quiz\question\bank\custom_view($contexts, $thispageurl, $course, $cm, $quiz);
$questionbank->set_quiz_has_attempts($quizhasattempts);
// End of process commands =====================================================.
$PAGE->set_pagelayout('incourse');
@@ -184,8 +180,13 @@ $node = $PAGE->settingsnav->find('mod_quiz_edit', navigation_node::TYPE_SETTING)
if ($node) {
$node->make_active();
}
echo $OUTPUT->header();
// Add random question - result message.
if ($message = optional_param('message', '', PARAM_TEXT)) {
core\notification::add($message, core\notification::SUCCESS);
}
echo $OUTPUT->header();
// Initialise the JavaScript.
$quizeditconfig = new stdClass();
$quizeditconfig->url = $thispageurl->out(true, ['qbanktool' => '0']);
+30 -15
View File
@@ -24,9 +24,11 @@
*/
use mod_quiz\quiz_settings;
use mod_quiz\question\bank\random_question_view;
require_once(__DIR__ . '/../../config.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/mod/quiz/lib.php');
$slotid = required_param('slotid', PARAM_INT);
$returnurl = optional_param('returnurl', '', PARAM_LOCALURL);
@@ -53,16 +55,14 @@ $PAGE->add_body_class('limitedwidth');
$setreference = $DB->get_record('question_set_references',
['itemid' => $slot->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']);
$filterconditions = json_decode($setreference->filtercondition);
$filterconditions = json_decode($setreference->filtercondition, true);
// Validate the question category.
if (!$category = $DB->get_record('question_categories', ['id' => $filterconditions->questioncategoryid])) {
new moodle_exception('categorydoesnotexist', 'question', $returnurl);
}
$params = $filterconditions;
$params['cmid'] = $cm->id;
$extraparams['view'] = random_question_view::class;
// Check permissions.
$catcontext = context::instance_by_id($category->contextid);
require_capability('moodle/question:useall', $catcontext);
// Build required parameters.
[$contexts, $thispageurl, $cm, $pagevars, $extraparams] = build_required_parameters_for_custom_view($params, $extraparams);
$thiscontext = $quizobj->get_context();
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
@@ -72,11 +72,15 @@ $mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editran
// Set the form data.
$toform = new stdClass();
$toform->category = "{$category->id},{$category->contextid}";
$toform->includesubcategories = $filterconditions->includingsubcategories;
$toform->category = $filterconditions['filter']['category']['values'][0];
$includesubcategories = false;
if (!empty($filterconditions['filter']['category']['filteroptions']['includesubcategories'])) {
$includesubcategories = true;
}
$toform->includesubcategories = $includesubcategories;
$toform->fromtags = [];
if (isset($filterconditions->tags)) {
$currentslottags = $filterconditions->tags;
if (isset($filterconditions['tags'])) {
$currentslottags = $filterconditions['tags'];
foreach ($currentslottags as $slottag) {
$toform->fromtags[] = $slottag;
}
@@ -130,11 +134,22 @@ $PAGE->set_title('Random question');
$PAGE->set_heading($COURSE->fullname);
$PAGE->navbar->add('Random question');
// Custom View.
$questionbank = new random_question_view($contexts, $thispageurl, $course, $cm, $params, $extraparams);
// Output.
$renderer = $PAGE->get_renderer('mod_quiz', 'edit');
$data = new \stdClass();
$data->questionbank = $renderer->question_bank_contents($questionbank, $params);
$data->cmid = $cm->id;
$data->slotid = $slot->id;
$data->returnurl = $returnurl;
$updateform = $OUTPUT->render_from_template('mod_quiz/update_filter_condition_form', $data);
$PAGE->requires->js_call_amd('mod_quiz/update_random_question_filter_condition', 'init');
// Display a heading, question editing form.
echo $OUTPUT->header();
$heading = get_string('randomediting', 'mod_quiz');
echo $OUTPUT->heading_with_help($heading, 'randomquestion', 'mod_quiz');
$mform->display();
echo $updateform;
echo $OUTPUT->footer();
+5
View File
@@ -32,6 +32,7 @@ $string['addaquestion'] = 'a new question';
$string['addasection'] = 'a new section heading';
$string['addarandomquestion'] = 'a random question';
$string['addarandomquestion_help'] = 'When a random question is added, it results in a randomly-chosen question from the category being inserted into the quiz. This means that different students are likely to get a different selection of questions, and when a quiz allows multiple attempts then each attempt is likely to contain a new selection of questions.';
$string['addarandomquestion_success'] = 'Random questions have been added';
$string['addarandomselectedquestion'] = 'Add a random selected question ...';
$string['adddescriptionlabel'] = 'Add a description item';
$string['addingquestion'] = 'Adding a question';
@@ -817,6 +818,8 @@ $string['randomfromexistingcategory'] = 'Random question from an existing catego
$string['randomfromunavailabletag'] = '{$a} (unavailable)';
$string['randomnumber'] = 'Number of random questions';
$string['randomnosubcat'] = 'Questions from this category only, not its subcategories.';
$string['randomqname'] = 'Random question based on filter condition';
$string['randomqnametags'] = 'Random question based on filter condition with tags: {$a}';
$string['randomquestion'] = 'Random question';
$string['randomquestion_help'] = 'A random question is a way of inserting a randomly-chosen question from a specified category or by a specified tag into an activity.';
$string['randomquestiontags'] = 'Tags';
@@ -1049,6 +1052,8 @@ $string['unfinished'] = 'open';
$string['ungraded'] = 'Ungraded';
$string['unit'] = 'Unit';
$string['unknowntype'] = 'Question type not supported at line {$a}. The question will be ignored';
$string['updatefilterconditon'] = 'Update filter conditions';
$string['updatefilterconditon_success'] = 'Successfully updated filter conditions';
$string['updateoverdueattemptstask'] = 'Updating overdue quiz attempts';
$string['updatesettings'] = 'Update quiz settings';
$string['updatequizslotswithrandomxofy'] = 'Updating quiz slots with "random" question data ({$a->done}/{$a->total})';
+116 -37
View File
@@ -26,10 +26,11 @@
*/
use qbank_managecategories\helper;
defined('MOODLE_INTERNAL') || die();
use mod_quiz\access_manager;
use mod_quiz\form\add_random_form;
use mod_quiz\grade_calculator;
use mod_quiz\question\bank\custom_view;
use mod_quiz\question\bank\qbank_helper;
@@ -41,6 +42,7 @@ use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
require_once($CFG->dirroot . '/calendar/lib.php');
require_once($CFG->dirroot . '/question/editlib.php');
/**#@+
* Option controlling what options are offered on the quiz settings form.
@@ -2392,27 +2394,27 @@ function mod_quiz_core_calendar_event_timestart_updated(\calendar_event $event,
* @param array $args The fragment arguments.
* @return string The rendered mform fragment.
*/
function mod_quiz_output_fragment_quiz_question_bank($args) {
global $CFG, $DB, $PAGE;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/question/editlib.php');
function mod_quiz_output_fragment_quiz_question_bank($args): string {
global $PAGE;
$querystring = preg_replace('/^\?/', '', $args['querystring']);
// Retrieve params.
$params = [];
$extraparams = [];
$querystring = parse_url($args['querystring'], PHP_URL_QUERY);
parse_str($querystring, $params);
// Build the required resources. The $params are all cleaned as
// part of this process.
list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) =
question_build_edit_resources('editq', '/mod/quiz/edit.php', $params, custom_view::DEFAULT_PAGE_SIZE);
$viewclass = \mod_quiz\question\bank\custom_view::class;
$extraparams['view'] = $viewclass;
// Get the course object and related bits.
$course = get_course($quiz->course);
// Build required parameters.
[$contexts, $thispageurl, $cm, $pagevars, $extraparams] =
build_required_parameters_for_custom_view($params, $extraparams);
$course = get_course($cm->course);
require_capability('mod/quiz:manage', $contexts->lowest());
// Create quiz question bank view.
$questionbank = new custom_view($contexts, $thispageurl, $course, $cm, $quiz);
$questionbank->set_quiz_has_attempts(quiz_has_attempts($quiz->id));
// Custom View.
$questionbank = new $viewclass($contexts, $thispageurl, $course, $cm, $pagevars, $extraparams);
// Output.
$renderer = $PAGE->get_renderer('mod_quiz', 'edit');
@@ -2433,32 +2435,54 @@ function mod_quiz_output_fragment_quiz_question_bank($args) {
* @return string The rendered mform fragment.
*/
function mod_quiz_output_fragment_add_random_question_form($args) {
global $CFG;
global $PAGE, $OUTPUT;
$contexts = new \core_question\local\bank\question_edit_contexts($args['context']);
$formoptions = [
'contexts' => $contexts,
'cat' => $args['cat']
];
$formdata = [
'category' => $args['cat'],
'addonpage' => $args['addonpage'],
'returnurl' => $args['returnurl'],
'cmid' => $args['cmid']
$extraparams = [];
// Build required parameters.
[$contexts, $thispageurl, $cm, $pagevars, $extraparams] =
build_required_parameters_for_custom_view($args, $extraparams);
// Additional param to differentiate with other question bank view.
$extraparams['view'] = mod_quiz\question\bank\random_question_view::class;
$course = get_course($cm->course);
require_capability('mod/quiz:manage', $contexts->lowest());
// Custom View.
$questionbank = new mod_quiz\question\bank\random_question_view($contexts, $thispageurl, $course, $cm, $pagevars, $extraparams);
$renderer = $PAGE->get_renderer('mod_quiz', 'edit');
$questionbankoutput = $renderer->question_bank_contents($questionbank, $pagevars);
$maxrand = 100;
for ($i = 1; $i <= min(100, $maxrand); $i++) {
$randomcount[] = ['value' => $i, 'name' => $i];
}
// Parent category select.
$usablecontexts = $contexts->having_cap('moodle/question:useall');
$categoriesarray = helper::question_category_options($usablecontexts);
$catoptions = [];
foreach ($categoriesarray as $group => $opts) {
// Options for each category group.
$categories = [];
foreach ($opts as $context => $name) {
$categories[] = ['value' => $context, 'name' => $name];
}
$catoptions[] = ['label' => $group, 'options' => $categories];
}
// Template data.
$data = [
'questionbank' => $questionbankoutput,
'randomoptions' => $randomcount,
'questioncategoryoptions' => $catoptions,
];
$form = new add_random_form(
new \moodle_url('/mod/quiz/addrandom.php'),
$formoptions,
'post',
'',
null,
true,
$formdata
);
$form->set_data($formdata);
$result = $OUTPUT->render_from_template('mod_quiz/add_random_question_form', $data);
return $form->render();
return $result;
}
/**
@@ -2505,6 +2529,61 @@ function quiz_delete_references($quizid): void {
}
}
/**
* Question data fragment to get the question html via ajax call.
*
* @param array $args
* @return string
*/
function mod_quiz_output_fragment_question_data(array $args): string {
// Return if there is no args.
if (empty($args)) {
return '';
}
// Retrieve params from query string.
[$params, $extraparams] = \core_question\local\bank\filter_condition_manager::extract_parameters_from_fragment_args($args);
// Build required parameters.
$cmid = clean_param($args['cmid'], PARAM_INT);
$thispageurl = new \moodle_url('/mod/quiz/edit.php', ['cmid' => $cmid]);
$thiscontext = \context_module::instance($cmid);
$contexts = new \core_question\local\bank\question_edit_contexts($thiscontext);
$course = get_course($params['courseid']);
[, $cm] = get_module_from_cmid($cmid);
// Custom question bank View.
$viewclass = clean_param($args['view'], PARAM_NOTAGS);
$questionbank = new $viewclass($contexts, $thispageurl, $course, $cm, $params, $extraparams);
// Question table.
return $questionbank->display_questions_table();
}
/**
* Build required parameters for question bank custom view
*
* @param array $params the page parameters
* @param array $extraparams additional parameters
* @return array
*/
function build_required_parameters_for_custom_view(array $params, array $extraparams): array {
// Retrieve questions per page.
$viewclass = $extraparams['view'] ?? null;
$defaultpagesize = $viewclass ? $viewclass::DEFAULT_PAGE_SIZE : DEFAULT_QUESTIONS_PER_PAGE;
// Build the required params.
[$thispageurl, $contexts, $cmid, $cm, , $pagevars] = question_build_edit_resources(
'editq',
'/mod/quiz/edit.php',
array_merge($params, $extraparams),
$defaultpagesize);
// Add cmid so we can retrieve later in extra params.
$extraparams['cmid'] = $cmid;
return [$contexts, $thispageurl, $cm, $pagevars, $extraparams];
}
/**
* Implement the calculate_question_stats callback.
*
+17 -50
View File
@@ -213,8 +213,9 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time
// Deal with fixed random choices for testing.
if (isset($questionids[$quba->next_slot_number()])) {
if ($randomloader->is_question_available($questiondata->category,
(bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()], $tagids)) {
$filtercondition = $questiondata->filtercondition;
$filters = $filtercondition['filter'] ?? [];
if ($randomloader->is_filtered_question_available($filters, $questionids[$quba->next_slot_number()])) {
$questions[$slot] = question_bank::load_question(
$questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers);
continue;
@@ -224,8 +225,10 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time
}
// Normal case, pick one at random.
$questionid = $randomloader->get_next_question_id($questiondata->category,
$questiondata->randomrecurse, $tagids);
$filtercondition = $questiondata->filtercondition;
$filters = $filtercondition['filter'] ?? [];
$questionid = $randomloader->get_next_filtered_question_id($filters);
if ($questionid === null) {
throw new moodle_exception('notenoughrandomquestions', 'quiz',
$quizobj->view_url(), $questiondata);
@@ -1884,54 +1887,18 @@ function quiz_update_section_firstslots($quizid, $direction, $afterslot, $before
* @param int $addonpage the page on which to add the question.
* @param int $categoryid the question category to add the question from.
* @param int $number the number of random questions to add.
* @param bool $includesubcategories whether to include questoins from subcategories.
* @param int[] $tagids Array of tagids. The question that will be picked randomly should be tagged with all these tags.
* @deprecated Since Moodle 4.3 MDL-72321
* @todo Final deprecation in Moodle 4.7 MDL-78091
*/
function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number,
$includesubcategories, $tagids = []) {
global $DB;
function quiz_add_random_questions(stdClass $quiz, int $addonpage, int $categoryid, int $number): void {
debugging(
'quiz_add_random_questions is deprecated. Please use mod_quiz\structure::add_random_questions() instead.',
DEBUG_DEVELOPER
);
$category = $DB->get_record('question_categories', ['id' => $categoryid]);
if (!$category) {
new moodle_exception('invalidcategoryid');
}
$catcontext = context::instance_by_id($category->contextid);
require_capability('moodle/question:useall', $catcontext);
// Tags for filter condition.
$tags = \core_tag_tag::get_bulk($tagids, 'id, name');
$tagstrings = [];
foreach ($tags as $tag) {
$tagstrings[] = "{$tag->id},{$tag->name}";
}
// Create the selected number of random questions.
for ($i = 0; $i < $number; $i++) {
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $categoryid;
$filtercondition->includingsubcategories = $includesubcategories ? 1 : 0;
if (!empty($tagstrings)) {
$filtercondition->tags = $tagstrings;
}
if (!isset($quiz->cmid)) {
$cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
$quiz->cmid = $cm->id;
}
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
$randomslotdata->maxmark = 1;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_quiz($quiz);
$randomslot->set_filter_condition($filtercondition);
$randomslot->insert($addonpage);
}
$settings = quiz_settings::create($quiz->id);
$structure = \mod_quiz\structure::create_for_quiz($settings);
$structure->add_random_questions($addonpage, $number, $categoryid);
}
/**
@@ -197,7 +197,6 @@ Feature: Regrading quiz attempts using the Grades report
And I click on "Yes" "button" in the "Confirm" "dialogue"
And I click on "Add" "link"
And I follow "a random question"
And I set the field "Number of random questions" to "1"
And I press "Add random question"
And I am on the "Quiz for testing regrading" "quiz activity" page logged in as student3
And I click on "Attempt quiz" "button"
@@ -0,0 +1,90 @@
{{!
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 mod_quiz/add_random_question_form
From to add random questions.
Example context (json):
{
"returnurl": "http://example.com",
"randomoptions": [
{"name": "1", "value": "1"},
{"name": "2", "value": "2"}
],
"questioncategoryoptions": {
"label": "Category 1",
"options": [
{"name": "Category 2", "value": "1"},
{"name": "Category 3", "value": "2"}
]
},
"questionbank": "<pre>Question bank HTML goes here</pre>"
}
}}
<div class="mt-2 position-relative" data-region="add_random_question_form-container">
<form action="{{returnurl}}" method="POST" id="add_random_question_form">
<fieldset id="id_existingcategoryheader">
<legend>{{#str}} randomfromexistingcategory, mod_quiz {{/str}}</legend>
<!-- Question bank -->
{{{questionbank}}}
<!-- Select number of random question -->
<label>
{{#str}}randomnumber, mod_quiz{{/str}}
<select id="menurandomcount" name="randomcount" class="form-control custom-select">
{{#randomoptions}}
<option value="{{value}}">{{name}}</option>
{{/randomoptions}}
</select>
</label>
<!-- Buttons -->
<div class="modal-footer mt-1" data-region="footer">
<input type="hidden" name="message" value="">
<input type="submit" class="btn btn-primary" name="addrandom" value="{{#str}} addrandomquestion, mod_quiz {{/str}}">
<input type="submit" class="btn btn-secondary" name="cancel" value="{{#str}} cancel {{/str}}">
</div>
</fieldset>
<fieldset id="id_newcategoryheader">
<legend>{{#str}} randomquestionusinganewcategory, mod_quiz {{/str}}</legend>
<!-- New categoryname -->
<label class="mr-md-2 mb-md-0" for="categoryname">{{#str}} name {{/str}} </label>
<input type="text" class="form-control" id="categoryname" name="categoryname" maxlength="254" size="50">
<!-- Select number of random question -->
<label class="mr-md-2 mb-md-0" for="parentcategory">{{#str}} parentcategory, question {{/str}} </label>
<select id="parentcategory" name="parentcategory" class="form-control">
{{#questioncategoryoptions}}
<optgroup label="{{label}}">
{{#options}}
<option value="{{value}}">{{{name}}}</option>
{{/options}}
</optgroup>
{{/questioncategoryoptions}}
</select>
<!-- Buttons -->
<div class="modal-footer mt-1" data-region="footer">
<input type="submit" class="btn btn-primary" name="newcategory" value="{{#str}} createcategoryandaddrandomquestion, mod_quiz {{/str}}">
<input type="submit" class="btn btn-secondary" name="cancel" value="{{#str}} cancel {{/str}}">
</div>
</fieldset>
</form>
</div>
@@ -15,26 +15,14 @@
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template mod_quiz/random_question_form_preview
@template mod_quiz/random_question_container
A preview section for the list of questions that match the random
question criteria in the random question form.
Classes required for JS:
* none
Data attributes required for JS:
* none
Context variables required for this template:
* title A cleaned string (use clean_text()) to display.
* body HTML content for the boday
Container for list of filtered questions.
Example context (json):
{}
{
}
}}
<div class="mt-2 position-relative" data-region="random-question-preview-container">
<div data-region="question-count-container"></div>
<div data-region="question-list-container"></div>
{{> core/overlay_loading}}
<div class="categoryquestionscontainer" id="questionscontainer">
</div>
@@ -0,0 +1,43 @@
{{!
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 mod_quiz/update_filter_condition_form
From to add random questions.
Example context (json):
{
"questionbank": "<pre>Question bank HTML goes here</pre>",
"returnurl": "https://example.com",
"cmid": "1234",
"slotid": "5678"
}
}}
<div>
<!-- Question bank -->
<div id="questionbank_container">
{{{questionbank}}}
</div>
<!-- submit buttons -->
<form action="{{returnurl}}" method="POST" id="update_filter_condition_form" data-cmid="{{cmid}}" data-slotid="{{slotid}}">
<input type="hidden" name="message" value="">
<input type="submit" class="btn btn-primary" name="update" value="{{#str}} updatefilterconditon, mod_quiz {{/str}}">
<input type="submit" class="btn btn-secondary" name="cancel" value="{{#str}} cancel {{/str}}">
</form>
</div>
@@ -23,6 +23,7 @@ defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Quiz attempt walk through using data from csv file.
@@ -35,6 +36,8 @@ require_once($CFG->dirroot . '/mod/quiz/locallib.php');
*/
class attempt_walkthrough_from_csv_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* @var string[] names of the files which contain the test data.
*/
@@ -134,7 +137,7 @@ class attempt_walkthrough_from_csv_test extends \advanced_testcase {
if ($slotquestion['type'] !== 'random') {
quiz_add_quiz_question($slotquestion['id'], $this->quiz, 0, $slotquestion['mark']);
} else {
quiz_add_random_questions($this->quiz, 0, $slotquestion['catid'], 1, 0);
$this->add_random_questions($this->quiz->id, 0, $slotquestion['catid'], 1);
$this->randqids[$slotno] = $qidsbycat[$slotquestion['catid']];
}
}
+4 -1
View File
@@ -24,6 +24,7 @@ defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Quiz attempt walk through.
@@ -37,6 +38,8 @@ require_once($CFG->dirroot . '/mod/quiz/locallib.php');
*/
class attempt_walkthrough_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Create a quiz with questions and walk through a quiz attempt.
*/
@@ -288,7 +291,7 @@ class attempt_walkthrough_test extends \advanced_testcase {
$numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
// Add random question to the quiz.
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
// Make another category.
$cat2 = $questiongenerator->create_question_category();
+12 -1
View File
@@ -276,7 +276,18 @@ class behat_mod_quiz extends behat_question_base {
} else {
$includingsubcategories = clean_param($questiondata['includingsubcategories'], PARAM_BOOL);
}
quiz_add_random_questions($quiz, $page, $question->category, 1, $includingsubcategories);
$filter = [
'category' => [
'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT,
'values' => [$question->category],
'filteroptions' => ['includesubcategories' => $includingsubcategories],
],
];
$filtercondition['filter'] = $filter;
$settings = quiz_settings::create($quiz->id);
$structure = \mod_quiz\structure::create_for_quiz($settings);
$structure->add_random_questions($page, 1, $filtercondition);
} else {
// Add the question.
quiz_add_quiz_question($question->id, $quiz, $page, $maxmark);
-6
View File
@@ -117,7 +117,6 @@ Feature: Edit quiz page - adding things
And I select "Questions" from the "Question bank tertiary navigation" singleselect
And I should see "Question bank"
And I should see "Select a category"
# Create the Essay 01 question.
When I press "Create a new question ..."
@@ -131,8 +130,6 @@ Feature: Edit quiz page - adding things
And I should see "Essay 01"
# Create the Essay 02 question.
And I should see "Select a category"
And I set the field "Select a category:" to "Subcat 1"
When I press "Create a new question ..."
And I set the field "item_qtype_essay" to "1"
And I click on "Add" "button" in the "Choose a question type to add" "dialogue"
@@ -144,7 +141,6 @@ Feature: Edit quiz page - adding things
And I should see "Essay 02"
# Create the Essay 03 question.
And I set the field "Select a category" to "Default for C1"
And I wait until the page is ready
When I press "Create a new question ..."
And I set the field "item_qtype_essay" to "1"
@@ -201,8 +197,6 @@ Feature: Edit quiz page - adding things
# Add Esay 02 from question bank.
And I open the "Page 1" add to quiz menu
And I follow "from question bank"
And I should see "Select a category"
And I set the field "Select a category" to "Subcat 1"
And I click on "Add to quiz" "link" in the "Essay 02" "table_row"
And I should see "Essay 03" on quiz page "1"
And I should see "Essay 01" on quiz page "1"
@@ -40,8 +40,7 @@ Feature: Adding questions to a quiz from the question bank
Then I should see "foo" in the "question 01 name" "table_row"
And I should see "bar" in the "question 02 name" "table_row"
And I should see "qidnum" in the "question 02 name" "table_row"
And I set the field "Filter by tags..." to "foo"
And I press the enter key
When I apply question bank filter "Tag" with value "foo"
And I should see "question 01 name" in the "categoryquestions" "table"
And I should not see "question 02 name" in the "categoryquestions" "table"
@@ -76,9 +75,8 @@ Feature: Adding questions to a quiz from the question bank
Then I should see "question 21 name" in the "categoryquestions" "table"
And I should see "question 22 name" in the "categoryquestions" "table"
And I should not see "question 01 name" in the "categoryquestions" "table"
And I click on "Show all 22" "link" in the ".question-showall-text" "css_element"
And I click on "1" "link" in the ".pagination" "css_element"
And I should see "question 01 name" in the "categoryquestions" "table"
And I should see "question 22 name" in the "categoryquestions" "table"
Scenario: Questions are added in the right place with multiple sections
Given the following "questions" exist:
+16 -18
View File
@@ -43,7 +43,9 @@ Feature: Adding random questions to a quiz based on category and tags
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
When I open the "last" add to quiz menu
And I follow "a random question"
And I open the autocomplete suggestions list
And I add question bank filter "Tag"
And I click on "Tag" "field"
And I press the down key
Then "foo" "autocomplete_suggestions" should exist
And "bar" "autocomplete_suggestions" should exist
@@ -51,21 +53,16 @@ Feature: Adding random questions to a quiz based on category and tags
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
When I open the "last" add to quiz menu
And I follow "a random question"
And I set the field "Category" to "Top for Course 1"
And I wait until the page is ready
And I open the autocomplete suggestions list
And I click on "foo" item in the autocomplete list
Then I should see "question 1 name"
And I should see "question 3 name"
And I should not see "question 2 name"
And I should not see "question 4 name"
And I set the field "Category" to "Questions Category 1"
And I apply question bank filter "Category" with value "Questions Category 1"
And I apply question bank filter "Tag" with value "foo"
And I click on "Apply filters" "button"
And I wait until the page is ready
And I should see "question 1 name"
And I should not see "question 3 name"
And I should not see "question 2 name"
And I should not see "question 4 name"
And I click on "Include questions from subcategories too" "checkbox"
And I set the field "Also show questions from subcategories" to "1"
And I click on "Apply filters" "button"
And I wait until the page is ready
And I should see "question 1 name"
And I should see "question 3 name"
@@ -76,10 +73,11 @@ Feature: Adding random questions to a quiz based on category and tags
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
When I open the "last" add to quiz menu
And I follow "a random question"
And I set the field "Tags" to "foo"
And I apply question bank filter "Tag" with value "foo"
And I select "1" from the "randomcount" singleselect
And I press "Add random question"
And I should see "Random (Questions Category 1, tags: foo)" on quiz page "1"
And I click on "(See questions)" "link"
And I should see "Random question based on filter condition with tags: foo" on quiz page "1"
When I click on "Configure question" "link" in the "Random question based on filter condition with tags: foo" "list_item"
Then I should see "Questions Category 1"
And I should see "foo"
And I should see "question 1 name"
@@ -101,8 +99,8 @@ Feature: Adding random questions to a quiz based on category and tags
And I follow "New category"
And I set the following fields to these values:
| Name | New Random category |
| Parent category | Top for Quiz 1 |
| Parent category | Default for Quiz 1 |
And I press "Create category and add random question"
And I should see "Random (New Random category)" on quiz page "1"
And I click on "(See questions)" "link"
Then I should see "Top for Quiz 1"
And I should see "Random question based on filter condition" on quiz page "1"
And I click on "Configure question" "link" in the "Random question based on filter condition" "list_item"
Then I should see "New Random category"
@@ -38,18 +38,17 @@ Feature: Editing random questions already in a quiz based on category and tags
And I follow "a random question"
# To actually reproduce MDL-68733 it would be better to set tags easy,essay here, and then below just delete one tag.
# However, the state of Behat for autocomplete fields does not let us actually do that.
And I set the field "Tags" to "easy"
And I apply question bank filter "Tag" with value "easy"
And I press "Add random question"
And I open the "Page 1" add to quiz menu
And I follow "a random question"
And I set the field "Tags" to "hard"
And I apply question bank filter "Tag" with value "hard"
And I press "Add random question"
And I follow "Add page break"
When I click on "Configure question" "link" in the "Random (Questions Category 1, tags: easy)" "list_item"
And I click on "easy" "autocomplete_selection"
And I set the field "Tags" to "essay"
And I press "Save changes"
Then I should see "Random (Questions Category 1, tags: essay)" on quiz page "1"
And I should see "Random (Questions Category 1, tags: hard)" on quiz page "2"
And I click on "Configure question" "link" in the "Questions Category 1, tags: hard" "list_item"
When I click on "Configure question" "link" in the "Random question based on filter condition with tags: easy" "list_item"
And I apply question bank filter "Tag" with value "essay"
And I press "Update filter conditions"
Then I should see "Random question based on filter condition with tags: essay" on quiz page "1"
And I should see "Random question based on filter condition with tags: hard" on quiz page "2"
And I click on "Configure question" "link" in the "Random question based on filter condition with tags: hard" "list_item"
And "hard" "autocomplete_selection" should be visible
@@ -261,11 +261,9 @@ Feature: Edit quiz page - remove multiple questions
And I click on "selectquestion-2" "checkbox"
And I click on "Delete selected" "button"
And I click on "Yes" "button" in the "Confirm" "dialogue"
# To make sure question is deleted completely.
And I reload the page
Then I should see "Random (Test questions)" on quiz page "1"
And I should not see "Random (Test questions)" on quiz page "2"
And I should not see "Random (Test questions)" on quiz page "3"
Then I should see "Random question based on filter condition" on quiz page "1"
And I should not see "Random question based on filter condition" on quiz page "2"
And I should not see "Random question based on filter condition" on quiz page "3"
And I should see "Total of marks: 1.00"
And I should see "Questions: 1"
@@ -284,16 +282,15 @@ Feature: Edit quiz page - remove multiple questions
# Delete all questions in page. Page contains multiple questions.
When I open the "last" add to quiz menu
And I follow "a random question"
And I set the field "Number of random questions" to "3"
And I click on "menurandomcount" "select"
And I click on "3" "option"
And I press "Add random question"
And I click on "Select multiple items" "button"
And I press "Select all"
And I click on "Delete selected" "button"
And I click on "Yes" "button" in the "Confirm" "dialogue"
# To make sure question is deleted completely.
And I reload the page
Then I should not see "Random (Test questions)" on quiz page "1"
And I should not see "Random (Test questions)" on quiz page "2"
And I should not see "Random (Test questions)" on quiz page "3"
Then I should not see "Random question based on filter condition" on quiz page "1"
And I should not see "Random question based on filter condition" on quiz page "2"
And I should not see "Random question based on filter condition" on quiz page "3"
And I should see "Total of marks: 0.00"
And I should see "Questions: 0"
+12 -11
View File
@@ -33,23 +33,24 @@ Feature: Moving a question to another category should not affect random question
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
When I open the "last" add to quiz menu
And I follow "a random question"
And I set the field "Category" to "Used category"
And I apply question bank filter "Category" with value "Used category"
And I press "Add random question"
And I should see "Random (Used category)" on quiz page "1"
And I click on "(See questions)" "link"
And I should see "Random question based on filter condition" on quiz page "1"
And I click on "Configure question" "link" in the "Random question based on filter condition" "list_item"
And I should see "Used category"
And I am on "Course 1" course homepage
And I navigate to "Question bank" in current page administration
And I apply question bank filter "Category" with value "Used category"
And I click on "Test question to be moved" "checkbox" in the "Test question to be moved" "table_row"
And I click on "With selected" "button"
And I click on question bulk action "move"
And I set the field "Question category" to "Subcategory"
And I press "Move to"
Then I should see "Test question to be moved"
And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Subcategory (1)"
And the "Select a category" select box should contain "Used category"
And the "Select a category" select box should not contain "Used category (1)"
And I should see "Subcategory (1)"
And I am on the "Quiz 1" "mod_quiz > Edit" page
And I should see "Random (Used category)" on quiz page "1"
And I click on "(See questions)" "link"
And I should see "Random question based on filter condition" on quiz page "1"
And I click on "Configure question" "link" in the "Random question based on filter condition" "list_item"
And I should see "Used category"
@javascript
@@ -57,9 +58,9 @@ Feature: Moving a question to another category should not affect random question
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
When I open the "last" add to quiz menu
And I follow "a random question"
And I set the field "Category" to "Used category"
And I apply question bank filter "Category" with value "Used category"
And I press "Add random question"
And I should see "Random (Used category)" on quiz page "1"
And I should see "Random question based on filter condition" on quiz page "1"
And I am on the "Course 1" "core_question > course question categories" page
And I click on "Edit this category" "link" in the "Used category" "list_item"
And I set the following fields to these values:
@@ -69,4 +70,4 @@ Feature: Moving a question to another category should not affect random question
Then I should see "Used category new"
And I should see "I was edited" in the "Used category new" "list_item"
And I am on the "Quiz 1" "mod_quiz > Edit" page
And I should see "Random (Used category new)" on quiz page "1"
And I should see "Random question based on filter condition" on quiz page "1"
+7 -4
View File
@@ -41,6 +41,7 @@ defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Silly class to access mod_quiz_external internal methods.
@@ -86,6 +87,8 @@ class testable_mod_quiz_external extends mod_quiz_external {
*/
class external_test extends externallib_advanced_testcase {
use \quiz_question_helper_test_trait;
/** @var \stdClass course record. */
protected $course;
@@ -1898,7 +1901,7 @@ class external_test extends externallib_advanced_testcase {
$question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
$question = $questiongenerator->create_question('essay', null, ['category' => $cat->id]);
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
$quizobj = quiz_settings::create($quiz->id, $this->student->id);
@@ -2024,8 +2027,8 @@ class external_test extends externallib_advanced_testcase {
$question = $questiongenerator->create_question('essay', null, ['category' => $anothercat->id]);
// Add a couple of random questions from the same category.
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
$this->setUser($this->student);
@@ -2039,7 +2042,7 @@ class external_test extends externallib_advanced_testcase {
// Add more questions to the quiz, this time from the other category.
$this->setAdminUser();
quiz_add_random_questions($quiz, 0, $anothercat->id, 1, false);
$this->add_random_questions($quiz->id, 0, $anothercat->id, 1);
$this->setUser($this->student);
$result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
Binary file not shown.
+4 -1
View File
@@ -31,12 +31,15 @@ defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/lib.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* @copyright 2008 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
*/
class lib_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
public function test_quiz_has_grades() {
$quiz = new \stdClass();
$quiz->grade = '100.0000';
@@ -109,7 +112,7 @@ class lib_test extends \advanced_testcase {
$standardq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
quiz_add_quiz_question($standardq->id, $quiz);
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
// Get the random question.
$randomq = $DB->get_record('question', ['qtype' => 'random']);
@@ -16,8 +16,14 @@
namespace mod_quiz;
defined('MOODLE_INTERNAL') || die();
use mod_quiz\question\bank\qbank_helper;
global $CFG;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Class mod_quiz_local_structure_slot_random_test
* Class for tests related to the {@link \mod_quiz\local\structure\slot_random} class.
@@ -26,8 +32,12 @@ use mod_quiz\question\bank\qbank_helper;
* @category test
* @copyright 2018 Shamim Rezaie <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\local\structure\slot_random
*/
class local_structure_slot_random_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Constructor test.
*/
@@ -57,8 +67,7 @@ class local_structure_slot_random_test extends \advanced_testcase {
// Set the filter conditions.
$filtercondition = new \stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
$filtercondition->filters = \question_filter_test_helper::create_filters([$category->id], true);
// Slot data.
$randomslotdata = new \stdClass();
@@ -69,7 +78,7 @@ class local_structure_slot_random_test extends \advanced_testcase {
// Insert the random question to the quiz.
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_filter_condition($filtercondition);
$randomslot->set_filter_condition(json_encode($filtercondition));
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('filtercondition');
@@ -77,8 +86,8 @@ class local_structure_slot_random_test extends \advanced_testcase {
$record = json_decode($rcp->getValue($randomslot));
$this->assertEquals($quiz->id, $randomslot->get_quiz()->id);
$this->assertEquals($category->id, $record->questioncategoryid);
$this->assertEquals(1, $record->includingsubcategories);
$this->assertEquals($category->id, $record->filters->category->values[0]);
$this->assertTrue($record->filters->category->filteroptions->includesubcategories);
$rcp = $rc->getProperty('record');
$rcp->setAccessible(true);
@@ -100,12 +109,11 @@ class local_structure_slot_random_test extends \advanced_testcase {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $questiongenerator->create_question_category();
quiz_add_random_questions($quiz, 0, $category->id, 1, false);
$this->add_random_questions($quiz->id, 0, $category->id, 1);
// Set the filter conditions.
$filtercondition = new \stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
$filtercondition->filters = \question_filter_test_helper::create_filters([$category->id], 1);
// Slot data.
$randomslotdata = new \stdClass();
@@ -115,7 +123,7 @@ class local_structure_slot_random_test extends \advanced_testcase {
$randomslotdata->questionscontextid = $category->contextid;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_filter_condition($filtercondition);
$randomslot->set_filter_condition(json_encode($filtercondition));
// The create_instance had injected an additional cmid propery to the quiz. Let's remove that.
unset($quiz->cmid);
@@ -137,12 +145,11 @@ class local_structure_slot_random_test extends \advanced_testcase {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $questiongenerator->create_question_category();
quiz_add_random_questions($quiz, 0, $category->id, 1, false);
$this->add_random_questions($quiz->id, 0, $category->id, 1);
// Set the filter conditions.
$filtercondition = new \stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
$filtercondition->filters = \question_filter_test_helper::create_filters([$category->id], 1);
// Slot data.
$randomslotdata = new \stdClass();
@@ -152,7 +159,7 @@ class local_structure_slot_random_test extends \advanced_testcase {
$randomslotdata->questionscontextid = $category->contextid;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_filter_condition($filtercondition);
$randomslot->set_filter_condition(json_encode($filtercondition));
// The create_instance had injected an additional cmid propery to the quiz. Let's remove that.
unset($quiz->cmid);
@@ -178,7 +185,7 @@ class local_structure_slot_random_test extends \advanced_testcase {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $questiongenerator->create_question_category();
quiz_add_random_questions($quiz, 0, $category->id, 1, false);
$this->add_random_questions($quiz->id, 0, $category->id, 1);
// Slot data.
$randomslotdata = new \stdClass();
@@ -203,147 +210,24 @@ class local_structure_slot_random_test extends \advanced_testcase {
return [$randomslot, $tags];
}
public function test_set_tags() {
public function test_set_tags_filter() {
$this->resetAfterTest();
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar']);
$qtagids = [$tags['foo']->id, $tags['bar']->id];
$filtercondition = new \stdClass();
$randomslot->set_tags([$tags['foo'], $tags['bar']]);
$randomslot->set_filter_condition($filtercondition);
$filtercondition->filters = \question_filter_test_helper::create_filters([], 0, $qtagids);
$randomslot->set_filter_condition(json_encode($filtercondition));
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
$this->assertEquals([
$tags['foo']->id => $tags['foo'],
$tags['bar']->id => $tags['bar'],
], (array)json_decode($tagspropery)->tags);
}
public function test_set_tags_twice() {
$this->resetAfterTest();
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
// Set tags for the first time.
$filtercondition = new \stdClass();
$randomslot->set_tags([$tags['foo'], $tags['bar']]);
// Now set the tags again.
$randomslot->set_tags([$tags['baz']]);
$randomslot->set_filter_condition($filtercondition);
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
$this->assertEquals([
$tags['baz']->id => $tags['baz'],
], (array)json_decode($tagspropery)->tags);
}
public function test_set_tags_duplicates() {
$this->resetAfterTest();
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
$filtercondition = new \stdClass();
$randomslot->set_tags([$tags['foo'], $tags['bar'], $tags['foo']]);
$randomslot->set_filter_condition($filtercondition);
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
$this->assertEquals([
$tags['foo']->id => $tags['foo'],
$tags['bar']->id => $tags['bar'],
], (array)json_decode($tagspropery)->tags);
}
public function test_set_tags_by_id() {
$this->resetAfterTest();
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
$filtercondition = new \stdClass();
$randomslot->set_tags_by_id([$tags['foo']->id, $tags['bar']->id]);
$randomslot->set_filter_condition($filtercondition);
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
// The set_tags_by_id function only retrieves id and name fields of the tag object.
$this->assertCount(2, $tagspropery);
$this->assertArrayHasKey($tags['foo']->id, $tagspropery);
$this->assertArrayHasKey($tags['bar']->id, $tagspropery);
$this->assertEquals(
(object)['id' => $tags['foo']->id, 'name' => $tags['foo']->name],
$tagspropery[$tags['foo']->id]->to_object()
);
$this->assertEquals(
(object)['id' => $tags['bar']->id, 'name' => $tags['bar']->name],
$tagspropery[$tags['bar']->id]->to_object()
);
}
public function test_set_tags_by_id_twice() {
$this->resetAfterTest();
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
// Set tags for the first time.
$randomslot->set_tags_by_id([$tags['foo']->id, $tags['bar']->id]);
// Now set the tags again.
$randomslot->set_tags_by_id([$tags['baz']->id]);
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
// The set_tags_by_id function only retrieves id and name fields of the tag object.
$this->assertCount(1, $tagspropery);
$this->assertArrayHasKey($tags['baz']->id, $tagspropery);
$this->assertEquals(
(object)['id' => $tags['baz']->id, 'name' => $tags['baz']->name],
$tagspropery[$tags['baz']->id]->to_object()
);
}
public function test_set_tags_by_id_duplicates() {
$this->resetAfterTest();
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
$randomslot->set_tags_by_id([$tags['foo']->id, $tags['bar']->id], $tags['foo']->id);
$rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
// The set_tags_by_id function only retrieves id and name fields of the tag object.
$this->assertCount(2, $tagspropery);
$this->assertArrayHasKey($tags['foo']->id, $tagspropery);
$this->assertArrayHasKey($tags['bar']->id, $tagspropery);
$this->assertEquals(
(object)['id' => $tags['foo']->id, 'name' => $tags['foo']->name],
$tagspropery[$tags['foo']->id]->to_object()
);
$this->assertEquals(
(object)['id' => $tags['bar']->id, 'name' => $tags['bar']->name],
$tagspropery[$tags['bar']->id]->to_object()
);
$this->assertEquals([$tags['foo']->id, $tags['bar']->id],
(array)json_decode($tagspropery)->filters->qtagids->values);
}
public function test_insert() {
@@ -389,8 +273,7 @@ class local_structure_slot_random_test extends \advanced_testcase {
// Set the filter conditions.
$filtercondition = new \stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
$filtercondition->filter = \question_filter_test_helper::create_filters([$category->id], true, [$footag->id, $bartag->id]);
// Slot data.
$randomslotdata = new \stdClass();
@@ -401,26 +284,26 @@ class local_structure_slot_random_test extends \advanced_testcase {
// Insert the random question to the quiz.
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_tags([$footag, $bartag]);
$randomslot->set_filter_condition($filtercondition);
$randomslot->set_filter_condition(json_encode($filtercondition));
$randomslot->insert(1); // Put the question on the first page of the quiz.
$slots = qbank_helper::get_question_structure($quiz->id, $quizcontext);
$quizslot = reset($slots);
$this->assertEquals($category->id, $quizslot->category);
$this->assertEquals(1, $quizslot->randomrecurse);
$this->assertEquals(1, $quizslot->maxmark);
$tagspropery = $quizslot->randomtags;
$filter = $quizslot->filtercondition['filter'];
$this->assertCount(2, $tagspropery);
$this->assertEquals($category->id, $filter['category']['values'][0]);
$this->assertTrue($filter['category']['filteroptions']['includesubcategories']);
$this->assertEquals(1, $quizslot->maxmark);
$this->assertCount(2, $filter['qtagids']['values']);
$this->assertEqualsCanonicalizing(
[
['tagid' => $footag->id, 'tagname' => $footag->name],
['tagid' => $bartag->id, 'tagname' => $bartag->name]
['tagid' => $footag->id],
['tagid' => $bartag->id]
],
array_map(function($slottag) {
return ['tagid' => $slottag->id, 'tagname' => $slottag->name];
}, $tagspropery));
array_map(function($tagid) {
return ['tagid' => $tagid];
}, $filter['qtagids']['values']));
}
}
+4 -2
View File
@@ -31,7 +31,7 @@ defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Unit tests for (some of) mod/quiz/locallib.php.
@@ -41,6 +41,8 @@ require_once($CFG->dirroot . '/mod/quiz/locallib.php');
*/
class locallib_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
public function test_quiz_rescale_grade() {
$quiz = new \stdClass();
$quiz->decimalpoints = 2;
@@ -523,7 +525,7 @@ class locallib_test extends \advanced_testcase {
$tagids[] = $tagobjects[$tagname]->id;
}
}
quiz_add_random_questions($quiz, 0, $cat->id, 1, false, $tagids);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
}
return [$quiz, $tagobjects];
@@ -49,7 +49,8 @@ class quiz_question_bank_view_test extends \advanced_testcase {
// Create a question in the default category.
$contexts = new question_edit_contexts($context);
$cat = question_make_default_categories($contexts->all());
question_make_default_categories($contexts->all());
$cat = question_get_default_category($context->id);
$questiondata = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $cat->id]);
@@ -58,17 +59,19 @@ class quiz_question_bank_view_test extends \advanced_testcase {
$cache->delete($questiondata->id);
// Generate the view.
$view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $quiz);
ob_start();
$pagevars = [
$params = [
'qpage' => 0,
'qperpage' => 20,
'cat' => $cat->id . ',' . $context->id,
'recurse' => false,
'showhidden' => false,
'qbshowtext' => false
'qbshowtext' => false,
'tabname' => 'editq'
];
$view->display($pagevars, 'editq');
$extraparams = ['cmid' => $cm->id];
$view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $params, $extraparams);
ob_start();
$view->display();
$html = ob_get_clean();
// Verify the output includes the expected question.
@@ -92,7 +92,7 @@ trait quiz_question_helper_test_trait {
$cat = $questiongenerator->create_question_category($override);
$questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
$questiongenerator->create_question('essay', null, ['category' => $cat->id]);
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
$this->add_random_questions($quiz->id, 0, $cat->id, 1);
}
/**
@@ -180,4 +180,28 @@ trait quiz_question_helper_test_trait {
protected function duplicate_quiz($course, $quiz): ?\cm_info {
return duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid));
}
/**
* Add random questions to a quiz, with a filter condition based on a category ID.
*
* @param int $quizid The quiz to add the questions to.
* @param int $page The page number to add the questions to.
* @param int $categoryid The category ID to use for the filter condition.
* @param int $number The number of questions to add.
* @return void
*/
protected function add_random_questions(int $quizid, int $page, int $categoryid, int $number): void {
$settings = quiz_settings::create($quizid);
$structure = \mod_quiz\structure::create_for_quiz($settings);
$filtercondition = [
'filter' => [
'category' => [
'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT,
'values' => [$categoryid],
'filteroptions' => ['includesubcategories' => false],
],
],
];
$structure->add_random_questions($page, $number, $filtercondition);
}
}
+62 -1
View File
@@ -470,7 +470,7 @@ class quiz_question_restore_test extends \advanced_testcase {
quiz_add_quiz_question($saq->id, $quiz, 1, 3);
quiz_add_quiz_question($numq->id, $quiz, 2, 2);
quiz_add_quiz_question($matchq->id, $quiz, 3, 1);
quiz_add_random_questions($quiz, 3, $randomcat->id, 2, false);
$this->add_random_questions($quiz->id, 3, $randomcat->id, 2);
$quizobj = quiz_settings::create($quiz->id, $user1->id);
$originalstructure = \mod_quiz\structure::create_for_quiz($quizobj);
@@ -508,4 +508,65 @@ class quiz_question_restore_test extends \advanced_testcase {
$this->assertEquals($originalslot->maxmark, $restoredslot->maxmark);
}
}
/**
* Test pre 4.3 quiz restore for random question filter conditions.
*
* @covers \restore_question_set_reference_data_trait::process_question_set_reference
*/
public function test_pre_43_quiz_restore_for_random_question_filtercondition() {
global $USER, $DB;
$this->resetAfterTest();
$backupid = 'abc';
$backuppath = make_backup_temp_directory($backupid);
get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
__DIR__ . "/fixtures/moodle_42_random_question.mbz", $backuppath);
// Do the restore to new course with default settings.
$categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
$newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
$rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
\backup::TARGET_NEW_COURSE);
$this->assertTrue($rc->execute_precheck());
$rc->execute_plan();
$rc->destroy();
// Get the information about the resulting course and check that it is set up correctly.
$modinfo = get_fast_modinfo($newcourseid);
$quiz = array_values($modinfo->get_instances_of('quiz'))[0];
$quizobj = \mod_quiz\quiz_settings::create($quiz->instance);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
// Count the questions in quiz qbank.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id);
$this->assertEquals(2, $this->question_count($context->id));
// Are the correct slots returned?
$slots = $structure->get_slots();
$this->assertCount(1, $slots);
// Check that the filtercondition now matches the 4.3 structure.
foreach ($slots as $slot) {
$setreference = $DB->get_record('question_set_references',
['itemid' => $slot->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']);
$filterconditions = json_decode($setreference->filtercondition, true);
$this->assertArrayHasKey('cat', $filterconditions);
$this->assertArrayHasKey('jointype', $filterconditions);
$this->assertArrayHasKey('qpage', $filterconditions);
$this->assertArrayHasKey('qperpage', $filterconditions);
$this->assertArrayHasKey('filter', $filterconditions);
$this->assertArrayHasKey('category', $filterconditions['filter']);
$this->assertArrayHasKey('qtagids', $filterconditions['filter']);
$this->assertArrayNotHasKey('questioncategoryid', $filterconditions);
$this->assertArrayNotHasKey('tags', $filterconditions);
$expectedtags = \core_tag_tag::get_by_name_bulk(1, ['foo', 'bar']);
$expectedtagids = array_values(array_map(fn($expectedtag) => $expectedtag->id, $expectedtags));
$this->assertEquals($expectedtagids, $filterconditions['filter']['qtagids']['values']);
$expectedcategory = $DB->get_record('question_categories', ['idnumber' => 'RAND']);
$this->assertEquals($expectedcategory->id, $filterconditions['filter']['category']['values'][0]);
$expectedcat = implode(',', [$expectedcategory->id, $expectedcategory->contextid]);
$this->assertEquals($expectedcat, $filterconditions['cat']);
}
}
}
+8 -1
View File
@@ -16,6 +16,11 @@
namespace mod_quiz;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Unit tests for quiz events.
*
@@ -26,6 +31,8 @@ namespace mod_quiz;
*/
class structure_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Create a course with an empty quiz.
* @return array with three elements quiz, cm and course.
@@ -691,7 +698,7 @@ class structure_test extends \advanced_testcase {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
quiz_add_random_questions($quizobj->get_quiz(), 1, $cat->id, 1, false);
$this->add_random_questions($quizobj->get_quizid(), 1, $cat->id, 1);
$structure = structure::create_for_quiz($quizobj);
$sql = 'SELECT qsr.*
FROM {question_set_references} qsr
+1 -1
View File
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2023082400;
$plugin->version = 2023083100;
$plugin->requires = 2023041800;
$plugin->component = 'mod_quiz';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+268
View File
@@ -0,0 +1,268 @@
// 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/>.
/**
* Question bank filter management.
*
* @module core_question/filter
* @copyright 2021 Tomo Tsuyuki <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import CoreFilter from 'core/datafilter';
import Notification from 'core/notification';
import Selectors from 'core/datafilter/selectors';
import Templates from 'core/templates';
import Fragment from 'core/fragment';
/**
* Initialise the question bank filter on the element with the given id.
*
* @param {String} filterRegionId ID of the HTML element containing the filters.
* @param {String} defaultcourseid Course ID for the default course to pass back to the view.
* @param {String} defaultcategoryid Question bank category ID for the default course to pass back to the view.
* @param {Number} perpage The number of questions to display per page.
* @param {Number} contextId Context ID of the question bank view.
* @param {string} component Frankenstyle name of the component for the fragment API callback (e.g. core_question)
* @param {string} callback Name of the callback for the fragment API (e.g question_data)
* @param {string} view The class name of the question bank view class used for this page.
* @param {Number} cmid If we are in an activitiy, the course module ID.
* @param {string} pagevars JSON-encoded parameters from passed from the view, including filters and jointype.
* @param {string} extraparams JSON-encoded additional parameters specific to this view class, used for re-rendering the view.
*/
export const init = (
filterRegionId,
defaultcourseid,
defaultcategoryid,
perpage,
contextId,
component,
callback,
view,
cmid,
pagevars,
extraparams
) => {
const SELECTORS = {
QUESTION_CONTAINER_ID: '#questionscontainer',
QUESTION_TABLE: '#questionscontainer table',
SORT_LINK: '#questionscontainer div.sorters a',
PAGINATION_LINK: '#questionscontainer a[href].page-link',
LASTCHANGED_FIELD: '#questionsubmit input[name=lastchanged]',
BULK_ACTIONS: '#bulkactionsui-container input',
MENU_ACTIONS: '.menu-action',
EDIT_SWITCH: '.editmode-switch-form input[name=setmode]',
EDIT_SWITCH_URL: '.editmode-switch-form input[name=pageurl]',
};
const filterSet = document.querySelector(`#${filterRegionId}`);
const filterCondition = {
cat: defaultcategoryid,
courseid: defaultcourseid,
filter: {},
jointype: 0,
qpage: 0,
qperpage: perpage,
sortdata: {},
tabname: 'questions',
};
const defaultSort = document.querySelector(SELECTORS.QUESTION_TABLE)?.dataset?.defaultsort;
if (defaultSort) {
filterCondition.sortData = JSON.parse(defaultSort);
}
let filterQuery = '';
/**
* Retrieve table data.
*
* @param {Object} filterdata data
* @param {Promise} pendingPromise pending promise
*/
const applyFilter = (filterdata, pendingPromise) => {
// Reload the questions based on the specified filters. If no filters are provided,
// use the default category filter condition.
if (filterdata) {
// Main join types.
filterCondition.jointype = parseInt(filterSet.dataset.filterverb, 10);
delete filterdata.jointype;
// Retrieve filter info.
filterCondition.filter = filterdata;
if (Object.keys(filterdata).length !== 0) {
if (!isNaN(filterCondition.jointype)) {
filterdata.jointype = filterCondition.jointype;
}
updateUrlParams(filterdata);
}
}
// Load questions for first page.
const viewData = {
view: view,
cmid: cmid,
filtercondition: JSON.stringify(filterCondition),
extraparams: extraparams,
filterquery: filterQuery,
lastchanged: document.querySelector(SELECTORS.LASTCHANGED_FIELD)?.value ?? null
};
Fragment.loadFragment(component, callback, contextId, viewData)
// Render questions for first page and pagination.
.then((questionhtml, jsfooter) => {
const questionscontainer = document.querySelector(SELECTORS.QUESTION_CONTAINER_ID);
if (questionhtml === undefined) {
questionhtml = '';
}
if (jsfooter === undefined) {
jsfooter = '';
}
Templates.replaceNodeContents(questionscontainer, questionhtml, jsfooter);
// Resolve filter promise.
if (pendingPromise) {
pendingPromise.resolve();
}
return {questionhtml, jsfooter};
})
.catch(Notification.exception);
};
// Init core filter processor with apply callback.
const coreFilter = new CoreFilter(filterSet, applyFilter);
coreFilter.activeFilters = {}; // Unset useless courseid filter.
coreFilter.init();
/**
* Update URL Param based upon the current filter.
*
* @param {Object} filters Active filters.
*/
const updateUrlParams = (filters) => {
const url = new URL(location.href);
const filterQuery = JSON.stringify(filters);
url.searchParams.set('filter', filterQuery);
history.pushState(filters, '', url);
document.querySelectorAll(SELECTORS.BULK_ACTIONS).forEach(bulkAction => {
const actionUrl = new URL(bulkAction.formAction);
const returnUrl = new URL(actionUrl.searchParams.get('returnurl'));
returnUrl.searchParams.set('filter', filterQuery);
actionUrl.searchParams.set('returnurl', returnUrl);
bulkAction.formAction = actionUrl;
});
const editSwitch = document.querySelector(SELECTORS.EDIT_SWITCH);
const editSwitchUrlInput = document.querySelector(SELECTORS.EDIT_SWITCH_URL);
const editSwitchUrl = new URL(editSwitchUrlInput.value);
editSwitchUrl.searchParams.set('filter', filterQuery);
editSwitchUrlInput.value = editSwitchUrl;
editSwitch.dataset.pageurl = editSwitchUrl;
};
/**
* Cleans URL parameters.
*/
const cleanUrlParams = () => {
const queryString = location.search;
const urlParams = new URLSearchParams(queryString);
if (urlParams.has('cmid')) {
const cleanedUrl = new URL(location.href.replace(location.search, ''));
cleanedUrl.searchParams.set('cmid', urlParams.get('cmid'));
history.pushState({}, '', cleanedUrl);
}
if (urlParams.has('courseid')) {
const cleanedUrl = new URL(location.href.replace(location.search, ''));
cleanedUrl.searchParams.set('courseid', urlParams.get('courseid'));
history.pushState({}, '', cleanedUrl);
}
};
// Add listeners for the sorting, paging and clear actions.
document.addEventListener('click', e => {
const sortableLink = e.target.closest(SELECTORS.SORT_LINK);
const paginationLink = e.target.closest(SELECTORS.PAGINATION_LINK);
const clearLink = e.target.closest(Selectors.filterset.actions.resetFilters);
if (sortableLink) {
e.preventDefault();
const oldSort = filterCondition.sortdata;
filterCondition.sortdata = {};
filterCondition.sortdata[sortableLink.dataset.sortname] = sortableLink.dataset.sortorder;
for (const sortname in oldSort) {
if (sortname !== sortableLink.dataset.sortname) {
filterCondition.sortdata[sortname] = oldSort[sortname];
}
}
filterCondition.qpage = 0;
coreFilter.updateTableFromFilter();
}
if (paginationLink) {
e.preventDefault();
const paginationURL = new URL(paginationLink.getAttribute("href"));
const qpage = paginationURL.searchParams.get('qpage');
if (paginationURL.search !== null) {
filterCondition.qpage = qpage;
coreFilter.updateTableFromFilter();
}
}
if (clearLink) {
cleanUrlParams();
}
});
// Run apply filter at page load.
pagevars = JSON.parse(pagevars);
let initialFilters;
let jointype = null;
if (pagevars.filter) {
// Load initial filter based on page vars.
initialFilters = pagevars.filter;
if (pagevars.jointype) {
jointype = pagevars.jointype;
}
}
if (Object.entries(initialFilters).length !== 0) {
// Remove the default empty filter row.
const emptyFilterRow = filterSet.querySelector(Selectors.filterset.regions.emptyFilterRow);
if (emptyFilterRow) {
emptyFilterRow.remove();
}
// Add filters.
let rowcount = 0;
for (const urlFilter in initialFilters) {
if (urlFilter === 'jointype') {
jointype = initialFilters[urlFilter];
continue;
}
// Add each filter row.
rowcount += 1;
const filterdata = {
filtertype: urlFilter,
values: initialFilters[urlFilter].values,
jointype: initialFilters[urlFilter].jointype,
filteroptions: initialFilters[urlFilter].filteroptions,
rownum: rowcount
};
coreFilter.addFilterRow(filterdata);
}
coreFilter.filterSet.dataset.filterverb = jointype;
// Since we must filter by category, it does not make sense to allow the top-level "match any" or "match none" conditions,
// as this would exclude the category. Remove those options and disable the select.
const join = coreFilter.filterSet.querySelector(Selectors.filterset.fields.join);
join.querySelectorAll(`option:not([value="${jointype}"])`).forEach((option) => option.remove());
join.disabled = true;
}
};
+5 -1
View File
@@ -75,7 +75,11 @@ if ($movequestionselected && $confirm && confirm_sesskey()) {
if ($confirm == md5($movequestionselected)) {
\qbank_bulkmove\helper::bulk_move_questions($movequestionselected, $tocategory);
}
redirect(new moodle_url($returnurl, ['category' => "{$tocategoryid},{$contextid}"]));
$returnfilters = \core_question\local\bank\filter_condition_manager::update_filter_param_to_category(
$returnurl->param('filter'),
$tocategoryid,
);
redirect(new moodle_url($returnurl, ['filter' => $returnfilters]));
}
echo $OUTPUT->header();
+52 -44
View File
@@ -120,66 +120,74 @@ class helper_test extends \advanced_testcase {
];
}
/**
* Count how many questions in the list belong to the given category.
*
* @param string $categoryid a category id
* @param array $questionids list of question ids
* @return int
*/
private function count_category_questions(string $categoryid, array $questionids): int {
global $DB;
$this->assertNotEmpty($questionids);
list($insql, $inparams) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED);
$sql = "SELECT COUNT(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.id = :categoryid
AND q.id $insql";
return $DB->count_records_sql($sql, array_merge(['categoryid' => $categoryid], $inparams));
}
/**
* Assert that the given category contains following questions
*
* @param string $categoryid a category id
* @param array $questionids list of question ids
* @return void
*/
protected function assert_category_contains_questions(string $categoryid, array $questionids) {
// The category need to contain all the questions.
$this->assertEquals(count($questionids), $this->count_category_questions($categoryid, $questionids));
}
/**
* Assert that the given category does not contain following questions
*
* @param string $categoryid a category id
* @param array $questionids list of question ids
* @return void
*/
protected function assert_category_does_not_contain_questions(string $categoryid, array $questionids) {
// The category does not contain any question.
$this->assertEquals(0, $this->count_category_questions($categoryid, $questionids));
}
/**
* Test bulk move of questions.
*
* @covers ::bulk_move_questions
*/
public function test_bulk_move_questions() {
global $DB;
$this->helper_setup();
// Verify that the questions are available in the current view.
$view = new \core_question\local\bank\view($this->contexts, new \moodle_url('/'), $this->course);
ob_start();
$pagevars = [
'qpage' => 0,
'qperpage' => DEFAULT_QUESTIONS_PER_PAGE,
'cat' => $this->cat->id . ',' . $this->context->id,
'recurse' => false,
'showhidden' => false,
'qbshowtext' => false
];
$view->display($pagevars, 'editq');
$html = ob_get_clean();
$this->assertStringContainsString('Example question', $html);
$this->assertStringContainsString('Example question second', $html);
// Get the processed question ids.
$questionlist = $this->process_question_ids_test();
$questionids = array_map('intval', explode(',', $questionlist));
// Verify that the questions are available in the current view.
$this->assert_category_contains_questions($this->cat->id, $questionids);
helper::bulk_move_questions($questionlist, $this->secondcategory);
// Verify the questions are not in the current category.
$view = new \core_question\local\bank\view($this->contexts, new \moodle_url('/'), $this->course);
ob_start();
$pagevars = [
'qpage' => 0,
'qperpage' => DEFAULT_QUESTIONS_PER_PAGE,
'cat' => $this->cat->id . ',' . $this->context->id,
'recurse' => false,
'showhidden' => false,
'qbshowtext' => false
];
$view->display($pagevars, 'editq');
$html = ob_get_clean();
$this->assertStringNotContainsString('Example question', $html);
$this->assertStringNotContainsString('Example question second', $html);
$this->assert_category_does_not_contain_questions($this->cat->id, $questionids);
// Verify the questions are in the new category.
$view = new \core_question\local\bank\view($this->contexts, new \moodle_url('/'), $this->course);
ob_start();
$pagevars = [
'qpage' => 0,
'qperpage' => DEFAULT_QUESTIONS_PER_PAGE,
'cat' => $this->secondcategory->id . ',' . $this->context->id,
'category' => $this->secondcategory->id . ',' . $this->context->id,
'recurse' => false,
'showhidden' => false,
'qbshowtext' => false
];
$view->display($pagevars, 'editq');
$html = ob_get_clean();
$this->assertStringContainsString('Example question', $html);
$this->assertStringContainsString('Example question second', $html);
$this->assert_category_contains_questions($this->secondcategory->id, $questionids);
}
/**
@@ -27,7 +27,7 @@ Feature: An plugin column can be reordered and displayed in the question bank vi
Scenario: Teacher can see proper view
Given I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1"
And I set the field "category" to "Question category 1"
When I apply question bank filter "Category" with value "Question category 1"
And I should see "Test question to be seen"
Then I should see "Teacher 1"
@@ -36,7 +36,7 @@ Feature: An plugin column can be reordered and displayed in the question bank vi
When I navigate to "Plugins > Question bank plugins > Column sort order" in site administration
And I drag "Created by (creator_name_column)" "text" and I drop it in "T (question_type_column)" "text"
And I am on the "Test quiz Q001" "mod_quiz > question bank" page logged in as "teacher1"
And I set the field "category" to "Question category 1"
And I apply question bank filter "Category" with value "Question category 1"
Then ".creatorname" "css_element" should appear before ".qtype" "css_element"
Scenario: Disabling and enabling column display is proper
+1 -1
View File
@@ -6,6 +6,6 @@ define("qbank_comment/comment",["exports","core/fragment","core/str","core/modal
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_fragment=_interopRequireDefault(_fragment),Str=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}(Str),_modal_events=_interopRequireDefault(_modal_events),_modal_factory=_interopRequireDefault(_modal_factory),_notification=_interopRequireDefault(_notification);_exports.init=questionSelector=>{const target=document.querySelector(questionSelector);target.addEventListener("click",(()=>{((questionId,courseID,contextId)=>{let args={questionid:questionId,courseid:courseID};_modal_factory.default.create({type:_modal_factory.default.types.SAVE_CANCEL,title:Str.get_string("commentheader","qbank_comment"),body:_fragment.default.loadFragment("qbank_comment","question_comment",contextId,args),large:!0}).then((modal=>{let root=modal.getRoot();return root.on(_modal_events.default.bodyRendered,(function(){document.querySelectorAll("div.comment-area a")[0].style.display="none"})),root.on("change","#question_comment_version_dropdown",(function(e){args.questionid=e.target.value,modal.setBody(_fragment.default.loadFragment("qbank_comment","question_comment",contextId,args))})),Str.get_strings([{key:"addcomment",component:"qbank_comment"},{key:"close",component:"qbank_comment"}]).then((strings=>{modal.setButtonText("save",strings[0]),modal.setButtonText("cancel",strings[1])})).fail(_notification.default.exception),root.on(_modal_events.default.cancel,(function(){location.reload(),modal.hide()})),root.on(_modal_events.default.save,(function(e){e.preventDefault();const submitlink=document.querySelectorAll("div.comment-area a")[0],textarea=document.querySelectorAll("div.comment-area textarea")[0];textarea.value!=textarea.getAttribute("aria-label")&&""!=textarea.value&&submitlink.click()})),root.on("click",'button[data-action="hide"]',(()=>{location.reload(),modal.hide()})),modal.show(),modal})).fail(_notification.default.exception)})(target.dataset.questionid,target.dataset.courseid,target.dataset.contextid)}))}}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_fragment=_interopRequireDefault(_fragment),Str=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}(Str),_modal_events=_interopRequireDefault(_modal_events),_modal_factory=_interopRequireDefault(_modal_factory),_notification=_interopRequireDefault(_notification);_exports.init=()=>{const target=document.querySelector("#categoryquestions");null!==target&&target.addEventListener("click",(e=>{e.target.dataset.target&&e.target.dataset.target.includes("questioncommentpreview")&&((questionId,courseID,contextId)=>{let args={questionid:questionId,courseid:courseID};_modal_factory.default.create({type:_modal_factory.default.types.SAVE_CANCEL,title:Str.get_string("commentheader","qbank_comment"),body:_fragment.default.loadFragment("qbank_comment","question_comment",contextId,args),large:!0}).then((modal=>{let root=modal.getRoot();return root.on(_modal_events.default.bodyRendered,(function(){document.querySelectorAll("div.comment-area a")[0].style.display="none"})),root.on("change","#question_comment_version_dropdown",(function(e){args.questionid=e.target.value,modal.setBody(_fragment.default.loadFragment("qbank_comment","question_comment",contextId,args))})),Str.get_strings([{key:"addcomment",component:"qbank_comment"},{key:"close",component:"qbank_comment"}]).then((strings=>{modal.setButtonText("save",strings[0]),modal.setButtonText("cancel",strings[1])})).fail(_notification.default.exception),root.on(_modal_events.default.cancel,(function(){location.reload(),modal.hide()})),root.on(_modal_events.default.save,(function(e){e.preventDefault();const submitlink=document.querySelectorAll("div.comment-area a")[0],textarea=document.querySelectorAll("div.comment-area textarea")[0];textarea.value!=textarea.getAttribute("aria-label")&&""!=textarea.value&&submitlink.click()})),root.on("click",'button[data-action="hide"]',(()=>{location.reload(),modal.hide()})),modal.show(),modal})).fail(_notification.default.exception)})(e.target.dataset.questionid,e.target.dataset.courseid,e.target.dataset.contextid)}))}}));
//# sourceMappingURL=comment.min.js.map
File diff suppressed because one or more lines are too long
+10 -7
View File
@@ -101,12 +101,15 @@ const commentEvent = (questionId, courseID, contextId) => {
* Entrypoint of the js.
*
* @method init
* @param {string} questionSelector the question comment identifier.
*/
export const init = (questionSelector) => {
const target = document.querySelector(questionSelector);
target.addEventListener('click', () => {
// Call for the event listener to listed for clicks in any comment count row.
commentEvent(target.dataset.questionid, target.dataset.courseid, target.dataset.contextid);
});
export const init = () => {
const target = document.querySelector('#categoryquestions');
if (target !== null) {
target.addEventListener('click', (e) => {
if (e.target.dataset.target && e.target.dataset.target.includes('questioncommentpreview')) {
// Call for the event listener to listed for clicks in any comment count row.
commentEvent(e.target.dataset.questionid, e.target.dataset.courseid, e.target.dataset.contextid);
}
});
}
};
@@ -29,6 +29,35 @@ use question_bank;
*/
class comment_count_column extends column_base {
/**
* @var bool Comments enabled or not from config.
*/
protected $commentsenabled = true;
/**
* Load javascript module if enabled.
*
* @return void
*/
public function init(): void {
parent::init();
$this->check_comments_status();
if ($this->commentsenabled) {
global $PAGE;
$PAGE->requires->js_call_amd('qbank_comment/comment', 'init');
}
}
/**
* Check if comments is turned on in the system or not.
*/
protected function check_comments_status(): void {
global $CFG;
if (!$CFG->usecomments) {
$this->commentsenabled = false;
}
}
/**
* Get the name of the column, used internally.
*
@@ -54,7 +83,7 @@ class comment_count_column extends column_base {
* @param string $rowclasses Classes that can be added.
*/
protected function display_content($question, $rowclasses): void {
global $DB, $PAGE;
global $DB;
$syscontext = \context_system::instance();
$args = [
'component' => 'qbank_comment',
@@ -66,8 +95,6 @@ class comment_count_column extends column_base {
$attributes = [];
if (question_has_capability_on($question, 'comment')) {
$target = 'questioncommentpreview_' . $question->id;
$datatarget = '[data-target="' . $target . '"]';
$PAGE->requires->js_call_amd('qbank_comment/comment', 'init', [$datatarget]);
$attributes = [
'href' => '#',
'data-target' => $target,
@@ -26,7 +26,7 @@ Feature: A Teacher can comment in a question
@javascript
Scenario: Add a comment in question
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I set the field "Select a category" to "Test questions"
And I apply question bank filter "Category" with value "Test questions"
And I should see "0" on the comments column
When I click "0" on the row on the comments column
And I add "Super test comment 01" comment to question
@@ -38,7 +38,7 @@ Feature: A Teacher can comment in a question
@javascript
Scenario: Delete a comment from question
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I set the field "Select a category" to "Test questions"
And I apply question bank filter "Category" with value "Test questions"
And I should see "0" on the comments column
When I click "0" on the row on the comments column
And I add "Super test comment 01 to be deleted" comment to question
@@ -55,7 +55,7 @@ Feature: A Teacher can comment in a question
@javascript
Scenario: Preview question with comments
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I set the field "Select a category" to "Test questions"
And I apply question bank filter "Category" with value "Test questions"
And I choose "Preview" action for "First question" in the question bank
And I click on "Comments" "link"
Then I should see "Save comment"
@@ -79,7 +79,7 @@ Feature: A Teacher can comment in a question
| moodle/question:commentmine | allow |
| moodle/question:commentall | prevent |
And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I set the field "Select a category" to "Test questions"
And I apply question bank filter "Category" with value "Test questions"
And I choose "Preview" action for "First question" in the question bank
Then I should not see "Save comment"
And I click on "Close preview" "button"
@@ -96,6 +96,7 @@ Feature: A Teacher can comment in a question
Then I should see "Save comment"
And I log out
And I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher2"
And I apply question bank filter "Category" with value "Test questions"
And I choose "Preview" action for "First question" in the question bank
Then I should not see "Save comment"
And I click on "Close preview" "button"
@@ -135,7 +136,6 @@ Feature: A Teacher can comment in a question
Given I log in as "teacher1"
And I am on the "Test quiz" "quiz activity" page
When I navigate to "Question bank" in current page administration
And I set the field "Select a category" to "Test questions"
And I should see "First question"
And I choose "Edit question" action for "First question" in the question bank
And I set the field "id_name" to "Renamed question v2"
@@ -0,0 +1,11 @@
define("qbank_deletequestion/datafilter/filtertypes/hidden",["exports","core/datafilter/filtertypes/binary"],(function(_exports,_binary){var obj;
/**
* Filter managing hidden questions.
*
* @module qbank_deletequestion/datafilter/filtertypes/hidden
* @author 2022 Ghaly Marc-Alexandre <marc-alexandreghaly@catalyst-ca.net>
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_binary=(obj=_binary)&&obj.__esModule?obj:{default:obj};class _default extends _binary.default{}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=hidden.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"hidden.min.js","sources":["../../../src/datafilter/filtertypes/hidden.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 * Filter managing hidden questions.\n *\n * @module qbank_deletequestion/datafilter/filtertypes/hidden\n * @author 2022 Ghaly Marc-Alexandre <[email protected]>\n * @copyright 2022 Catalyst IT Australia Pty Ltd\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Binary from 'core/datafilter/filtertypes/binary';\n\nexport default class extends Binary {\n}\n"],"names":["Binary"],"mappings":";;;;;;;;oKA0B6BA"}
@@ -1,4 +1,3 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
@@ -15,11 +14,15 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* File only retained to prevent fatal errors in code that tries to require/include this.
* Filter managing hidden questions.
*
* @todo MDL-76612 delete this file as part of Moodle 4.6 development.
* @deprecated This file is no longer required in Moodle 4.2+.
* @module qbank_deletequestion/datafilter/filtertypes/hidden
* @author 2022 Ghaly Marc-Alexandre <marc-alexandreghaly@catalyst-ca.net>
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER);
import Binary from 'core/datafilter/filtertypes/binary';
export default class extends Binary {
}
@@ -0,0 +1,72 @@
<?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 qbank_deletequestion;
use core\output\datafilter;
use core_question\local\bank\condition;
use core_question\local\bank\question_version_status;
/**
* This class controls whether hidden / deleted questions are hidden in the list.
*
* @package qbank_deletequestion
* @copyright 2013 Ray Morris
* @author 2021 Safat Shahin <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class hidden_condition extends condition {
public static function get_condition_key() {
return 'hidden';
}
/**
* Build query from filter value
*
* @param array $filter filter properties
* @return array where sql and params
*/
public static function build_query_from_filter(array $filter): array {
$showhidden = (bool)$filter['values'][0];
$where = "";
$params = [];
if (!$showhidden) {
$where = "qv.status <> :hidden_condition";
$params = ['hidden_condition' => question_version_status::QUESTION_STATUS_HIDDEN];
}
return [$where, $params];
}
public function get_title() {
return get_string('showhidden', 'core_question');
}
public function get_join_list(): array {
return [
datafilter::JOINTYPE_ANY,
];
}
public function get_filter_class() {
return 'qbank_deletequestion/datafilter/filtertypes/hidden';
}
public function is_required(): bool {
return true;
}
}
@@ -27,6 +27,7 @@ namespace qbank_deletequestion;
use core_question\local\bank\bulk_action_base;
use core_question\local\bank\plugin_features_base;
use core_question\local\bank\view;
/**
* Class columns is the entrypoint for the columns.
@@ -48,4 +49,10 @@ class plugin_feature extends plugin_features_base {
new bulk_delete_action(),
];
}
public function get_question_filters(view $qbank = null): array {
return [
new hidden_condition($qbank),
];
}
}
+12 -4
View File
@@ -106,10 +106,18 @@ if ($deleteselected) {
}
$questionlist = rtrim($questionlist, ',');
$deleteurl = new \moodle_url('/question/bank/deletequestion/delete.php', [
'deleteselected' => $questionlist, 'deleteall' => $deleteall, 'confirm' => md5($questionlist),
'sesskey' => sesskey(), 'returnurl' => $returnurl, 'cmid' => $cmid, 'courseid' => $courseid,
]);
$deleteurl = new \moodle_url(
'/question/bank/deletequestion/delete.php',
[
'deleteselected' => $questionlist,
'deleteall' => $deleteall,
'confirm' => md5($questionlist),
'sesskey' => sesskey(),
'returnurl' => $returnurl->out_as_local_url(false),
'cmid' => $cmid,
'courseid' => $courseid,
],
);
$continue = new \single_button($deleteurl, get_string('delete'), 'post');
$questionids = explode(',', $questionlist);
@@ -15,7 +15,7 @@
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template core_question/hidden_condition_advanced
@template qbank_deletequestion/hidden_condition_advanced
Example context (json):
{
@@ -62,9 +62,7 @@ Feature: Use the qbank plugin manager page for deletequestion
| Tags | foo |
And I click on "Save changes" "button"
And I am on the "Test quiz" "mod_quiz > question bank" page
And I open the autocomplete suggestions list
And I click on "foo" item in the autocomplete list
And "foo" "autocomplete_selection" should exist
And I apply question bank filter "Tag" with value "foo"
And I click on "First question" "checkbox"
And I click on "With selected" "button"
And I click on question bulk action "deleteselected"
@@ -20,7 +20,7 @@ Feature: Use the qbank base view to test the status change using
@javascript
Scenario: Question status dropdown should change the status of the question
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin"
And I set the field "Select a category" to "Test questions"
And I apply question bank filter "Category" with value "Test questions"
And I should see "Test questions"
And the field "question_status_dropdown" in the "First question" "table_row" matches value "Ready"
And the field "question_status_dropdown" in the "Second question" "table_row" matches value "Ready"
@@ -50,14 +50,22 @@ class question_history_view extends view {
* @param question_edit_contexts $contexts the contexts of api call
* @param moodle_url $pageurl url of the page
* @param stdClass $course course settings
* @param int $entryid quiz settings
* @param string $returnurl url to return to
* @param stdClass|null $cm (optional) activity settings.
* @param array $params the parameters required to initialize the api.
* @param array $extraparams any extra parameters need to initialized if the api is extended, it will be passed to js.
* @throws \moodle_exception
*/
public function __construct(question_edit_contexts $contexts, moodle_url $pageurl, stdClass $course, int $entryid,
string $returnurl) {
parent::__construct($contexts, $pageurl, $course);
$this->entryid = $entryid;
$this->basereturnurl = new \moodle_url($returnurl);
public function __construct(
question_edit_contexts $contexts,
moodle_url $pageurl,
stdClass $course,
stdClass $cm = null,
array $params = [],
array $extraparams = [],
) {
$this->entryid = $extraparams['entryid'];
$this->basereturnurl = new \moodle_url($extraparams['returnurl']);
parent::__construct($contexts, $pageurl, $course, $cm, $params, $extraparams);
}
protected function init_question_actions(): void {
@@ -78,31 +86,6 @@ class question_history_view extends view {
return $this->requiredcolumns;
}
public function wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext): void {
$categorydata = explode(',', $cat);
$contextid = $categorydata[1];
$catcontext = \context::instance_by_id($contextid);
$thiscontext = $this->get_most_specific_context();
$this->display_question_bank_header();
// Display tag filter if usetags setting is enabled/enablefilters is true.
if ($this->enablefilters) {
if (is_array($this->customfilterobjects)) {
foreach ($this->customfilterobjects as $filterobjects) {
$this->searchconditions[] = $filterobjects;
}
} else {
if (get_config('core', 'usetags')) {
array_unshift($this->searchconditions,
new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids));
}
array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
}
}
$this->display_options_form($showquestiontext);
}
protected function display_advanced_search_form($advancedsearch): void {
foreach ($advancedsearch as $searchcondition) {
echo $searchcondition->display_options_adv();
@@ -133,9 +116,9 @@ class question_history_view extends view {
// Build the order by clause.
$sorts = [];
foreach ($this->sort as $sort => $order) {
list($colname, $subsort) = $this->parse_subsort($sort);
$sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort);
foreach ($this->sort as $sortname => $sortorder) {
list($colname, $subsort) = $this->parse_subsort($sortname);
$sorts[] = $this->requiredcolumns[$colname]->sort_expression($sortorder == SORT_DESC, $subsort);
}
// Build the where clause.
+8 -3
View File
@@ -34,10 +34,15 @@ $returnurl = required_param('returnurl', PARAM_LOCALURL);
list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) =
question_edit_setup('questions', '/question/bank/history/history.php');
$pagevars['entryid'] = $entryid;
$pagevars['returnurl'] = $returnurl;
$url = new moodle_url($thispageurl, ['entryid' => $entryid, 'returnurl' => $returnurl]);
$PAGE->set_url($url);
$questionbank = new \qbank_history\question_history_view($contexts, $url, $COURSE, $entryid, $returnurl, $cm);
// Additional param to differentiate with other question bank view.
$extraparams['entryid'] = $entryid;
$extraparams['returnurl'] = $returnurl;
$questionbank = new \qbank_history\question_history_view($contexts, $url, $COURSE, null, $pagevars, $extraparams);
$streditingquestions = get_string('history_header', 'qbank_history');
$PAGE->set_title($streditingquestions);
@@ -49,5 +54,5 @@ $PAGE->navbar->add($streditingquestions, $url);
echo $OUTPUT->header();
// Print the question area.
$questionbank->display($pagevars, 'questions');
$questionbank->display();
echo $OUTPUT->footer();
@@ -16,7 +16,6 @@ Feature: Use the qbank plugin manager page for question history
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | First question | Answer the first question |
@javascript
Scenario: Enable/disable question history column from the base view
Given I log in as "admin"
When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration
@@ -32,8 +31,6 @@ Feature: Use the qbank plugin manager page for question history
Scenario: History page shows only the specified features and questions
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin"
And I choose "History" action for "First question" in the question bank
Then I should not see "Select a category"
And I should see "No tag filters applied"
And I should see "Question"
And I should see "Actions"
And I should see "Status"

Some files were not shown because too many files have changed in this diff Show More