MDL-72397 qbank_managecategories: UI enhancement

This implementation will introduce a new user interface for
"qbank_managecategories" plugin which will allow user to drag and drop.
Having this plugin will give users the flexibility of reordering, adding
or editing
categories from the qbank_managecategories view.

Co-authored-by: Marc-Alexandre Ghaly <[email protected]>
Co-authored-by: Luca Bösch <[email protected]>
This commit is contained in:
Mark Johnson
2024-08-22 10:59:24 +01:00
co-authored by Marc-Alexandre Ghaly Luca Bösch
parent 2419bd64de
commit 2dad710f12
66 changed files with 4200 additions and 280 deletions
@@ -0,0 +1,7 @@
issueNumber: MDL-72397
notes:
qbank_managecategories:
- message: >-
question_category_list and question_category_list_item are no longer
used, and are deprecated. Category lists are now generated by templates.
type: deprecated
+6 -16
View File
@@ -99,22 +99,12 @@ Feature: Edit quiz page - adding things
in various categories and add them to the question bank.
# Create a couple of sub categories.
When I am on the "Course 1" "core_question > course question categories" page
Then I should see "Add category"
And I follow "Add category"
Then I set the field "Parent category" to "Default for C1"
And I set the field "Name" to "Subcat 1"
And I set the field "Category info" to "This is sub category 1"
And I press "id_submitbutton"
And I should see "Subcat 1"
And I follow "Add category"
Then I set the field "Parent category" to "Default for C1"
And I set the field "Name" to "Subcat 2"
And I set the field "Category info" to "This is sub category 2"
And I press "id_submitbutton"
And I should see "Subcat 2"
Given the following "question categories" exist:
| contextlevel | reference | questioncategory | name |
| Course | C1 | Default for C1 | Subcat 1 |
| Course | C1 | Default for C1 | Subcat 2 |
When I am on "Course 1" course homepage
And I navigate to "Question bank" in current page administration
And I select "Questions" from the "Question bank tertiary navigation" singleselect
And I should see "Question bank"
+3 -1
View File
@@ -62,11 +62,13 @@ Feature: Moving a question to another category should not affect random question
And I press "Add random question"
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 open the action menu in "Used category" "list_item"
And I choose "Edit" in the open action menu
And I set the following fields to these values:
| Name | Used category new |
| Category info | I was edited |
And I press "Save changes"
And I set the field "Show descriptions" to "1"
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
@@ -0,0 +1,3 @@
define("qbank_managecategories/addcategory",["exports","core/reactive","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_categorymanager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={ADD_BUTTON:'[data-action="addeditcategory"]'}}stateReady(){this.addEventListener(this.getElement(this.selectors.ADD_BUTTON),"click",_categorymanager.categorymanager.showEditModal)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=addcategory.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"addcategory.min.js","sources":["../src/addcategory.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 * Add category button for displaying the modal form.\n *\n * This just connects up the button to the showEditModal listener.\n *\n * @module qbank_managecategories/addcategory\n * @class qbank_managecategories/addcategory\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n ADD_BUTTON: '[data-action=\"addeditcategory\"]',\n };\n }\n\n stateReady() {\n this.addEventListener(this.getElement(this.selectors.ADD_BUTTON), 'click', categorymanager.showEditModal);\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n const targetElement = document.querySelector(target);\n return new this({\n element: targetElement,\n selectors,\n reactive: categorymanager,\n });\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","ADD_BUTTON","stateReady","addEventListener","this","getElement","categorymanager","showEditModal","target","document","querySelector","reactive"],"mappings":"uQA2B6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,UAAY,CACbC,WAAY,mCAIpBC,kBACSC,iBAAiBC,KAAKC,WAAWD,KAAKJ,UAAUC,YAAa,QAASK,iCAAgBC,2BAUnFC,OAAQR,kBAET,IAAII,KAAK,CACZN,QAFkBW,SAASC,cAAcF,QAGzCR,UAAAA,UACAW,SAAUL"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
define("qbank_managecategories/categorylist",["exports","core/reactive","core/templates","core/str","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_templates,_str,_categorymanager){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=(obj=_templates)&&obj.__esModule?obj:{default:obj};class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={CATEGORY_LIST:".qbank_managecategories-categorylist",CATEGORY_ITEM:".qbank_managecategories-item[data-categoryid]",CATEGORY_CONTENTS:".qbank_managecategories-item > .container",CATEGORY_DETAILS:".qbank_managecategories-details",CATEGORY_NO_DRAGHANDLE:".qbank_managecategories-item[data-categoryid]:not(.draghandle)",CATEGORY_ID:id=>"#category-".concat(id)},this.classes={DROP_TARGET_BEFORE:"qbank_managecategories-droptarget-before",DROP_TARGET:"qbank_managecategories-droptarget",NO_BOTTOM_PADDING:"pb-0"},this.ids={CATEGORY:id=>"category-".concat(id)}}stateReady(){this.dragdrop=new _reactive.DragDrop(this)}destroy(){void 0!==this.dragdrop&&(this.dragdrop.unregister(),this.dragdrop=void 0)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}validateDropData(){return!0}showDropZone(dropData,event){const dropTarget=this.getElement();return!dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))&&(this.getInsertBefore(event,dropTarget)?(dropTarget.classList.add(this.classes.DROP_TARGET_BEFORE),dropTarget.classList.remove(this.classes.DROP_TARGET)):(dropTarget.classList.add(this.classes.DROP_TARGET),dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE)),!0)}hideDropZone(dropData,event){const dropTarget=event.target.closest(this.selectors.CATEGORY_LIST);dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE),dropTarget.classList.remove(this.classes.DROP_TARGET)}getInsertBefore(event,dropTarget){return event.clientY-dropTarget.getBoundingClientRect().top<dropTarget.clientHeight/2}drop(dropData,event){var _precedingSibling;const dropTarget=event.target.closest(this.selectors.CATEGORY_LIST);if(!dropTarget)return;if(dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id)))return;if(!document.getElementById(this.ids.CATEGORY(dropData.id)))return;const targetParentId=dropTarget.dataset.categoryid;let precedingSibling;precedingSibling=this.getInsertBefore(event,dropTarget)?null:dropTarget.lastElementChild,_categorymanager.categorymanager.moveCategory(dropData.id,targetParentId,null===(_precedingSibling=precedingSibling)||void 0===_precedingSibling?void 0:_precedingSibling.dataset.categoryid)}getWatchers(){return[{watch:"categoryLists[".concat(this.element.dataset.categoryid,"].childCount:updated"),handler:this.checkEmptyList},{watch:"categories:created",handler:this.addCategory}]}async checkEmptyList(_ref){let{element:element}=_ref;if(0===element.childCount){const categoryItem=this.getElement().closest(this.selectors.CATEGORY_ITEM),{html:html,js:js}=await _templates.default.renderForPromise("qbank_managecategories/newchild",{categoryid:this.getElement().dataset.categoryid,tooltip:(0,_str.getString)("newchild","qbank_managecategories",categoryItem.dataset.categoryname)}),activityNameArea=categoryItem.querySelector(this.selectors.CATEGORY_DETAILS);await _templates.default.appendNodeContents(activityNameArea,html,js),this.element.closest(this.selectors.CATEGORY_CONTENTS).classList.remove(this.classes.NO_BOTTOM_PADDING),this.remove()}}async addCategory(_ref2){let{element:element}=_ref2;if(element.parent!==this.getElement().dataset.categoryid)return;const{html:html,js:js}=await _templates.default.renderForPromise("qbank_managecategories/category",element.templatecontext);_templates.default.appendNodeContents(this.getElement(),html,js);const noDragHandle=this.getElement(this.selectors.CATEGORY_NO_DRAGHANDLE);noDragHandle&&this.reactive.dispatch("showDragHandle",noDragHandle.dataset.categoryid)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=categorylist.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
define("qbank_managecategories/categorymanager",["exports","core/reactive","core/str","qbank_managecategories/mutations","qbank_managecategories/events","core/ajax","core/notification","core_form/modalform"],(function(_exports,_reactive,_str,_mutations,_events,_ajax,_notification,_modalform){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=_exports.categorymanager=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_modalform=_interopRequireDefault(_modalform);const SELECTORS_CATEGORY_LIST=".qbank_managecategories-categorylist",SELECTORS_CONTEXT=".qbank_managecategories-categorylist[data-contextid]",SELECTORS_CATEGORY_ITEM=".qbank_managecategories-item[data-categoryid]",SELECTORS_CATEGORY_ROOT="#categoryroot",SELECTORS_SHOWDESCRIPTIONS_TOGGLE="#showdescriptions-toggle",SELECTORS_ADD_EDIT_BUTTON='[data-action="addeditcategory"]',CLASSES_DRAGHANDLE="draghandle",CLASSES_DANGER="alert-danger";class CategoryManager extends _reactive.Reactive{moveCategory(categoryId,targetParentId){let precedingSiblingId=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const call={methodname:"qbank_managecategories_move_category",args:{pagecontextid:this.state.page.contextid,categoryid:categoryId,targetparentid:targetParentId,precedingsiblingid:precedingSiblingId}};_ajax.default.call([call])[0].then((stateUpdates=>(this.stateManager.processUpdates(stateUpdates),stateUpdates))).catch((error=>{var _document$getElements;_notification.default.addNotification({message:error.message,type:"error"}),null===(_document$getElements=document.getElementsByClassName(CLASSES_DANGER)[0])||void 0===_document$getElements||_document$getElements.scrollIntoView()}))}getTitle(isEdit){return(0,_str.get_string)(isEdit?"editcategory":"addcategory","question")}getSave(isEdit){return isEdit?(0,_str.get_string)("savechanges","core"):(0,_str.get_string)("addcategory","question")}showEditModal(e){const addEditButton=e.target.closest(SELECTORS_ADD_EDIT_BUTTON);if(!addEditButton)return;if(!addEditButton.dataset.actiontype)return;e.preventDefault();const title=categorymanager.getTitle("edit"===addEditButton.dataset.actiontype),save=categorymanager.getSave("edit"===addEditButton.dataset.actiontype),cmid=addEditButton.dataset.cmid,courseid=addEditButton.dataset.courseid,questioncount=addEditButton.dataset.questioncount;let contextid=addEditButton.dataset.contextid,categoryid=null,sortorder=null,parent=null;const categoryItem=e.target.closest(SELECTORS_CATEGORY_ITEM);if(categoryItem){contextid=categoryItem.dataset.contextid,categoryid=categoryItem.dataset.categoryid,sortorder=categoryItem.dataset.sortorder;const parentContext=categoryItem.closest(SELECTORS_CONTEXT);parent=categoryItem.dataset.parent+","+parentContext.dataset.contextid}const modalForm=new _modalform.default({formClass:"qbank_managecategories\\form\\question_category_edit_form",args:{cmid:cmid,courseid:courseid,questioncount:questioncount,contextid:contextid,categoryid:categoryid,sortorder:sortorder,parent:parent},modalConfig:{title:title,large:!0},saveButtonText:save,returnFocus:addEditButton});modalForm.addEventListener(modalForm.events.FORM_SUBMITTED,(response=>{categorymanager.stateManager.processUpdates(response.detail)})),modalForm.show()}}const categorymanager=new CategoryManager({name:"qtype_managecategories_categorymanager",eventName:_events.eventTypes.qbankManagecategoriesStateUpdated,eventDispatch:_events.notifyQbankManagecategoriesStateUpdated,mutations:_mutations.mutations});_exports.categorymanager=categorymanager;_exports.init=()=>{(async reactive=>{const stateData={page:{contextid:document.querySelector(SELECTORS_CATEGORY_ROOT).dataset.contextid,showdescriptions:document.querySelector(SELECTORS_SHOWDESCRIPTIONS_TOGGLE).checked},categories:[],categoryLists:[]};document.querySelectorAll(SELECTORS_CATEGORY_ITEM).forEach((item=>{stateData.categories.push({id:item.dataset.categoryid,name:item.dataset.categoryname,parent:item.dataset.parent,contextid:item.dataset.contextid,sortorder:item.dataset.sortorder,draghandle:item.classList.contains(CLASSES_DRAGHANDLE)})})),document.querySelectorAll(SELECTORS_CATEGORY_LIST).forEach((categoryList=>{stateData.categoryLists.push({id:categoryList.dataset.categoryid,childCount:categoryList.querySelectorAll(SELECTORS_CATEGORY_ITEM).length})})),reactive.setInitialState(stateData)})(categorymanager)}}));
//# sourceMappingURL=categorymanager.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
define("qbank_managecategories/categoryroot",["exports","core/reactive","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_categorymanager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.classes={SHOWDESCRIPTIONS:"showdescriptions"}}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}getWatchers(){return[{watch:"page.showdescriptions:updated",handler:this.toggleDescriptions}]}toggleDescriptions(_ref){let{element:element}=_ref;element.showdescriptions?this.getElement().classList.add(this.classes.SHOWDESCRIPTIONS):this.getElement().classList.remove(this.classes.SHOWDESCRIPTIONS)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=categoryroot.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"categoryroot.min.js","sources":["../src/categoryroot.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 * The category root component.\n *\n * @module qbank_managecategories/categoryroot\n * @class qbank_managecategories/categoryroot\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.classes = {\n SHOWDESCRIPTIONS: 'showdescriptions',\n };\n }\n\n /**\n * Static method to create a component instance.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n selectors,\n reactive: categorymanager,\n });\n }\n\n /**\n * Watch for changes to the page state.\n *\n * @return {Array} A list of watchers.\n */\n getWatchers() {\n return [\n // Watch for descriptions being toggled.\n {watch: `page.showdescriptions:updated`, handler: this.toggleDescriptions}\n ];\n }\n\n /**\n * Show or hide descriptions when the flag in the state is changed.\n *\n * @param {Object} args\n * @param {Object} args.element The updated page state.\n */\n toggleDescriptions({element}) {\n if (element.showdescriptions) {\n this.getElement().classList.add(this.classes.SHOWDESCRIPTIONS);\n } else {\n this.getElement().classList.remove(this.classes.SHOWDESCRIPTIONS);\n }\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","classes","SHOWDESCRIPTIONS","target","selectors","this","document","querySelector","reactive","categorymanager","getWatchers","watch","handler","toggleDescriptions","showdescriptions","getElement","classList","add","remove"],"mappings":"wQAyB6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,QAAU,CACXC,iBAAkB,gCAWdC,OAAQC,kBACT,IAAIC,KAAK,CACZN,QAASO,SAASC,cAAcJ,QAChCC,UAAAA,UACAI,SAAUC,mCASlBC,oBACW,CAEH,CAACC,sCAAwCC,QAASP,KAAKQ,qBAU/DA,6BAAmBd,QAACA,cACZA,QAAQe,sBACHC,aAAaC,UAAUC,IAAIZ,KAAKJ,QAAQC,uBAExCa,aAAaC,UAAUE,OAAOb,KAAKJ,QAAQC"}
@@ -0,0 +1,3 @@
define("qbank_managecategories/events",["exports","core/event_dispatcher"],(function(_exports,_event_dispatcher){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.notifyQbankManagecategoriesStateUpdated=_exports.eventTypes=void 0;const eventTypes={qbankManagecategoriesStateUpdated:"qbank_managecategories/stateUpdated"};_exports.eventTypes=eventTypes;_exports.notifyQbankManagecategoriesStateUpdated=(detail,container)=>(0,_event_dispatcher.dispatchEvent)(eventTypes.qbankManagecategoriesStateUpdated,detail,container)}));
//# sourceMappingURL=events.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"events.min.js","sources":["../src/events.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\nimport {dispatchEvent} from 'core/event_dispatcher';\n\n/**\n * Reactive events for qbank_managecategories\n *\n * @module qbank_managecategories/events\n *\n */\nexport const eventTypes = {\n /**\n * Event triggered when the activity reactive state is updated.\n *\n * @event qbankManagecategoriesStateUpdated\n * @type {CustomEvent}\n * @property {Array} nodes The list of parent nodes which were updated\n */\n qbankManagecategoriesStateUpdated: 'qbank_managecategories/stateUpdated',\n};\n\n/**\n * Trigger an event to indicate that the activity state is updated.\n *\n * @method notifyQbankManagecategoriesStateUpdated\n * @param {Object} detail the full state\n * @param {HTMLElement} container the custom event target (document if none provided)\n * @returns {CustomEvent}\n * @fires qbankManagecategoriesStateUpdated\n */\nexport const notifyQbankManagecategoriesStateUpdated = (detail, container) => {\n return dispatchEvent(eventTypes.qbankManagecategoriesStateUpdated, detail, container);\n};\n"],"names":["eventTypes","qbankManagecategoriesStateUpdated","detail","container"],"mappings":"2PAuBaA,WAAa,CAQtBC,kCAAmC,uHAYgB,CAACC,OAAQC,aACrD,mCAAcH,WAAWC,kCAAmCC,OAAQC"}
@@ -0,0 +1,3 @@
define("qbank_managecategories/mutations",["exports","core_user/repository"],(function(_exports,_repository){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.mutations=void 0;const mutations=new class{async toggleDescriptions(stateManager,showDescriptions){stateManager.setReadOnly(!1),await(0,_repository.setUserPreference)("qbank_managecategories_showdescriptions",showDescriptions),stateManager.state.page.showdescriptions=showDescriptions,stateManager.setReadOnly(!0)}async showDragHandle(stateManager,categoryId){stateManager.setReadOnly(!1),stateManager.state.categories.get(categoryId).draghandle=!0,stateManager.setReadOnly(!0)}};_exports.mutations=mutations}));
//# sourceMappingURL=mutations.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"mutations.min.js","sources":["../src/mutations.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\nimport {setUserPreference} from 'core_user/repository';\n\n/**\n * Default mutation manager\n *\n * @module qbank_managecategories/mutations\n */\nclass Mutations {\n /**\n * Set the showdescriptions property to true or false in the page's state.\n *\n * @param {stateManager} stateManager\n * @param {boolean} showDescriptions\n * @return {Promise<void>}\n */\n async toggleDescriptions(stateManager, showDescriptions) {\n stateManager.setReadOnly(false);\n await setUserPreference('qbank_managecategories_showdescriptions', showDescriptions);\n stateManager.state.page.showdescriptions = showDescriptions;\n stateManager.setReadOnly(true);\n }\n\n /**\n * Set the draghandle property to true in a given category's state.\n *\n * @param {stateManager} stateManager\n * @param {Number} categoryId\n * @return {Promise<void>}\n */\n async showDragHandle(stateManager, categoryId) {\n stateManager.setReadOnly(false);\n stateManager.state.categories.get(categoryId).draghandle = true;\n stateManager.setReadOnly(true);\n }\n}\n\nexport const mutations = new Mutations();\n"],"names":["mutations","stateManager","showDescriptions","setReadOnly","state","page","showdescriptions","categoryId","categories","get","draghandle"],"mappings":"qMAmDaA,UAAY,mCArBIC,aAAcC,kBACnCD,aAAaE,aAAY,SACnB,iCAAkB,0CAA2CD,kBACnED,aAAaG,MAAMC,KAAKC,iBAAmBJ,iBAC3CD,aAAaE,aAAY,wBAURF,aAAcM,YAC/BN,aAAaE,aAAY,GACzBF,aAAaG,MAAMI,WAAWC,IAAIF,YAAYG,YAAa,EAC3DT,aAAaE,aAAY"}
@@ -0,0 +1,3 @@
define("qbank_managecategories/newchild",["exports","core/reactive","jquery","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_jquery,_categorymanager){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=(obj=_jquery)&&obj.__esModule?obj:{default:obj};class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={NEW_CHILD:".qbank_managecategories-newchild",CATEGORY_ID:id=>"#category-".concat(id)},this.classes={DROP_TARGET:"qbank_managecategories-droptarget"},this.ids={CATEGORY:id=>"category-".concat(id)}}stateReady(){this.dragdrop=new _reactive.DragDrop(this)}destroy(){void 0!==this.dragdrop&&(this.dragdrop.unregister(),this.dragdrop=void 0)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}validateDropData(dropData){return!this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))}showDropZone(dropData,event){const dropTarget=event.target.closest(this.selectors.NEW_CHILD);dropTarget.classList.add(this.classes.DROP_TARGET),(0,_jquery.default)(dropTarget).tooltip("show")}hideDropZone(dropData,event){const dropTarget=event.target.closest(this.selectors.NEW_CHILD);dropTarget.classList.remove(this.classes.DROP_TARGET),(0,_jquery.default)(dropTarget).tooltip("hide")}drop(dropData,event){const dropTarget=event.target.closest(this.selectors.NEW_CHILD);if(!dropTarget)return;if(!document.getElementById(this.ids.CATEGORY(dropData.id)))return;const targetParentId=dropTarget.dataset.parent;_categorymanager.categorymanager.moveCategory(dropData.id,targetParentId)}getWatchers(){return[{watch:"categories.parent:updated",handler:this.checkNewChild}]}checkNewChild(_ref){let{element:element}=_ref;element.parent===parseInt(this.element.dataset.parent)&&this.remove()}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=newchild.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
define("qbank_managecategories/showdescriptions",["exports","core/reactive","qbank_managecategories/categorymanager"],(function(_exports,_reactive,_categorymanager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _default extends _reactive.BaseComponent{create(descriptor){this.name=descriptor.element.id,this.selectors={TOGGLE:"#showdescriptions-toggle"}}stateReady(){this.addEventListener(this.getElement(this.selectors.TOGGLE),"change",this.updateShowDescriptions)}static init(target,selectors){return new this({element:document.querySelector(target),selectors:selectors,reactive:_categorymanager.categorymanager})}async updateShowDescriptions(event){const checked=event.target.checked;this.reactive.dispatch("toggleDescriptions",checked)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=showdescriptions.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"showdescriptions.min.js","sources":["../src/showdescriptions.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 * The show descriptions toggle component.\n *\n * @module qbank_managecategories/showdescriptions\n * @class qbank_managecategories/showdescriptions\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {categorymanager} from 'qbank_managecategories/categorymanager';\n\nexport default class extends BaseComponent {\n\n create(descriptor) {\n this.name = descriptor.element.id;\n this.selectors = {\n TOGGLE: '#showdescriptions-toggle',\n };\n }\n\n stateReady() {\n this.addEventListener(this.getElement(this.selectors.TOGGLE), 'change', this.updateShowDescriptions);\n }\n\n /**\n * Static method to create a component instance.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n const targetElement = document.querySelector(target);\n return new this({\n element: targetElement,\n selectors,\n reactive: categorymanager,\n });\n }\n\n /**\n * Dispatch a mutation to toggle the showDescriptions setting.\n *\n * @param {Event} event The toggle change event.\n * @return {Promise<void>}\n */\n async updateShowDescriptions(event) {\n const checked = event.target.checked;\n this.reactive.dispatch('toggleDescriptions', checked);\n }\n}\n"],"names":["BaseComponent","create","descriptor","name","element","id","selectors","TOGGLE","stateReady","addEventListener","this","getElement","updateShowDescriptions","target","document","querySelector","reactive","categorymanager","event","checked","dispatch"],"mappings":"4QAyB6BA,wBAEzBC,OAAOC,iBACEC,KAAOD,WAAWE,QAAQC,QAC1BC,UAAY,CACbC,OAAQ,4BAIhBC,kBACSC,iBAAiBC,KAAKC,WAAWD,KAAKJ,UAAUC,QAAS,SAAUG,KAAKE,oCAUrEC,OAAQP,kBAET,IAAII,KAAK,CACZN,QAFkBU,SAASC,cAAcF,QAGzCP,UAAAA,UACAU,SAAUC,gEAUWC,aACnBC,QAAUD,MAAML,OAAOM,aACxBH,SAASI,SAAS,qBAAsBD"}
@@ -0,0 +1,56 @@
// 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/>.
/**
* Add category button for displaying the modal form.
*
* This just connects up the button to the showEditModal listener.
*
* @module qbank_managecategories/addcategory
* @class qbank_managecategories/addcategory
*/
import {BaseComponent} from 'core/reactive';
import {categorymanager} from 'qbank_managecategories/categorymanager';
export default class extends BaseComponent {
create(descriptor) {
this.name = descriptor.element.id;
this.selectors = {
ADD_BUTTON: '[data-action="addeditcategory"]',
};
}
stateReady() {
this.addEventListener(this.getElement(this.selectors.ADD_BUTTON), 'click', categorymanager.showEditModal);
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
const targetElement = document.querySelector(target);
return new this({
element: targetElement,
selectors,
reactive: categorymanager,
});
}
}
@@ -0,0 +1,469 @@
// 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/>.
/**
* The category component.
*
* @module qbank_managecategories/category
* @class qbank_managecategories/category
*/
import {BaseComponent, DragDrop} from 'core/reactive';
import {categorymanager} from 'qbank_managecategories/categorymanager';
import Templates from 'core/templates';
import Modal from "core/modal";
import {get_string as getString} from "core/str";
export default class extends BaseComponent {
create(descriptor) {
this.name = descriptor.element.id;
this.selectors = {
CATEGORY_LIST: '.qbank_managecategories-categorylist',
CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',
CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',
EDIT_BUTTON: '[data-action="addeditcategory"]',
MOVE_BUTTON: '[role="menuitem"][data-actiontype="move"]',
CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',
MODAL_CATEGORY_ITEM: '.modal_category_item[data-movingcategoryid]',
CONTENT_AREA: '.qbank_managecategories-details',
CATEGORY_ID: id => `#category-${id}`,
CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`,
CHILD_LIST: id => `ul[data-categoryid="${id}"]`,
PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder="${sortorder}"]`,
};
this.classes = {
NO_BOTTOM_PADDING: 'pb-0',
DRAGHANDLE: 'draghandle',
DROPTARGET: 'qbank_managecategories-droptarget-before',
};
this.ids = {
CATEGORY: id => `category-${id}`,
};
}
stateReady() {
this.initDragDrop();
this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON), 'click', categorymanager.showEditModal);
const moveButton = this.getElement(this.selectors.MOVE_BUTTON);
this.addEventListener(moveButton, 'click', this.showMoveModal);
}
destroy() {
// The draggable element must be unregistered.
this.deInitDragDrop();
}
/**
* Remove any existing DragDrop component, and create a new one.
*/
initDragDrop() {
this.deInitDragDrop();
// If the element is currently draggable, register the getDraggableData method.
if (this.element.classList.contains(this.classes.DRAGHANDLE)) {
this.getDraggableData = this._getDraggableData;
}
this.dragdrop = new DragDrop(this);
}
/**
* If the DragDrop component is currently registered, unregister it.
*/
deInitDragDrop() {
if (this.dragdrop !== undefined) {
if (this.getDraggableData !== undefined) {
this.dragdrop.setDraggable(false);
this.getDraggableData = undefined;
}
this.dragdrop.unregister();
this.dragdrop = undefined;
}
}
/**
* Static method to create a component instance.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.querySelector(target),
selectors,
reactive: categorymanager,
});
}
/**
* Return the category ID from the component's element.
*
* This method is referenced as getDraggableData when the component can be dragged.
*
* @return {{id: string}}
* @private
*/
_getDraggableData() {
return {
id: this.getElement().dataset.categoryid
};
}
validateDropData() {
return true;
}
/**
* Highlight the top border of the category item.
*
* @param {Object} dropData
*/
showDropZone(dropData) {
if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {
// Can't drop onto itself or its own child.
return false;
}
this.getElement().classList.add(this.classes.DROPTARGET);
return true;
}
/**
* Remove highlighting.
*/
hideDropZone() {
this.getElement().classList.remove(this.classes.DROPTARGET);
}
/**
* Find the new position of the dropped category, and trigger the move.
*
* @param {Object} dropData The category being moved.
* @param {Event} event The drop event.
*/
drop(dropData, event) {
const dropTarget = event.target.closest(this.selectors.CATEGORY_ITEM);
if (!dropTarget) {
return;
}
if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {
// Can't drop onto your own child.
return;
}
const source = document.getElementById(this.ids.CATEGORY(dropData.id));
if (!source) {
return;
}
const targetParentId = dropTarget.dataset.parent;
const parentList = dropTarget.closest(this.selectors.CATEGORY_LIST);
let precedingSibling;
if (dropTarget === parentList.firstElementChild) {
// Dropped at the top of the list.
precedingSibling = null;
} else {
precedingSibling = dropTarget.previousElementSibling;
}
// Insert the category after the target category
categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);
}
getWatchers() {
return [
// After any update to this category, move it to the new position.
{watch: `categories[${this.element.dataset.categoryid}]:updated`, handler: this.updatePosition},
// When the template context is added or updated, re-render the content.
{watch: `categories[${this.element.dataset.categoryid}].templatecontext:created`, handler: this.rerender},
{watch: `categories[${this.element.dataset.categoryid}].templatecontext:updated`, handler: this.rerender},
// When a new category is created, check whether we need to add a child list to this category.
{watch: `categories:created`, handler: this.checkChildList},
];
}
/**
* Re-render the category content.
*
* @param {Object} args
* @param {Element} args.element
* @return {Promise<Array>}
*/
async rerender({element}) {
const {html, js} = await Templates.renderForPromise(
'qbank_managecategories/category_details',
element.templatecontext
);
return Templates.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA), html, js);
}
/**
* Render and append a new child list.
*
* @param {Object} context Template context, must include at least categoryid.
* @return {Promise<Element>}
*/
async createChildList(context) {
const {html, js} = await Templates.renderForPromise(
'qbank_managecategories/childlist',
context,
);
const parentContainer = document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid));
await Templates.appendNodeContents(parentContainer, html, js);
const childList = document.querySelector(this.selectors.CHILD_LIST(context.categoryid));
childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING);
return childList;
}
/**
* Move a category to its new position.
*
* A category may change its parent, sortorder and draghandle independently or at the same time. This method will resolve those
* changes and move the element to the new position. If the parent doesn't already have a child list, one will be created.
*
* If the parent has changed, this will also update the state with the new child count of the old and new parents.
*
* @param {Object} args
* @param {Object} args.element
* @return {Promise<void>}
*/
async updatePosition({element}) {
window.console.log('updating', this.getElement());
window.console.log('new state', element);
// Move to a new parent category.
let newParent;
const originParent = document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent));
if (parseInt(this.getElement().dataset.parent) !== element.parent) {
newParent = document.querySelector(this.selectors.CHILD_LIST(element.parent));
if (!newParent) {
// The target category doesn't have a child list yet. We'd better create one.
newParent = await this.createChildList({categoryid: element.parent});
}
this.getElement().dataset.parent = element.parent;
} else {
newParent = this.getElement().parentElement;
}
window.console.log('newParent', newParent);
// Move to a new position within the parent.
let previousSibling;
let nextSibling;
if (newParent.firstElementChild && parseInt(element.sortorder) <= parseInt(newParent.firstElementChild.dataset.sortorder)) {
// Move to the top of the list.
nextSibling = newParent.firstElementChild;
} else {
// Move later in the list.
previousSibling = newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder - 1));
nextSibling = previousSibling?.nextElementSibling;
}
window.console.log('previousSibling', previousSibling);
window.console.log('nextSibling', nextSibling);
// Check if this has actually moved, or if it's just having its sortorder updated due to another element moving.
const moved = (newParent !== this.getElement().parentElement || nextSibling !== this.getElement());
window.console.log('moved', moved);
if (moved) {
if (nextSibling) {
window.console.log('insertBefore');
// Move to the specified position in the list.
newParent.insertBefore(this.getElement(), nextSibling);
} else {
window.console.log('appendChild');
// Move to the end of the list (may also be the top of the list is empty).
newParent.appendChild(this.getElement());
}
}
if (originParent !== newParent) {
// Update child count of old and new parent.
this.reactive.stateManager.processUpdates([
{
name: 'categoryLists',
action: 'put',
fields: {
id: originParent.dataset.categoryid,
childCount: originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
}
},
{
name: 'categoryLists',
action: 'put',
fields: {
id: newParent.dataset.categoryid,
childCount: newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
}
}
]);
}
this.element.dataset.sortorder = element.sortorder;
// Enable/disable dragging.
const isDraggable = this.element.classList.contains(this.classes.DRAGHANDLE);
if (isDraggable && !element.draghandle) {
this.element.classList.remove(this.classes.DRAGHANDLE);
this.initDragDrop();
} else if (!isDraggable && element.draghandle) {
this.element.classList.add(this.classes.DRAGHANDLE);
this.initDragDrop();
}
}
/**
* Recursively create a list of all valid destinations for a current category within a parent category.
*
* @param {Element} item
* @param {Number} movingCategoryId
* @return {Array<Object>}
*/
createMoveCategoryList(item, movingCategoryId) {
const categories = [];
if (item.children) {
let precedingSibling = null;
item.children.forEach(category => {
const categoryId = parseInt(category.dataset.categoryid);
// Don't create a target for the category that's moving.
if (categoryId === movingCategoryId) {
return;
}
// Create a target to move before this child.
let child = {
categoryid: categoryId,
movingcategoryid: movingCategoryId,
precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0,
parent: category.dataset.parent,
categoryname: category.dataset.categoryname,
categories: null,
current: categoryId === movingCategoryId,
};
const childList = category.querySelector(this.selectors.CATEGORY_LIST);
if (childList) {
// If the child has its own children, recursively make a list of those.
child.categories = this.createMoveCategoryList(childList, movingCategoryId);
} else {
// Otherwise, create a target to move as a new child of this one.
child.categories = [
{
movingcategoryid: movingCategoryId,
precedingsiblingid: 0,
parent: categoryId,
categoryname: category.dataset.categoryname,
categories: null,
newchild: true,
}
];
}
categories.push(child);
precedingSibling = category;
});
const precedingId = parseInt(precedingSibling.dataset.categoryid);
if (precedingId !== movingCategoryId) {
// If this is the last child of its parent, also create a target to move the category after this one.
categories.push({
movingcategoryid: movingCategoryId,
precedingsiblingid: precedingId,
parent: precedingSibling.dataset.parent,
categoryname: precedingSibling.dataset.categoryname,
categories: null,
lastchild: true,
});
}
}
return categories;
}
/**
* Displays a modal containing links to move the category to a new location.
*
* @param {Event} e Button click event.
*/
async showMoveModal(e) {
// Return if it is not menu item.
const item = e.target;
if (!item) {
return;
}
// Return if it is disabled.
if (item.getAttribute('aria-disabled') === 'true') {
return;
}
// Prevent addition click on the item.
item.setAttribute('aria-disabled', true);
// Build the list of move links.
let moveList = {contexts: []};
const contexts = document.querySelectorAll(this.selectors.CONTEXT);
contexts.forEach(context => {
const moveContext = {
contextname: context.dataset.contextname,
categories: [],
hascategories: false,
};
moveContext.categories = this.createMoveCategoryList(context, parseInt(item.dataset.categoryid));
moveContext.hascategories = moveContext.categories.length > 0;
moveList.contexts.push(moveContext);
});
const modal = await Modal.create({
title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname),
body: Templates.render('qbank_managecategories/move_context_list', moveList),
footer: '',
show: true,
large: true,
});
// Show modal and add click event for list items.
modal.getBody()[0].addEventListener('click', e => {
const target = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);
if (!target) {
return;
}
categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid);
modal.destroy();
});
item.setAttribute('aria-disabled', false);
}
/**
* Check and add a child list if needed.
*
* Check whether the category that has just been added has this category as its parent. If it does,
* check that this category has a child list, and if not, add one.
*
* @param {Object} args
* @param {Element} args.element The new category.
* @return {Promise<Element>}
*/
async checkChildList({element}) {
if (element.parent !== this.getElement().dataset.categoryid) {
return null; // Not for me.
}
let childList = this.getElement(this.selectors.CATEGORY_LIST);
if (childList) {
return null; // List already exists, it will handle adding the new category.
}
// Render and add a new child list containing the new category.
return this.createChildList({
categoryid: element.parent,
children: [
element.templatecontext,
]
});
}
}
@@ -0,0 +1,236 @@
// 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/>.
/**
* The category list component.
*
* The category list is a drop target, so that a category may be dropped at the top or bottom of the list.
*
* @module qbank_managecategories/categorylist
* @class qbank_managecategories/categorylist
*/
import {BaseComponent, DragDrop} from 'core/reactive';
import Templates from 'core/templates';
import {getString} from 'core/str';
import {categorymanager} from 'qbank_managecategories/categorymanager';
export default class extends BaseComponent {
create(descriptor) {
this.name = descriptor.element.id;
this.selectors = {
CATEGORY_LIST: '.qbank_managecategories-categorylist',
CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',
CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',
CATEGORY_DETAILS: '.qbank_managecategories-details',
CATEGORY_NO_DRAGHANDLE: '.qbank_managecategories-item[data-categoryid]:not(.draghandle)',
CATEGORY_ID: id => `#category-${id}`,
};
this.classes = {
DROP_TARGET_BEFORE: 'qbank_managecategories-droptarget-before',
DROP_TARGET: 'qbank_managecategories-droptarget',
NO_BOTTOM_PADDING: 'pb-0',
};
this.ids = {
CATEGORY: id => `category-${id}`,
};
}
stateReady() {
this.dragdrop = new DragDrop(this);
}
destroy() {
// The draggable element must be unregistered.
if (this.dragdrop !== undefined) {
this.dragdrop.unregister();
this.dragdrop = undefined;
}
}
/**
* Static method to create a component instance.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.querySelector(target),
selectors,
reactive: categorymanager,
});
}
validateDropData() {
return true;
}
/**
* Highlight the border of the list where the category will be moved.
*
* If dropping at the top of the list, highlight the top border.
* If dropping at the bottom, highlight the bottom border.
*
* @param {Object} dropData
* @param {Event} event
*/
showDropZone(dropData, event) {
const dropTarget = this.getElement();
if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {
// Can't drop onto its own child.
return false;
}
if (this.getInsertBefore(event, dropTarget)) {
dropTarget.classList.add(this.classes.DROP_TARGET_BEFORE);
dropTarget.classList.remove(this.classes.DROP_TARGET);
} else {
dropTarget.classList.add(this.classes.DROP_TARGET);
dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE);
}
return true;
}
/**
* Remove highlighting.
*
* @param {Object} dropData
* @param {Event} event
*/
hideDropZone(dropData, event) {
const dropTarget = event.target.closest(this.selectors.CATEGORY_LIST);
dropTarget.classList.remove(this.classes.DROP_TARGET_BEFORE);
dropTarget.classList.remove(this.classes.DROP_TARGET);
}
/**
* Determine whether we're dragging over the top or bottom half of the list.
*
* @param {Event} event
* @param {Element} dropTarget
* @return {boolean}
*/
getInsertBefore(event, dropTarget) {
// Get the current mouse position within the drop target
const mouseY = event.clientY - dropTarget.getBoundingClientRect().top;
// Get the height of the drop target
const targetHeight = dropTarget.clientHeight;
// Check if the mouse is over the top half of the drop target
return mouseY < targetHeight / 2;
}
/**
* Find the new position of the dropped category, and trigger the move.
*
* @param {Object} dropData
* @param {Event} event
*/
drop(dropData, event) {
const dropTarget = event.target.closest(this.selectors.CATEGORY_LIST);
if (!dropTarget) {
return;
}
if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {
// Can't drop onto your own child.
return;
}
const source = document.getElementById(this.ids.CATEGORY(dropData.id));
if (!source) {
return;
}
const targetParentId = dropTarget.dataset.categoryid;
let precedingSibling;
if (this.getInsertBefore(event, dropTarget)) {
// Dropped at the top of the list.
precedingSibling = null;
} else {
// Dropped at the bottom of the list.
precedingSibling = dropTarget.lastElementChild;
}
// Insert the category after the target category
categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);
}
/**
* Watch for categories moving to a new parent.
*
* @return {Array} A list of watchers.
*/
getWatchers() {
return [
// Watch for this category having its child count updated.
{watch: `categoryLists[${this.element.dataset.categoryid}].childCount:updated`, handler: this.checkEmptyList},
// Watch for any new category being created.
{watch: `categories:created`, handler: this.addCategory},
];
}
/**
* If this list is now empty, remove it.
*
* @param {Object} args
* @param {Object} args.element The categoryList state element.
*/
async checkEmptyList({element}) {
if (element.childCount === 0) {
// Display a new child drop zone.
const categoryItem = this.getElement().closest(this.selectors.CATEGORY_ITEM);
const {html, js} = await Templates.renderForPromise(
'qbank_managecategories/newchild',
{
categoryid: this.getElement().dataset.categoryid,
tooltip: getString('newchild', 'qbank_managecategories', categoryItem.dataset.categoryname)
}
);
const activityNameArea = categoryItem.querySelector(this.selectors.CATEGORY_DETAILS);
await Templates.appendNodeContents(activityNameArea, html, js);
// Reinstate padding on the parent element.
this.element.closest(this.selectors.CATEGORY_CONTENTS).classList.remove(this.classes.NO_BOTTOM_PADDING);
// Remove this list.
this.remove();
}
}
/**
* If a newly-created category has this list's category as its parent, add it to this list.
*
* @param {Object} args
* @param {Object} args.element
* @return {Promise<void>}
*/
async addCategory({element}) {
if (element.parent !== this.getElement().dataset.categoryid) {
return; // Not for me.
}
const {html, js} = await Templates.renderForPromise('qbank_managecategories/category', element.templatecontext);
Templates.appendNodeContents(this.getElement(), html, js);
// If one of the children has no draghandle, it should do now it has a sibling.
const noDragHandle = this.getElement(this.selectors.CATEGORY_NO_DRAGHANDLE);
if (noDragHandle) {
this.reactive.dispatch('showDragHandle', noDragHandle.dataset.categoryid);
}
}
}
@@ -0,0 +1,226 @@
// 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/>.
/**
* Reactive module for category manager
*
* @module qbank_managecategories/categorymanager
*/
import {Reactive} from 'core/reactive';
import {get_string as getString} from 'core/str';
import {mutations} from 'qbank_managecategories/mutations';
import {eventTypes, notifyQbankManagecategoriesStateUpdated} from 'qbank_managecategories/events';
import Ajax from "core/ajax";
import Notification from "core/notification";
import ModalForm from 'core_form/modalform';
const SELECTORS = {
CATEGORY_LIST: '.qbank_managecategories-categorylist',
CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',
CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',
CATEGORY_ROOT: '#categoryroot',
SHOWDESCRIPTIONS_TOGGLE: '#showdescriptions-toggle',
ADD_EDIT_BUTTON: '[data-action="addeditcategory"]',
};
const CLASSES = {
DRAGHANDLE: 'draghandle',
DANGER: 'alert-danger',
};
/**
* Load the initial state.
*
* This iterates over the initial tree of category items, and captures the data required for the state from each category.
* It also captures a count of the number of children in each list.
*
* @param {Reactive} reactive
* @return {Promise<void>}
*/
const loadState = async(reactive) => {
const rootElement = document.querySelector(SELECTORS.CATEGORY_ROOT);
const stateData = {
page: {
contextid: rootElement.dataset.contextid,
showdescriptions: document.querySelector(SELECTORS.SHOWDESCRIPTIONS_TOGGLE).checked,
},
categories: [],
categoryLists: [],
};
const listItems = document.querySelectorAll(SELECTORS.CATEGORY_ITEM);
listItems.forEach(item => {
stateData.categories.push({
id: item.dataset.categoryid,
name: item.dataset.categoryname,
parent: item.dataset.parent,
contextid: item.dataset.contextid,
sortorder: item.dataset.sortorder,
draghandle: item.classList.contains(CLASSES.DRAGHANDLE),
});
});
const categoryLists = document.querySelectorAll(SELECTORS.CATEGORY_LIST);
categoryLists.forEach(categoryList => {
stateData.categoryLists.push({
id: categoryList.dataset.categoryid,
childCount: categoryList.querySelectorAll(SELECTORS.CATEGORY_ITEM).length,
});
});
reactive.setInitialState(stateData);
};
/**
* Reactive instance for the category manager.
*/
class CategoryManager extends Reactive {
/**
* Move a category to a new position within the given parent.
*
* This will call the move_category web service function to re-order the categories, then update
* the state with the returned updates.
*
* @param {Number} categoryId The ID of the category being moved.
* @param {Number} targetParentId The ID of the destination parent category (this may not have changed).
* @param {Number} precedingSiblingId The ID of the category to put the moved category after.
* This may be null if moving to the top of a list.
*/
moveCategory(
categoryId,
targetParentId,
precedingSiblingId = null,
) {
const call = {
methodname: 'qbank_managecategories_move_category',
args: {
pagecontextid: this.state.page.contextid,
categoryid: categoryId,
targetparentid: targetParentId,
precedingsiblingid: precedingSiblingId,
}
};
Ajax.call([call])[0]
.then((stateUpdates) => {
this.stateManager.processUpdates(stateUpdates);
return stateUpdates;
})
.catch(error => {
Notification.addNotification({
message: error.message,
type: 'error',
});
document.getElementsByClassName(CLASSES.DANGER)[0]?.scrollIntoView();
});
}
/**
* Return title for the add/edit modal.
*
* @param {boolean} isEdit is 'add' or 'edit' form
* @returns {String} title string
*/
getTitle(isEdit) {
return getString(isEdit ? 'editcategory' : 'addcategory', 'question');
}
/**
* Return save button label for the add/edit modal.
*
* @param {boolean} isEdit is 'add' or 'edit' form
* @returns {String} save string
*/
getSave(isEdit) {
return isEdit ? getString('savechanges', 'core') : getString('addcategory', 'question');
}
/**
* Function handling display of modal form.
*
* @param {Event} e The click event triggering the modal.
*/
showEditModal(e) {
const addEditButton = e.target.closest(SELECTORS.ADD_EDIT_BUTTON);
// Return if it is not 'addeditcategory' button.
if (!addEditButton) {
return;
}
// Return if the action type is not specified.
if (!addEditButton.dataset.actiontype) {
return;
}
e.preventDefault();
// Data for the modal.
const title = categorymanager.getTitle(addEditButton.dataset.actiontype === 'edit');
const save = categorymanager.getSave(addEditButton.dataset.actiontype === 'edit');
const cmid = addEditButton.dataset.cmid;
const courseid = addEditButton.dataset.courseid;
const questioncount = addEditButton.dataset.questioncount;
let contextid = addEditButton.dataset.contextid;
let categoryid = null;
let sortorder = null;
let parent = null;
const categoryItem = e.target.closest(SELECTORS.CATEGORY_ITEM);
if (categoryItem) {
contextid = categoryItem.dataset.contextid;
categoryid = categoryItem.dataset.categoryid;
sortorder = categoryItem.dataset.sortorder;
const parentContext = categoryItem.closest(SELECTORS.CONTEXT);
parent = categoryItem.dataset.parent + ',' + parentContext.dataset.contextid;
}
// Call the modal.
const modalForm = new ModalForm({
formClass: "qbank_managecategories\\form\\question_category_edit_form",
args: {
cmid,
courseid,
questioncount,
contextid,
categoryid,
sortorder,
parent,
},
modalConfig: {
title: title,
large: true,
},
saveButtonText: save,
returnFocus: addEditButton,
});
// Once the form has been submitted via the web service, update the state with the new or updated
// category based on the web service response.
modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (response) => {
categorymanager.stateManager.processUpdates(response.detail);
});
// Show the form.
modalForm.show();
}
}
export const categorymanager = new CategoryManager({
name: 'qtype_managecategories_categorymanager',
eventName: eventTypes.qbankManagecategoriesStateUpdated,
eventDispatch: notifyQbankManagecategoriesStateUpdated,
mutations,
});
/**
* Load the initial state.
*/
export const init = () => {
loadState(categorymanager);
};
@@ -0,0 +1,75 @@
// 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/>.
/**
* The category root component.
*
* @module qbank_managecategories/categoryroot
* @class qbank_managecategories/categoryroot
*/
import {BaseComponent} from 'core/reactive';
import {categorymanager} from 'qbank_managecategories/categorymanager';
export default class extends BaseComponent {
create(descriptor) {
this.name = descriptor.element.id;
this.classes = {
SHOWDESCRIPTIONS: 'showdescriptions',
};
}
/**
* Static method to create a component instance.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.querySelector(target),
selectors,
reactive: categorymanager,
});
}
/**
* Watch for changes to the page state.
*
* @return {Array} A list of watchers.
*/
getWatchers() {
return [
// Watch for descriptions being toggled.
{watch: `page.showdescriptions:updated`, handler: this.toggleDescriptions}
];
}
/**
* Show or hide descriptions when the flag in the state is changed.
*
* @param {Object} args
* @param {Object} args.element The updated page state.
*/
toggleDescriptions({element}) {
if (element.showdescriptions) {
this.getElement().classList.add(this.classes.SHOWDESCRIPTIONS);
} else {
this.getElement().classList.remove(this.classes.SHOWDESCRIPTIONS);
}
}
}
@@ -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/>.
import {dispatchEvent} from 'core/event_dispatcher';
/**
* Reactive events for qbank_managecategories
*
* @module qbank_managecategories/events
*
*/
export const eventTypes = {
/**
* Event triggered when the activity reactive state is updated.
*
* @event qbankManagecategoriesStateUpdated
* @type {CustomEvent}
* @property {Array} nodes The list of parent nodes which were updated
*/
qbankManagecategoriesStateUpdated: 'qbank_managecategories/stateUpdated',
};
/**
* Trigger an event to indicate that the activity state is updated.
*
* @method notifyQbankManagecategoriesStateUpdated
* @param {Object} detail the full state
* @param {HTMLElement} container the custom event target (document if none provided)
* @returns {CustomEvent}
* @fires qbankManagecategoriesStateUpdated
*/
export const notifyQbankManagecategoriesStateUpdated = (detail, container) => {
return dispatchEvent(eventTypes.qbankManagecategoriesStateUpdated, detail, container);
};
@@ -0,0 +1,52 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import {setUserPreference} from 'core_user/repository';
/**
* Default mutation manager
*
* @module qbank_managecategories/mutations
*/
class Mutations {
/**
* Set the showdescriptions property to true or false in the page's state.
*
* @param {stateManager} stateManager
* @param {boolean} showDescriptions
* @return {Promise<void>}
*/
async toggleDescriptions(stateManager, showDescriptions) {
stateManager.setReadOnly(false);
await setUserPreference('qbank_managecategories_showdescriptions', showDescriptions);
stateManager.state.page.showdescriptions = showDescriptions;
stateManager.setReadOnly(true);
}
/**
* Set the draghandle property to true in a given category's state.
*
* @param {stateManager} stateManager
* @param {Number} categoryId
* @return {Promise<void>}
*/
async showDragHandle(stateManager, categoryId) {
stateManager.setReadOnly(false);
stateManager.state.categories.get(categoryId).draghandle = true;
stateManager.setReadOnly(true);
}
}
export const mutations = new Mutations();
@@ -0,0 +1,139 @@
// 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/>.
/**
* The newchild component.
*
* This is a drop target for moving a category to an as-yet-nonexistant child list under another category.
*
* @module qbank_managecategories/newchild
* @class qbank_managecategories/newchild
*/
import {BaseComponent, DragDrop} from 'core/reactive';
import $ from 'jquery';
import {categorymanager} from 'qbank_managecategories/categorymanager';
export default class extends BaseComponent {
create(descriptor) {
this.name = descriptor.element.id;
this.selectors = {
NEW_CHILD: '.qbank_managecategories-newchild',
CATEGORY_ID: id => `#category-${id}`
};
this.classes = {
DROP_TARGET: 'qbank_managecategories-droptarget',
};
this.ids = {
CATEGORY: id => `category-${id}`,
};
}
stateReady() {
this.dragdrop = new DragDrop(this);
}
destroy() {
// The draggable element must be unregistered.
if (this.dragdrop !== undefined) {
this.dragdrop.unregister();
this.dragdrop = undefined;
}
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
const targetElement = document.querySelector(target);
return new this({
element: targetElement,
selectors,
reactive: categorymanager,
});
}
/**
* Cannot drop a category as a new child of its own descendant.
*
* @param {Object} dropData
* @return {boolean}
*/
validateDropData(dropData) {
if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {
return false;
}
return true;
}
showDropZone(dropData, event) {
const dropTarget = event.target.closest(this.selectors.NEW_CHILD);
dropTarget.classList.add(this.classes.DROP_TARGET);
$(dropTarget).tooltip('show');
}
hideDropZone(dropData, event) {
const dropTarget = event.target.closest(this.selectors.NEW_CHILD);
dropTarget.classList.remove(this.classes.DROP_TARGET);
$(dropTarget).tooltip('hide');
}
drop(dropData, event) {
const dropTarget = event.target.closest(this.selectors.NEW_CHILD);
if (!dropTarget) {
return;
}
const source = document.getElementById(this.ids.CATEGORY(dropData.id));
if (!source) {
return;
}
const targetParentId = dropTarget.dataset.parent;
// Insert the category as the first child of the new parent.
categorymanager.moveCategory(dropData.id, targetParentId);
}
/**
* Watch for categories moving to a new parent.
*
* @return {Array} A list of watchers.
*/
getWatchers() {
return [
// Watch for any category having its parent changed.
{watch: `categories.parent:updated`, handler: this.checkNewChild},
];
}
/**
* If an element now has this category as the parent, remove this new child target.
*
* @param {Object} args
* @param {Element} args.element
*/
checkNewChild({element}) {
if (element.parent === parseInt(this.element.dataset.parent)) {
this.remove();
}
}
}
@@ -0,0 +1,65 @@
// 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/>.
/**
* The show descriptions toggle component.
*
* @module qbank_managecategories/showdescriptions
* @class qbank_managecategories/showdescriptions
*/
import {BaseComponent} from 'core/reactive';
import {categorymanager} from 'qbank_managecategories/categorymanager';
export default class extends BaseComponent {
create(descriptor) {
this.name = descriptor.element.id;
this.selectors = {
TOGGLE: '#showdescriptions-toggle',
};
}
stateReady() {
this.addEventListener(this.getElement(this.selectors.TOGGLE), 'change', this.updateShowDescriptions);
}
/**
* Static method to create a component instance.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
const targetElement = document.querySelector(target);
return new this({
element: targetElement,
selectors,
reactive: categorymanager,
});
}
/**
* Dispatch a mutation to toggle the showDescriptions setting.
*
* @param {Event} event The toggle change event.
* @return {Promise<void>}
*/
async updateShowDescriptions(event) {
const checked = event.target.checked;
this.reactive.dispatch('toggleDescriptions', checked);
}
}
+48 -107
View File
@@ -24,101 +24,62 @@
*/
require_once(__DIR__ . '/../../../config.php');
require_once($CFG->dirroot."/question/editlib.php");
require_once($CFG->dirroot . '/question/editlib.php');
use core_question\output\qbank_actionbar;
use core_question\category_manager;
use qbank_managecategories\form\question_move_form;
use qbank_managecategories\helper;
use qbank_managecategories\question_category_object;
use qbank_managecategories\output\categories;
use qbank_managecategories\output\categories_header;
use qbank_managecategories\question_categories;
require_login();
core_question\local\bank\helper::require_plugin_enabled(helper::PLUGINNAME);
list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) =
question_edit_setup('categories', '/question/bank/managecategories/category.php');
question_edit_setup('categories', '/question/bank/managecategories/category.php');
$courseid = optional_param('courseid', 0, PARAM_INT);
// Get values from form for actions on this page.
$param = new stdClass();
$param->moveup = optional_param('moveup', 0, PARAM_INT);
$param->movedown = optional_param('movedown', 0, PARAM_INT);
$param->moveupcontext = optional_param('moveupcontext', 0, PARAM_INT);
$param->movedowncontext = optional_param('movedowncontext', 0, PARAM_INT);
$param->tocontext = optional_param('tocontext', 0, PARAM_INT);
$param->left = optional_param('left', 0, PARAM_INT);
$param->right = optional_param('right', 0, PARAM_INT);
$param->delete = optional_param('delete', 0, PARAM_INT);
$param->confirm = optional_param('confirm', 0, PARAM_INT);
$param->cancel = optional_param('cancel', '', PARAM_ALPHA);
$param->move = optional_param('move', 0, PARAM_INT);
$param->moveto = optional_param('moveto', 0, PARAM_INT);
$param->edit = optional_param('edit', null, PARAM_INT);
$url = new moodle_url($thispageurl);
foreach ((array)$param as $key => $value) {
if (($key !== 'cancel' && $key !== 'edit' && $value !== 0) ||
($key === 'cancel' && $value !== '') ||
($key === 'edit' && $value !== null)) {
$url->param($key, $value);
}
}
$PAGE->set_url($url);
$qcobject = new question_category_object($pagevars['cpage'], $thispageurl,
$contexts->having_one_edit_tab_cap('categories'), $param->edit,
$pagevars['cat'], $param->delete, $contexts->having_cap('moodle/question:add'));
if ($param->left || $param->right || $param->moveup || $param->movedown) {
require_sesskey();
foreach ($qcobject->editlists as $list) {
// Processing of these actions is handled in the method where appropriate and page redirects.
$list->process_actions($param->left, $param->right, $param->moveup, $param->movedown);
}
if (!is_null($cmid)) {
$thiscontext = context_module::instance($cmid)->id;
} else {
$course = get_course($courseid);
$thiscontext = context_course::instance($course->id)->id;
}
if ($param->moveupcontext || $param->movedowncontext) {
require_sesskey();
$todelete = optional_param('delete', 0, PARAM_INT); // The ID of a category to delete.
if ($param->moveupcontext) {
$catid = $param->moveupcontext;
} else {
$catid = $param->movedowncontext;
}
$newtopcat = question_get_top_category($param->tocontext);
if (!$newtopcat) {
throw new moodle_exception('invalidcontext');
}
$oldcat = $DB->get_record('question_categories', ['id' => $catid], '*', MUST_EXIST);
// Log the move to another context.
$category = new stdClass();
$category->id = explode(',', $pagevars['cat'], -1)[0];
$category->contextid = $param->tocontext;
$event = \core\event\question_category_moved::create_from_question_category_instance($category);
$event->trigger();
// Update the set_reference records when moving a category to a different context.
move_question_set_references($catid, $catid, $oldcat->contextid, $category->contextid);
$qcobject->update_category($catid, "{$newtopcat->id},{$param->tocontext}", $oldcat->name, $oldcat->info);
// The previous line does a redirect().
}
$PAGE->set_url($thispageurl);
$PAGE->add_body_class('limitedwidth');
if ($param->delete) {
if (!$category = $DB->get_record("question_categories", ["id" => $param->delete])) {
throw new moodle_exception('nocate', 'question', $thispageurl->out(), $param->delete);
$manager = new category_manager($thispageurl);
if ($todelete) {
if (!$category = $DB->get_record("question_categories", ["id" => $todelete])) {
throw new moodle_exception('nocate', 'question', $thispageurl->out(), $todelete);
}
helper::question_remove_stale_questions_from_category($param->delete);
helper::question_remove_stale_questions_from_category($todelete);
$questionstomove = count($qcobject->get_real_question_ids_in_category($param->delete));
$questionstomove = count($manager->get_real_question_ids_in_category($todelete));
// Second pass, if we still have questions to move, setup the form.
if ($questionstomove) {
$categorycontext = context::instance_by_id($category->contextid);
$moveform = new question_move_form($thispageurl,
['contexts' => [$categorycontext], 'currentcat' => $param->delete]);
['contexts' => [$categorycontext], 'currentcat' => $todelete]);
if ($moveform->is_cancelled()) {
$thispageurl->remove_all_params();
if (!is_null($cmid)) {
$thispageurl->param('cmid', $cmid);
} else {
$thispageurl->param('courseid', $courseid);
}
redirect($thispageurl);
} else if ($formdata = $moveform->get_data()) {
list($tocategoryid, $tocontextid) = explode(',', $formdata->category);
$qcobject->move_questions_and_delete_category($formdata->delete, $tocategoryid);
$manager->move_questions_and_delete_category($formdata->delete, $tocategoryid);
$thispageurl->remove_params('cat', 'category');
redirect($thispageurl);
}
@@ -127,54 +88,24 @@ if ($param->delete) {
$questionstomove = 0;
}
if ($qcobject->catform->is_cancelled()) {
redirect($thispageurl);
} else if ($catformdata = $qcobject->catform->get_data()) {
$catformdata->infoformat = $catformdata->info['format'];
$catformdata->info = $catformdata->info['text'];
if (!$catformdata->id) {// New category.
$qcobject->add_category($catformdata->parent, $catformdata->name,
$catformdata->info, false, $catformdata->infoformat, $catformdata->idnumber);
} else {
$qcobject->update_category($catformdata->id, $catformdata->parent,
$catformdata->name, $catformdata->info, $catformdata->infoformat, $catformdata->idnumber);
}
redirect($thispageurl);
} else if ((!empty($param->delete) and (!$questionstomove) and confirm_sesskey())) {
$qcobject->delete_category($param->delete);// Delete the category now no questions to move.
if ((!empty($todelete) && (!$questionstomove) && confirm_sesskey())) {
$manager->delete_category($todelete);// Delete the category now no questions to move.
$thispageurl->remove_params('cat', 'category');
redirect($thispageurl);
}
if ($param->edit !== null || $qcobject->catform->is_submitted()) {
// In the is_submitted case, we only get here if it was submitted,
// but not valid, so we need to show the validation error.
$PAGE->navbar->add(get_string('editingcategory', 'question'));
}
$PAGE->set_title(get_string('editcategories', 'question'));
$PAGE->set_heading($COURSE->fullname);
$PAGE->activityheader->disable();
echo $OUTPUT->header();
// Print horizontal nav if needed.
$renderer = $PAGE->get_renderer('core_question', 'bank');
$qbankaction = new \core_question\output\qbank_action_menu($url);
if (is_null($param->edit)) {
$actionurl = new moodle_url($url, ['edit' => 0]);
$qbankaction->set_action_button($actionurl, get_string('addcategory', 'question'));
}
$categoriesrenderer = $PAGE->get_renderer('qbank_managecategories');
echo $OUTPUT->header();
$qbankaction = new \core_question\output\qbank_action_menu($thispageurl);
echo $renderer->render($qbankaction);
// Display the UI.
if ($param->edit !== null || $qcobject->catform->is_submitted()) {
// In the is_submitted case, we only get here if it was submitted,
// but not valid, so we need to show the validation error.
// In this case, category id is in the 'id' hidden filed.
$qcobject->edit_single_category($param->edit ?? required_param('id', PARAM_INT));
} else if ($questionstomove) {
if ($questionstomove) {
$vars = new stdClass();
$vars->name = $category->name;
$vars->count = $questionstomove;
@@ -182,6 +113,16 @@ if ($param->edit !== null || $qcobject->catform->is_submitted()) {
$moveform->display();
} else {
// Display the user interface.
$qcobject->display_user_interface();
$questioncategories = new question_categories(
$thispageurl,
$contexts->having_one_edit_tab_cap('categories'),
$cmid,
$courseid,
$thiscontext,
);
$PAGE->requires->js_call_amd('qbank_managecategories/categorymanager', 'init'); // Load reactive module.
echo $OUTPUT->render(new categories_header($questioncategories));
echo $OUTPUT->render(new categories($questioncategories));
}
echo $OUTPUT->footer();
@@ -0,0 +1,264 @@
<?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_managecategories\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_value;
use core_external\external_single_structure;
use core_external\external_multiple_structure;
use core_question\category_manager;
use moodle_exception;
use context;
use qbank_managecategories\helper;
/**
* External class used for category reordering.
*
* @package qbank_managecategories
* @category external
* @copyright 2024 Catalyst IT Europe Ltd.
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class move_category extends external_api {
/**
* Describes the parameters for update_category_order webservice.
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'pagecontextid' => new external_value(PARAM_INT, 'The context of the current page'),
'categoryid' => new external_value(PARAM_INT, 'Category being moved'),
'targetparentid' => new external_value(PARAM_INT, 'The ID of the parent category to move to.'),
'precedingsiblingid' => new external_value(
PARAM_INT,
'The ID of the preceding category. Null if this is being moved to top of its parent',
allownull: NULL_ALLOWED,
),
]);
}
/**
* Returns description of method result value.
*
* This function will always return a set of state updates for the core/reactive state.
* {@link https://moodledev.io/docs/4.2/guides/javascript/reactive#controlling-the-state-from-the-backend}
*
* @return external_multiple_structure
*/
public static function execute_returns(): external_multiple_structure {
return new external_multiple_structure(
new external_single_structure(
[
'name' => new external_value(PARAM_ALPHA, 'State object name (always "categories" from this function).'),
'action' => new external_value(PARAM_ALPHA, 'State update type (always "put" from this function).'),
'fields' => new external_single_structure(
[
'id' => new external_value(PARAM_INT, 'The ID of the category that was updated.'),
'sortorder' => new external_value(PARAM_INT, 'The new sortorder', VALUE_OPTIONAL),
'parent' => new external_value(PARAM_INT, 'The ID of the new parent category.', VALUE_OPTIONAL),
'context' => new external_value(PARAM_INT, 'The ID of the new context.', VALUE_OPTIONAL),
'draghandle' => new external_value(
PARAM_BOOL,
'Should this category have a drag handle?',
VALUE_OPTIONAL
),
]
),
],
'An individual state update',
),
'Category state updates',
);
}
/**
* Move category to new location.
*
* @param int $pagecontextid ID of the context of the current page.
* @param int $categoryid ID of the category to move.
* @param int $targetparentid The ID of the parent category to move to.
* @param ?int $precedingsiblingid The ID of the preceding category. Null if this is being moved to top of its parent.
* @return array Reactive state updates representing the changes made to the categories.
*/
public static function execute(
int $pagecontextid,
int $categoryid,
int $targetparentid,
?int $precedingsiblingid = null
): array {
// Update category location.
global $DB, $CFG;
require_once($CFG->libdir . '/questionlib.php');
$context = context::instance_by_id($pagecontextid);
self::validate_context($context);
$manager = new category_manager();
$origincategory = $DB->get_record('question_categories', ['id' => $categoryid], '*', MUST_EXIST);
$targetparent = $DB->get_record('question_categories', ['id' => $targetparentid], '*', MUST_EXIST);
if ($precedingsiblingid) {
$precedingsibling = $DB->get_record('question_categories', ['id' => $precedingsiblingid], '*', MUST_EXIST);
}
// Check permission for original and destination contexts.
$manager->require_manage_category(context::instance_by_id($origincategory->contextid));
if ($origincategory->contextid != $targetparent->contextid) {
$manager->require_manage_category(context::instance_by_id($targetparent->contextid));
}
$originstateupdate = self::make_state_update($origincategory->id);
$stateupdates = [];
$transaction = $DB->start_delegated_transaction();
// Set new parent.
if ($origincategory->parent !== $targetparent->id) {
$newsiblings = $DB->get_fieldset('question_categories', 'id', ['parent' => $targetparent->id]);
if (
count($newsiblings) == 1
&& $manager->is_only_child_of_top_category_in_context(reset($newsiblings))
) {
// If we are moving to a top-level parent that only had 1 category before, allow re-ordering of that category.
$stateupdates[] = self::make_state_update(reset($newsiblings), draghandle: true);
}
$originstateupdate->fields->parent = $targetparent->id;
}
// Change to the same context.
if ($origincategory->contextid !== $targetparent->contextid) {
// Check for duplicate idnumber.
if (
!is_null($origincategory->idnumber)
&& !$manager->idnumber_is_unique_in_context($origincategory->idnumber, $targetparent->contextid)
) {
throw new moodle_exception('idnumberexists', 'qbank_managecategories');
}
$originstateupdate->fields->context = $targetparent->contextid;
}
// Update sort order.
if ($precedingsiblingid) {
$sortorder = $precedingsibling->sortorder + 1;
} else {
$sortorder = 1;
}
$originstateupdate->fields->sortorder = $sortorder;
// Save the updated parent, context and sortorder.
$manager->update_category(
$categoryid,
helper::combine_id_context($targetparent),
$origincategory->name,
$origincategory->info,
$origincategory->infoformat,
$origincategory->idnumber,
$sortorder,
);
// Get other categories which are after the new position, and update their sortorder.
$params = [
'parent' => $targetparent->id,
'sortorder' => $sortorder,
'origincategoryid' => $origincategory->id,
];
$select = "
parent = :parent
AND id <> :origincategoryid
AND sortorder >= :sortorder";
$sort = "sortorder ASC";
$toupdatesortorder = $DB->get_records_select('question_categories', $select, $params, $sort);
foreach ($toupdatesortorder as $category) {
$DB->set_field('question_categories', 'sortorder', ++$sortorder, ['id' => $category->id]);
$stateupdates[] = self::make_state_update($category->id, sortorder: $sortorder);
}
if (isset($originstateupdate->fields->parent)) {
// If the category has moved parent, re-order the original siblings to fill the gap.
$originsortorder = 1;
$params = [
'parent' => $origincategory->parent,
];
$select = "parent = :parent";
$sort = "sortorder ASC";
$originsiblings = $DB->get_records_select('question_categories', $select, $params, $sort);
if (
count($originsiblings) == 1
&& $manager->is_only_child_of_top_category_in_context(reset($originsiblings)->id)
) {
// If this is now the only category in the context, don't allow re-ordering.
$stateupdates[] = self::make_state_update(
reset($originsiblings)->id,
sortorder: $originsortorder,
draghandle: false,
);
} else {
foreach ($originsiblings as $category) {
if ($category->sortorder !== $originsortorder) {
$DB->set_field('question_categories', 'sortorder', $originsortorder, ['id' => $category->id]);
$stateupdates[] = self::make_state_update($category->id, sortorder: $originsortorder);
}
$originsortorder++;
}
}
}
$transaction->allow_commit();
// Return the updated for the moved category, followed by any additional updates that happened as a result.
array_unshift($stateupdates, $originstateupdate);
return $stateupdates;
}
/**
* Generate a category state update based on the provided fields.
*
* @param int $id Category ID, required.
* @param int|null $sortorder New sortorder, optional.
* @param int|null $parent Category ID of new parent, optional.
* @param bool|null $draghandle Set display of the drag handle. Optional.
* @return \stdClass The update object.
*/
protected static function make_state_update(
int $id,
?int $sortorder = null,
?int $parent = null,
?bool $draghandle = null,
): \stdClass {
$update = (object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $id,
],
];
if (!is_null($sortorder)) {
$update->fields->sortorder = $sortorder;
}
if (!is_null($parent)) {
$update->fields->parent = $parent;
}
if (!is_null($draghandle)) {
$update->fields->draghandle = $draghandle;
}
return $update;
}
}
@@ -16,12 +16,20 @@
namespace qbank_managecategories\form;
use moodleform;
use context;
use context_module;
use context_course;
use qbank_managecategories\helper;
use moodle_exception;
use moodle_url;
use core_question\local\bank\question_edit_contexts;
use qbank_managecategories\output\category;
use core_question\category_manager;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/formslib.php');
require_once($CFG->libdir . '/formslib.php');
require_once($CFG->libdir . '/questionlib.php');
/**
* Defines the form for editing question categories.
@@ -32,7 +40,7 @@ require_once($CFG->libdir.'/formslib.php');
* @copyright 2007 Jamie Pratt [email protected]
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_category_edit_form extends moodleform {
class question_category_edit_form extends \core_form\dynamic_form {
/** @var ?category_manager $manager */
protected ?category_manager $manager = null;
@@ -51,6 +59,39 @@ class question_category_edit_form extends moodleform {
return $this->manager;
}
/**
* Get the category, contexts, course and cmid based on the data provided via AJAX.
*
* @return array
* @throws \coding_exception
*/
protected function get_current_data() {
// If categoryid is set, we are editing an existing category.
$currentcat = isset($this->_ajaxformdata['categoryid']) ? (int)$this->_ajaxformdata['categoryid'] : 0;
// Determine the context based on the provided IDs.
$cmid = isset($this->_ajaxformdata['cmid']) ? (int)$this->_ajaxformdata['cmid'] : 0;
$courseid = isset($this->_ajaxformdata['courseid']) ? (int)$this->_ajaxformdata['courseid'] : 0;
if ($cmid !== 0) {
$thiscontext = context_module::instance($cmid);
}
if ($courseid !== 0) {
$thiscontext = context_course::instance($courseid);
}
if ($courseid === 0 && $cmid === 0) {
$parentcontext = (int)explode(',', $this->_ajaxformdata['parent'])[1];
$contextid = $parentcontext === 0 ? $this->_ajaxformdata['contextid'] : (int)$parentcontext;
$thiscontext = context::instance_by_id($contextid);
}
if ($thiscontext) {
$contexts = new question_edit_contexts($thiscontext);
$contexts = $contexts->all();
}
return [$currentcat, $cmid, $courseid, $thiscontext, $contexts];
}
/**
* Build the form definition.
*
@@ -60,13 +101,32 @@ class question_category_edit_form extends moodleform {
protected function definition() {
$mform = $this->_form;
$contexts = $this->_customdata['contexts'];
$currentcat = $this->_customdata['currentcat'];
[$currentcat, $cmid, $courseid, $thiscontext, $contexts] = $this->get_current_data();
$mform->addElement('header', 'categoryheader', get_string('addcategory', 'question'));
$mform->addElement('hidden', 'contextid', $thiscontext->id);
$mform->setType('contextid', PARAM_INT);
$mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'),
['contexts' => $contexts, 'top' => true, 'currentcat' => $currentcat, 'nochildrenof' => $currentcat]);
$mform->addElement('hidden', 'courseid', $courseid);
$mform->setType('courseid', PARAM_INT);
$mform->addElement('hidden', 'cmid', $cmid);
$mform->setType('cmid', PARAM_INT);
$mform->addElement('hidden', 'currentparent');
$mform->setDefault('currentparent', $this->_ajaxformdata['parent'] ?? '');
$mform->setType('currentparent', PARAM_SEQUENCE);
$mform->addElement(
'questioncategory',
'parent',
get_string('parentcategory', 'question'),
[
'contexts' => $contexts,
'top' => true,
'currentcat' => $currentcat,
'nochildrenof' => $currentcat,
],
);
$mform->setType('parent', PARAM_SEQUENCE);
if ($this->get_manager()->is_only_child_of_top_category_in_context($currentcat)) {
$mform->hardFreeze('parent');
@@ -87,33 +147,22 @@ class question_category_edit_form extends moodleform {
$mform->addHelpButton('idnumber', 'idnumber', 'question');
$mform->setType('idnumber', PARAM_RAW);
$this->add_action_buttons(true, get_string('addcategory', 'question'));
$mform->addElement('hidden', 'id', 0);
$mform->setType('id', PARAM_INT);
}
/**
* Set data method.
*
* Add additional information to current data.
* @param \stdClass|array $current Object or array of default current data.
*/
public function set_data($current) {
if (is_object($current)) {
$current = (array) $current;
}
if (!empty($current['info'])) {
$current['info'] = ['text' => $current['info'], 'infoformat' => $current['infoformat']];
} else {
$current['info'] = ['text' => '', 'infoformat' => FORMAT_HTML];
}
parent::set_data($current);
$mform->addElement('hidden', 'questioncount', $this->_ajaxformdata['questioncount'] ?? 0);
$mform->setType('questioncount', PARAM_INT);
$mform->addElement('hidden', 'sortorder', $this->_ajaxformdata['sortorder'] ?? 0);
$mform->setType('sortorder', PARAM_INT);
}
/**
* Validation.
*
* Ensure that we aren't trying to move the only child of a top category to a different context.
* Ensure that the ID Number is unique within the target context.
*
* @param array $data
* @param array $files
* @return array the errors that were found
@@ -123,21 +172,150 @@ class question_category_edit_form extends moodleform {
global $DB;
$errors = parent::validation($data, $files);
$currentrec = $DB->get_record('question_categories', ['id' => $data['id']]);
$currentparent = $data['currentparent'];
$data['parent'] ??= $currentparent; // If no parent was submitted, use the current parent.
[$parentid, $contextid] = explode(',', $data['parent']);
if ($currentrec) {
$currentparent = $currentrec->parent . ',' . $currentrec->contextid;
// Cannot move the last category in a context to another parent.
$lastcategoryinthiscontext = $this->get_manager()->is_only_child_of_top_category_in_context($data['id']);
if ($lastcategoryinthiscontext && $currentparent !== $data['parent']) {
if ($parentid !== $this->_ajaxformdata['id']) {
$errors['parent'] = get_string('lastcategoryinthiscontext', 'qbank_managecategories');
}
}
// Cannot move category in same category.
if ($currentrec->id === $parentid && $currentrec->contextid === $contextid) {
$errors['parent'] = get_string('categoryincategory', 'qbank_managecategories');
}
}
// Add field validation check for duplicate idnumber.
list($parentid, $contextid) = explode(',', $data['parent']);
if (((string) $data['idnumber'] !== '') && !empty($contextid)) {
$conditions = 'contextid = ? AND idnumber = ?';
$params = [$contextid, $data['idnumber']];
if (!empty($data['id'])) {
$conditions .= ' AND id <> ?';
$params[] = $data['id'];
}
if ($DB->record_exists_select('question_categories', $conditions, $params)) {
$errors['idnumber'] = get_string('idnumbertaken', 'error');
}
$id = $data['id'] ?? null;
if (!empty($data['idnumber']) && !$this->get_manager()->idnumber_is_unique_in_context($data['idnumber'], $contextid, $id)) {
$errors['idnumber'] = get_string('idnumbertaken', 'error');
}
return $errors;
}
#[\Override]
protected function get_context_for_dynamic_submission(): context {
$contextid = $this->optional_param('contextid', 0, PARAM_INT);
if ($contextid === 0) {
$contextid = $this->_ajaxformdata['contextid'];
}
return context::instance_by_id($contextid);
}
#[\Override]
protected function check_access_for_dynamic_submission(): void {
$this->get_manager()->require_manage_category($this->get_context_for_dynamic_submission());
}
/**
* Process the category form
*
* Either add or update a question category, then return a reactive state update for the category.
*
* @return array[] Reactive state update.
* {@link https://moodledev.io/docs/4.2/guides/javascript/reactive#controlling-the-state-from-the-backend}
* @throws \coding_exception
* @throws \dml_exception
* @throws moodle_exception
*/
public function process_dynamic_submission(): array {
global $DB, $OUTPUT;
$values = $this->get_data();
$newinfo = format_text($values->info['text'], (int)$values->info['format'], ['noclean' => false]);
$idnumber = $values->idnumber;
if ((string)$idnumber === '') {
$idnumber = null;
}
if (empty($values->id)) {
$values->id = $this->get_manager()->add_category(
$values->parent,
$values->name,
$newinfo,
(int)$values->info['format'],
$idnumber,
);
} else {
$this->get_manager()->update_category(
$values->id,
$values->parent ?? '', // If we can't change the parent, it is not passed through he form.
$values->name,
$newinfo,
(int)$values->info['format'],
$idnumber,
);
}
$record = $DB->get_record('question_categories', ['id' => $values->id]);
// The question count will never change, we just need it passed through to re-render the category.
$record->questioncount = $values->questioncount;
$category = new category(
$record,
context::instance_by_id($record->contextid),
$values->cmid ?? 0,
$values->courseid ?? 0,
);
return [
[
'name' => 'categories',
'action' => 'put',
'fields' => [
'id' => $record->id,
'name' => $record->name,
'parent' => $record->parent,
'sortorder' => $record->sortorder,
'draghandle' => $category->get_canreorder(),
'templatecontext' => $category->export_for_template($OUTPUT),
],
],
];
}
#[\Override]
public function set_data_for_dynamic_submission(): void {
$categoryid = isset($this->_ajaxformdata['categoryid']) ? (int)$this->_ajaxformdata['categoryid'] : 0;
if ($categoryid !== 0) {
global $DB;
$cattoset = $DB->get_record('question_categories', ['id' => $categoryid]);
$this->set_data((object)[
'id' => (int)$cattoset->id,
'name' => $cattoset->name,
'contextid' => (int)$cattoset->contextid,
'info' => [
'format' => FORMAT_HTML,
'text' => $cattoset->info,
],
'infoformat' => (int)$cattoset->infoformat,
'parent' => (int)$cattoset->parent . ',' . (int)$cattoset->contextid,
'idnumber' => $cattoset->idnumber,
]);
}
}
#[\Override]
protected function get_page_url_for_dynamic_submission(): moodle_url {
$params = [];
$cmid = isset($this->_ajaxformdata['cmid']) ? (int)$this->_ajaxformdata['cmid'] : 0;
$courseid = isset($this->_ajaxformdata['courseid']) ? (int)$this->_ajaxformdata['courseid'] : 0;
if ($cmid !== 0) {
$params['cmid'] = $cmid;
}
if ($courseid !== 0) {
$params['courseid'] = $courseid;
}
return new moodle_url('/question/bank/managecategories/category.php', $params);
}
}
@@ -16,6 +16,10 @@
namespace qbank_managecategories;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . "/questionlib.php");
use context;
use core_question\category_manager;
use core_question\local\bank\question_version_status;
@@ -34,7 +38,6 @@ use html_writer;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class helper {
/**
* Name of this plugin.
*/
@@ -167,7 +170,11 @@ class helper {
foreach ($categories[$id]->childids as $childid) {
if ($childid != $nochildrenof) {
$newcategories = $newcategories + self::flatten_category_tree(
$categories, $childid, $depth + 1, $nochildrenof);
$categories,
$childid,
$depth + 1,
$nochildrenof,
);
}
}
@@ -198,8 +205,10 @@ class helper {
// categories from other courses, but not their parents.
$toplevelcategoryids = [];
foreach (array_keys($categories) as $id) {
if (!empty($categories[$id]->parent) &&
array_key_exists($categories[$id]->parent, $categories)) {
if (
!empty($categories[$id]->parent) &&
array_key_exists($categories[$id]->parent, $categories)
) {
$categories[$categories[$id]->parent]->childids[] = $id;
} else {
$toplevelcategoryids[] = $id;
@@ -210,7 +219,11 @@ class helper {
$newcategories = [];
foreach ($toplevelcategoryids as $id) {
$newcategories = $newcategories + self::flatten_category_tree(
$categories, $id, 0, $nochildrenof);
$categories,
$id,
0,
$nochildrenof,
);
}
return $newcategories;
@@ -229,19 +242,36 @@ class helper {
* default in the dropdown.
* @param int $nochildrenof
* @param bool $return to return the string of the select menu or echo that from the method
* @return ?string The HTML, or null if the $return is false.
* @throws \coding_exception|\dml_exception
*/
public static function question_category_select_menu(array $contexts, bool $top = false, int $currentcat = 0,
string $selected = "", int $nochildrenof = -1, bool $return = false) {
$categoriesarray = self::question_category_options($contexts, $top, $currentcat,
false, $nochildrenof, false);
public static function question_category_select_menu(
array $contexts,
bool $top = false,
int $currentcat = 0,
string $selected = "",
int $nochildrenof = -1,
bool $return = false,
): ?string {
$categoriesarray = self::question_category_options(
$contexts,
$top,
$currentcat,
false,
$nochildrenof,
false,
);
$choose = '';
$options = [];
foreach ($categoriesarray as $group => $opts) {
$options[] = [$group => $opts];
}
$outputhtml = html_writer::label(get_string('questioncategory', 'core_question'),
'id_movetocategory', false, ['class' => 'accesshide']);
$outputhtml = html_writer::label(
get_string('questioncategory', 'core_question'),
'id_movetocategory',
false,
['class' => 'accesshide'],
);
$attrs = [
'id' => 'id_movetocategory',
'class' => 'custom-select',
@@ -255,6 +285,7 @@ class helper {
return $outputhtml;
} else {
echo $outputhtml;
return null;
}
}
@@ -262,18 +293,22 @@ class helper {
* Get all the category objects, including a count of the number of questions in that category,
* for all the categories in the lists $contexts.
*
* @param context $contexts
* @param string $contexts
* @param string $sortorder used as the ORDER BY clause in the select statement.
* @param bool $top Whether to return the top categories or not.
* @param int $showallversions 1 to show all versions not only the latest.
* @return array of category objects.
* @throws \dml_exception
*/
public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC',
bool $top = false, int $showallversions = 0): array {
public static function get_categories_for_contexts(
string $contexts,
string $sortorder = 'parent, sortorder, name ASC',
bool $top = false,
int $showallversions = 0,
): array {
global $DB;
$topwhere = $top ? '' : 'AND c.parent <> 0';
$statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " .
$statuscondition = "AND (qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " .
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
$sql = "SELECT c.*,
@@ -311,9 +346,14 @@ class helper {
* @return array
* @throws \coding_exception|\dml_exception
*/
public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0,
bool $popupform = false, int $nochildrenof = -1,
bool $escapecontextnames = true): array {
public static function question_category_options(
array $contexts,
bool $top = false,
int $currentcat = 0,
bool $popupform = false,
int $nochildrenof = -1,
bool $escapecontextnames = true,
): array {
global $CFG;
$pcontexts = [];
foreach ($contexts as $context) {
@@ -339,9 +379,12 @@ class helper {
if ($category->contextid == $contextid) {
$cid = $category->id;
if ($currentcat != $cid || $currentcat == 0) {
$a = new \stdClass;
$a->name = format_string($category->indentedname, true,
['context' => $context]);
$a = new \stdClass();
$a->name = format_string(
$category->indentedname,
true,
['context' => $context]
);
if ($category->idnumber !== null && $category->idnumber !== '') {
$a->idnumber = s($category->idnumber);
}
@@ -413,4 +456,14 @@ class helper {
return $categories;
}
/**
* Combine id and context id for a question category
*
* @param \stdClass $category a category to extract its id and context id
* @return string the combined string
*/
public static function combine_id_context(\stdClass $category): string {
return $category->id . ',' . $category->contextid;
}
}
@@ -0,0 +1,86 @@
<?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_managecategories\output;
use context;
use qbank_managecategories\question_categories;
use renderable;
use renderer_base;
use templatable;
/**
* Output component for the Manage category page.
*
* This will return the template context for a page containing a tree of categories for each context in the provided
* question_categories object, with editing controls.
*
* @package qbank_managecategories
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class categories implements renderable, templatable {
/**
* Constructor.
*
* @param question_categories $categories Question categories for display.
*/
public function __construct(
/** @var question_categories $categories Question categories for display. */
protected question_categories $categories,
) {
}
#[\Override]
public function export_for_template(renderer_base $output): array {
$categories = [];
foreach ($this->categories->editlists as $contextid => $list) {
// Get list elements.
$context = context::instance_by_id($contextid);
$itemstab = [];
if (count($list->items)) {
foreach ($list->items as $item) {
$category = new category($item, $context);
$itemstab['items'][] = $category->export_for_template($output);
}
}
if (isset($itemstab['items'])) {
$ctxlvl = "contextlevel" . $list->context->contextlevel;
$contextname = $list->context->get_context_name();
$heading = get_string('questioncatsfor', 'question', $contextname);
// Get categories context.
$categories[] = [
'ctxlvl' => $ctxlvl,
'contextid' => $list->context->id,
'contextname' => $contextname,
'heading' => $heading,
'items' => $itemstab['items'],
'categoryid' => $list->categoryid,
];
}
}
$data = [
'categoriesrendered' => $categories,
'contextid' => $this->categories->contextid,
'cmid' => $this->categories->cmid,
'courseid' => $this->categories->courseid,
'showdescriptions' => get_user_preferences('qbank_managecategories_showdescriptions'),
];
return $data;
}
}
@@ -0,0 +1,77 @@
<?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_managecategories\output;
use context;
use renderable;
use renderer_base;
use templatable;
use qbank_managecategories\question_categories;
/**
* Output component for category page header.
*
* @package qbank_managecategories
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class categories_header implements renderable, templatable {
/**
* Constructor.
*
* @param question_categories $categories Question categories for display.
*/
public function __construct(
/** @var question_categories $categories Question categories for display. */
protected question_categories $categories,
) {
}
/**
* Output template data for the heading of the category management page.
*
* @param renderer_base $output
* @return array
* @throws \coding_exception
*/
public function export_for_template(renderer_base $output): array {
$helpstringhead = $output->heading_with_help(
get_string('editcategories', 'question'),
'editcategories',
'question',
);
$hascapability = has_capability(
'moodle/question:managecategory',
context::instance_by_id($this->categories->contextid),
);
$data = [
'helpstringhead' => $helpstringhead,
'showdescriptions' => [
'id' => 'showdescriptions-toggle',
'checked' => get_user_preferences('qbank_managecategories_showdescriptions'),
'label' => get_string('showcategorydescription', 'qbank_managecategories'),
],
'hascapability' => $hascapability,
'contextid' => $this->categories->contextid,
'cmid' => $this->categories->cmid,
'courseid' => $this->categories->courseid,
];
return $data;
}
}
@@ -0,0 +1,229 @@
<?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_managecategories\output;
use action_menu;
use action_menu_link;
use context;
use core\plugininfo\qbank;
use core_question\category_manager;
use moodle_url;
use pix_icon;
use qbank_managecategories\helper;
use renderable;
use renderer_base;
use stdClass;
use templatable;
/**
* Output component for a single category
*
* @package qbank_managecategories
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class category implements renderable, templatable {
/**
* @var bool Can this category be reordered?
*/
protected bool $canreorder;
/**
* Constructor
*
* @param stdClass $category The record of category we are rendering
* @param context $context The context the category belongs to.
* @param int $cmid The cmid of the course module context the category belongs to (optional).
* @param int $courseid The course ID of the course context the category belongs to (optional).
*/
public function __construct(
/** @var stdClass $category The record of category we are rendering */
protected stdClass $category,
/** @var context $context The context the category belongs to. */
protected context $context,
/** @var int $cmid The cmid of the course module context the category belongs to. */
protected int $cmid = 0,
/** @var int $courseid The course ID of the course context the category belongs to. */
protected int $courseid = 0,
) {
$manager = new category_manager();
$this->canreorder = !$manager->is_only_child_of_top_category_in_context($this->category->id);
}
/**
* Get the canreorder flag.
*
* @return bool
*/
public function get_canreorder(): bool {
return $this->canreorder;
}
/**
* Create the template data for a category, and call recursively for child categories.
*
* @param renderer_base $output
* @return array
*/
public function export_for_template(renderer_base $output): array {
global $PAGE;
$canmanagecategory = has_capability('moodle/question:managecategory', $this->context);
$params = $PAGE->url->params();
$cmid = $params['cmid'] ?? $this->cmid;
$courseid = $params['courseid'] ?? $this->courseid;
// Each section adds html to be displayed as part of this list item.
$questionbankurl = new moodle_url('/question/edit.php', $params);
$questionbankurl->param('cat', helper::combine_id_context($this->category));
$categoryname = format_string($this->category->name, true, ['context' => $this->context]);
$idnumber = null;
if ($this->category->idnumber !== null && $this->category->idnumber !== '') {
$idnumber = $this->category->idnumber;
}
$categorydesc = format_text(
$this->category->info,
$this->category->infoformat,
['context' => $this->context, 'noclean' => true],
);
$menu = new action_menu();
$menu->attributes['draggable'] = 'false';
$menu->set_kebab_trigger();
$menu->prioritise = true;
// Don't allow movement if only subcat.
if ($canmanagecategory) {
// This item display a modal for moving a category.
// Move category modal.
$menu->add(new action_menu_link(
new \moodle_url('#'),
new pix_icon(
't/move',
get_string('move'),
'moodle',
[
'class' => 'iconsmall',
]
),
get_string('move'),
false,
[
'class' => 'show-when-movable', // Don't allow moving when this is the only category in the context.
'data-categoryid' => $this->category->id,
'data-actiontype' => 'move',
'data-contextid' => (int) $this->category->contextid,
'data-categoryname' => $categoryname,
'title' => get_string('movecategory', 'qbank_managecategories', $categoryname),
]
));
$thiscontext = (int) $this->category->contextid;
$editurl = new moodle_url('#');
$menu->add(new action_menu_link(
$editurl,
new pix_icon('t/edit', 'edit'),
get_string('editsettings'),
false,
[
'data-action' => 'addeditcategory',
'data-actiontype' => 'edit',
'data-contextid' => $thiscontext,
'data-categoryid' => $this->category->id,
'data-cmid' => $cmid,
'data-courseid' => $courseid,
'data-questioncount' => $this->category->questioncount,
]
));
// Sets up delete link.
$deleteurl = new moodle_url(
'/question/bank/managecategories/category.php',
['delete' => $this->category->id, 'sesskey' => sesskey()]
);
if ($courseid !== 0) {
$deleteurl->param('courseid', $courseid);
} else {
$deleteurl->param('cmid', $cmid);
}
$menu->add(new action_menu_link(
$deleteurl,
new pix_icon('t/delete', 'delete'),
get_string('delete'),
false,
[
'class' => 'text-danger show-when-movable', // Don't allow deletion when this is the only category in context.
'data-confirmation' => 'modal',
'data-confirmation-type' => 'delete',
'data-confirmation-title-str' => json_encode(['delete', 'core']),
'data-confirmation-yes-button-str' => json_encode(['delete', 'core']),
'data-confirmation-content-str' => json_encode([
'confirmdelete',
'qbank_managecategories',
$this->category->name,
]),
],
));
}
// Sets up export to XML link.
if (qbank::is_plugin_enabled('qbank_exportquestions')) {
$exporturl = new moodle_url(
'/question/bank/exportquestions/export.php',
['cat' => helper::combine_id_context($this->category)]
);
if ($courseid !== 0) {
$exporturl->param('courseid', $courseid);
} else {
$exporturl->param('cmid', $cmid);
}
$menu->add(new action_menu_link(
$exporturl,
new pix_icon('t/download', 'download'),
get_string('exportasxml', 'question'),
false,
));
}
$children = [];
if (!empty($this->category->children)) {
foreach ($this->category->children as $child) {
$childcategory = new category($child, $this->context);
$children[] = $childcategory->export_for_template($output);
}
}
$itemdata = [
'categoryid' => $this->category->id,
'contextid' => $this->category->contextid,
'questionbankurl' => $questionbankurl->out(false),
'categoryname' => $categoryname,
'idnumber' => $idnumber,
'questioncount' => $this->category->questioncount,
'categorydesc' => $categorydesc,
'editactionmenu' => $menu->export_for_template($output),
'draghandle' => $canmanagecategory && $this->canreorder,
'haschildren' => !empty($children),
'children' => $children,
'parent' => $this->category->parent,
'sortorder' => $this->category->sortorder,
'newchildtooltip' => get_string('newchild', 'qbank_managecategories', $categoryname),
];
return $itemdata;
}
}
@@ -16,6 +16,9 @@
namespace qbank_managecategories\privacy;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\writer;
/**
* Privacy Subsystem for qbank_managecategories implementing null_provider.
*
@@ -25,14 +28,35 @@ namespace qbank_managecategories\privacy;
* @author Guillermo Gomez Arias <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\user_preference_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
* Returns meta data about this system.
*
* @return string
* @param collection $collection The initialised collection to add items to.
* @return collection A listing of user data stored through this system.
*/
public static function get_reason(): string {
return 'privacy:metadata';
public static function get_metadata(collection $collection): collection {
$collection->add_user_preference('qbank_managecategories_showdescriptions', 'privacy:preference:showdescriptions');
return $collection;
}
/**
* Export all user preferences for the plugin.
*
* @param int $userid The userid of the user whose data is to be exported.
*/
public static function export_user_preferences(int $userid) {
$showdescription = get_user_preferences('qbank_managecategories_showdescriptions', null, $userid);
if ($showdescription !== null) {
$displaydescription = $showdescription ? 'displaydescription' : 'descriptionnotdisplayed';
writer::export_user_preference(
'qbank_managecategories',
'showdescr',
$showdescription,
get_string($displaydescription, 'qbank_managecategories')
);
}
}
}
@@ -16,13 +16,6 @@
namespace qbank_managecategories;
/**
* QUESTION_PAGE_LENGTH - Number of categories to display on page.
*/
if (!defined('QUESTION_PAGE_LENGTH')) {
define('QUESTION_PAGE_LENGTH', 25);
}
use context;
use moodle_url;
@@ -30,7 +30,10 @@ use moodle_list;
* @package qbank_managecategories
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated MDL-72397 Since Moodle 4.5
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(null, since: '4.5', mdl: 'MDL-72397', reason: 'Output is now generated by templates.')]
class question_category_list extends moodle_list {
/**
@@ -80,18 +83,25 @@ class question_category_list extends moodle_list {
* @param string $pageparamname name of url param that is used for passing page no
* @param integer $itemsperpage no of top level items.
* @param \context $context
* @deprecated MDL-72397 Since Moodle 4.5
* @todo Final removal in Moodle 6.0 MDL-80804
*/
public function __construct($type='ul', $attributes='', $editable = false, $pageurl=null,
$page = 0, $pageparamname = 'page',
$itemsperpage = DEFAULT_QUESTIONS_PER_PAGE, $context = null) {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
parent::__construct('ul', '', $editable, $pageurl, $page, 'cpage', $itemsperpage);
$this->context = $context;
}
/**
* Set the array of records of list items.
*
* @deprecated MDL-72397 Since Moodle 4.5
* @todo Final removal in Moodle 6.0 MDL-80804
*/
public function get_records(): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$this->records = helper::get_categories_for_contexts($this->context->id, $this->sortby);
}
@@ -101,8 +111,11 @@ class question_category_list extends moodle_list {
*
* @param \list_item $item The item which its top level parent is going to be returned.
* @return int
* @deprecated MDL-72397 Since Moodle 4.5
* @todo Final removal in Moodle 6.0 MDL-80804
*/
public function get_top_level_parent_id($item): int {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
// Put the item at the highest level it can go.
$topcategory = question_get_top_category($item->item->contextid, true);
return $topcategory->id;
@@ -116,8 +129,11 @@ class question_category_list extends moodle_list {
* @param integer $moveup id of item to move up
* @param integer $movedown id of item to move down
* @return void
* @deprecated MDL-72397 Since Moodle 4.5
* @todo Final removal in Moodle 6.0 MDL-80804
*/
public function process_actions($left, $right, $moveup, $movedown): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$category = new stdClass();
if (!empty($left)) {
// Moved Left (In to another category).
@@ -24,7 +24,10 @@ use moodle_url;
* @package qbank_managecategories
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated MDL-72397 Since Moodle 4.5
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(null, since: '4.5', mdl: 'MDL-72397', reason: 'Output is now generated by templates.')]
class question_category_list_item extends \list_item {
/**
@@ -33,8 +36,10 @@ class question_category_list_item extends \list_item {
* @param bool $first Is the first on the list.
* @param bool $last Is the last on the list.
* @param \list_item $lastitem Last item.
* @deprecated MDL-72397 Since Moodle 4.5
*/
public function set_icon_html($first, $last, $lastitem): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $CFG;
$category = $this->item;
$url = new moodle_url('/question/bank/managecategories/category.php',
@@ -74,8 +79,10 @@ class question_category_list_item extends \list_item {
* @param array $extraargs
* @return string Item html.
* @throws \moodle_exception
* @deprecated MDL-72397 Since Moodle 4.5
*/
public function item_html($extraargs = []): string {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $PAGE, $OUTPUT;
$str = $extraargs['str'];
$category = $this->item;
@@ -34,6 +34,8 @@ use stdClass;
* @package qbank_managecategories
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager or \qbank_managecategories\question_categories.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
class question_category_object {
@@ -77,9 +79,17 @@ class question_category_object {
* @param int|null $defaultcategory id of the current category. null if none.
* @param int $todelete id of the category to delete. 0 if none.
* @param context[] $addcontexts contexts where the current user can add questions.
* @deprecated Since Moodle 4.5 MDL-72397. Use \qbank_managecategories\question_categories instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\qbank_managecategories\question_categories',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function __construct($page, $pageurl, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts) {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$this->tab = str_repeat('&nbsp;', $this->tabsize);
$this->str = new stdClass();
@@ -118,8 +128,17 @@ class question_category_object {
* @param int|null $defaultcategory id of the current category. null if none.
* @param int $todelete id of the category to delete. 0 if none.
* @param context[] $addcontexts contexts where the current user can add questions.
* @deprecated Since Moodle 4.5 MDL-72397. Use \qbank_managecategories\question_categories instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\qbank_managecategories\question_categories',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function initialize($page, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$lastlist = null;
foreach ($contexts as $context) {
$this->editlists[$context->id] =
@@ -146,16 +165,35 @@ class question_category_object {
/**
* Displays the user interface.
*
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function display_user_interface(): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
// Interface for editing existing categories.
$this->output_edit_lists();
}
/**
* Outputs a table to allow entry of a new category
*
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function output_new_table(): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$this->catform->display();
}
@@ -164,8 +202,17 @@ class question_category_object {
*
* $this->initialize() must have already been called
*
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function output_edit_lists(): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $OUTPUT;
echo $OUTPUT->heading_with_help(get_string('questioncategories', 'question'), 'editcategories', 'question');
@@ -189,8 +236,17 @@ class question_category_object {
*
* @param array $categories contains category objects in a tree representation
* @return array courseids flat array in form categoryid=>courseid
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function get_course_ids(array $categories): array {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$courseids = [];
foreach ($categories as $key => $cat) {
$courseids[$key] = $cat->course;
@@ -205,8 +261,17 @@ class question_category_object {
* Edit a category, or add a new one if the id is zero.
*
* @param int $categoryid Category id.
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function edit_single_category(int $categoryid): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
// Interface for adding a new category.
global $DB;
@@ -235,9 +300,17 @@ class question_category_object {
*
* @param array $parentstrings a list of parentstrings
* @param object $category Category object
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function set_viable_parents(array &$parentstrings, object $category): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
unset($parentstrings[$category->id]);
if (isset($category->children)) {
foreach ($category->children as $child) {
@@ -252,8 +325,17 @@ class question_category_object {
* @param int|null $parent - if given, restrict records to those with this parent id.
* @param string $sort - [[sortfield [,sortfield]] {ASC|DESC}].
* @return array categories.
* @deprecated Since Moodle 4.5 MDL-72397. No longer used.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'No longer used',
mdl: 'MDL-72397',
)]
public function get_question_categories(?int $parent = null, string $sort = "sortorder ASC"): array {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $COURSE, $DB;
if (is_null($parent)) {
$categories = $DB->get_records('question_categories', ['course' => $COURSE->id], $sort);
@@ -268,8 +350,17 @@ class question_category_object {
* Deletes an existing question category.
*
* @param int $categoryid id of category to delete.
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::delete_category',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function delete_category(int $categoryid): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $CFG, $DB;
helper::question_can_delete_cat($categoryid);
if (!$category = $DB->get_record("question_categories", ["id" => $categoryid])) { // Security.
@@ -293,8 +384,17 @@ class question_category_object {
*
* @param int $oldcat id of the old category.
* @param int $newcat id of the new category.
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::move_questions_and_delete_category',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function move_questions_and_delete_category(int $oldcat, int $newcat): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
helper::question_can_delete_cat($oldcat);
$this->move_questions($oldcat, $newcat);
$this->delete_category($oldcat);
@@ -310,6 +410,7 @@ class question_category_object {
* @deprecated No longer used by internal code and not recommended since Moodle 4.2 MDL-77299.
*/
public function display_move_form($questionsincategory, $category): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
debugging(
'display_move_form() is deprecated and no longer used by internal code.',
DEBUG_DEVELOPER
@@ -328,8 +429,17 @@ class question_category_object {
* @param int $oldcat id of the old category.
* @param int $newcat id of the new category.
* @throws \dml_exception
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::move_questions',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function move_questions(int $oldcat, int $newcat): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
$questionids = $this->get_real_question_ids_in_category($oldcat);
question_move_questions_to_category($questionids, $newcat);
}
@@ -348,9 +458,18 @@ class question_category_object {
* @param int|string $newinfoformat description format. One of the FORMAT_ constants.
* @param null $idnumber the idnumber. '' is converted to null.
* @return bool|int New category id if successful, else false.
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::add_category',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function add_category($newparent, $newcategory, $newinfo, $return = false, $newinfoformat = FORMAT_HTML,
$idnumber = null): int {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $DB;
if (empty($newcategory)) {
throw new moodle_exception('categorynamecantbeblank', 'question');
@@ -414,9 +533,18 @@ class question_category_object {
* @param int|string $newinfoformat description format. One of the FORMAT_ constants.
* @param int $idnumber the idnumber. '' is converted to null.
* @param bool $redirect if true, will redirect once the DB is updated (default).
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::update_category',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function update_category($updateid, $newparent, $newname, $newinfo, $newinfoformat = FORMAT_HTML,
$idnumber = null, $redirect = true): void {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $CFG, $DB;
if (empty($newname)) {
throw new moodle_exception('categorynamecantbeblank', 'question');
@@ -501,8 +629,17 @@ class question_category_object {
*
* @param int $categoryid id of the category.
* @return int[] array of question ids.
* @deprecated Since Moodle 4.5 MDL-72397. Use \core_question\category_manager instead.
* @todo Final removal in Moodle 6.0 MDL-80804
*/
#[\core\attribute\deprecated(
'\core_question\category_manager::get_real_question_ids_in_category',
since: 4.5,
reason: 'API properly divided between qbank_managecategories and core_question',
mdl: 'MDL-72397',
)]
public function get_real_question_ids_in_category(int $categoryid): array {
\core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]);
global $DB;
$sql = "SELECT q.id
@@ -0,0 +1,36 @@
<?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/>.
/**
* qbank_managecategories external functions and service definitions.
* @package qbank_managecategories
* @category webservice
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author 2021, Ghaly Marc-Alexandre <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$functions = [
'qbank_managecategories_move_category' => [
'classname' => 'qbank_managecategories\external\move_category',
'description' => 'Move a question category',
'type' => 'write',
'capabilities' => 'moodle/question:managecategory',
'ajax' => true,
],
];
@@ -23,5 +23,18 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['after'] = 'After {$a}';
$string['before'] = 'Before {$a}';
$string['categoryincategory'] = 'Cannot move category in same category.';
$string['categorymoved'] = 'The category has been moved successfully.';
$string['confirmdelete'] = 'Delete category "{$a}"?. Any child categories will be moved to this category\'s parent.';
$string['descriptionnotdisplayed'] = 'Categories description not displayed';
$string['displaydescription'] = 'Categories description displayed';
$string['idnumberexists'] = 'ID number already in use, please change it to move or update category';
$string['lastcategoryinthiscontext'] = 'This category is the only present under context,
please add another category if this one is to be moved';
$string['movecategory'] = 'Move {$a}';
$string['newchild'] = 'As new child of {$a}';
$string['pluginname'] = 'Manage categories';
$string['privacy:metadata'] = 'The Manage categories question bank plugin does not store any personal data.';
$string['privacy:preference:showdescriptions'] = 'A flag to indicate whether or not category descriptions are shown.';
$string['showcategorydescription'] = 'Show descriptions';
+40
View File
@@ -0,0 +1,40 @@
<?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/>.
/**
* Callbacks for qbank_manageacategories
*
* @package qbank_managecategories
* @copyright 2024 Catalyst IT Europe Ltd
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Allow update of user preferences via AJAX.
*
* @return array[]
*/
function qbank_managecategories_user_preferences(): array {
return [
'qbank_managecategories_showdescriptions' => [
'type' => PARAM_BOOL,
'null' => NULL_NOT_ALLOWED,
'default' => false,
'permissioncallback' => [core_user::class, 'is_current_user'],
],
];
}
+107
View File
@@ -0,0 +1,107 @@
/* Card-style border on category lists */
.qbank_managecategories-categorylist {
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 1rem;
}
/* Category item layout */
.qbank_managecategories-handlecontainer {
width: 32px;
height: 32px;
display: inline-flex;
justify-content: center;
align-items: center;
}
.qbank_managecategories-item .container {
padding: 0.75rem;
}
/* Separators between each item become drop indicators */
.qbank_managecategories-item {
width: 100%;
list-style: none;
border-top: 1px solid #dee2e6;
}
.qbank_managecategories-item:first-child {
border-top-color: transparent;
}
.qbank_managecategories-item:last-child {
border-bottom: 1px solid transparent;
}
.qbank_managecategories-item .qbank_managecategories-item:first-child {
border-top-color: #dee2e6;
}
.qbank_managecategories-item.qbank_managecategories-droptarget-before,
.qbank_managecategories-item .qbank_managecategories-item.qbank_managecategories-droptarget-before,
.qbank_managecategories-categorylist.qbank_managecategories-droptarget-before > .qbank_managecategories-item:first-child {
border-top-color: black;
}
.qbank_managecategories-item.qbank_managecategories-droptarget,
.qbank_managecategories-categorylist.qbank_managecategories-droptarget > .qbank_managecategories-item:last-child {
border-bottom-color: black;
}
.qbank_managecategories-details p:last-child {
margin-bottom: 0;
}
.qbank_managecategories-childlistcontainer:empty {
display: none;
}
/* Fade effect on drag handle */
.qbank_managecategories-draghandle {
display: none;
opacity: 0.45;
}
.qbank_managecategories-item.draghandle .qbank_managecategories-draghandle {
display: inline;
}
/* Drag indicator */
.qbank_managecategories-item.draghandle > .container {
border: 2px solid transparent;
border-radius: 1rem;
}
.qbank_managecategories-item.draghandle > .container:hover {
cursor: move;
border-color: #0f6cbf;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
/* No move/delete buttons on non-draggable items */
.dropdown-item.show-when-movable {
display: none;
}
.qbank_managecategories-item.draghandle .dropdown-item.show-when-movable {
display: block;
}
/* Don't show card border on nested lists. */
.qbank_managecategories-categorylist .qbank_managecategories-categorylist {
padding: 0 0 .5rem 0;
border: 0;
border-radius: 0;
}
/* Don't show hover indication on parent when a child is hovered */
.editing .qbank_managecategories-item .container:hover:has(.container:hover) {
border-color: transparent;
}
/* New child drop target */
.qbank_managecategories-newchild {
display: none;
border: 1px solid #dee2e6;
background-color: #fff;
padding: 0.25rem 1rem;
color: #dee2e6;
font-weight: bold;
}
.qbank_managecategories-newchild.qbank_managecategories-droptarget,
/* Show the drop target if it's directly inside a highlighted category, but not if its inside a child of that category */
.qbank_managecategories-droptarget-before > .container > .row > .qbank_managecategories-contentcontainer > :not(.qbank_managecategories-childlistcontainer) .qbank_managecategories-newchild {
display: unset;
}
.qbank_managecategories-newchild.qbank_managecategories-droptarget {
border-color: black;
color: black;
}
/* Hidden descriptions */
.qbank_managecategories-description {
display: none;
}
.showdescriptions .qbank_managecategories-description {
display: unset;
}
@@ -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 qbank_managecategories/addcategory
Add category button
Context variables required for this template:
* contextid - Context id for js init.
* cmid - Course module id, if in a plugin.
* courseid - The current course ID.
Example context (json):
{
"contextid": "18",
"cmid": "1234",
"courseid": "1234"
}
}}
<div id="qbank_managecategories-addcategory" class="pl-3">
<button type="submit" data-action="addeditcategory" data-actiontype="add"
data-contextid="{{contextid}}"
data-cmid="{{cmid}}" data-courseid="{{courseid}}" class="btn btn-primary">
{{# str}} addcategory, core_question {{/ str}}
</button>
</div>
{{#js}}
require(['qbank_managecategories/addcategory'], (addcategory) => {
addcategory.init('#qbank_managecategories-addcategory');
});
{{/js}}
@@ -0,0 +1,79 @@
{{!
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 qbank_managecategories/categories
Template for displaying category view.
Context variables required for this template:
* contextid - Context id for js init.
* categoriesrendered - Array representing categories data rendered.
* items - Categories data.
* heading - Category's heading for each context (ie: Course or Quiz).
* ctxlvl - Context level for categories section - context.
Example context (json):
{
"contextid": "18",
"categoriesrendered": [{
"items": [{
"categoryid": "1",
"questionbankurl": "question/edit.php?cmid=123",
"categoryname": "Default for Miscellaneous",
"idnumber": "1",
"questioncount": " (1)",
"categorydesc": "The default category for questions shared in context Miscellaneous",
"editactionmenu": "<div class='action-menu moodle-actionmenu'>...</div>",
"handle": true,
"iconleft": "<i class=\"icon fa fa-arrow-left fa-fw \" title=\"Move to top level\" role=\"img\" aria-label=\"Move to top level\"></i>",
"iconright": "<i class=\"icon fa fa-arrow-right fa-fw \" title=\"Make child of 'category'\" role=\"img\" aria-label=\"Make child of 'category'\"></i>",
"children": ""
}],
"heading": "Question categories for 'Quiz: qz'",
"ctxlvl": "contextlevel70"
}]
}
}}
<div id="categoryroot"
class="editing col-11 col-md-12 pl-0{{#showdescriptions}} showdescriptions{{/showdescriptions}}"
data-contextid="{{{contextid}}}">
{{#categoriesrendered}}
<h3 class="context-heading">{{heading}}</h3>
<div class="box py-3 boxwidthwide boxaligncenter generalbox questioncategories {{ctxlvl}}">
<ul class="qbank_managecategories-categorylist"
data-contextid="{{contextid}}" data-contextname="{{contextname}}" data-categoryid="{{categoryid}}">
{{#items}}
{{> qbank_managecategories/category }}
{{/items}}
</ul>
</div>
{{/categoriesrendered}}
</div>
{{#js}}
require(['qbank_managecategories/categoryroot'], (categoryroot) => {
categoryroot.init('#categoryroot');
});
require(['qbank_managecategories/categorylist'], (categorylist) => {
// Initialise top-level lists.
const categoryLists = document.querySelectorAll('.qbank_managecategories-categorylist[data-contextid]');
categoryLists.forEach(element => {
categorylist.init(`.qbank_managecategories-categorylist[data-categoryid="${element.dataset.categoryid}"]`);
});
});
{{/js}}
@@ -0,0 +1,51 @@
{{!
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 qbank_managecategories/categories_header
Template for displaying category view.
Context variables required for this template:
* helpstringhead - String header accompanied with it help button.
* hascapability - Boolean, true if user has capability to add categories.
* checkbox - Checkbox displaying descriptions.
* contextid - Context id for js init.
* cmid - Course module id, if in a plugin.
Example context (json):
{
"helpstringhead": "<h2> Edit categories <a href='someurl'>...</a></h2>",
"hascapability": true,
"checkbox": "<form>...</form>",
"contextid": "18",
"cmid": "1234",
"courseid": "1234"
}
}}
<div class="row">
<div class="pl-3">
{{{helpstringhead}}}
</div>
</div>
<div class="row">
{{#hascapability}}
{{>qbank_managecategories/addcategory}}
{{/hascapability}}
{{#showdescriptions}}
{{>qbank_managecategories/showdescriptions}}
{{/showdescriptions}}
</div>
@@ -0,0 +1,84 @@
{{!
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 qbank_managecategories/category
This template renders the list item for each category.
Context variables required for this template:
* categoryid - The id of the category this item represents.
* questionbankurl - Url for corresponding question bank.
* categoryname - Category name.
* idnumber - Id number string (optional).
* questioncount - Number of question in question bank.
* categorydesc - Category description.
* editactionmenu - Edit action menu link.
* draghandle - Boolean if handle should be displayed (if category is movable).
* children - Children category item to curent category (same variables as those above).
Example context (json):
{
"categoryid": "1",
"questionbankurl": "question/edit.php?cmid=123",
"categoryname": "Default for Miscellaneous",
"idnumber": "1",
"questioncount": " 1",
"categorydesc": "The default category for questions shared in context Miscellaneous",
"editactionmenu": "<div class='action-menu moodle-actionmenu'>...</div>",
"draghandle": true,
"children": ""
}
}}
<li class="qbank_managecategories-item{{#draghandle}} draghandle{{/draghandle}}"
id="category-{{{categoryid}}}"
data-categoryid="{{{categoryid}}}"
data-contextid="{{{contextid}}}"
data-categoryname="{{categoryname}}"
data-parent="{{parent}}"
data-sortorder="{{sortorder}}">
<div class="container{{#haschildren}} pb-0{{/haschildren}}">
<div class="row m-0">
<div class="p-2 mr-2 qbank_managecategories-handlecontainer">
<span class="qbank_managecategories-draghandle">
<i class="fa fa-grip-vertical"></i>
</span>
</div>
<div class="col-11 p-0 qbank_managecategories-contentcontainer">
<div class="row m-0">
<div class="col-11 p-0 pt-1 qbank_managecategories-details">
{{>qbank_managecategories/category_details}}
</div>
<div class="col-1 qbank_managecategories-actioncontainer">
{{#editactionmenu}}
{{>core/action_menu}}
{{/editactionmenu}}
</div>
</div>
<div class="row m-0 mt-1 qbank_managecategories-childlistcontainer">
{{#haschildren}}
{{> qbank_managecategories/childlist }}
{{/haschildren}}
</div>
</div>
</div>
</div>
</li>
{{#js}}
require(['qbank_managecategories/category'], function(component) {
component.init('#category-{{{categoryid}}}');
});
{{/js}}
@@ -0,0 +1,64 @@
{{!
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 qbank_managecategories/category_details
Partial template for rendering category details. Name, question count, tags, description, etc.
Context variables required for this template:
* categoryid - The id of the category this item represents.
* questionbankurl - Url for corresponding question bank.
* categoryname - Category name.
* idnumber - Id number string (optional).
* questioncount - Number of question in question bank.
* categorydesc - Category description.
* editactionmenu - Edit action menu link.
* draghandle - Boolean if handle should be displayed (if category is movable).
* children - Children category item to curent category (same variables as those above).
Example context (json):
{
"categoryid": "1",
"questionbankurl": "question/edit.php?cmid=123",
"categoryname": "Default for Miscellaneous",
"idnumber": "1",
"questioncount": " 1",
"categorydesc": "The default category for questions shared in context Miscellaneous",
"children": "",
"newchildtooltip": "New child of Default for Miscellaneous"
}
}}
<strong>
<a title="{{#str}}editquestions, question{{/str}}" href="{{{questionbankurl}}}" role="button">
{{{categoryname}}}
{{#idnumber}}
<span class="badge badge-primary">
<span class="accesshide">
{{#str}}idnumber, question{{/str}}
</span>
{{idnumber}}
</span>
{{/idnumber}}
({{questioncount}})
</a>
</strong>
{{^children}}
{{> qbank_managecategories/newchild }}
{{/children}}
<div class="qbank_managecategories-description">
{{{categorydesc}}}
</div>
@@ -0,0 +1,52 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template qbank_managecategories/childlist
This template a sub-list of children within a category.
Context variables required for this template:
* categoryid - The parent category of the child categories.
* children - Children category item to curent category (same variables as qbank_managecategories/category).
Example context (json):
{
"categoryid": "1",
"children": [
{
"categoryid": "2",
"questionbankurl": "question/edit.php?cmid=123",
"categoryname": "Default for Miscellaneous",
"idnumber": "1",
"questioncount": " 1",
"categorydesc": "The default category for questions shared in context Miscellaneous",
"editactionmenu": "<div class='action-menu moodle-actionmenu'>...</div>",
"draghandle": true
}
]
}
}}
<ul class="qbank_managecategories-categorylist row mt-1 col-12" data-categoryid="{{categoryid}}">
{{#children}}
{{> qbank_managecategories/category }}
{{/children}}
</ul>
{{#js}}
require(['qbank_managecategories/categorylist'], function(component) {
component.init('.qbank_managecategories-categorylist[data-categoryid="{{categoryid}}"]');
});
{{/js}}
@@ -49,7 +49,7 @@
</a>
</b>
{{#categorydesc}}
<div class="text_to_html">
<div>
{{{categorydesc}}}
</div>
{{/categorydesc}}
@@ -0,0 +1,117 @@
{{!
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 qbank_managecategories/move_category_list
Partial to render a list of categories that the current category can be moved next to.
This partial is included recursively to render nested lists. Take care to pass categories = null for bottom-level
categories, to avoid infinite recursion.
Context variables required for this template:
* categories - Array of category objects.
* categoryname - The name of the category to move to.
* categoryid - The ID of the category.
* firstchild - Is this the first child of its parent? If so, a "before" option will be rendered as well as "after".
* current - Is this the category being moved? If so, skip rendering it as a target.
* categories - Array containing child categories of this category.
If there are none, this must be set null or [] to avoid infinite recursion.
Example context (json):
{
"categories": [
{
"categoryname": "Default category for course 1",
"categoryid": 3,
"firstchild": true,
"current": false,
"hascategories": false,
"categories": []
},
{
"categoryname": "Test category 1",
"categoryid": 4,
"firstchild": false,
"current": false,
"hascategories": false,
"categories": []
},
{
"categoryname": "Test category 2",
"categoryid": 5,
"firstchild": false,
"current": true,
"hascategories": false,
"categories": []
},
{
"categoryname": "Test category 3 x < 1 && y > 2 ",
"categoryid": 6,
"firstchild": false,
"current": false,
"hascategories": true,
"categories": [
{
"categoryname": "Test category 4",
"categoryid": 7,
"firstchild": true,
"current": false,
"hascategories": false,
"categories": []
}
]
}
]
}
}}
<ul data-for="categorylist" data-contextid="{{contextid}}" role="group">
{{#categories}}
{{^lastchild}}
<li class="list_item d-flex" role="treeitem">
<a class="modal_category_item text-truncate" {{!
}}href="#" {{!
}}data-for="category" {{!
}}data-movingcategoryid="{{movingcategoryid}}" {{!
}}data-precedingsiblingid="{{precedingsiblingid}}" {{!
}}data-parent="{{parent}}">
{{#newchild}}
{{#str}} newchild, qbank_managecategories, {{{categoryname}}} {{/str}}
{{/newchild}}
{{^newchild}}
{{#str}} before, qbank_managecategories, {{{categoryname}}} {{/str}}
{{/newchild}}
</a>
</li>
<li class="list_item d-flex" role="treeitem">
{{>qbank_managecategories/move_category_list}}
</li>
{{/lastchild}}
{{#lastchild}}
<li class="list_item d-flex" role="treeitem">
<a class="modal_category_item text-truncate" {{!
}}href="#" {{!
}}data-for="category" {{!
}}data-movingcategoryid="{{movingcategoryid}}" {{!
}}data-precedingsiblingid="{{precedingsiblingid}}" {{!
}}data-parent="{{parent}}">
{{#str}} after, qbank_managecategories, {{{categoryname}}} {{/str}}
</a>
</li>
{{/lastchild}}
{{/categories}}
</ul>
@@ -0,0 +1,83 @@
{{!
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 qbank_managecategories/move_context_list
Modal to move a category.
Context variables required for this template:
* contexts - Array of contexts containing question categories
* contextid - The id of the context.
* contextname - The name of the context to display as a heading.
* categories - Array of category objects. See qbank_managecategories/move_category_list partial for details.
Example context (json):
{
"contexts": [
{
"contexid": 1,
"contextname": "Course 1",
"categories": [
{
"categoryname": "Default category for course 1",
"categoryid": 3,
"firstchild": true,
"current": false,
"hascategories": false,
"categories": []
},
{
"categoryname": "Default category for course category 1",
"categoryid": 4,
"firstchild": false,
"current": true,
"hascategories": false,
"categories": []
}
]
},
{
"contexid": 3,
"contextname": "Course 2",
"categories": [
{
"categoryname": "Default category for course 2",
"categoryid": 5,
"firstchild": true,
"current": false,
"hascategories": false,
"categories": []
},
{
"categoryname": "Default category for course category 2",
"categoryid": 6,
"firstchild": false,
"current": false,
"hascategories": false,
"categories": []
}
]
}
]
}
}}
<div role="tree">
{{#contexts}}
<h3>{{{contextname}}}</h3>
{{>qbank_managecategories/move_category_list}}
{{/contexts}}
</div>
@@ -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 qbank_managecategories/newchild
Drop zone for creating a new child category.
Context variables required for this template:
* categoryid - The id of the parent category.
Example context (json):
{
"categoryid": "1",
"newchildtooltip": "As new child of Example"
}
}}
<span
class="qbank_managecategories-newchild"
title="{{newchildtooltip}}"
data-parent="{{categoryid}}"
data-toggle="tooltip"
data-trigger="manual"
data-placement="top"
>
+
</span>
{{#js}}
require(['qbank_managecategories/newchild'], function(component) {
component.init('.qbank_managecategories-newchild[data-parent="{{categoryid}}"]');
});
{{/js}}
@@ -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 qbank_managecategories/showdescriptions
Template for displaying the Show descriptions toggle.
Context variables required for this template:
* helpstringhead - String header accompanied with it help button.
* hascapability - Boolean, true if user has capability to add categories.
* checkbox - Checkbox displaying descriptions.
* contextid - Context id for js init.
* cmid - Course module id, if in a plugin.
Example context (json):
{
"id": "showdescriptions-toggle",
"label": "Show descriptions",
"checked": true
}
}}
<div class="col-6 mt-2" id="qbank_managecategories-showdescriptions">
{{>core/toggle}}
</div>
{{#js}}
require(['qbank_managecategories/showdescriptions'], function(component) {
component.init('#qbank_managecategories-showdescriptions');
});
{{/js}}
@@ -1,43 +1,101 @@
@core @core_question
Feature: A teacher can move question categories in the question bank
In order to organize my questions
@qbank @qbank_managecategories @category_reorder @javascript
Feature: A teacher can reorder question categories
In order to change question category order
As a teacher
I create question categories and move them in the question bank
I need to reorder them
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | T1 | Teacher1 | teacher1@example.com |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "categories" exist:
| name | category | idnumber |
| Category 1 | 0 | CAT1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And the following "system role assigns" exist:
| user | role | contextlevel |
| teacher1 | editingteacher | System |
And the following "question categories" exist:
| contextlevel | reference | name | idnumber |
| Course | C1 | Course category 1 | questioncat1 |
| Course | C1 | Course category 2 | questioncat2 |
| Course | C1 | Course category 3 | questioncat3 |
| Category | CAT1 | Default for Category 1 | |
| System | S1 | System category | |
And I am on the "Course 1" "core_question > course question categories" page logged in as "teacher1"
Scenario: A question category can be moved to another context
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I select "Categories" from the "Question bank tertiary navigation" singleselect
And I follow "Add category"
And I set the following fields to these values:
| Name | Test category |
| Parent category | Top for Test quiz |
And I press "submitbutton"
And I click on "Share in context for Course: Course 1" "link" in the "Test category" "list_item"
Then I should see "Test category" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' questioncategories ') and contains(concat(' ', normalize-space(@class), ' '), ' contextlevel50 ')]" "xpath_element"
Scenario: Teacher cannot move or delete single category under context
When I open the action menu in "Default for Category 1" "list_item"
Then I should not see "Delete"
Scenario: A question category can be moved to top level
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I select "Categories" from the "Question bank tertiary navigation" singleselect
And I follow "Add category"
And I set the following fields to these values:
| Name | Test category |
| Parent category | Default for Test quiz |
| Category info | Created as a test |
And I press "submitbutton"
And I click on "Move to top level" "link" in the "Test category" "list_item"
Then I should see "Test category" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' questioncategories ') and contains(concat(' ', normalize-space(@class), ' '), ' contextlevel70 ')]" "xpath_element"
And "//div[contains(concat(' ', normalize-space(@class), ' '), ' questioncategories ') and contains(concat(' ', normalize-space(@class), ' '), ' contextlevel70 ')]//li//ul" "xpath_element" should not exist
Scenario: Teacher can see complete edit menu if multiples categories exist under context
When I open the action menu in "Course category 1" "list_item"
Then I should see "Edit settings"
And I should see "Delete"
And I should see "Export as Moodle XML"
Scenario: Teacher can move one category after another
Given "Course category 1" "list_item" should appear before "Course category 2" "list_item"
And "Course category 2" "list_item" should appear before "Course category 3" "list_item"
When I open the action menu in "Course category 1" "list_item"
And I choose "Move" in the open action menu
And I click on "After Course category 3" "link" in the "Move Course category 1" "dialogue"
Then "Course category 2" "list_item" should appear before "Course category 3" "list_item"
And "Course category 3" "list_item" should appear before "Course category 1" "list_item"
Scenario: Teacher can move one category before another
Given "Course category 1" "list_item" should appear before "Course category 2" "list_item"
And "Course category 2" "list_item" should appear before "Course category 3" "list_item"
And I open the action menu in "Course category 3" "list_item"
And I choose "Move" in the open action menu
And I click on "Before Course category 1" "link" in the "Move Course category 3" "dialogue"
Given "Course category 3" "list_item" should appear before "Course category 1" "list_item"
And "Course category 1" "list_item" should appear before "Course category 2" "list_item"
Scenario: Teacher can make a category a child of an existing category
Given "Course category 1" "list_item" should appear before "Course category 2" "list_item"
And "Course category 2" "list_item" should appear before "Course category 3" "list_item"
And "Course category 3" "list_item" should not exist in the "Course category 1" "list_item"
When I open the action menu in "Course category 3" "list_item"
And I choose "Move" in the open action menu
And I click on "As new child of Course category 1" "link" in the "Move Course category 3" "dialogue"
And "Course category 3" "list_item" should appear before "Course category 2" "list_item"
And "Course category 3" "list_item" should exist in the "Course category 1" "list_item"
Scenario: Teacher can move a category between contexts
Given "Course: Course 1" "text" should appear before "Course category 1" "list_item"
And "Category: Category 1" "text" should appear after "Course category 1" "list_item"
And "Category: Category 1" "text" should appear before "Default for Category 1" "list_item"
And "Course category 2" "list_item" should appear before "Course category 3" "list_item"
When I open the action menu in "Course category 1" "list_item"
And I choose "Move" in the open action menu
And I click on "After Default for Category 1" "link" in the "Move Course category 1" "dialogue"
Then "Course: Course 1" "text" should appear before "Course category 1" "list_item"
And "Category: Category 1" "text" should appear before "Course category 1" "list_item"
And "Default for Category 1" "list_item" should appear before "Course category 1" "list_item"
Scenario: Teacher can display and hide category descriptions
When I click on "Show descriptions" "checkbox"
Then I should see "The default category for questions shared in context 'Category 1'."
And I click on "Show descriptions" "checkbox"
And I should not see "The default category for questions shared in context 'Category 1'."
Scenario: Teacher cannot create a duplicate idnumber within a context by moving a category
Given "Course category 1" "list_item" should appear before "System category" "list_item"
And I open the action menu in "Course category 1" "list_item"
And I choose "Move" in the open action menu
And I click on "After System category" "link" in the "Move Course category 1" "dialogue"
Then "Course category 1" "list_item" should appear after "System category" "list_item"
And I open the action menu in "Course category 2" "list_item"
And I choose "Edit settings" in the open action menu
And I set the field "ID number" to "questioncat1"
And I click on "Save changes" "button" in the "Edit category" "dialogue"
And I open the action menu in "Course category 2" "list_item"
And I choose "Move" in the open action menu
And I click on "After Course category 1" "link" in the "Move Course category 2" "dialogue"
And I should see "ID number already in use, please change it to move or update category"
@@ -1,4 +1,4 @@
@qbank @qbank_managecategories @javascript
@qbank @qbank_managecategories @question_categories @javascript
Feature: A teacher can put questions in categories in the question bank
In order to organize my questions
As a teacher
@@ -6,44 +6,44 @@ Feature: A teacher can put questions in categories in the question bank
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | questioncategory | name |
| Course | C1 | Top | top |
| Course | C1 | top | Default for C1 |
| Course | C1 | Default for C1 | Subcategory |
| Course | C1 | Default for C1 | Another subcat |
| Course | C1 | top | Used category |
| Course | C1 | top | Default & testing |
| contextlevel | reference | questioncategory | name | sortorder | desciption |
| Course | C1 | Top | top | 0 | |
| Course | C1 | top | Default for C1 | 0 | Description for default for C1 |
| Course | C1 | Default for C1 | Subcategory | 0 | Description for Subcategory |
| Course | C1 | Default for C1 | Another subcat | 1 | Description for Another subcat |
| Course | C1 | top | Used category | 1 | |
| Course | C1 | top | Default & testing | 2 | |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Used category | essay | Test question to be moved | Write about whatever you want |
| Another subcat | essay | Question 1 | Write about whatever you want |
And I log in as "teacher1"
And I am on "Course 1" course homepage
Scenario: A new question category can be created
When I am on the "Course 1" "core_question > course question categories" page
And I follow "Add category"
And I press "Add category"
And I set the following fields to these values:
| Name | 'Test' & 'display' |
| Parent category | Default & testing |
| Category info | Created for testing category, HTML entity & its encoding |
| ID number | newcatidnumber |
And I press "submitbutton"
And I click on "Add category" "button" in the "Add category" "dialogue"
Then I should see "Default & testing"
And I should see "ID number"
And I should see "newcatidnumber"
And I should see "(0)"
And I click on "Show descriptions" "checkbox"
And I should see "Created for testing category, HTML entity & its encoding" in the "'Test' & 'display'" "list_item"
And I follow "Add category"
And I press "Add category"
And the "Parent category" select box should contain "'Test' & 'display' [newcatidnumber]"
Scenario: A question category can be edited
@@ -52,29 +52,35 @@ Feature: A teacher can put questions in categories in the question bank
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Subcategory | essay | Test question for renaming category | Write about whatever you want |
And I click on "Edit this category" "link" in the "Subcategory" "list_item"
And I open the action menu in "Subcategory" "list_item"
And I choose "Edit settings" in the open action menu
And the field "parent" matches value "&nbsp;&nbsp;&nbsp;Default for C1"
And I set the following fields to these values:
| Name | New name |
| Category info | I was edited |
And I press "Save changes"
Then I should see "New name"
And I should see "I was edited" in the "New name" "list_item"
And I click on "Show descriptions" "checkbox"
And I should see "I was edited"
Scenario: An empty question category can be deleted
When I am on the "Course 1" "core_question > course question categories" page
And I click on "Delete" "link" in the "Subcategory" "list_item"
And I open the action menu in "Subcategory" "list_item"
And I choose "Delete" in the open action menu
And I click on "Delete" "button" in the "Delete" "dialogue"
Then I should not see "Subcategory"
Scenario: An non-empty question category can be deleted if you move the contents elsewhere
When I am on the "Course 1" "core_question > course question categories" page
And I click on "Delete" "link" in the "Used category" "list_item"
And I open the action menu in "Used category" "list_item"
And I choose "Delete" in the open action menu
And I click on "Delete" "button" in the "Delete" "dialogue"
And I should see "The category 'Used category' contains 1 questions"
And I select "Default for C1" from the "Category" singleselect
And I press "Save in category"
Then I should not see "Used category"
And I follow "Add category"
And I should see "Default for C1 (1)"
And I should see "Default for C1"
And I should see "(1)"
@_file_upload
Scenario: Multi answer questions with their child questions can be moved to another category when the current category is deleted
@@ -84,12 +90,14 @@ Feature: A teacher can put questions in categories in the question bank
And I press "id_submitbutton"
And I press "Continue"
And I am on the "Course 1" "core_question > course question categories" page
And I click on "Delete" "link" in the "Default for Test images in backup" "list_item"
And I open the action menu in "Default for Test images in backup" "list_item"
And I choose "Delete" in the open action menu
And I click on "Delete" "button" in the ".modal-footer" "css_element"
And I should see "The category 'Default for Test images in backup' contains 1 questions"
And I select "Used category" from the "Category" singleselect
And I press "Save in category"
Then I should not see "Default for Test images in backup"
And I follow "Add category"
And I press "Add category"
And I should see "Used category (2)"
Scenario: Filter questions by category and subcategories
@@ -1,4 +1,4 @@
@qbank @qbank_managecategories
@qbank @qbank_managecategories @question_categories_idnumber @javascript
Feature: A teacher can put questions with idnumbers in categories with idnumbers in the question bank
In order to organize my questions
As a teacher
@@ -20,34 +20,36 @@ Feature: A teacher can put questions with idnumbers in categories with idnumbers
Scenario: A new question category can only be created with a unique idnumber for a context
# Note need to create the top category each time.
When the following "question categories" exist:
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Used category | c1used |
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Used category | c1used |
And I am on the "Course 1" "core_question > course question categories" page
And I follow "Add category"
And I press "Add category"
And I set the following fields to these values:
| Name | Sub used category |
| Parent category | Used category |
| Name | New cat |
| Parent category | Top for Course 1 |
| Category info | Created as a test |
| ID number | c1used |
And I press "submitbutton"
And I click on "Add category" "button" in the "Add category" "dialogue"
# Standard warning.
Then I should see "This ID number is already in use"
# Correction to a unique idnumber for the context.
And I set the field "ID number" to "c1unused"
And I press "submitbutton"
Then I should see "Sub used category"
And I click on "Add category" "button" in the "Add category" "dialogue"
Then I should see "New cat"
And I should see "ID number"
And I should see "c1unused"
And I should see "(0)"
And I should see "Created as a test" in the "Sub used category" "list_item"
And I click on "Show descriptions" "checkbox"
And I should see "Created as a test" in the "New cat" "list_item"
Scenario: A question category can be edited and saved without changing the idnumber
When the following "question categories" exist:
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Used category | c1used |
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Used category | c1used |
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 press "Save changes"
Then I open the action menu in "Used category" "list_item"
And I choose "Edit settings" in the open action menu
And I click on "Save changes" "button" in the "Edit category" "dialogue"
Then I should not see "This ID number is already in use"
@@ -1,4 +1,4 @@
@qbank @qbank_managecategories @javascript
@qbank @qbank_managecategories @view_manage_categories_plugin @javascript
Feature: Use the qbank plugin manager page for managecategories
In order to check the plugin behaviour with enable and disable
@@ -7,8 +7,8 @@ Feature: Use the qbank plugin manager page for managecategories
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
@@ -26,7 +26,7 @@ Feature: Use the qbank plugin manager page for managecategories
And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration
And I click on "Enable" "link" in the "Manage categories" "table_row"
And I am on the "Test quiz" "mod_quiz > question bank" page
And I should see "Categories" in the "//div[contains(@class, 'urlselect')]//option[contains(text(), 'Categories')]" "xpath_element"
And I should see "Categories" in the "Question bank tertiary navigation" "select"
Scenario: Enable/disable the tab New category when trying to add a random question to a quiz
Given I log in as "admin"
@@ -0,0 +1,537 @@
<?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_managecategories\external;
use context;
use context_course;
use moodle_url;
use qbank_managecategories\question_categories;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../manage_category_test_base.php');
/**
* Unit tests for move_category
*
* @package qbank_managecategories
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qbank_managecategories\external\move_category
*/
final class move_category_test extends \qbank_managecategories\manage_category_test_base {
/**
* Return order of categories for a given context.
*
* @param context $context The context to get the category order for.
* @return array Nested array, keyed by category IDs.
*/
private function get_current_order(context $context): array {
$categories = new question_categories(new moodle_url('/'), [$context]);
return $this->reduce_tree($categories->editlists[$context->id]->items);
}
/**
* Reduce the ordered tree of categories to a multi-dimensional array of IDs for easier comparison.
*
* @param array $tree Tree of categories from question_categories.
* @return array
*/
private function reduce_tree(array $tree): array {
$result = [];
foreach ($tree as $category) {
$result[$category->id] = [];
if (isset($category->children) && !empty((array)$category->children)) {
$result[$category->id] = $this->reduce_tree($category->children);
}
}
return $result;
}
/**
* Move a category below another category within the same parent.
*
* @return void
*/
public function test_move_category_down(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$context = context_course::instance($course->id);
$this->create_course_category();
// Question categories.
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$qcat3 = $this->create_question_category_for_a_course($course);
// Check current order.
$currentorder = $this->get_current_order($context);
$expectedorder = [
$qcat1->id => [],
$qcat2->id => [],
$qcat3->id => [],
];
$this->assertEquals($expectedorder, $currentorder);
// Move category 1 after category 2.
$stateupdates = move_category::execute($context->id, $qcat1->id, $qcat1->parent, $qcat2->id);
$neworder = $this->get_current_order($context);
$newexpectedorder = [
$qcat2->id => [],
$qcat1->id => [],
$qcat3->id => [],
];
$this->assertEquals($newexpectedorder, $neworder);
// We should have an update to the sortorder of the moved category and following categories.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat1->id,
'sortorder' => 3,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat3->id,
'sortorder' => 4,
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
}
/**
* Move a category to the top of its parent.
*
* @return void
* @throws \moodle_exception
*/
public function test_move_category_to_top(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$context = context_course::instance($course->id);
$this->create_course_category();
// Question categories.
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$qcat3 = $this->create_question_category_for_a_course($course);
// Check current order.
$currentorder = $this->get_current_order($context);
$expectedorder = [
$qcat1->id => [],
$qcat2->id => [],
$qcat3->id => [],
];
$this->assertEquals($expectedorder, $currentorder);
// Move category 3 to the top.
$stateupdates = move_category::execute($context->id, $qcat3->id, $qcat3->parent);
$neworder = $this->get_current_order($context);
$newexpectedorder = [
$qcat3->id => [],
$qcat1->id => [],
$qcat2->id => [],
];
$this->assertEquals($newexpectedorder, $neworder);
// Expecting all categories to have an updated sortorder.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat3->id,
'sortorder' => 1,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat1->id,
'sortorder' => 2,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat2->id,
'sortorder' => 3,
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
}
/**
* Move a category to a new parent that doesn't currently have any children.
*
* @return void
*/
public function test_move_category_as_new_child(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$context = context_course::instance($course->id);
$this->create_course_category();
// Question categories.
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$qcat3 = $this->create_question_category_for_a_course($course);
// Check current order.
$currentorder = $this->get_current_order($context);
$expectedorder = [
$qcat1->id => [],
$qcat2->id => [],
$qcat3->id => [],
];
$this->assertEquals($expectedorder, $currentorder);
// Set Category 2 as the parent of Category 1.
$stateupdates = move_category::execute($context->id, $qcat1->id, $qcat2->id);
$neworder = $this->get_current_order($context);
$newexpectedorder = [
$qcat2->id => [
$qcat1->id => [],
],
$qcat3->id => [],
];
$this->assertEquals($newexpectedorder, $neworder);
// Expecting an update to the parent and sortorder of the moved category, and updated sortorders for the children
// of the original parent.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat1->id,
'sortorder' => 1,
'parent' => $qcat2->id,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat2->id,
'sortorder' => 1,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat3->id,
'sortorder' => 2,
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
}
/**
* Move a category from one parent to another that already has a child.
*
* @return void
* @throws \moodle_exception
*/
public function test_move_category_between_parents(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$context = context_course::instance($course->id);
$this->create_course_category();
// Question categories.
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$qcat3 = $this->create_question_category_for_a_course($course, ['parent' => $qcat1->id]);
$qcat4 = $this->create_question_category_for_a_course($course, ['parent' => $qcat2->id]);
// Check current order.
$currentorder = $this->get_current_order($context);
$expectedorder = [
$qcat1->id => [
$qcat3->id => [],
],
$qcat2->id => [
$qcat4->id => [],
],
];
$this->assertEquals($expectedorder, $currentorder);
// Set Category 2 as the parent of Category 1.
$stateupdates = move_category::execute($context->id, $qcat3->id, $qcat2->id, $qcat4->id);
$neworder = $this->get_current_order($context);
$newexpectedorder = [
$qcat1->id => [],
$qcat2->id => [
$qcat4->id => [],
$qcat3->id => [],
],
];
$this->assertEquals($newexpectedorder, $neworder);
// As there are no remaining children of the original parent, and this was moved to the bottom of the new parent,
// just the moved category is updated.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat3->id,
'sortorder' => 2,
'parent' => $qcat2->id,
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
}
/**
* Move a category from the top category of one context to another.
*
* @return void
*/
public function test_move_category_between_contexts(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$coursecontext = context_course::instance($course->id);
$coursecategory = $this->create_course_category();
$categorycontext = \context_coursecat::instance($coursecategory->id);
// Question categories.
$coursecatqcat = question_get_top_category($categorycontext->id, true);
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$currentcourseorder = $this->get_current_order($coursecontext);
$expectedorder = [
$qcat1->id => [],
$qcat2->id => [],
];
$this->assertEquals($expectedorder, $currentcourseorder);
$currentcoursecatorder = $this->get_current_order($categorycontext);
$expectedcoursecatorder = [
];
$this->assertEquals($expectedcoursecatorder, $currentcoursecatorder);
$stateupdates = move_category::execute($coursecontext->id, $qcat1->id, $coursecatqcat->id);
$newcourseorder = $this->get_current_order($coursecontext);
$newexpectedorder = [
$qcat2->id => [],
];
$this->assertEquals($newcourseorder, $newexpectedorder);
$newcoursecatorder = $this->get_current_order($categorycontext);
$newexpectedcoursecatorder = [
$qcat1->id => [],
];
$this->assertEquals($newcoursecatorder, $newexpectedcoursecatorder);
// Expect an update to the sortorder, parent and context of the moved category.
// Since the original sibling is the only remaining child of a top-level category, it has its draghandle property
// updated as well as its sortorder.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat1->id,
'sortorder' => 1,
'parent' => $coursecatqcat->id,
'context' => $categorycontext->id,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat2->id,
'sortorder' => 1,
'draghandle' => false,
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
}
/**
* Move a category that has its own children.
*
* The children should move with the parent.
*
* @return void
*/
public function test_move_category_with_children(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$context = context_course::instance($course->id);
$this->create_course_category();
// Question categories.
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$qcat3 = $this->create_question_category_for_a_course($course);
$qcat4 = $this->create_question_category_for_a_course($course, ['parent' => $qcat3->id]);
$qcat5 = $this->create_question_category_for_a_course($course, ['parent' => $qcat3->id]);
// Check current order.
$currentorder = $this->get_current_order($context);
$expectedorder = [
$qcat1->id => [],
$qcat2->id => [],
$qcat3->id => [
$qcat4->id => [],
$qcat5->id => [],
],
];
$this->assertEquals($expectedorder, $currentorder);
$stateupdates = move_category::execute($context->id, $qcat3->id, $qcat3->parent, $qcat1->id);
$neworder = $this->get_current_order($context);
$newexpectedorder = [
$qcat1->id => [],
$qcat3->id => [
$qcat4->id => [],
$qcat5->id => [],
],
$qcat2->id => [],
];
$this->assertEquals($neworder, $newexpectedorder);
// Update the sortorder of the moving category and the following sibling. No updates to the children are required.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat3->id,
'sortorder' => 2,
],
],
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat2->id,
'sortorder' => 3,
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
}
/**
* Move a category that has its own children to a new parent.
*
* The children should also move and become descendants of the new parent.
*
* @return void
*/
public function test_change_parent_with_children(): void {
$this->setAdminUser();
$this->resetAfterTest();
// Create context for question categories.
$course = $this->create_course();
$context = context_course::instance($course->id);
$this->create_course_category();
// Question categories.
$qcat1 = $this->create_question_category_for_a_course($course);
$qcat2 = $this->create_question_category_for_a_course($course);
$qcat3 = $this->create_question_category_for_a_course($course);
$qcat4 = $this->create_question_category_for_a_course($course, ['parent' => $qcat3->id]);
$qcat5 = $this->create_question_category_for_a_course($course, ['parent' => $qcat4->id]);
// Check current order.
$currentorder = $this->get_current_order($context);
$expectedorder = [
$qcat1->id => [],
$qcat2->id => [],
$qcat3->id => [
$qcat4->id => [
$qcat5->id => [],
],
],
];
$this->assertEquals($expectedorder, $currentorder);
$stateupdates = move_category::execute($context->id, $qcat4->id, $qcat2->id);
$neworder = $this->get_current_order($context);
$newexpectedorder = [
$qcat1->id => [],
$qcat2->id => [
$qcat4->id => [
$qcat5->id => [],
],
],
$qcat3->id => [],
];
$this->assertEquals($newexpectedorder, $neworder);
// Expecting an update to the sortorder and parent of the moved category. No updates to the children are required.
$expectedstateupdates = [
(object)[
'name' => 'categories',
'action' => 'put',
'fields' => (object)[
'id' => $qcat4->id,
'parent' => $qcat2->id,
'sortorder' => 1,
],
],
];
$this->assertEquals($expectedstateupdates, $stateupdates);
}
}
@@ -13,6 +13,7 @@
//
// 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_managecategories;
use core_question\local\bank\question_edit_contexts;
@@ -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 qbank_managecategories\privacy;
use advanced_testcase;
use core_privacy\local\request\writer;
use qbank_managecategories\privacy\provider;
/**
* Unit tests for qbank_managecategories privacy provider.
*
* @package qbank_managecategories
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author 2021, Ghaly Marc-Alexandre <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \qbank_managecategories\privacy\provider
*/
final class provider_test extends advanced_testcase {
/**
* Test to check export_user_preferences.
*
* @covers ::export_user_preferences
*/
public function test_export_user_preferences(): void {
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
set_user_preference('qbank_managecategories_showdescriptions', 1, $user);
provider::export_user_preferences($user->id);
$writer = writer::with_context(\context_system::instance());
$prefs = $writer->get_user_preferences('qbank_managecategories');
$this->assertEquals(1, $prefs->showdescr->value);
$this->assertEquals(get_string('displaydescription', 'qbank_managecategories'), $prefs->showdescr->description);
}
}
@@ -13,6 +13,7 @@
//
// 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_managecategories;
defined('MOODLE_INTERNAL') || die();
@@ -29,7 +30,7 @@ require_once($CFG->dirroot . '/question/bank/managecategories/tests/manage_categ
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qbank_managecategories\question_categories
*/
class question_categories_test extends manage_category_test_base {
final class question_categories_test extends manage_category_test_base {
/**
* Test creation of an ordered tree of categories in the constructor.
*/
+1 -1
View File
@@ -26,6 +26,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'qbank_managecategories';
$plugin->version = 2024042200;
$plugin->version = 2024070400;
$plugin->requires = 2024041600;
$plugin->maturity = MATURITY_STABLE;