MDL-87264 questions: Add category route
This commit is contained in:
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user