question/bank/managecategories/amd/src/category.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * The category component.
  17. *
  18. * @module qbank_managecategories/category
  19. * @class qbank_managecategories/category
  20. */
  21. import {BaseComponent, DragDrop} from 'core/reactive';
  22. import {categorymanager} from 'qbank_managecategories/categorymanager';
  23. import Templates from 'core/templates';
  24. import Modal from "core/modal";
  25. import {get_string as getString} from "core/str";
  26. export default class extends BaseComponent {
  27. create(descriptor) {
  28. this.name = descriptor.element.id;
  29. this.selectors = {
  30. CATEGORY_LIST: '.qbank_managecategories-categorylist',
  31. CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',
  32. CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',
  33. EDIT_BUTTON: '[data-action="addeditcategory"]',
  34. MOVE_BUTTON: '[role="menuitem"][data-actiontype="move"]',
  35. CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',
  36. MODAL_CATEGORY_ITEM: '.modal_category_item[data-movingcategoryid]',
  37. CONTENT_AREA: '.qbank_managecategories-details',
  38. CATEGORY_ID: id => `#category-${id}`,
  39. CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`,
  40. CHILD_LIST: id => `ul[data-categoryid="${id}"]`,
  41. PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder="${sortorder}"]`,
  42. };
  43. this.classes = {
  44. NO_BOTTOM_PADDING: 'pb-0',
  45. DRAGHANDLE: 'draghandle',
  46. DROPTARGET: 'qbank_managecategories-droptarget-before',
  47. };
  48. this.ids = {
  49. CATEGORY: id => `category-${id}`,
  50. };
  51. }
  52. stateReady() {
  53. this.initDragDrop();
  54. this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON), 'click', categorymanager.showEditModal);
  55. const moveButton = this.getElement(this.selectors.MOVE_BUTTON);
  56. this.addEventListener(moveButton, 'click', this.showMoveModal);
  57. }
  58. destroy() {
  59. // The draggable element must be unregistered.
  60. this.deInitDragDrop();
  61. }
  62. /**
  63. * Remove any existing DragDrop component, and create a new one.
  64. */
  65. initDragDrop() {
  66. this.deInitDragDrop();
  67. // If the element is currently draggable, register the getDraggableData method.
  68. if (this.element.classList.contains(this.classes.DRAGHANDLE)) {
  69. this.getDraggableData = this._getDraggableData;
  70. }
  71. this.dragdrop = new DragDrop(this);
  72. }
  73. /**
  74. * If the DragDrop component is currently registered, unregister it.
  75. */
  76. deInitDragDrop() {
  77. if (this.dragdrop !== undefined) {
  78. if (this.getDraggableData !== undefined) {
  79. this.dragdrop.setDraggable(false);
  80. this.getDraggableData = undefined;
  81. }
  82. this.dragdrop.unregister();
  83. this.dragdrop = undefined;
  84. }
  85. }
  86. /**
  87. * Static method to create a component instance.
  88. *
  89. * @param {string} target the DOM main element or its ID
  90. * @param {object} selectors optional css selector overrides
  91. * @return {Component}
  92. */
  93. static init(target, selectors) {
  94. return new this({
  95. element: document.querySelector(target),
  96. selectors,
  97. reactive: categorymanager,
  98. });
  99. }
  100. /**
  101. * Return the category ID from the component's element.
  102. *
  103. * This method is referenced as getDraggableData when the component can be dragged.
  104. *
  105. * @return {{id: string}}
  106. * @private
  107. */
  108. _getDraggableData() {
  109. return {
  110. id: this.getElement().dataset.categoryid
  111. };
  112. }
  113. validateDropData() {
  114. return true;
  115. }
  116. /**
  117. * Highlight the top border of the category item.
  118. *
  119. * @param {Object} dropData
  120. */
  121. showDropZone(dropData) {
  122. if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {
  123. // Can't drop onto itself or its own child.
  124. return false;
  125. }
  126. this.getElement().classList.add(this.classes.DROPTARGET);
  127. return true;
  128. }
  129. /**
  130. * Remove highlighting.
  131. */
  132. hideDropZone() {
  133. this.getElement().classList.remove(this.classes.DROPTARGET);
  134. }
  135. /**
  136. * Find the new position of the dropped category, and trigger the move.
  137. *
  138. * @param {Object} dropData The category being moved.
  139. * @param {Event} event The drop event.
  140. */
  141. drop(dropData, event) {
  142. const dropTarget = event.target.closest(this.selectors.CATEGORY_ITEM);
  143. if (!dropTarget) {
  144. return;
  145. }
  146. if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {
  147. // Can't drop onto your own child.
  148. return;
  149. }
  150. const source = document.getElementById(this.ids.CATEGORY(dropData.id));
  151. if (!source) {
  152. return;
  153. }
  154. const targetParentId = dropTarget.dataset.parent;
  155. const parentList = dropTarget.closest(this.selectors.CATEGORY_LIST);
  156. let precedingSibling;
  157. if (dropTarget === parentList.firstElementChild) {
  158. // Dropped at the top of the list.
  159. precedingSibling = null;
  160. } else {
  161. precedingSibling = dropTarget.previousElementSibling;
  162. }
  163. // Insert the category after the target category
  164. categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);
  165. }
  166. getWatchers() {
  167. return [
  168. // After any update to this category, move it to the new position.
  169. {watch: `categories[${this.element.dataset.categoryid}]:updated`, handler: this.updatePosition},
  170. // When the template context is added or updated, re-render the content.
  171. {watch: `categories[${this.element.dataset.categoryid}].templatecontext:created`, handler: this.rerender},
  172. {watch: `categories[${this.element.dataset.categoryid}].templatecontext:updated`, handler: this.rerender},
  173. // When a new category is created, check whether we need to add a child list to this category.
  174. {watch: `categories:created`, handler: this.checkChildList},
  175. ];
  176. }
  177. /**
  178. * Re-render the category content.
  179. *
  180. * @param {Object} args
  181. * @param {Element} args.element
  182. * @return {Promise<Array>}
  183. */
  184. async rerender({element}) {
  185. const {html, js} = await Templates.renderForPromise(
  186. 'qbank_managecategories/category_details',
  187. element.templatecontext
  188. );
  189. return Templates.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA), html, js);
  190. }
  191. /**
  192. * Render and append a new child list.
  193. *
  194. * @param {Object} context Template context, must include at least categoryid.
  195. * @return {Promise<Element>}
  196. */
  197. async createChildList(context) {
  198. const {html, js} = await Templates.renderForPromise(
  199. 'qbank_managecategories/childlist',
  200. context,
  201. );
  202. const parentContainer = document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid));
  203. await Templates.appendNodeContents(parentContainer, html, js);
  204. const childList = document.querySelector(this.selectors.CHILD_LIST(context.categoryid));
  205. childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING);
  206. return childList;
  207. }
  208. /**
  209. * Move a category to its new position.
  210. *
  211. * A category may change its parent, sortorder and draghandle independently or at the same time. This method will resolve those
  212. * changes and move the element to the new position. If the parent doesn't already have a child list, one will be created.
  213. *
  214. * If the parent has changed, this will also update the state with the new child count of the old and new parents.
  215. *
  216. * @param {Object} args
  217. * @param {Object} args.element
  218. * @return {Promise<void>}
  219. */
  220. async updatePosition({element}) {
  221. // Move to a new parent category.
  222. let newParent;
  223. const originParent = document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent));
  224. if (parseInt(this.getElement().dataset.parent) !== element.parent) {
  225. newParent = document.querySelector(this.selectors.CHILD_LIST(element.parent));
  226. if (!newParent) {
  227. // The target category doesn't have a child list yet. We'd better create one.
  228. newParent = await this.createChildList({categoryid: element.parent});
  229. }
  230. this.getElement().dataset.parent = element.parent;
  231. } else {
  232. newParent = this.getElement().parentElement;
  233. }
  234. // Move to a new position within the parent.
  235. let previousSibling;
  236. let nextSibling;
  237. if (newParent.firstElementChild && parseInt(element.sortorder) <= parseInt(newParent.firstElementChild.dataset.sortorder)) {
  238. // Move to the top of the list.
  239. nextSibling = newParent.firstElementChild;
  240. } else {
  241. // Move later in the list.
  242. previousSibling = newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder - 1));
  243. nextSibling = previousSibling?.nextElementSibling;
  244. }
  245. // Check if this has actually moved, or if it's just having its sortorder updated due to another element moving.
  246. const moved = (newParent !== this.getElement().parentElement || nextSibling !== this.getElement());
  247. if (moved) {
  248. if (nextSibling) {
  249. // Move to the specified position in the list.
  250. newParent.insertBefore(this.getElement(), nextSibling);
  251. } else {
  252. // Move to the end of the list (may also be the top of the list is empty).
  253. newParent.appendChild(this.getElement());
  254. }
  255. }
  256. if (originParent !== newParent) {
  257. // Update child count of old and new parent.
  258. this.reactive.stateManager.processUpdates([
  259. {
  260. name: 'categoryLists',
  261. action: 'put',
  262. fields: {
  263. id: originParent.dataset.categoryid,
  264. childCount: originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
  265. }
  266. },
  267. {
  268. name: 'categoryLists',
  269. action: 'put',
  270. fields: {
  271. id: newParent.dataset.categoryid,
  272. childCount: newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
  273. }
  274. }
  275. ]);
  276. }
  277. this.element.dataset.sortorder = element.sortorder;
  278. // Enable/disable dragging.
  279. const isDraggable = this.element.classList.contains(this.classes.DRAGHANDLE);
  280. if (isDraggable && !element.draghandle) {
  281. this.element.classList.remove(this.classes.DRAGHANDLE);
  282. this.initDragDrop();
  283. } else if (!isDraggable && element.draghandle) {
  284. this.element.classList.add(this.classes.DRAGHANDLE);
  285. this.initDragDrop();
  286. }
  287. }
  288. /**
  289. * Recursively create a list of all valid destinations for a current category within a parent category.
  290. *
  291. * @param {Element} item
  292. * @param {Number} movingCategoryId
  293. * @return {Array<Object>}
  294. */
  295. createMoveCategoryList(item, movingCategoryId) {
  296. const categories = [];
  297. if (item.children) {
  298. let precedingSibling = null;
  299. item.children.forEach(category => {
  300. const categoryId = parseInt(category.dataset.categoryid);
  301. // Don't create a target for the category that's moving.
  302. if (categoryId === movingCategoryId) {
  303. return;
  304. }
  305. // Create a target to move before this child.
  306. let child = {
  307. categoryid: categoryId,
  308. movingcategoryid: movingCategoryId,
  309. precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0,
  310. parent: category.dataset.parent,
  311. categoryname: category.dataset.categoryname,
  312. categories: null,
  313. current: categoryId === movingCategoryId,
  314. };
  315. const childList = category.querySelector(this.selectors.CATEGORY_LIST);
  316. if (childList) {
  317. // If the child has its own children, recursively make a list of those.
  318. child.categories = this.createMoveCategoryList(childList, movingCategoryId);
  319. } else {
  320. // Otherwise, create a target to move as a new child of this one.
  321. child.categories = [
  322. {
  323. movingcategoryid: movingCategoryId,
  324. precedingsiblingid: 0,
  325. parent: categoryId,
  326. categoryname: category.dataset.categoryname,
  327. categories: null,
  328. newchild: true,
  329. }
  330. ];
  331. }
  332. categories.push(child);
  333. precedingSibling = category;
  334. });
  335. if (precedingSibling) {
  336. const precedingId = parseInt(precedingSibling.dataset.categoryid);
  337. if (precedingId !== movingCategoryId) {
  338. // If this is the last child of its parent, also create a target to move the category after this one.
  339. categories.push({
  340. movingcategoryid: movingCategoryId,
  341. precedingsiblingid: precedingId,
  342. parent: precedingSibling.dataset.parent,
  343. categoryname: precedingSibling.dataset.categoryname,
  344. categories: null,
  345. lastchild: true,
  346. });
  347. }
  348. }
  349. }
  350. return categories;
  351. }
  352. /**
  353. * Displays a modal containing links to move the category to a new location.
  354. *
  355. * @param {Event} e Button click event.
  356. */
  357. async showMoveModal(e) {
  358. // Return if it is not menu item.
  359. const item = e.target.closest(this.selectors.MOVE_BUTTON);
  360. if (!item) {
  361. return;
  362. }
  363. // Return if it is disabled.
  364. if (item.getAttribute('aria-disabled') === 'true') {
  365. return;
  366. }
  367. // Prevent addition click on the item.
  368. item.setAttribute('aria-disabled', true);
  369. // Build the list of move links.
  370. let moveList = {contexts: []};
  371. const contexts = document.querySelectorAll(this.selectors.CONTEXT);
  372. contexts.forEach(context => {
  373. const moveContext = {
  374. contextname: context.dataset.contextname,
  375. categories: [],
  376. hascategories: false,
  377. };
  378. moveContext.categories = this.createMoveCategoryList(context, parseInt(item.dataset.categoryid));
  379. moveContext.hascategories = moveContext.categories.length > 0;
  380. moveList.contexts.push(moveContext);
  381. });
  382. const modal = await Modal.create({
  383. title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname),
  384. body: Templates.render('qbank_managecategories/move_context_list', moveList),
  385. footer: '',
  386. show: true,
  387. large: true,
  388. });
  389. // Show modal and add click event for list items.
  390. modal.getBody()[0].addEventListener('click', e => {
  391. const target = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);
  392. if (!target) {
  393. return;
  394. }
  395. categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid);
  396. modal.destroy();
  397. });
  398. item.setAttribute('aria-disabled', false);
  399. }
  400. /**
  401. * Check and add a child list if needed.
  402. *
  403. * Check whether the category that has just been added has this category as its parent. If it does,
  404. * check that this category has a child list, and if not, add one.
  405. *
  406. * @param {Object} args
  407. * @param {Element} args.element The new category.
  408. * @return {Promise<Element>}
  409. */
  410. async checkChildList({element}) {
  411. if (element.parent !== this.getElement().dataset.categoryid) {
  412. return null; // Not for me.
  413. }
  414. let childList = this.getElement(this.selectors.CATEGORY_LIST);
  415. if (childList) {
  416. return null; // List already exists, it will handle adding the new category.
  417. }
  418. // Render and add a new child list containing the new category.
  419. return this.createChildList({
  420. categoryid: element.parent,
  421. children: [
  422. element.templatecontext,
  423. ]
  424. });
  425. }
  426. }