MDL-87264 questions: Add category route

This commit is contained in:
Mark Johnson
2026-02-27 14:43:56 +00:00
parent c6cf0cb951
commit b88e4c78b7
6 changed files with 386 additions and 83 deletions
@@ -0,0 +1,7 @@
issueNumber: MDL-87264
notes:
core_question:
- message: >-
Added a new route at `/api/rest/v2/question/categories` for returning a
list of question categories in a particular course module.
type: improved
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -25,6 +25,10 @@ import {categorymanager} from 'qbank_managecategories/categorymanager';
import Templates from 'core/templates';
import Modal from "core/modal";
import {get_string as getString} from "core/str";
import BankSwitcher from 'core_question/bank_switcher';
import Fetch from 'core/fetch';
import Notification from 'core/notification';
import * as CoreUrl from 'core/url';
import {eventTypes as inplaceEditableEventTypes} from 'core/local/inplace_editable/events';
export default class extends BaseComponent {
@@ -45,6 +49,8 @@ export default class extends BaseComponent {
CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`,
CHILD_LIST: id => `ul[data-categoryid="${id}"]`,
PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder="${sortorder}"]`,
SWITCH_QUESTION_BANK: '[data-action="switch-question-bank"]',
MOVE_BANK_HEADER: '.bank-header',
};
this.classes = {
NO_BOTTOM_PADDING: 'pb-0',
@@ -254,41 +260,50 @@ export default class extends BaseComponent {
// 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;
}
// 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;
}
// 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());
if (moved) {
if (nextSibling) {
// Move to the specified position in the list.
newParent.insertBefore(this.getElement(), nextSibling);
if (element.contextid === categorymanager.state.page.contextid) {
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 {
// Move to the end of the list (may also be the top of the list is empty).
newParent.appendChild(this.getElement());
newParent = this.getElement().parentElement;
}
// 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;
}
// 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());
if (moved) {
if (nextSibling) {
// Move to the specified position in the list.
newParent.insertBefore(this.getElement(), nextSibling);
} else {
// Move to the end of the list (may also be the top of the list is empty).
newParent.appendChild(this.getElement());
}
}
} else {
// The category was moved to a different context, it should no longer appear on this page.
this.getElement().remove();
}
if (originParent !== newParent) {
// Update child count of old and new parent.
this.reactive.stateManager.processUpdates([
@@ -325,44 +340,106 @@ export default class extends BaseComponent {
}
/**
* Recursively create a list of all valid destinations for a current category within a parent category.
* Create a list of category data from the elements on the page.
*
* @param {Element} item
* @param {Number} movingCategoryId
* @return {Array<Object>}
* This will find the category list item elements on the page and extract the category ID, parent ID, and name from the dataset
* of each element, with any child categories nested underneath. This list can then be passed to createMoveCategoryList to
* create a tree of move targets.
*
* @param {Element} element The category element from the page.
* @return {Array} List of categories containing categoryId, parentId, categoryName, and a nested array of children.
*/
createMoveCategoryList(item, movingCategoryId) {
getCategoryDataFromElements(element) {
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.
if (element.children) {
element.children.forEach(category => {
// Add this category to the list.
let child = {
categoryid: categoryId,
movingcategoryid: movingCategoryId,
precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0,
parent: category.dataset.parent,
categoryname: category.dataset.categoryname,
categories: null,
current: categoryId === movingCategoryId,
categoryId: category.dataset.categoryid,
parentId: category.dataset.parent,
categoryName: category.dataset.categoryname,
children: null,
};
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);
child.children = this.getCategoryDataFromElements(childList);
}
categories.push(child);
});
}
return categories;
}
/**
* Get the category data from the records retrieved from the web service.
*
* This will process the list of category records and extract the category ID, parent ID, and name from each,
* with any child categories nested underneath. This list can then be passed to createMoveCategoryList to
* create a tree of move targets.
*
* @param {Object} record The category record.
* @return {Array} List of categories containing categoryId, parentId, categoryName, and a nested array of children.
*/
getCategoryDataFromRecords(record) {
const categories = [];
if (record.children) {
for (const childId in record.children) {
const category = record.children[childId];
// Add this category to the list.
let child = {
categoryId: parseInt(category.id),
parentId: parseInt(category.parent),
categoryName: category.name,
children: null,
};
if (category.children) {
// If the child has its own children, recursively make a list of those.
child.children = this.getCategoryDataFromRecords(category);
}
categories.push(child);
}
}
return categories;
}
/**
* Recursively create a list of all valid destinations for a current category within a parent category.
*
* @param {Object} categoryData A list of category data from getCategoryDataFromElements() or getCategoryDataFromRecords().
* @param {Number} movingCategoryId The ID of the category currently being moved.
* @return {Array<Object>}
*/
createMoveCategoryList(categoryData, movingCategoryId) {
const categories = [];
if (categoryData) {
let precedingSibling = null;
categoryData.forEach(category => {
// Don't create a target for the category that's moving.
if (category.categoryId === movingCategoryId) {
return;
}
// Create a target to move before this child.
let child = {
categoryid: category.categoryId,
movingcategoryid: movingCategoryId,
precedingsiblingid: precedingSibling?.categoryId ?? 0,
parent: category.parentId,
categoryname: category.categoryName,
categories: null,
current: category.categoryId === movingCategoryId,
};
if (category.children) {
// If the child has its own children, recursively make a list of those.
child.categories = this.createMoveCategoryList(category.children, 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,
parent: category.categoryId,
categoryname: category.categoryName,
categories: null,
newchild: true,
}
@@ -372,14 +449,13 @@ export default class extends BaseComponent {
precedingSibling = category;
});
if (precedingSibling) {
const precedingId = parseInt(precedingSibling.dataset.categoryid);
if (precedingId !== movingCategoryId) {
if (precedingSibling.categoryId !== 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,
precedingsiblingid: precedingSibling.categoryId,
parent: precedingSibling.parentId,
categoryname: precedingSibling.categoryName,
categories: null,
lastchild: true,
});
@@ -409,34 +485,111 @@ export default class extends BaseComponent {
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 contextElement = document.querySelector(this.selectors.CONTEXT);
const categoryData = this.getCategoryDataFromElements(contextElement);
const moveContext = {
contextname: contextElement.dataset.contextname,
contextid: contextElement.dataset.contextid,
cmid: categorymanager.state.page.cmid,
categories: [],
hascategories: false,
};
const movingCategoryId = parseInt(item.dataset.categoryid);
moveContext.categories = this.createMoveCategoryList(categoryData, movingCategoryId);
moveContext.hascategories = moveContext.categories.length > 0;
const moveCategory = getString('movecategory', 'qbank_managecategories', item.dataset.categoryname);
const modal = await Modal.create({
title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname),
body: Templates.render('qbank_managecategories/move_context_list', moveList),
title: moveCategory,
body: Templates.render('qbank_managecategories/move_context_list', moveContext),
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) {
const switcher = new BankSwitcher();
// Show modal and add click event for list items and bank switcher.
modal.getBody()[0].addEventListener('click', async(e) => {
const categoryItem = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);
const moveHeader = e.currentTarget.querySelector(this.selectors.MOVE_BANK_HEADER);
if (categoryItem) {
categorymanager.moveCategory(
categoryItem.dataset.movingcategoryid,
categoryItem.dataset.parent,
categoryItem.dataset.precedingsiblingid,
);
if (moveHeader.dataset.cmid !== categorymanager.state.page.cmid) {
const url = CoreUrl.relativeUrl(
'/question/bank/managecategories/category.php',
{cmid: moveHeader.dataset.cmid}
);
const message = await getString(
'categorymovedto',
'qbank_managecategories',
{url, name: moveHeader.textContent},
);
Notification.addNotification({message: message, type: 'info'});
}
modal.destroy();
return;
}
categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid);
modal.destroy();
const switchButton = e.target.closest(this.selectors.SWITCH_QUESTION_BANK);
if (switchButton) {
const pageState = categorymanager.state.page;
try {
const contextId = parseInt(contextElement.dataset.contextid);
await switcher.show(modal, pageState.courseid, contextId, parseInt(moveHeader.dataset.cmid), pageState.cmid);
} catch (ex) {
Notification.exception(ex);
}
}
});
modal.getModal()[0].addEventListener('bankSwitched', async(e) => {
try {
const params = {coursemodule: e.detail.cmid};
const categoriesResponse = await Fetch.performGet(
'core_question',
'categories',
{params},
);
const {context, categories} = await categoriesResponse.json();
// Convert the list of categories into a nested tree.
for (const id in categories) {
const category = categories[id];
if (category.parent > 0) {
const parentitem = categories[category.parent];
if (!parentitem.hasOwnProperty('children')) {
parentitem.children = {};
}
categories[category.parent].children[category.id] = category;
}
}
// Get the top category with all the others nested below.
let topCategory;
for (const id in categories) {
if (parseInt(categories[id].parent) === 0) {
topCategory = categories[id];
}
}
const categoryData = this.getCategoryDataFromRecords(topCategory);
const moveContext = {
contextname: context.prefixedname,
contextid: context.prefixedname,
cmid: e.detail.cmid,
categories: [],
hascategories: false,
};
moveContext.categories = this.createMoveCategoryList(categoryData, movingCategoryId);
moveContext.hascategories = moveContext.categories.length > 0;
modal.setBody(
Templates.render('qbank_managecategories/move_context_list', moveContext),
);
await modal.getBodyPromise();
modal.setTitle(moveCategory);
modal.setFooter('');
} catch (ex) {
Notification.alert(getString('error', 'error'), ex);
}
});
item.setAttribute('aria-disabled', false);
}
@@ -0,0 +1,89 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core\context;
use JsonSerializable;
use stdClass;
/**
* A simple value object representing a question category.
*
* When serialised to JSON for output via routes, this the name and intro will be formatted.
*
* @package core_question
* @copyright 2026 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_category implements JsonSerializable {
/**
* Set properties and format strings.
*
* @param int $id The category ID
* @param string $name The raw category name, this will be formatted.
* @param int $contextid The module context ID the category belongs to.
* @param string $info The category's info, this with be formatted according to $infoformat.
* @param string $infoformat The format for $info.
* @param string $stamp Generated identifier for this category.
* @param int $parent Parent category ID.
* @param int $sortorder Category sort order within its parent.
* @param int $idnumber ID number.
*/
public function __construct(
/** @var int $id The category ID */
public int $id,
/** @var string $name The raw category name, this will be formatted. */
public string $name,
/** @var int $contextid The module context ID the category belongs to. */
public int $contextid,
/** @var string $info The category's info, this with be formatted according to $infoformat. */
public string $info,
/** @var string $infoformat The format for $info. */
public string $infoformat,
/** @var string $stamp Generated identifier for this category. */
public string $stamp,
/** @var int $parent Parent category ID. */
public int $parent,
/** @var int $sortorder Category sort order within its parent. */
public int $sortorder,
/** @var int $idnumber ID number. */
public int $idnumber,
) {
}
/**
* Returns the properties with formatted 'name' and 'info'
*
* This is used when encoding the object for output, for example via web service routes.
*
* @return stdClass
*/
public function jsonSerialize(): stdClass {
$context = context::instance_by_id($this->contextid);
return (object) [
'id' => $this->id,
'name' => format_string($this->name, ['context' => $context]),
'contextid' => $this->contextid,
'info' => format_text($this->info, $this->infoformat, ['context' => $context]),
'stamp' => $this->stamp,
'parent' => $this->parent,
'sortorder' => $this->sortorder,
'idnumber' => $this->idnumber,
];
}
}
@@ -33,6 +33,8 @@ use core\router\schema\response\payload_response;
use core\router\schema\response\response;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use core_question\output\question_category_selector;
use core_question\question_category;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -179,4 +181,56 @@ class bank {
);
}
#[route(
path: '/categories', // Resolves to /api/rest/v2/question/categories.
queryparams: [
new query_coursemodule(),
],
responses: [
new response(
statuscode: 200,
description: 'OK',
content: [
new json_media_type(
schema: new schema_object(
content: [
'context' => new array_of_strings(param::ALPHA, param::TEXT),
'categories' => new array_of_things(question_category::class),
],
),
),
],
),
],
)]
/**
* Return a list of question categories with names and info formatted for output.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param module $coursemodulecontext The module context.
* @param question_category_selector $categoryselector Injected dependency.
* @return payload_response The course module context id and name, and a list of question categories in that context.
*/
public function categories(
ServerRequestInterface $request,
ResponseInterface $response,
module $coursemodulecontext,
question_category_selector $categoryselector,
): payload_response {
require_login();
$categories = $categoryselector->get_categories_for_contexts($coursemodulecontext->id, top: true);
return new payload_response(
request: $request,
response: $response,
payload: [
'context' => [
'id' => $coursemodulecontext->id,
'name' => $coursemodulecontext->get_context_name(false),
'prefixedname' => $coursemodulecontext->get_context_name(),
],
'categories' => $categories,
],
);
}
}