This commit is contained in:
Mihail Geshoski
2026-03-06 09:11:14 +07:00
committed by Huong Nguyen
61 changed files with 1785 additions and 786 deletions
@@ -0,0 +1,26 @@
issueNumber: MDL-87264
notes:
mod_quiz:
- message: >
The `mod_quiz_output_fragment_switch_question_bank()` Fragment API
callback is deprecated in favour of
`core_question\route\api\bank::banks()`, available via the route
`/api/rest/v2/core/question/banks?courseid=X`.
type: deprecated
- message: >
The "gobacktoquiz" and "selectquestionbank" lang strings have been deprecated.
These are only used by the question bank switching UI, so have been replaced
with the "switchergoback" and "switcherselectbank" strings in the core_question
component.
type: deprecated
core_question:
- message: >
The `\core_question\output\switch_question_bank` renderable is now deprecated,
as rendering of the switch_question_bank template is now all handled client-side
by the `core_question/bank_switcher` Javascript module.
type: deprecated
- message: >
The UI for switching question banks is now encapsulated in the
`core_question/bank_switcher` Javascript module. This takes an existing modal
and replaces the content with the switcher. It will then emit a custom
`bankSwitched` event on the modal's DOM element when a new bank is selected.
@@ -0,0 +1,11 @@
issueNumber: MDL-87264
notes:
qbank_managecategories:
- message: >-
Support for displaying multiple contexts has been removed from the
question category management UI. As such, the `editlists` property,
`contexts`, `courseid` and `thiscontext` constructor parameters in
`qbank_managecategories\question_categories` are deprecated. The
constructor now just uses `cmid` to determine the current context
parent course.
type: deprecated
@@ -0,0 +1,10 @@
issueNumber: MDL-87264
notes:
core:
- message: >
Added new `core\router\parameter\query_course` and
`core\router\parameter\query_coursemodule` parameter types, to allow
course IDs and course module IDs to be accepted as query string
parameters for routes, and automatically converted into the
corresponding objects and contexts.
type: improved
@@ -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
@@ -40,13 +40,13 @@ class course_navigation {
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param \stdClass $cm
* @param \stdClass $cmdata
* @return ResponseInterface
*/
#[route(
path: '/cms/{cm}/next',
pathtypes: [
new \core\router\parameters\path_module(),
new \core\router\parameters\path_coursemodule(name: 'cm'),
],
requirelogin: new require_login(
requirelogin: true,
@@ -56,11 +56,11 @@ class course_navigation {
public function cm_next_element(
ServerRequestInterface $request,
ResponseInterface $response,
\stdClass $cm,
\stdClass $cmdata,
): ResponseInterface {
// The pathinfo module returns a stdClass and not a cm_info, so we need to
// get the cm_info instance from the course modinfo.
$cm = cm_info::create($cm);
$cm = cm_info::create($cmdata);
$modinfo = $cm->get_modinfo();
$section = $this->get_section($cm);
$allsectioncms = $this->get_all_section_cms($modinfo, $section);
@@ -88,13 +88,13 @@ class course_navigation {
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param \stdClass $cm
* @param \stdClass $cmdata
* @return ResponseInterface
*/
#[route(
path: '/cms/{cm}/previous',
pathtypes: [
new \core\router\parameters\path_module(),
new \core\router\parameters\path_coursemodule(name: 'cm'),
],
requirelogin: new require_login(
requirelogin: true,
@@ -104,11 +104,11 @@ class course_navigation {
public function cm_previous_element(
ServerRequestInterface $request,
ResponseInterface $response,
\stdClass $cm,
\stdClass $cmdata,
): ResponseInterface {
// The pathinfo module returns a stdClass and not a cm_info, so we need to
// get the cm_info instance from the course modinfo.
$cm = cm_info::create($cm);
$cm = cm_info::create($cmdata);
$modinfo = $cm->get_modinfo();
$section = $this->get_section($cm);
$allsectioncms = $this->get_all_section_cms($modinfo, $section);
@@ -36,13 +36,13 @@ class restricted_module {
* Restricted module.
*
* @param ResponseInterface $response
* @param modinfo $cm
* @param \stdClass $cmdata
* @return ResponseInterface
*/
#[route(
path: '/cms/{cm}/restricted',
pathtypes: [
new \core\router\parameters\path_module(),
new \core\router\parameters\path_coursemodule(name: 'cm'),
],
requirelogin: new require_login(
requirelogin: true,
@@ -52,28 +52,28 @@ class restricted_module {
public function restricted_module_page(
ServerRequestInterface $request,
ResponseInterface $response,
\stdClass $cm,
\stdClass $cmdata,
): ResponseInterface {
global $OUTPUT, $PAGE;
$context = \context_module::instance($cm->id);
$context = \context_module::instance($cmdata->id);
$course = get_course($cm->course);
$course = get_course($cmdata->course);
$modinfo = get_fast_modinfo($course);
$cminfo = $modinfo->get_cm($cm->id);
$sectioninfo = $modinfo->get_section_info_by_id($cm->section);
$cminfo = $modinfo->get_cm($cmdata->id);
$sectioninfo = $modinfo->get_section_info_by_id($cmdata->section);
$format = course_get_format($course);
$course->format = $format->get_format();
$url = \core\router\util::get_path_for_callable(
[self::class, 'restricted_module_page'],
['cm' => $cm->id],
['cm' => $cmdata->id],
);
$PAGE->set_url($url, ['cm' => $cm->id]);
$PAGE->set_url($url, ['cm' => $cmdata->id]);
$PAGE->add_body_class('limitedwidth');
$PAGE->set_context($context);
$PAGE->set_pagetype('mod-' . $cm->modname . '-restricted');
$PAGE->set_pagetype('mod-' . $cminfo->modname . '-restricted');
$strtitle = get_string('restrictedtitle', 'course', $cminfo->get_name());
$PAGE->set_title($strtitle . \moodle_page::TITLE_SEPARATOR . $course->shortname);
$PAGE->set_heading($course->fullname);
@@ -94,7 +94,7 @@ class restricted_module {
$response->getBody()->write($OUTPUT->footer());
$eventdata = [
'objectid' => $cm->id,
'objectid' => $cmdata->id,
'context' => $context,
];
$event = \core\event\course_restricted_module_viewed::create($eventdata);
+4
View File
@@ -82,6 +82,7 @@ $string['clicktounflag'] = 'Remove flag';
$string['clickunflag'] = 'Remove flag';
$string['contexterror'] = 'You shouldn\'t have got here if you\'re not moving a category to another context.';
$string['copy'] = 'Copy from {$a} and change links.';
$string['coursenamebankname'] = '{$a->coursename} - {$a->bankname}';
$string['created'] = 'Created';
$string['createdefault'] = 'Create default question bank';
$string['createdby'] = 'Created by';
@@ -327,6 +328,9 @@ $string['shareincontext'] = 'Share in context for {$a}';
$string['stoponerror'] = 'Stop on error';
$string['stoponerror_help'] = 'This setting determines whether the import process stops when an error is detected, resulting in no questions being imported, or whether any questions containing errors are ignored and any valid questions are imported.';
$string['switchbank'] = 'Switch bank';
$string['switchergoback'] = 'Go back';
$string['switchersearchbyname'] = 'Search by name...';
$string['switcherselectbank'] = 'Select question bank';
$string['tofilecategory'] = 'Write category to file';
$string['tofilecontext'] = 'Write context to file';
$string['topfor'] = 'Top for {$a}';
@@ -0,0 +1,143 @@
<?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\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use Psr\Http\Message\ServerRequestInterface;
/**
* Trait for route parameters that pass a course identifier, to attach the course data and context to the request.
*
* This holds common code used in {@see path_course} and {@see query_course}.
*
* @package core
* @copyright 2026 Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait course_parameter_trait {
/**
* Create a new course parameter.
*
* @param string $name The name of the parameter to use for the course identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'course',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The course identifier.
This can be the id of the course, the idnumber of the course, or the shortname of the course.
If specifying a course idnumber, the value should be in the format `idnumber:[idnumber]`.
If specifying a course shortname, the value should be in the format `name:[shortname]`.
EOF;
$extra['examples'] = [
new example(
name: 'A course id',
value: 54,
),
new example(
name: 'A course specified by its idnumber',
value: 'idnumber:000117-physics-101-1',
),
new example(
name: 'A course specified by its shortname',
value: 'name:000117-phys101-0',
),
];
parent::__construct(...$extra);
}
/**
* Get the course object for the given identifier.
*
* @param string $value A course id, idnumber, or shortname
* @return object
* @throws not_found_exception If the course cannot be found
*/
protected function get_course_for_value(string $value): mixed {
global $DB;
$data = false;
if (is_numeric($value)) {
$data = $DB->get_record('course', [
'id' => $value,
]);
} else if (str_starts_with($value, 'idnumber:')) {
$data = $DB->get_record('course', [
'idnumber' => substr($value, strlen('idnumber:')),
]);
} else if (str_starts_with($value, 'name:')) {
$data = $DB->get_record('course', [
'shortname' => substr($value, strlen('name:')),
]);
}
if ($data) {
return $data;
}
throw new not_found_exception('course', $value);
}
/**
* Add course and course context parameters to the request.
*
* @param ServerRequestInterface $request
* @param string $value
* @return ServerRequestInterface
*/
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
$course = $this->get_course_for_value($value);
return $request
->withAttribute($this->name, $course)
->withAttribute("{$this->name}context", \core\context\course::instance($course->id));
}
/**
* Get the schema for the parameter type, with valid patterns.
*
* @param param $type
* @return \stdClass
*/
public function get_schema_from_type(param $type): \stdClass {
$schema = parent::get_schema_from_type($type);
$schema->pattern = "^(";
$schema->pattern .= implode("|", [
'\d+',
'idnumber:.+',
'name:.+',
]);
$schema->pattern .= ")$";
return $schema;
}
}
@@ -0,0 +1,142 @@
<?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\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use Psr\Http\Message\ServerRequestInterface;
/**
* Trait for route parameters that pass a course module identifier, to attach the course module data and context to the request.
*
* This code used in {@see query_coursemodule} that can be reused for other parameter types referencing a course module.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait coursemodule_parameter_trait {
/**
* Create a new coursemodule parameter.
*
* @param string $name The name of the parameter to use for the course identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'coursemodule',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The course module identifier.
This can be the id of the course module or the idnumber of the course module.
If specifying a course module idnumber, the value should be in the format `idnumber:[idnumber]`.
EOF;
$extra['examples'] = [
new example(
name: 'A course module id',
value: 54,
),
new example(
name: 'A course module specified by its idnumber',
value: 'idnumber:000117-physics-101-1',
),
];
parent::__construct(...$extra);
}
/**
* Get the course module record for the given identifier.
*
* @param string $value A course module id or idnumber
* @return object Course module record.
* @throws not_found_exception If the course module cannot be found
*/
protected function get_data_for_value(string $value): mixed {
global $DB;
$data = false;
if (is_numeric($value)) {
$data = $DB->get_record('course_modules', [
'id' => $value,
]);
} else if (str_starts_with($value, 'idnumber:')) {
$data = $DB->get_record('course_modules', [
'idnumber' => substr($value, strlen('idnumber:')),
]);
}
if ($data) {
return $data;
}
throw new not_found_exception('coursemodule', $value);
}
/**
* Add course module data and course module context parameters to the request.
*
* @param ServerRequestInterface $request
* @param string $value
* @return ServerRequestInterface
*/
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
$data = $this->get_data_for_value($value);
return $request
->withAttribute($this->get_value_name('data'), $data)
->withAttribute($this->get_value_name('context'), \core\context\module::instance($data->id));
}
/**
* Get the schema for the parameter type, with valid patterns.
*
* @param param $type
* @return \stdClass
*/
public function get_schema_from_type(param $type): \stdClass {
$schema = parent::get_schema_from_type($type);
$schema->pattern = "^(";
$schema->pattern .= implode("|", [
'\d+',
'idnumber:.+',
]);
$schema->pattern .= ")$";
return $schema;
}
/**
* Get the name of the parameter with the given suffix.
*
* @param string $suffix
* @return string
*/
protected function get_value_name(string $suffix): string {
return $this->name . $suffix;
}
}
@@ -16,120 +16,16 @@
namespace core\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use core\router\schema\parameters\mapped_property_parameter;
use core\router\schema\referenced_object;
use Psr\Http\Message\ServerRequestInterface;
/**
* A Moodle parameter referenced in the path.
*
* @package core
* @copyright 2023 Andrew Lyons <[email protected]>
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class path_course extends \core\router\schema\parameters\path_parameter implements
mapped_property_parameter,
referenced_object
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
/**
* Create a new path_course parameter.
*
* @param string $name The name of the parameter to use for the course identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'course',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The course identifier.
This can be the id of the course, the idnumber of the course, or the shortname of the course.
If specifying a course idnumber, the value should be in the format `idnumber:[idnumber]`.
If specifying a course shortname, the value should be in the format `name:[shortname]`.
EOF;
$extra['examples'] = [
new example(
name: 'A course id',
value: 54,
),
new example(
name: 'A course specified by its idnumber',
value: 'idnumber:000117-physics-101-1',
),
new example(
name: 'A course specified by its shortname',
value: 'name:000117-phys101-0',
),
];
parent::__construct(...$extra);
}
/**
* Get the course object for the given identifier.
*
* @param string $value A course id, idnumber, or shortname
* @return object
* @throws not_found_exception If the course cannot be found
*/
protected function get_course_for_value(string $value): mixed {
global $DB;
$data = false;
if (is_numeric($value)) {
$data = $DB->get_record('course', [
'id' => $value,
]);
} else if (str_starts_with($value, 'idnumber:')) {
$data = $DB->get_record('course', [
'idnumber' => substr($value, strlen('idnumber:')),
]);
} else if (str_starts_with($value, 'name:')) {
$data = $DB->get_record('course', [
'shortname' => substr($value, strlen('name:')),
]);
}
if ($data) {
return $data;
}
throw new not_found_exception('course', $value);
}
#[\Override]
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
$course = $this->get_course_for_value($value);
return $request
->withAttribute($this->name, $course)
->withAttribute("{$this->name}context", \core\context\course::instance($course->id));
}
#[\Override]
public function get_schema_from_type(param $type): \stdClass {
$schema = parent::get_schema_from_type($type);
$schema->pattern = "^(";
$schema->pattern .= implode("|", [
'\d+',
'idnumber:.+',
'name:.+',
]);
$schema->pattern .= ")$";
return $schema;
}
use course_parameter_trait;
}
@@ -0,0 +1,32 @@
<?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\router\parameters;
/**
* A path parameter referencing a course module ID. This will add the course module data object and context to the request.
*
* @package core
* @copyright Catalyst IT Europe Ltd.
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class path_coursemodule extends \core\router\schema\parameters\path_parameter implements
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
use coursemodule_parameter_trait;
}
@@ -1,77 +0,0 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use core\router\schema\parameters\mapped_property_parameter;
use core\router\schema\referenced_object;
use Psr\Http\Message\ServerRequestInterface;
/**
* A Moodle parameter referenced in the path.
*
* @package core
* @copyright 2026 Amaia Anabitarte <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class path_module extends \core\router\schema\parameters\path_parameter implements
mapped_property_parameter,
referenced_object
{
/**
* Create a new path_module parameter.
*
* @param string $name The name of the parameter to use for the module identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'cm',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The module identifier.
This can be the id of the module.
EOF;
$extra['examples'] = [
new example(
name: 'A module id',
value: 54,
),
];
parent::__construct(...$extra);
}
#[\Override]
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
if (!$cm = get_coursemodule_from_id('', $value)) {
throw new not_found_exception('course_module', $value);
}
return $request
->withAttribute($this->name, $cm)
->withAttribute("{$this->name}context", \core\context\module::instance($cm->id));
}
}
@@ -0,0 +1,31 @@
<?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\router\parameters;
/**
* A query parameter referencing a course.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class query_course extends \core\router\schema\parameters\query_parameter implements
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
use course_parameter_trait;
}
@@ -0,0 +1,31 @@
<?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\router\parameters;
/**
* A query parameter referencing a course module.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class query_coursemodule extends \core\router\schema\parameters\query_parameter implements
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
use coursemodule_parameter_trait;
}
@@ -74,7 +74,13 @@ class query_parameter extends parameter {
default => throw new \ValueError('Invalid boolean value.'),
};
}
$this->type->validate_param($params[$this->name]);
$value = $params[$this->name];
$this->type->validate_param($value);
if ($value !== null && is_a($this, mapped_property_parameter::class)) {
$request = $this->add_attributes_for_parameter_value($request, $value);
}
return $this->update_request_params(
$request,
@@ -17,7 +17,6 @@
namespace core\router\parameters;
use core\exception\not_found_exception;
use core\router\parameters\path_module;
use core\tests\router\route_testcase;
use GuzzleHttp\Psr7\ServerRequest;
use stdClass;
@@ -28,9 +27,9 @@ use stdClass;
* @package core
* @copyright Amaia Anabitarte <amaia@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core\router\parameters\path_module
* @covers \core\router\parameters\path_coursemodule
*/
final class path_module_test extends route_testcase {
final class path_coursemodule_test extends route_testcase {
public function test_module_id(): void {
$this->resetAfterTest();
@@ -38,15 +37,15 @@ final class path_module_test extends route_testcase {
$mod = $this->getDataGenerator()->create_module('page', ['course' => $course->id]);
$modcontext = \context_module::instance($mod->cmid);
$param = new path_module();
$param = new path_coursemodule();
$request = new ServerRequest('GET', '/course/cms/' . $mod->cmid . '/restricted');
$newrequest = $param->add_attributes_for_parameter_value($request, $mod->cmid);
$this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('cm'));
$this->assertInstanceOf(\core\context\module::class, $newrequest->getAttribute('cmcontext'));
$this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('coursemoduledata'));
$this->assertInstanceOf(\core\context\module::class, $newrequest->getAttribute('coursemodulecontext'));
$this->assertEquals($mod->cmid, $newrequest->getAttribute('cm')->id);
$this->assertEquals($modcontext->id, $newrequest->getAttribute('cmcontext')->id);
$this->assertEquals($mod->cmid, $newrequest->getAttribute('coursemoduledata')->id);
$this->assertEquals($modcontext->id, $newrequest->getAttribute('coursemodulecontext')->id);
}
/**
@@ -55,7 +54,7 @@ final class path_module_test extends route_testcase {
public function test_module_not_found(): void {
$this->resetAfterTest();
$param = new path_module();
$param = new path_coursemodule();
$course = $this->getDataGenerator()->create_course();
$modid = 9999;
+2 -2
View File
@@ -1,10 +1,10 @@
define("mod_quiz/add_question_modal",["exports","core/modal","core/fragment","core/str","core/form-autocomplete"],(function(_exports,_modal,Fragment,_str,_formAutocomplete){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
define("mod_quiz/add_question_modal",["exports","core/modal"],(function(_exports,_modal){var obj;
/**
* Contain the logic for the add random question modal.
*
* @module mod_quiz/add_question_modal
* @copyright 2023 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=_interopRequireDefault(_modal),Fragment=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Fragment),_formAutocomplete=_interopRequireDefault(_formAutocomplete);class AddQuestionModal extends _modal.default{configure(modalConfig){modalConfig.large=!0,modalConfig.show=!0,modalConfig.removeOnClose=!0,this.setContextId(modalConfig.contextId),this.setAddOnPageId(modalConfig.addOnPage),this.quizCmId=modalConfig.quizCmId,this.bankCmId=modalConfig.bankCmId,this.originalTitle=modalConfig.title,super.configure(modalConfig)}constructor(root){super(root),this.contextId=null,this.addOnPageId=null}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setAddOnPageId(id){this.addOnPageId=id}getAddOnPageId(){return this.addOnPageId}async handleSwitchBankContentReload(Selector){var _document$querySelect;this.setTitle((0,_str.getString)("selectquestionbank","mod_quiz"));const el=document.createElement("button");el.classList.add("btn","btn-primary"),el.textContent=await(0,_str.getString)("gobacktoquiz","mod_quiz"),el.setAttribute("data-action","go-back"),el.setAttribute("value",this.bankCmId),this.setFooter(el),this.setBody(Fragment.loadFragment("mod_quiz","switch_question_bank",this.getContextId(),{quizcmid:this.quizCmId,bankcmid:this.bankCmId}));const placeholder=await(0,_str.getString)("searchbyname","mod_quiz");return await this.getBodyPromise(),await _formAutocomplete.default.enhance(Selector,!1,"core_question/question_banks_datasource",placeholder,!1,!0,"",!0),null===(_document$querySelect=document.querySelector(".search-banks .form-autocomplete-selection"))||void 0===_document$querySelect||_document$querySelect.classList.add("d-none"),this}}return _exports.default=AddQuestionModal,_exports.default}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=(obj=_modal)&&obj.__esModule?obj:{default:obj};class AddQuestionModal extends _modal.default{configure(modalConfig){modalConfig.large=!0,modalConfig.show=!0,modalConfig.removeOnClose=!0,this.setContextId(modalConfig.contextId),this.setAddOnPageId(modalConfig.addOnPage),this.quizCmId=modalConfig.quizCmId,this.bankCmId=modalConfig.bankCmId,this.courseId=modalConfig.courseId,this.originalTitle=modalConfig.title,super.configure(modalConfig)}constructor(root){super(root),this.contextId=null,this.addOnPageId=null}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setAddOnPageId(id){this.addOnPageId=id}getAddOnPageId(){return this.addOnPageId}}return _exports.default=AddQuestionModal,_exports.default}));
//# sourceMappingURL=add_question_modal.min.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -49
View File
@@ -22,9 +22,6 @@
*/
import Modal from 'core/modal';
import * as Fragment from 'core/fragment';
import {getString} from 'core/str';
import AutoComplete from 'core/form-autocomplete';
export default class AddQuestionModal extends Modal {
configure(modalConfig) {
@@ -43,6 +40,7 @@ export default class AddQuestionModal extends Modal {
// This is because the URL cmid param will change during filter operations as we will be in another bank context.
this.quizCmId = modalConfig.quizCmId;
this.bankCmId = modalConfig.bankCmId;
this.courseId = modalConfig.courseId;
// Store the original title of the modal, so we can revert back to it once we have switched to another bank.
this.originalTitle = modalConfig.title;
@@ -99,50 +97,4 @@ export default class AddQuestionModal extends Modal {
getAddOnPageId() {
return this.addOnPageId;
}
/**
* Update the modal with a list of banks to switch to and enhance the standard selects to Autocomplete fields.
*
* @param {String} Selector for the original select element.
* @return {Promise} Modal.
*/
async handleSwitchBankContentReload(Selector) {
this.setTitle(getString('selectquestionbank', 'mod_quiz'));
// Create a 'Go back' button and set it in the footer.
const el = document.createElement('button');
el.classList.add('btn', 'btn-primary');
el.textContent = await getString('gobacktoquiz', 'mod_quiz');
el.setAttribute('data-action', 'go-back');
el.setAttribute('value', this.bankCmId);
this.setFooter(el);
this.setBody(
Fragment.loadFragment(
'mod_quiz',
'switch_question_bank',
this.getContextId(),
{
'quizcmid': this.quizCmId,
'bankcmid': this.bankCmId,
})
);
const placeholder = await getString('searchbyname', 'mod_quiz');
await this.getBodyPromise();
await AutoComplete.enhance(
Selector,
false,
'core_question/question_banks_datasource',
placeholder,
false,
true,
'',
true
);
// Hide the selection element as we don't need it.
document.querySelector('.search-banks .form-autocomplete-selection')?.classList.add('d-none');
return this;
}
}
@@ -29,6 +29,7 @@ import * as Templates from 'core/templates';
import * as FormChangeChecker from 'core_form/changechecker';
import {call as fetchMany} from 'core/ajax';
import Pending from 'core/pending';
import BankSwitcher from 'core_question/bank_switcher';
const SELECTORS = {
ANCHOR: 'a[href]',
@@ -70,6 +71,7 @@ export default class ModalAddRandomQuestion extends Modal {
* @param {string} returnUrl URL to return to after form submission.
* @param {Number} quizCmId Current quiz course module id.
* @param {boolean} showNewCategory Display the New category tab when selecting random questions.
* @param {Number} courseId Current course id, required for switching banks.
*/
static init(
contextId,
@@ -77,7 +79,8 @@ export default class ModalAddRandomQuestion extends Modal {
category,
returnUrl,
quizCmId,
showNewCategory = true
showNewCategory = true,
courseId,
) {
const selector = '.menu [data-action="addarandomquestion"], [data-action="editrandomquestion"]';
document.addEventListener('click', (e) => {
@@ -104,6 +107,7 @@ export default class ModalAddRandomQuestion extends Modal {
templateContext: {
hidden: showNewCategory,
},
courseId,
});
});
}
@@ -131,6 +135,7 @@ export default class ModalAddRandomQuestion extends Modal {
this.showNewCategory = modalConfig.showNewCategory;
this.setSlotId(modalConfig.slotId ?? 0);
this.setSavedFilterCondition(modalConfig.savedFilterCondition ?? null);
this.setCourseId(modalConfig.courseId);
super.configure(modalConfig);
}
@@ -206,6 +211,24 @@ export default class ModalAddRandomQuestion extends Modal {
return this.slotId;
}
/**
* Store the ID of the current course
*
* @param {Number} courseId
*/
setCourseId(courseId) {
this.courseId = courseId;
}
/**
* Get the current course ID.
*
* @return {Number}
*/
getCourseId() {
return this.courseId;
}
/**
* Store the current filterCondition JSON string.
*
@@ -366,61 +389,22 @@ export default class ModalAddRandomQuestion extends Modal {
}
});
const switcher = new BankSwitcher();
this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => {
this.setSavedFilterCondition(
document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition
);
this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH)
.then(function(ModalQuizQuestionBank) {
$(SELECTORS.BANK_SEARCH)?.on('change', (e) => {
const bankCmId = $(e.currentTarget).val();
// Have to recreate the modal as we have already used the body for the switch bank content.
if (bankCmId > 0) {
ModalAddRandomQuestion.create({
'contextId': ModalQuizQuestionBank.getContextId(),
'bankCmId': bankCmId,
'category': ModalQuizQuestionBank.getCategory(),
'returnUrl': ModalQuizQuestionBank.getReturnUrl(),
'quizCmId': ModalQuizQuestionBank.quizCmId,
'title': ModalQuizQuestionBank.originalTitle,
'addOnPage': ModalQuizQuestionBank.getAddOnPageId(),
'templateContext': {hidden: ModalQuizQuestionBank.showNewCategory},
'showNewCategory': ModalQuizQuestionBank.showNewCategory,
'slotId': ModalQuizQuestionBank.getSlotId(),
})
.then(ModalQuizQuestionBank.destroy())
.catch(Notification.exception);
}
});
return ModalQuizQuestionBank;
});
try {
switcher.show(this, this.courseId, this.getContextId(), this.bankCmId, this.quizCmId);
} catch (ex) {
Notification.exception(ex);
}
});
this.getModal().on('click', SELECTORS.GO_BACK_BUTTON, (e) => {
const anchorElement = $(e.currentTarget);
// Have to recreate the modal as we have already used the body for the switch bank content.
ModalAddRandomQuestion.create({
'contextId': this.getContextId(),
'bankCmId': anchorElement.attr('value'),
'category': this.getCategory(),
'returnUrl': this.getReturnUrl(),
'quizCmId': this.quizCmId,
'title': this.originalTitle,
'addOnPage': this.getAddOnPageId(),
'templateContext': {hidden: this.showNewCategory},
'showNewCategory': this.showNewCategory,
'savedFilterCondition': this.getSavedFilterCondition(),
'slotId': this.getSlotId(),
}).then(this.destroy()).catch(Notification.exception);
});
this.getModal().on('click', SELECTORS.ANCHOR, (e) => {
const anchorElement = $(e.currentTarget);
// Have to recreate the modal as we have already used the body for the switch bank content.
if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']').length) {
ModalAddRandomQuestion.create({
this.getModal().get(0).addEventListener('bankSwitched', async(e) => {
if (e.detail.cmid > 0) {
const modalConfig = {
'contextId': this.getContextId(),
'bankCmId': anchorElement.attr(SELECTORS.NEW_BANKMOD_ID),
'bankCmId': e.detail.cmid,
'category': this.getCategory(),
'returnUrl': this.getReturnUrl(),
'quizCmId': this.quizCmId,
@@ -429,7 +413,18 @@ export default class ModalAddRandomQuestion extends Modal {
'templateContext': {hidden: this.showNewCategory},
'showNewCategory': this.showNewCategory,
'slotId': this.getSlotId(),
}).then(this.destroy()).catch(Notification.exception);
'courseId': this.getCourseId(),
};
if (e.detail.cmid === this.bankCmId) {
// We're displaying the same bank as before, keep the current filters.
modalConfig.savedFilterCondition = this.getSavedFilterCondition();
}
try {
await ModalAddRandomQuestion.create(modalConfig);
this.destroy();
} catch (ex) {
await Notification.exception(ex);
}
}
});
})
@@ -26,6 +26,7 @@ import * as Fragment from 'core/fragment';
import * as FormChangeChecker from 'core_form/changechecker';
import * as ModalEvents from 'core/modal_events';
import * as Notification from 'core/notification';
import BankSwitcher from 'core_question/bank_switcher';
const SELECTORS = {
ADD_TO_QUIZ_CONTAINER: 'td.addtoquizaction',
@@ -50,8 +51,9 @@ export default class ModalQuizQuestionBank extends Modal {
* @param {Number} contextId Current module context id.
* @param {Number} bankCmId Current question bank course module id.
* @param {Number} quizCmId Current quiz course module id.
* @param {Number} courseId Current course id, required for switching banks.
*/
static init(contextId, bankCmId, quizCmId) {
static init(contextId, bankCmId, quizCmId, courseId) {
const selector = '.menu [data-action="questionbank"]';
document.addEventListener('click', (e) => {
const trigger = e.target.closest(selector);
@@ -70,6 +72,7 @@ export default class ModalQuizQuestionBank extends Modal {
hidden: true,
},
large: true,
courseId,
});
});
}
@@ -157,23 +160,27 @@ export default class ModalQuizQuestionBank extends Modal {
formElement.setAttribute('action', actionUrl.toString());
});
const switcher = new BankSwitcher();
this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => {
this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH)
.then(function(ModalQuizQuestionBank) {
document.querySelector(SELECTORS.BANK_SEARCH)?.addEventListener('change', (e) => {
const bankCmId = e.currentTarget.value;
if (bankCmId > 0) {
ModalQuizQuestionBank.bankCmId = bankCmId;
ModalQuizQuestionBank.reloadBodyContent(window.location.search);
}
});
document.querySelector(SELECTORS.GO_BACK_BUTTON).addEventListener('click', (e) => {
ModalQuizQuestionBank.bankCmId = e.currentTarget.value;
ModalQuizQuestionBank.reloadBodyContent(window.location.search);
});
}
)
.catch(Notification.exception);
try {
switcher.show(this, this.courseId, this.getContextId(), this.bankCmId, this.quizCmId);
} catch (ex) {
Notification.exception(ex);
}
});
this.getModal().get(0).addEventListener('bankSwitched', (e) => {
const bankCmId = e.detail.cmid;
if (bankCmId > 0) {
// We need to clear the filter as we are about to reload the content.
const url = new URL(location.href);
url.searchParams.delete('filter');
history.pushState({}, '', url);
this.bankCmId = bankCmId;
this.reloadBodyContent(window.location.search);
}
});
this.getModal().on('click', SELECTORS.ANCHOR, (e) => {
@@ -185,28 +192,7 @@ export default class ModalQuizQuestionBank extends Modal {
return;
}
// If the anchor element was a preview question link.
if (anchorElement.closest(SELECTORS.PREVIEW_CONTAINER)) {
return;
}
// Sorting links have their own handler.
if (anchorElement.closest(SELECTORS.SORTERS)) {
return;
}
if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']')) {
this.bankCmId = anchorElement.getAttribute(SELECTORS.NEW_BANKMOD_ID);
// We need to clear the filter as we are about to reload the content.
const url = new URL(location.href);
url.searchParams.delete('filter');
history.pushState({}, '', url);
}
// Anything else means reload the pop-up contents.
e.preventDefault();
this.reloadBodyContent(anchorElement.search);
});
// Disable the form change checker when the body is rendered.
@@ -122,6 +122,7 @@ class edit_renderer extends \plugin_renderer_base {
$thiscontext->id,
$quizobj->get_cm()->id,
$quizobj->get_cm()->id,
$quizobj->get_courseid(),
]);
$this->page->requires->js_call_amd('mod_quiz/modal_add_random_question', 'init', [
@@ -131,6 +132,7 @@ class edit_renderer extends \plugin_renderer_base {
$pageurl->out_as_local_url(true),
$pageurl->param('cmid'),
\core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME),
$quizobj->get_courseid(),
]);
// Include the question chooser.
+2
View File
@@ -5,3 +5,5 @@ groupoverridesdeleted,mod_quiz
randomcoursecatwithsubcat,mod_quiz
randomcoursewithsubcat,mod_quiz
randomsystemwithsubcat,mod_quiz
selectquestionbank,mod_quiz
gobacktoquiz,mod_quiz
+4 -2
View File
@@ -1012,7 +1012,6 @@ $string['selectedattempts'] = 'Selected attempts...';
$string['selectmultipleitems'] = 'Select multiple items';
$string['selectmultipletoolbar'] = 'Select multiple toolbar';
$string['selectnone'] = 'Deselect all';
$string['selectquestionbank'] = 'Select question bank';
$string['selectquestionslot'] = 'Select question {$a}';
$string['selectquestiontype'] = '-- Select question type --';
$string['sendnotificationopendatesoon'] = 'Notify user of an approaching quiz open date';
@@ -1155,7 +1154,6 @@ $string['yourfinalgradeis'] = 'Your final grade for this quiz is {$a}.';
$string['questionversion'] = 'v{$a}';
$string['questionversionlatest'] = 'v{$a} (latest)';
$string['alwayslatest'] = 'Always latest';
$string['gobacktoquiz'] = 'Go back';
// Deprecated since Moodle 4.5.
$string['attemptsdeleted'] = 'Quiz attempts deleted';
@@ -1167,3 +1165,7 @@ $string['groupoverridesdeleted'] = 'Group overrides deleted';
$string['randomcoursecatwithsubcat'] = 'Any category inside course category {$a}';
$string['randomcoursewithsubcat'] = 'Any category in this course';
$string['randomsystemwithsubcat'] = 'Any system-level category';
// Deprecated since Moodle 5.2.
$string['gobacktoquiz'] = 'Go back';
$string['selectquestionbank'] = 'Select question bank';
+7
View File
@@ -2490,8 +2490,15 @@ function mod_quiz_output_fragment_quiz_question_bank($args): string {
* @param array $args provided by the AJAX request.
* @return string html to render to the modal.
*/
#[\core\attribute\deprecated(
replacement: 'core_question\route\api\bank::switcher',
since: '5.2',
reason: 'Replaced with a non-quiz-specific API endpoint',
mdl: 'MDL-87264',
)]
function mod_quiz_output_fragment_switch_question_bank($args): string {
global $USER, $COURSE, $OUTPUT;
\core\deprecation::emit_deprecation(__FUNCTION__);
$quizcmid = clean_param($args['quizcmid'], PARAM_INT);
+21
View File
@@ -0,0 +1,21 @@
define("core_question/bank_switcher",["exports","core/str","core/fetch","core/templates","core/form-autocomplete"],(function(_exports,_str,_fetch,_templates,_formAutocomplete){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Bank switcher module for switching between question banks.
*
* The BankSwitcher class is used to display the bank switching UI in an existing Modal. This can either be a new Modal
* created for the purpose, or an existing modal, in which case it will replace the existing title, body and footer.
*
* When a new bank is selected, it will emit a custom `bankSwitched` event on the modal's DOM element,
* with the cmid of the selected bank in `event.details.cmid`. For example:
*
* const modal = Modal.create(...);
* const switcher = new BankSwitcher();
* switcher.show(modal, courseId, cmId);
* modal.getModal().get(0).addEventListener('bankSwitched', (e) => window.console.log(e.details.cmid));
*
* @module core_question/bank_switcher
* @copyright 2025 Catalyst IT Europe Ltd.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_fetch=_interopRequireDefault(_fetch),_templates=_interopRequireDefault(_templates),_formAutocomplete=_interopRequireDefault(_formAutocomplete);const SELECTORS_ANCHOR="a[data-newmodid]",SELECTORS_AUTOCOMPLETE_SELECTION=".search-banks .form-autocomplete-selection",SELECTORS_BANK_SEARCH="#searchbanks",SELECTORS_BANK_SWITCHER=".bank-switcher",SELECTORS_GO_BACK_BUTTON='button[data-action="go-back"]',SELECTORS_MODAL='div[data-region="modal"]';return _exports.default=class{async show(modal,courseId,contextId){var _modalBody$querySelec;let currentCmId=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,activityCmId=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;if(!courseId)throw new Error("courseId is required by the bank switcher.");if(modal.setTitle((0,_str.get_string)("switcherselectbank","core_question")),currentCmId){const el=document.createElement("button");el.classList.add("btn","btn-primary"),el.textContent=await(0,_str.get_string)("switchergoback","core_question"),el.setAttribute("data-action","go-back"),el.setAttribute("value",currentCmId),el.addEventListener("click",this.clickListener),modal.setFooter(el)}const params={course:courseId,includeshared:!0,includerecent:!0};activityCmId&&(params.currentmodule=activityCmId);const banksResponse=await _fetch.default.performGet("question","banks",{params:params}),{banks:banks}=await banksResponse.json(),templateContext={contextid:contextId,hasactivitybank:!1,hascoursesharedbanks:!1,coursesharedbanks:[],hasrecentlyviewedbanks:!1,recentlyviewedbanks:[]};banks.forEach((bank=>bank.current?(templateContext.hasactivitybank=!0,void(templateContext.activitybank={name:bank.name,cmid:bank.modid})):bank.recent?(templateContext.hasrecentlyviewedbanks=!0,void templateContext.recentlyviewedbanks.push({coursenamebankname:bank.coursenamebankname,modid:bank.modid})):(templateContext.hascoursesharedbanks=!0,void templateContext.coursesharedbanks.push({name:bank.name,modid:bank.modid})))),modal.setBody(_templates.default.render("core_question/switch_question_bank",templateContext));const placeholder=await(0,_str.get_string)("switchersearchbyname","core_question");await modal.getBodyPromise(),await _formAutocomplete.default.enhance(SELECTORS_BANK_SEARCH,!1,"core_question/question_banks_datasource",placeholder,!1,!0,"",!0);const modalBody=modal.getBody()[0];null===(_modalBody$querySelec=modalBody.querySelector(SELECTORS_AUTOCOMPLETE_SELECTION))||void 0===_modalBody$querySelec||_modalBody$querySelec.classList.add("d-none");const switcherElement=modalBody.querySelector(SELECTORS_BANK_SWITCHER);switcherElement.addEventListener("click",this.clickListener),switcherElement.addEventListener("change",this.changeListener)}clickListener(e){const goBack=e.target.closest(SELECTORS_GO_BACK_BUTTON);let cmid;if(goBack)cmid=goBack.value;else{const anchor=e.target.closest(SELECTORS_ANCHOR);if(!anchor)return;cmid=anchor.dataset.newmodid}e.preventDefault();const modalElement=e.target.closest(SELECTORS_MODAL),bankSwitched=new CustomEvent("bankSwitched",{detail:{cmid:cmid}});modalElement.dispatchEvent(bankSwitched)}changeListener(e){const search=e.target.closest(SELECTORS_BANK_SEARCH);if(!search)return;const bankCmId=search.value;if(bankCmId>0){const modalElement=e.target.closest(SELECTORS_MODAL),bankSwitched=new CustomEvent("bankSwitched",{detail:{cmid:bankCmId}});modalElement.dispatchEvent(bankSwitched)}}},_exports.default}));
//# sourceMappingURL=bank_switcher.min.js.map
File diff suppressed because one or more lines are too long
+177
View File
@@ -0,0 +1,177 @@
// 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 {get_string as getString} from 'core/str';
import Fetch from 'core/fetch';
import Templates from 'core/templates';
import AutoComplete from 'core/form-autocomplete';
/**
* Bank switcher module for switching between question banks.
*
* The BankSwitcher class is used to display the bank switching UI in an existing Modal. This can either be a new Modal
* created for the purpose, or an existing modal, in which case it will replace the existing title, body and footer.
*
* When a new bank is selected, it will emit a custom `bankSwitched` event on the modal's DOM element,
* with the cmid of the selected bank in `event.details.cmid`. For example:
*
* const modal = Modal.create(...);
* const switcher = new BankSwitcher();
* switcher.show(modal, courseId, cmId);
* modal.getModal().get(0).addEventListener('bankSwitched', (e) => window.console.log(e.details.cmid));
*
* @module core_question/bank_switcher
* @copyright 2025 Catalyst IT Europe Ltd.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const SELECTORS = {
ANCHOR: 'a[data-newmodid]',
AUTOCOMPLETE_SELECTION: '.search-banks .form-autocomplete-selection',
BANK_SEARCH: '#searchbanks',
BANK_SWITCHER: '.bank-switcher',
GO_BACK_BUTTON: 'button[data-action="go-back"]',
MODAL: 'div[data-region="modal"]',
};
export default class BankSwitcher {
/**
* Show the bank switcher.
*
* This will replace the content of the given modal with the bank switcher, and emit a bankChanged event on the modal
* when a bank is selected (either a new bank, or the current one again).
*
* @param {Modal} modal The modal to display the switcher within.
* @param {Number} courseId The course ID to display banks from.
* @param {Number} contextId The context the bank switcher is being displayed in, for text filters.
* @param {Number} currentCmId The cmid of the currently selected question bank.
* @param {Number} activityCmId The cmid of activity we are currently in (if any).
* @return {Promise<void>} Resolves once the switcher has finished displaying.
*/
async show(modal, courseId, contextId, currentCmId = null, activityCmId = null) {
if (!courseId) {
throw new Error('courseId is required by the bank switcher.');
}
modal.setTitle(getString('switcherselectbank', 'core_question'));
if (currentCmId) {
// Create a 'Go back' button and set it in the footer.
const el = document.createElement('button');
el.classList.add('btn', 'btn-primary');
el.textContent = await getString('switchergoback', 'core_question');
el.setAttribute('data-action', 'go-back');
el.setAttribute('value', currentCmId);
el.addEventListener('click', this.clickListener);
modal.setFooter(el);
}
const params = {
course: courseId,
includeshared: true,
includerecent: true,
};
if (activityCmId) {
params.currentmodule = activityCmId;
}
const banksResponse = await Fetch.performGet('question', 'banks', {params});
const {banks} = await banksResponse.json();
const templateContext = {
contextid: contextId,
hasactivitybank: false,
hascoursesharedbanks: false,
coursesharedbanks: [],
hasrecentlyviewedbanks: false,
recentlyviewedbanks: [],
};
banks.forEach((bank) => {
if (bank.current) {
templateContext.hasactivitybank = true;
templateContext.activitybank = {
name: bank.name,
cmid: bank.modid,
};
return;
}
if (bank.recent) {
templateContext.hasrecentlyviewedbanks = true;
templateContext.recentlyviewedbanks.push({
coursenamebankname: bank.coursenamebankname,
modid: bank.modid,
});
return;
}
templateContext.hascoursesharedbanks = true;
templateContext.coursesharedbanks.push({
name: bank.name,
modid: bank.modid,
});
});
modal.setBody(
Templates.render('core_question/switch_question_bank', templateContext)
);
const placeholder = await getString('switchersearchbyname', 'core_question');
await modal.getBodyPromise();
await AutoComplete.enhance(
SELECTORS.BANK_SEARCH,
false,
'core_question/question_banks_datasource',
placeholder,
false,
true,
'',
true
);
const modalBody = modal.getBody()[0];
// Hide the selection element as we don't need it.
modalBody.querySelector(SELECTORS.AUTOCOMPLETE_SELECTION)?.classList.add('d-none');
const switcherElement = modalBody.querySelector(SELECTORS.BANK_SWITCHER);
switcherElement.addEventListener('click', this.clickListener);
switcherElement.addEventListener('change', this.changeListener);
}
clickListener(e) {
const goBack = e.target.closest(SELECTORS.GO_BACK_BUTTON);
let cmid;
if (goBack) {
cmid = goBack.value;
} else {
const anchor = e.target.closest(SELECTORS.ANCHOR);
if (!anchor) {
return;
}
cmid = anchor.dataset.newmodid;
}
e.preventDefault();
const modalElement = e.target.closest(SELECTORS.MODAL);
const bankSwitched = new CustomEvent('bankSwitched', {detail: {cmid}});
modalElement.dispatchEvent(bankSwitched);
}
changeListener(e) {
const search = e.target.closest(SELECTORS.BANK_SEARCH);
if (!search) {
return;
}
const bankCmId = search.value;
if (bankCmId > 0) {
const modalElement = e.target.closest(SELECTORS.MODAL);
const bankSwitched = new CustomEvent('bankSwitched', {detail: {cmid: bankCmId}});
modalElement.dispatchEvent(bankSwitched);
}
}
}
@@ -72,14 +72,14 @@ class bulk_move implements \renderable, \templatable {
currentbankid: $this->currentbankid,
filtercontext: $currentbankcm->context,
limit: 1,
)[0];
)[0]->get_formatted();
} else {
$banktorender = question_bank_helper::get_activity_instances_with_private_questions(
incourseids: [$currentbankcm->course],
havingcap: ['moodle/question:add'],
currentbankid: $this->currentbankid,
filtercontext: $currentbankcm->context,
)[0];
)[0]->get_formatted();
}
$categoryselector = new question_category_selector(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +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()}updateCategoryName(categoryId,newName){categorymanager.stateManager.setReadOnly(!1),categorymanager.stateManager.processUpdate("categories","put",{id:categoryId,name:newName}),categorymanager.stateManager.setReadOnly(!0)}}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)}}));
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()}updateCategoryName(categoryId,newName){categorymanager.stateManager.setReadOnly(!1),categorymanager.stateManager.processUpdate("categories","put",{id:categoryId,name:newName}),categorymanager.stateManager.setReadOnly(!0)}}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 rootElement=document.querySelector(SELECTORS_CATEGORY_ROOT),stateData={page:{contextid:rootElement.dataset.contextid,courseid:rootElement.dataset.courseid,cmid:rootElement.dataset.cmid,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
@@ -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,45 +340,117 @@ 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.
*
* Each entry in the list represents moving the category "before" another category by default, but may also represent
* moving "after" another category, or "as a new child of" 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>} A list of objects representing valid move targets for the category. Each object has:
* movingcategoryid - The ID of the category we are moving.
* precedingsiblingid - The ID of the previous category under the same parent as this target. 0 if this is the first child.
* parent - The ID of the target category's parent category. 0 if this is the top category.
* categoryname - The name of the target category, to display as part of the destination.
* categories - An array of child category targets. If there are no children, this must be null
* to prevent infinite recursion in the template.
* newchild - If true, this destination is "as a new child of the parent".
* lastchild - If true, this destination is after the target category, rather than before.
*/
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 (parseInt(category.categoryId) === movingCategoryId) {
return;
}
// Create a target to move before this child.
let child = {
movingcategoryid: movingCategoryId,
precedingsiblingid: precedingSibling?.categoryId ?? 0,
parent: category.parentId,
categoryname: category.categoryName,
categories: null, // Prevent infinite recursion in the template.
};
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,
categories: null,
parent: category.categoryId,
categoryname: category.categoryName,
categories: null, // Prevent infinite recursion in the template.
newchild: true,
}
];
@@ -372,15 +459,14 @@ 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,
categories: null,
precedingsiblingid: precedingSibling.categoryId,
parent: precedingSibling.parentId,
categoryname: precedingSibling.categoryName,
categories: null, // Prevent infinite recursion in the template.
lastchild: true,
});
}
@@ -409,34 +495,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);
}
@@ -55,6 +55,8 @@ const loadState = async(reactive) => {
const stateData = {
page: {
contextid: rootElement.dataset.contextid,
courseid: rootElement.dataset.courseid,
cmid: rootElement.dataset.cmid,
showdescriptions: document.querySelector(SELECTORS.SHOWDESCRIPTIONS_TOGGLE).checked,
},
categories: [],
@@ -45,8 +45,6 @@ if ($courseid = optional_param('courseid', 0, PARAM_INT)) {
list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) =
question_edit_setup('categories', '/question/bank/managecategories/category.php');
$thiscontext = context_module::instance($cmid)->id;
$todelete = optional_param('delete', 0, PARAM_INT); // The ID of a category to delete.
$PAGE->set_url($thispageurl);
@@ -110,16 +108,10 @@ if ($questionstomove) {
echo $OUTPUT->box(get_string('categorymove', 'question', $vars), 'generalbox boxaligncenter');
$moveform->display();
} else {
// Get module contexts we have capabilities to manage.
$contextswithcaps = $contexts->having_one_edit_tab_cap('categories');
$modcontexts = array_filter($contextswithcaps, static fn ($context) => $context->contextlevel === CONTEXT_MODULE);
// Display the user interface.
$questioncategories = new question_categories(
$thispageurl,
$modcontexts,
$cmid,
$courseid,
$thiscontext,
cmid: $cmid,
);
$PAGE->requires->js_call_amd('qbank_managecategories/categorymanager', 'init'); // Load reactive module.
echo $OUTPUT->render(new categories_header($questioncategories));
@@ -72,7 +72,7 @@ class move_category extends external_api {
'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),
'contextid' => 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?',
@@ -151,7 +151,7 @@ class move_category extends external_api {
) {
$transaction->rollback(new moodle_exception('idnumberexists', 'qbank_managecategories'));
}
$originstateupdate->fields->context = $targetparent->contextid;
$originstateupdate->fields->contextid = $targetparent->contextid;
}
// Update sort order.
@@ -187,7 +187,10 @@ class move_category extends external_api {
$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 ($targetparent->contextid == $pagecontextid) {
// If we're updating other categories in the current context, return state updates for those too.
$stateupdates[] = self::make_state_update($category->id, sortorder: $sortorder);
}
}
if (isset($originstateupdate->fields->parent)) {
@@ -68,28 +68,14 @@ class question_category_edit_form extends \core_form\dynamic_form {
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.
// Determine the context based on the provided ID.
$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];
return [$currentcat, $cmid, $courseid, $thiscontext, [$thiscontext]];
}
/**
@@ -48,31 +48,27 @@ class categories implements renderable, templatable {
#[\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);
}
$list = $this->categories->editlist;
// Get list elements.
$itemstab = [];
if (count($list->items)) {
foreach ($list->items as $item) {
$category = new category($item, $list->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);
}
if (isset($itemstab['items'])) {
$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,
];
}
// Get categories context.
$categories[] = [
'contextid' => $list->context->id,
'contextname' => $contextname,
'heading' => $heading,
'items' => $itemstab['items'],
'categoryid' => $list->categoryid,
];
}
$data = [
'categoriesrendered' => $categories,
@@ -17,6 +17,8 @@
namespace qbank_managecategories;
use context;
use core\context\module;
use core\exception\coding_exception;
use moodle_url;
/**
@@ -51,65 +53,91 @@ class question_categories {
/**
* @var array An array containing a tree of categories for each context.
*/
#[\core\attribute\deprecated(
'editlist',
5.2,
'Multiple contexts are no longer supported.',
'MDL-87264'
)]
public array $editlists;
/**
* @var \stdClass The tree of categories for the current context.
*/
public \stdClass $editlist;
/**
* Constructor.
*
* @param moodle_url $pageurl base URL of the display categories page. Used for redirects.
* @param context[] $contexts contexts where the current user can edit categories.
* @param context[] $contexts Deprecated since Moodle 5.2, do not use anymore.
* @param ?int $cmid course module id for the current page.
* @param ?int $courseid course id for the current page.
* @param ?int $thiscontext The context ID of the current page.
* @param ?int $courseid Deprecated since Moodle 5.2, do not use anymore.
* @param ?int $thiscontext Deprecated since Moodle 5.2, do not use anymore.
*/
public function __construct(
moodle_url $pageurl,
array $contexts,
?array $contexts = null,
?int $cmid = null,
?int $courseid = null,
?int $thiscontext = null,
) {
global $DB;
if (!is_null($contexts)) {
debugging(
'The contexts argument has been deprecated. Multiple contexts are no longer supported. Please remove the '
. 'argument from your calls.',
DEBUG_DEVELOPER,
);
}
if (!is_null($courseid)) {
debugging(
'The courseid argument has been deprecated. The course will be found from the cmid. Please remove the '
. 'argument from your calls.',
DEBUG_DEVELOPER,
);
}
if (!is_null($thiscontext)) {
debugging(
'The thiscontext argument has been deprecated. The context will be found from the cmid. Please remove the '
. 'argument from your calls.',
DEBUG_DEVELOPER,
);
}
$this->cmid = $cmid;
$this->courseid = $courseid;
$context = module::instance($this->cmid);
[$course] = get_course_and_cm_from_cmid($cmid);
$this->courseid = $course->id;
$this->pageurl = $pageurl;
$this->contextid = $thiscontext;
$this->contextid = $context->id;
$contextids = array_map(fn($context) => $context->id, $contexts);
[$insql, $params] = $DB->get_in_or_equal($contextids);
$topcategories = $DB->get_records_select_menu(
$topcategory = $DB->get_record(
'question_categories',
'parent = 0 AND contextid ' . $insql,
$params,
fields: 'contextid, id'
['parent' => 0, 'contextid' => $this->contextid],
);
foreach ($contexts as $context) {
// We can only have categories in module context.
if ($context->contextlevel !== CONTEXT_MODULE) {
continue;
$items = helper::get_categories_for_contexts($context->id);
// Create an ordered tree with children correctly nested under parents.
foreach ($items as $item) {
if (array_key_exists((int) $item->parent, $items)) {
$item->parentitem = $items[$item->parent];
$items[$item->parent]->children[$item->id] = $item;
}
$items = helper::get_categories_for_contexts($context->id);
// Create an ordered tree with children correctly nested under parents.
foreach ($items as $item) {
if (array_key_exists((int) $item->parent, $items)) {
$item->parentitem = $items[$item->parent];
$items[$item->parent]->children[$item->id] = $item;
}
}
foreach ($items as $item) {
if (isset($item->children)) {
foreach ($item->children as $children) {
unset($items[$children->id]);
}
}
}
$this->editlists[$context->id] = (object) [
'items' => $items,
'context' => $context,
'categoryid' => $topcategories[$context->id],
];
}
foreach ($items as $item) {
if (isset($item->children)) {
foreach ($item->children as $children) {
unset($items[$children->id]);
}
}
}
$this->editlist = (object) [
'items' => $items,
'context' => $context,
'categoryid' => $topcategory->id,
];
}
}
@@ -27,6 +27,7 @@ $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['categorymovedto'] = 'Category moved to <a href="{$a->url}">{$a->name}</a>.';
$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';
@@ -21,6 +21,8 @@
Context variables required for this template:
* contextid - Context id for js init.
* courseid - The course the current module context sits within.
* cmid - The module the context belongs to.
* categoriesrendered - Array representing categories data rendered.
* items - Categories data.
* heading - Category's heading for each context (ie: Course or Quiz).
@@ -29,6 +31,8 @@
Example context (json):
{
"contextid": "18",
"courseid": "2",
"cmid": "10",
"categoriesrendered": [{
"items": [{
"categoryid": "1",
@@ -43,17 +47,19 @@
"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"
"heading": "Question categories for 'Quiz: qz'"
}]
}
}}
<div id="categoryroot"
class="editing col-11 col-md-12 mt-3 ps-0{{#showdescriptions}} showdescriptions{{/showdescriptions}}"
data-contextid="{{{contextid}}}">
data-contextid="{{{contextid}}}"
data-courseid="{{courseid}}"
data-cmid="{{cmid}}"
>
{{#categoriesrendered}}
<h3 class="context-heading">{{heading}}</h3>
<div class="box py-3 boxwidthwide boxaligncenter generalbox questioncategories {{ctxlvl}}">
<div class="box py-3 boxwidthwide boxaligncenter generalbox questioncategories">
<ul class="qbank_managecategories-categorylist"
data-contextid="{{contextid}}" data-contextname="{{contextname}}" data-categoryid="{{categoryid}}">
{{#items}}
@@ -24,56 +24,94 @@
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.
* categoryid - The ID of the category to move relative to.
* movingcategoryid - The ID of the category we are moving.
* categoryname - The name of the category to move relative to.
* lastchild - Is this the last child of its parent? If true, an "after X" option will be rendered.
* precedingsiblingid - The ID of the preceding category under the same parent.
* parent - The ID of the parent category.
* newchild - If true, render this as a "new child of X" option rather than "before X".
* categories - Array containing child categories of this category.
If there are none, this must be set null or [] to avoid infinite recursion.
If there are none, this must be to [] 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,
"categoryid": "154001",
"movingcategoryid": 154007,
"precedingsiblingid": 0,
"parent": "154000",
"categoryname": "Default for Qbank 1",
"categories": [
{
"categoryname": "Test category 4",
"categoryid": 7,
"firstchild": true,
"current": false,
"hascategories": false,
"categories": []
"movingcategoryid": 154007,
"precedingsiblingid": 0,
"parent": "154001",
"categoryname": "Default for Qbank 1",
"categories": [],
"newchild": true
}
]
},
{
"categoryid": "154004",
"movingcategoryid": 154007,
"precedingsiblingid": "154001",
"parent": "154000",
"categoryname": "Course category 1",
"categories": [
{
"categoryid": "154006",
"movingcategoryid": 154007,
"precedingsiblingid": 0,
"parent": "154004",
"categoryname": "Course category 3",
"categories": [
{
"movingcategoryid": 154007,
"precedingsiblingid": 0,
"parent": "154006",
"categoryname": "Course category 3",
"categories": [],
"newchild": true
}
]
},
{
"movingcategoryid": 154007,
"precedingsiblingid": "154006",
"parent": "154004",
"categoryname": "Course category 3",
"categories": [],
"lastchild": true
}
]
},
{
"categoryid": "154005",
"movingcategoryid": 154007,
"precedingsiblingid": "154004",
"parent": "154000",
"categoryname": "Course category 2",
"categories": [
{
"movingcategoryid": 154007,
"precedingsiblingid": 0,
"parent": "154005",
"categoryname": "Course category 2",
"categories": [],
"newchild": true
}
]
},
{
"movingcategoryid": 154007,
"precedingsiblingid": "154005",
"parent": "154000",
"categoryname": "Course category 2",
"categories": [],
"lastchild": true
}
]
}
@@ -17,67 +17,44 @@
{{!
@template qbank_managecategories/move_context_list
Modal to move a category.
Modal to move a category within a context.
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.
* 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,
"cmid": 1,
"contextname": "Question bank: Qbank 1",
"categories": [
{
"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": []
}
]
"categoryname": "Default category for Qbank 1",
"categoryid": 3,
"firstchild": true,
"current": false,
"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": []
}
]
"categoryname": "New category",
"categoryid": 4,
"firstchild": false,
"current": true,
"hascategories": false,
"categories": []
}
]
}
}}
<div role="tree">
{{#contexts}}
<h3>{{{contextname}}}</h3>
{{>qbank_managecategories/move_category_list}}
{{/contexts}}
<div class="d-flex">
<h3 class="bank-header" data-cmid="{{cmid}}">{{{contextname}}}</h3>
<button data-action="switch-question-bank" type="button" class="btn btn-secondary ms-auto mb-2">
{{#str}}switchbank, core_question{{/str}}
</button>
</div>
{{>qbank_managecategories/move_category_list}}
</div>
@@ -104,3 +104,34 @@ Feature: A teacher can reorder question categories
And I should not see "+" in the "Course category 2" "list_item"
When I drag "Course category 3" "list_item" and I drop it in "Course category 1" "text"
Then I should see "+" in the "Course category 1" "list_item"
Scenario: Teacher can move a category to another bank
Given the following "question categories" exist:
| contextlevel | reference | name | idnumber | questioncategory |
| Activity module | qbank1 | Course category 4 | questioncat4 | Course category 1 |
And I reload the page
And "Default for Qbank 1" "list_item" should exist
And "Course category 1" "list_item" should exist
And "Course category 2" "list_item" should exist
And "Course category 3" "list_item" should exist
And "Course category 4" "list_item" should exist
And I open the action menu in "Course category 1" "list_item"
And I choose "Move" in the open action menu
And I should see "Question bank: Qbank 1" in the "Move Course category 1" "dialogue"
And I should see "Before Course category 3" in the "Move Course category 1" "dialogue"
And I press "Switch bank"
And I click on "Qbank 2" "link" in the "Select question bank" "dialogue"
And I should see "Question bank: Qbank 2" in the "Move Course category 1" "dialogue"
And I should not see "Before Course category 3" in the "Move Course category 1" "dialogue"
And I should see "As new child of Default for Qbank 2" in the "Move Course category 1" "dialogue"
When I follow "As new child of Default for Qbank 2"
Then "Default for Qbank 1" "list_item" should exist
And "Course category 1" "list_item" should not exist
And "Course category 2" "list_item" should exist
And "Course category 3" "list_item" should exist
And "Course category 4" "list_item" should not exist
And I should see "Category moved to Question bank: Qbank 2"
And I follow "Question bank: Qbank 2"
And "Default for Qbank 2" "list_item" should exist
And "Course category 1" "list_item" should exist
And "Course category 4" "list_item" should exist
@@ -18,6 +18,7 @@ namespace qbank_managecategories\external;
use context;
use context_module;
use core\context\module;
use moodle_url;
use qbank_managecategories\question_categories;
@@ -39,12 +40,12 @@ final class move_category_test extends \qbank_managecategories\manage_category_t
/**
* Return order of categories for a given context.
*
* @param context $context The context to get the category order for.
* @param module $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);
private function get_current_order(module $context): array {
$categories = new question_categories(new moodle_url('/'), cmid: $context->instanceid);
return $this->reduce_tree($categories->editlist->items);
}
/**
@@ -127,7 +128,7 @@ final class move_category_test extends \qbank_managecategories\manage_category_t
],
],
];
$this->assertEquals($stateupdates, $expectedstateupdates);
$this->assertEquals($expectedstateupdates, $stateupdates);
}
/**
@@ -49,9 +49,9 @@ final class question_categories_test extends manage_category_test_base {
// Create ordered tree.
$questioncategories = new question_categories(
new \moodle_url('/'),
[$context],
cmid: $qbank->cmid,
);
$items = $questioncategories->editlists[$context->id]->items;
$items = $questioncategories->editlist->items;
// Two top categories (1 and 3) in the course.
$this->assertCount(2, $items);
+8 -4
View File
@@ -22,6 +22,7 @@ use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_multiple_structure;
use core_external\external_value;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use core\context;
@@ -135,10 +136,13 @@ class search_shared_banks extends external_api {
);
$suggestions = array_map(
fn($sharedbank) => [
'value' => $sharedbank->modid,
'label' => $sharedbank->coursenamebankname,
],
function (formatted_bank $sharedbank): array {
$sharedbank = $sharedbank->get_formatted();
return [
'value' => $sharedbank->modid,
'label' => $sharedbank->coursenamebankname,
];
},
$sharedbanks,
);
@@ -0,0 +1,109 @@
<?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\local\bank;
use core\context;
use core_course\cm_info;
use JsonSerializable;
/**
* Data class to hold bank info and categories, and return them with formatted names for output.
*
* @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 formatted_bank implements JsonSerializable {
/**
* @var bool True if the bank is the module currently being viewed.
*/
public bool $current;
/**
* Constructor
*
* @param cm_info $cminfo The course module info for the bank's course module
* @param context $filtercontext The context used for applying filters when formatting strings.
* @param array $questioncategories Array of categories belonging to the bank.
* @param bool $shared True if the bank contains shared questions, false if it contains private questions.
* @param bool $recent True if the bank was recently viewed by the user.
*/
public function __construct(
/** @var cm_info $cminfo Course module info. */
public cm_info $cminfo,
/** @var context $filtercontext The context used for applying filters when formatting strings. */
public context $filtercontext,
/** @var array $questioncategories Array of categories belonging to the bank. */
public array $questioncategories,
/** @var bool True if the bank contains shared questions, false if it contains private questions. */
public bool $shared,
/** @var bool True if the bank was recently viewed by the user. */
public bool $recent,
) {
}
/**
* Return the question bank formatted for output.
*
* @return \stdClass
*/
public function get_formatted(): \stdClass {
$filteroptions = [
'escape' => false,
'context' => $this->filtercontext,
];
$formattedname = $this->cminfo->get_formatted_name($filteroptions);
return (object) [
'name' => $formattedname,
'modid' => $this->cminfo->id,
'contextid' => $this->cminfo->context->id,
'coursenamebankname' => get_string(
'coursenamebankname',
'question',
(object) [
'coursename' => format_string($this->cminfo->get_course()->shortname, true, $filteroptions),
'bankname' => $formattedname,
],
),
'cminfo' => $this->cminfo,
'questioncategories' => $this->questioncategories,
'shared' => $this->shared,
'recent' => $this->recent,
];
}
/**
* Return the formatted object for encoding as JSON, for example by web service routes.
*/
public function jsonSerialize(): \stdClass {
return $this->get_formatted();
}
/**
* Format multiple banks.
*
* @param formatted_bank[] $banks The formatted_bank objects to return formatted.
* @return array The formatted bank data.
*/
public static function format_banks(array $banks): array {
return array_map(
fn($bank) => $bank->get_formatted(),
$banks,
);
}
}
@@ -19,6 +19,8 @@ namespace core_question\local\bank;
use cm_info;
use context;
use context_course;
use core\context\course;
use core\context\module;
use core\context_helper;
use core\di;
use core\task\manager;
@@ -366,7 +368,7 @@ class question_bank_helper {
* @param int $notincourseid if supplied don't return any in this course id
* @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
* parameters that will get banks across multiple contexts.
* @return cm_info[]
* @return formatted_bank[]
*/
public static function get_recently_used_open_banks(
int $userid,
@@ -397,7 +399,7 @@ class question_bank_helper {
if (!empty($havingcap) && !(new question_edit_contexts($context))->have_one_cap($havingcap)) {
continue;
}
$record = self::get_formatted_bank($cm, filtercontext: $filtercontext);
$record = self::get_formatted_bank($cm, filtercontext: $filtercontext, isrecent: true);
$banks[] = $record;
}
@@ -445,9 +447,15 @@ class question_bank_helper {
* Used in qbank_bulkmove/bulk_move.mustache
* @param ?context $filtercontext Optional context in which to apply filters.
*
* @return stdClass
* @return formatted_bank
*/
private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass {
private static function get_formatted_bank(
stdClass $cm,
int $currentbankid = 0,
?context $filtercontext = null,
bool $isshared = true,
bool $isrecent = false,
): formatted_bank {
$cminfo = cm_info::create($cm);
$concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : [];
@@ -461,21 +469,14 @@ class question_bank_helper {
return $cat;
}, $concatedcats);
$bank = new stdClass();
$filteroptions = ['escape' => false];
if (!is_null($filtercontext)) {
$filteroptions['context'] = $filtercontext;
}
$bank->name = $cminfo->get_formatted_name($filteroptions);
$bank->modid = $cminfo->id;
$bank->contextid = $cminfo->context->id;
if (!isset($filteroptions['context'])) {
$filteroptions['context'] = context_course::instance($cminfo->get_course()->id);
}
$bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, $filteroptions) . " - {$bank->name}";
$bank->cminfo = $cminfo;
$bank->questioncategories = $categories;
return $bank;
$filtercontext ??= context_course::instance($cminfo->get_course()->id);
return new formatted_bank(
$cminfo,
$filtercontext,
$categories,
$isshared,
$isrecent,
);
}
/**
@@ -737,4 +738,51 @@ class question_bank_helper {
// As a failsafe, limit the length of the final string in case the lang string is too long.
return shorten_text($bankname, self::BANK_NAME_MAX_LENGTH);
}
/**
* Return a list of question banks.
*
* By default, this will just return a list of shared question banks (activities with FEATURE_PUBLISHES_QUESTIONS) in the given
* course context.
* Other options allow you to include private question banks and banks recently accessed by the user
* (which may be in other courses).
*
* @param course $coursecontext The course context to find shared and private banks within.
* @param module|null $modulecontext If set, use this instead of $coursecontext when applying text filters on bank names.
* @param bool $includeshared Include banks with shared questions.
* @param bool $includerecent Include banks recently viewed by the user.
* @return formatted_bank[]
*/
public static function get_banks_for_course(
course $coursecontext,
?module $modulecontext = null,
bool $includeshared = true,
bool $includerecent = false,
): array {
global $USER;
if ($modulecontext) {
$context = $modulecontext;
} else {
$context = $coursecontext;
}
$capabilities = ['moodle/question:useall', 'moodle/question:usemine'];
$banks = [];
if ($includeshared) {
$sharedbanks = self::get_activity_instances_with_shareable_questions(
incourseids: [$coursecontext->instanceid],
havingcap: $capabilities,
filtercontext: $context,
);
$banks = array_merge($banks, $sharedbanks);
}
if ($includerecent) {
$recentbanks = self::get_recently_used_open_banks(
$USER->id,
filtercontext: $context,
havingcap: $capabilities,
);
$banks = array_merge($banks, $recentbanks);
}
return $banks;
}
}
@@ -18,6 +18,7 @@ namespace core_question\output;
use action_link;
use core\output\pix_icon;
use core_question\local\bank\formatted_bank;
use renderer_base;
use core_courseformat\output\local\content\cm\controlmenu;
use core_question\local\bank\question_bank_helper;
@@ -34,10 +35,10 @@ class question_bank_list implements \renderable, \templatable {
/**
* Instantiate the output class.
*
* @param array $bankinstances {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
* @param formatted_bank[] $bankinstances {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
*/
public function __construct(
/** @var array $bankinstances */
/** @var formatted_bank[] $bankinstances */
protected readonly array $bankinstances
) {
}
@@ -51,6 +52,7 @@ class question_bank_list implements \renderable, \templatable {
public function export_for_template(renderer_base $output): array {
$banks = [];
foreach ($this->bankinstances as $instance) {
$instance = $instance->get_formatted();
if (plugin_supports('mod', $instance->cminfo->modname, FEATURE_PUBLISHES_QUESTIONS)) {
$returnurl = question_bank_helper::get_url_for_qbank_list($instance->cminfo->course);
$format = course_get_format($instance->cminfo->course);
@@ -26,12 +26,21 @@
namespace core_question\output;
use cm_info;
use core\attribute\deprecated;
use core\deprecation;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use renderer_base;
/**
* Get the switch question bank rendered content. Displays lists of shared banks the viewing user has access to.
*/
#[deprecated(
replacement: 'core_question/bank_switcher Javascript module',
since: 5.2,
reason: 'Bank switching interface is now rendered client-side.',
mdl: 'MDL-87264',
)]
class switch_question_bank implements \renderable, \templatable {
/**
@@ -41,6 +50,12 @@ class switch_question_bank implements \renderable, \templatable {
* @param int $courseid of the current course.
* @param int $userid of the user viewing the page.
*/
#[deprecated(
replacement: 'core_question/bank_switcher Javascript module',
since: 5.2,
reason: 'Bank switching interface is now rendered client-side.',
mdl: 'MDL-87264',
)]
public function __construct(
/** @var int quiz course module id */
private readonly int $quizcmid,
@@ -49,6 +64,7 @@ class switch_question_bank implements \renderable, \templatable {
/** @var int id of the user viewing the page */
private readonly int $userid
) {
deprecation::emit_deprecation([$this, __FUNCTION__]);
}
/**
@@ -57,18 +73,28 @@ class switch_question_bank implements \renderable, \templatable {
* @param renderer_base $output
* @return array
*/
#[deprecated(
replacement: 'core_question/bank_switcher Javascript module',
since: 5.2,
reason: 'Bank switching interface is now rendered client-side.',
mdl: 'MDL-87264',
)]
public function export_for_template(renderer_base $output) {
deprecation::emit_deprecation([$this, __FUNCTION__]);
[, $cm] = get_module_from_cmid($this->quizcmid);
$cminfo = cm_info::create($cm);
$capabilities = ['moodle/question:useall', 'moodle/question:usemine'];
$coursesharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(
incourseids: [$this->courseid],
havingcap: $capabilities,
filtercontext: $cminfo->context,
$coursesharedbanks = formatted_bank::format_banks(
question_bank_helper::get_activity_instances_with_shareable_questions(
incourseids: [$this->courseid],
havingcap: $capabilities,
filtercontext: $cminfo->context,
),
);
$recentlyviewedbanks = formatted_bank::format_banks(
question_bank_helper::get_recently_used_open_banks($this->userid, havingcap: $capabilities),
);
$recentlyviewedbanks = question_bank_helper::get_recently_used_open_banks($this->userid, havingcap: $capabilities);
return [
'quizname' => $cminfo->get_formatted_name(),
@@ -17,6 +17,7 @@
namespace core_question\output;
use context_course;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
use renderer_base;
use single_button;
@@ -35,14 +36,14 @@ class view_banks implements \templatable, \renderable {
/**
* Create a new view_banks instance.
*
* @param array $sharedbanks {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
* @param array $privatebanks {@see question_bank_helper::get_activity_instances_with_private_questions()}
* @param formatted_bank[] $sharedbanks {@see question_bank_helper::get_activity_instances_with_shareable_questions()}
* @param formatted_bank[] $privatebanks {@see question_bank_helper::get_activity_instances_with_private_questions()}
* @param stdClass $course the viewing course.
*/
public function __construct(
/** @var array Banks that can be shared */
/** @var formatted_bank[] Banks that can be shared */
protected readonly array $sharedbanks,
/** @var array Banks that cannot be shared */
/** @var formatted_bank[] Banks that cannot be shared */
protected readonly array $privatebanks,
/** @var stdClass current course object */
protected readonly stdClass $course,
@@ -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,
];
}
}
+122
View File
@@ -20,12 +20,25 @@ use core\context\course;
use core\context\module;
use core\exception\required_capability_exception;
use core\param;
use core\router\parameters\query_course;
use core\router\parameters\query_coursemodule;
use core\router\require_login;
use core\router\route;
use core\router\schema\example;
use core\router\schema\objects\array_of_strings;
use core\router\schema\objects\array_of_things;
use core\router\schema\objects\schema_object;
use core\router\schema\parameters\path_parameter;
use core\router\schema\parameters\query_parameter;
use core\router\schema\response\content\json_media_type;
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\local\bank\question_edit_contexts;
use core_question\local\bank\question_version_status;
use core_question\output\question_category_selector;
use core_question\question_category;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -115,4 +128,113 @@ class bank {
response: $response,
);
}
/**
* Return a list of formatted question banks matching the parameters.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param course $coursecontext The course context.
* @param module $currentmodulecontext The module context.
* @param question_bank_helper $helper Injected dependency.
* @return payload_response A list of question banks with formatted names, and whether they are shared and recently used.
*/
#[route(
path: '/banks', // Resolves to /api/rest/v2/question/banks.
queryparams: [
new query_course(required: true),
new query_coursemodule('currentmodule'),
new query_parameter(name: 'includeshared', type: param::BOOL, default: true),
new query_parameter(name: 'includerecent', type: param::BOOL, default: false),
],
responses: [
new response(
statuscode: 200,
description: 'OK',
content: [
new json_media_type(
schema: new schema_object(
content: [
'banks' => new array_of_things(thingtype: formatted_bank::class),
],
),
),
],
),
],
requirelogin: new require_login(true, courseattributename: 'course'),
)]
public function banks(
ServerRequestInterface $request,
ResponseInterface $response,
course $coursecontext,
module $currentmodulecontext,
question_bank_helper $helper,
): payload_response {
$params = $request->getQueryParams();
$banks = $helper::get_banks_for_course(
$coursecontext,
$currentmodulecontext,
$params['includeshared'],
$params['includerecent'],
);
return new payload_response(
request: $request,
response: $response,
payload: ['banks' => $banks],
);
}
#[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,
],
);
}
}
@@ -19,122 +19,77 @@
Example context (json):
{
"quizname": "Quiz 1",
"quizcmid": 1,
"quizcontextid": 1,
"contextid": 1,
"hasactivitybank": true,
"activitybank": {
"name": "Quiz 1",
"cmid": 1
},
"hascoursesharedbanks": true,
"coursesharedbanks": [
{
"name": "Question bank 1",
"modid": "2",
"contextid": 2,
"coursenamebankname": "c1 - Question bank 1",
"cminfo": {},
"questioncategories": []
"modid": "2"
},
{
"name": "Question bank 2",
"modid": "3",
"contextid": 3,
"coursenamebankname": "c1 - Question bank 2",
"cminfo": {},
"questioncategories": []
"modid": "3"
}
],
"hasrecentlyviewedbanks": true,
"recentlyviewedbanks": [
{
"name": "Question bank 3",
"modid": "4",
"contextid": 4,
"coursenamebankname": "c2 - Question bank 4",
"cminfo": {},
"questioncategories": []
"coursenamebankname": "c2 - Question bank 4"
},
{
"name": "Question bank 4",
"modid": "6",
"contextid": 6,
"coursenamebankname": "c3 - Question bank 5",
"cminfo": {},
"questioncategories": []
}
],
"hassharedbanks": true,
"sharedbanks": [
{
"name": "Question bank 1",
"modid": "2",
"contextid": 2,
"coursenamebankname": "c1 - Question bank 1",
"cminfo": {},
"questioncategories": []
},
{
"name": "Question bank 2",
"modid": "3",
"contextid": 3,
"coursenamebankname": "c1 - Question bank 2",
"cminfo": {},
"questioncategories": []
},
{
"name": "Question bank 3",
"modid": "4",
"contextid": 4,
"coursenamebankname": "c2 - Question bank 4",
"cminfo": {},
"questioncategories": []
},
{
"name": "Question bank 4",
"modid": "6",
"contextid": 6,
"coursenamebankname": "c3 - Question bank 5",
"cminfo": {},
"questioncategories": []
"coursenamebankname": "c3 - Question bank 5"
}
]
}
}}
<div class="quiz-bank">
<h5>{{#str}}quizquestionbank, core_question{{/str}}</h5>
<a href="#" class="ms-3" data-newmodid="{{quizcmid}}">{{quizname}}</a>
</div>
<div class="bank-switcher">
{{#hasactivitybank}}
<div class="quiz-bank">
<h5>{{#str}}quizquestionbank, core_question{{/str}}</h5>
<a href="#" class="ms-3" data-newmodid="{{activitybank.cmid}}">{{activitybank.name}}</a>
</div>
<hr class="w-75">
{{/hasactivitybank}}
<hr class="w-75">
{{#hascoursesharedbanks}}
<div class="course-shared-banks">
<h5>{{#str}}banksincourse, core_question{{/str}}</h5>
{{#coursesharedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{name}}</a>
</li>
</ul>
{{/coursesharedbanks}}
</div>
<hr class="w-75">
{{/hascoursesharedbanks}}
{{#hascoursesharedbanks}}
<div class="course-shared-banks">
<h5>{{#str}}banksincourse, core_question{{/str}}</h5>
{{#coursesharedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{name}}</a>
</li>
</ul>
{{/coursesharedbanks}}
{{#hasrecentlyviewedbanks}}
<div class="recently-viewed-banks">
<h5>{{#str}}recentlyviewedquestionbanks, core_question{{/str}}</h5>
{{#recentlyviewedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{coursenamebankname}}</a>
</li>
</ul>
{{/recentlyviewedbanks}}
</div>
<hr class="w-75">
{{/hasrecentlyviewedbanks}}
<div class="search-banks">
<h5>{{#str}}otherquestionbank, core_question{{/str}}</h5>
<select class="form-select" id="searchbanks" data-contextid="{{contextid}}">
</select>
</div>
<hr class="w-75">
{{/hascoursesharedbanks}}
{{#hasrecentlyviewedbanks}}
<div class="recently-viewed-banks">
<h5>{{#str}}recentlyviewedquestionbanks, core_question{{/str}}</h5>
{{#recentlyviewedbanks}}
<ul class="list-unstyled ms-3">
<li>
<a href="#" data-newmodid="{{modid}}">{{coursenamebankname}}</a>
</li>
</ul>
{{/recentlyviewedbanks}}
</div>
<hr class="w-75">
{{/hasrecentlyviewedbanks}}
<div class="search-banks">
<h5>{{#str}}otherquestionbank, core_question{{/str}}</h5>
<select class="form-select" id="searchbanks" data-contextid="{{quizcontextid}}">
</select>
</div>
@@ -17,6 +17,7 @@
namespace core_question;
use core\exception\coding_exception;
use core_question\local\bank\formatted_bank;
use core_question\local\bank\question_bank_helper;
/**
@@ -127,7 +128,8 @@ final class question_bank_helper_test extends \advanced_testcase {
);
$count = 0;
foreach ($sharedbanks as $courseinstance) {
foreach ($sharedbanks as $sharedbank) {
$courseinstance = $sharedbank->get_formatted();
// Must all be mod_qbanks.
$this->assertEquals('qbank', $courseinstance->cminfo->modname);
// Must have 2 categories each bank.
@@ -148,7 +150,8 @@ final class question_bank_helper_test extends \advanced_testcase {
);
$count = 0;
foreach ($privatebanks as $courseinstance) {
foreach ($privatebanks as $privatebank) {
$courseinstance = $privatebank->get_formatted();
// Must all be mod_quiz.
$this->assertEquals('quiz', $courseinstance->cminfo->modname);
// Must have 1 category in each bank.
@@ -239,7 +242,9 @@ final class question_bank_helper_test extends \advanced_testcase {
$this->assertCount(22, $allsharedbanks);
// Searching for "2", we get the 4 banks with "2" in the name.
$twobanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: '2');
$twobanks = formatted_bank::format_banks(
question_bank_helper::get_activity_instances_with_shareable_questions(search: '2'),
);
$this->assertCount(4, $twobanks);
$this->assertEquals(
[$sharedmods[2]->cmid, $sharedmods[12]->cmid, $sharedmods[20]->cmid, $sharedmods[21]->cmid],
@@ -247,12 +252,16 @@ final class question_bank_helper_test extends \advanced_testcase {
);
// Searching for "Shared bank" with no limit, we should get all 21, but not "Another bank".
$sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank');
$sharedbanks = formatted_bank::format_banks(
question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank'),
);
$this->assertCount(21, $sharedbanks);
$this->assertEmpty(array_filter($sharedbanks, fn($bank) => in_array($bank->name, ['Another bank'])));
// Searching for "Shared bank" with a limit of 20, we should get all except number 21 and "Another bank".
$limitedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank', limit: 20);
$limitedbanks = formatted_bank::format_banks(
question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank', limit: 20),
);
$this->assertCount(20, $limitedbanks);
$this->assertEmpty(array_filter($limitedbanks, fn($bank) => in_array($bank->name, ['Shared bank 21', 'Another bank'])));
}
@@ -413,14 +422,18 @@ final class question_bank_helper_test extends \advanced_testcase {
$viewedorder = array_reverse($banks);
// Check that the courseid filter works.
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, $course1->id);
$recentlyviewed = formatted_bank::format_banks(
question_bank_helper::get_recently_used_open_banks($user->id, $course1->id),
);
$this->assertCount(3, $recentlyviewed);
// We should have the viewed banks in course 2.
$courseviewed = array_slice($banks, 3, 3);
$this->assertEqualsCanonicalizing(array_column($recentlyviewed, 'modid'), array_column($courseviewed, 'cmid'));
// Check that the capability filter works.
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, havingcap: ['moodle/question:useall']);
$recentlyviewed = formatted_bank::format_banks(
question_bank_helper::get_recently_used_open_banks($user->id, havingcap: ['moodle/question:useall']),
);
$this->assertCount(2, $recentlyviewed);
// We should have the 2 most recently viewed banks in course 1.
$capabilityviewed = array_slice($banks, 1, 2);
@@ -431,7 +444,7 @@ final class question_bank_helper_test extends \advanced_testcase {
// We only keep a record of 5 maximum.
$this->assertCount(5, $recentlyviewed);
foreach ($recentlyviewed as $order => $record) {
$this->assertEquals($viewedorder[$order]->cmid, $record->modid);
$this->assertEquals($viewedorder[$order]->cmid, $record->cminfo->id);
}
// Now if we view one of those again it should get bumped to the front of the list.
@@ -444,12 +457,12 @@ final class question_bank_helper_test extends \advanced_testcase {
// We should still have 5 maximum.
$this->assertCount(5, $recentlyviewed);
// The recently viewed on got bumped to the front.
$this->assertEquals($banks[2]->cmid, $recentlyviewed[0]->modid);
$this->assertEquals($banks[2]->cmid, $recentlyviewed[0]->cminfo->id);
// The others got sorted accordingly behind it.
$this->assertEquals($banks[5]->cmid, $recentlyviewed[1]->modid);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[2]->modid);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[3]->modid);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[4]->modid);
$this->assertEquals($banks[5]->cmid, $recentlyviewed[1]->cminfo->id);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[2]->cminfo->id);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[3]->cminfo->id);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[4]->cminfo->id);
// Now create a quiz and trigger the bank view of it.
$quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(['course' => $course1]);
@@ -463,7 +476,7 @@ final class question_bank_helper_test extends \advanced_testcase {
// Make sure that we only store bank views for plugins that support FEATURE_PUBLISHES_QUESTIONS.
foreach ($recentlyviewed as $record) {
$this->assertNotEquals($quiz->cmid, $record->modid);
$this->assertNotEquals($quiz->cmid, $record->cminfo->id);
}
// Now delete one of the viewed bank modules and get the records again.
@@ -472,10 +485,10 @@ final class question_bank_helper_test extends \advanced_testcase {
$this->assertCount(4, $recentlyviewed);
// Check the order was retained.
$this->assertEquals($banks[5]->cmid, $recentlyviewed[0]->modid);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[1]->modid);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[2]->modid);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[3]->modid);
$this->assertEquals($banks[5]->cmid, $recentlyviewed[0]->cminfo->id);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[1]->cminfo->id);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[2]->cminfo->id);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[3]->cminfo->id);
}
/**