From 66a22969e1c5ee3c5b0e1a496a296282de6530d1 Mon Sep 17 00:00:00 2001 From: James C <5689414+james-cnz@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:01:57 +1300 Subject: [PATCH 01/79] MDL-87356 qtype_random: orphaned random questions should be deleted This patch reinstates the task from MDL-63260 and MDL-66273. --- backup/moodle2/restore_stepslib.php | 9 ++ .../classes/task/remove_unused_questions.php | 98 ++++++++++++ question/type/random/db/tasks.php | 38 +++++ .../type/random/tests/cleanup_task_test.php | 144 ++++++++++++++++++ .../tests/fixtures/broken_question_course.mbz | Bin 0 -> 4802 bytes question/type/random/version.php | 2 +- 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 question/type/random/classes/task/remove_unused_questions.php create mode 100644 question/type/random/db/tasks.php create mode 100644 question/type/random/tests/cleanup_task_test.php create mode 100644 question/type/random/tests/fixtures/broken_question_course.mbz diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index 0c292a9edd87d..182665e699afa 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -5285,6 +5285,15 @@ protected function process_question($data) { $this->set_mapping('question_bank_entry', $this->latestqbe->oldid, $this->latestqbe->newid); } + if ( + ($data->qtype === 'random') + && ($this->latestversion->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN) + ) { + // Ensure that this newly created question is considered by + // \qtype_random\task\remove_unused_questions. + $this->latestversion->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT; + } + // Now store the question. $newitemid = $DB->insert_record('question', $data); $this->set_mapping('question', $oldid, $newitemid); diff --git a/question/type/random/classes/task/remove_unused_questions.php b/question/type/random/classes/task/remove_unused_questions.php new file mode 100644 index 0000000000000..eb059b4ecc678 --- /dev/null +++ b/question/type/random/classes/task/remove_unused_questions.php @@ -0,0 +1,98 @@ +. + +/** + * A scheduled task to remove unneeded random questions. + * + * @package qtype_random + * @category task + * @copyright 2018 Bo Pierce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace qtype_random\task; + +use core\task\manager; + + +/** + * A scheduled task to remove unneeded random questions. + * + * @copyright 2018 Bo Pierce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class remove_unused_questions extends \core\task\scheduled_task { + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name(): string { + return get_string('taskunusedrandomscleanup', 'qtype_random'); + } + + /** + * Do the job. + * + * @return void + */ + public function execute() { + global $DB, $CFG; + require_once($CFG->libdir . '/questionlib.php'); + + // Confirm, that there is no restore in progress to make sure we do not + // clean up questions that have their quiz slots not restored yet. + $restoretasks = [ + '\core\task\asynchronous_copy_task', + '\core\task\asynchronous_restore_task', + ]; + + $running = manager::get_running_tasks(); + foreach ($running as $task) { + if (in_array($task->classname, $restoretasks)) { + mtrace('Detected running async restore. Aborting the task.'); + return; + } + } + + // Find potentially unused random questions (up to 5000). + // Note, because we call question_delete_question below, + // the question will not actually be deleted if something else + // is using them, but nothing else in Moodle core uses qtype_random, + // and not many third-party plugins do. + $unusedrandomids = $DB->get_records_sql( + " SELECT DISTINCT q.id, 1 + FROM {question} q + JOIN {question_versions} qv on qv.questionid = q.id + JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid + LEFT JOIN {question_references} qr on qr.questionbankentryid = qbe.id + WHERE qr.questionbankentryid IS NULL + AND q.qtype = ? AND qv.status <> ?", + ['random', 'hidden'], + 0, + 5000 + ); + + $count = 0; + foreach ($unusedrandomids as $unusedrandomid => $notused) { + question_delete_question($unusedrandomid); + // In case the question was not actually deleted (because it was in use somehow), + // it will be marked as hidden, so the query above will not return it again. + $count += 1; + } + mtrace('Cleaned up ' . $count . ' unused random questions.'); + } +} diff --git a/question/type/random/db/tasks.php b/question/type/random/db/tasks.php new file mode 100644 index 0000000000000..198dcba16c917 --- /dev/null +++ b/question/type/random/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * Definition of question/type/random scheduled tasks. + * + * @package qtype_random + * @category task + * @copyright 2018 Bo Pierce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'qtype_random\task\remove_unused_questions', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], +]; diff --git a/question/type/random/tests/cleanup_task_test.php b/question/type/random/tests/cleanup_task_test.php new file mode 100644 index 0000000000000..4448aee648d79 --- /dev/null +++ b/question/type/random/tests/cleanup_task_test.php @@ -0,0 +1,144 @@ +. + +/** + * Tests of the scheduled task for cleaning up random questions. + * + * @package qtype_random + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +namespace qtype_random; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + + +/** + * Tests of the scheduled task for cleaning up random questions. + * + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \qtype_random\task\remove_unused_questions + */ +final class cleanup_task_test extends \advanced_testcase { + /** + * Test that remove_unused_questions deletes questions as appropriate. + * + * @covers ::execute + */ + public function test_cleanup_task_removes_unused_question(): void { + global $DB, $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + // To do the test, we will be restoring a backup that contains 3 questions: + // A non-hidden broken question, a hidden broken question, and a non-broken question. + // Only the non-hidden broken question should be deleted, + // because questions are hidden when a delete was attempted but failed, + // and this is used to indicate we should skip over them in future deletes, + // to avoid continuingly attempting to delete undeletable questions. + + // Extract backup file. + $backupid = 'test_cleanup_task_removes_unused_question'; + $backuppath = make_backup_temp_directory($backupid); + get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( + __DIR__ . '/fixtures/broken_question_course.mbz', + $backuppath + ); + + // Do restore to new course with default settings. + $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}"); + $newcourseid = \restore_dbops::create_new_course('Broken Question Course', 'BQC', $categoryid); + $rc = new \restore_controller( + $backupid, + $newcourseid, + \backup::INTERACTIVE_NO, + \backup::MODE_GENERAL, + $USER->id, + \backup::TARGET_NEW_COURSE + ); + $rc->execute_precheck(); + $rc->execute_plan(); + $rc->destroy(); + + // Check the hidden question was unhidden during the restore, + // to make it eligible for deletion. + $hiddenquestionid = $DB->get_field('question', 'id', ['name' => 'Random (BQC hidden broken question)']); + $this->assertNotEquals( + 'hidden', + $DB->get_field('question_versions', 'status', ['questionid' => $hiddenquestionid]) + ); + + // Revert the hidden question back to hidden, so we can check it isn't deleted. + $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $hiddenquestionid]); + + // Run the scheduled task. + $task = new \qtype_random\task\remove_unused_questions(); + $this->expectOutputString("Cleaned up 1 unused random questions.\n"); + $task->execute(); + + // Verify. + $this->assertFalse( + $DB->record_exists('question', ['name' => 'Random (BQC non-hidden broken question)']) + ); + $this->assertTrue( + $DB->record_exists('question', ['name' => 'Random (BQC hidden broken question)']) + ); + $this->assertTrue( + $DB->record_exists('question', ['name' => 'BQC non-broken question']) + ); + } + + /** + * Test that remove_unused_questions aborts when there is a course restore in progress. + * + * @covers ::execute + */ + public function test_cleanup_task_checks_for_active_restores(): void { + $this->resetAfterTest(); + + // Get ready the tasks. + $cleanuptask = new \qtype_random\task\remove_unused_questions(); + $restoretask = new \core\task\asynchronous_restore_task(); + \core\task\manager::queue_adhoc_task($restoretask); + $copytask = new \core\task\asynchronous_copy_task(); + \core\task\manager::queue_adhoc_task($copytask); + + // Start the first adhoc task. This might be either restore or copy adhoc task. + $task1 = \core\task\manager::get_next_adhoc_task(time()); + \core\task\manager::adhoc_task_starting($task1); + $cleanuptask->execute(); + + // Complete the first task and start the second one. + \core\task\manager::adhoc_task_complete($task1); + $task2 = \core\task\manager::get_next_adhoc_task(time()); + \core\task\manager::adhoc_task_starting($task2); + $cleanuptask->execute(); + + // Complete the second adhoc task. + \core\task\manager::adhoc_task_complete($task2); + $cleanuptask->execute(); + + $aborted = 'Detected running async restore. Aborting the task.'; + $completed = 'Cleaned up 0 unused random questions.'; + $this->expectOutputRegex("/.*$aborted.*\s.*$aborted.*\s.*$completed.*/"); + } +} diff --git a/question/type/random/tests/fixtures/broken_question_course.mbz b/question/type/random/tests/fixtures/broken_question_course.mbz new file mode 100644 index 0000000000000000000000000000000000000000..204a62ddcaaa1727c00a4ca58ac698fe3cc9b744 GIT binary patch literal 4802 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 literal 0 HcmV?d00001 diff --git a/question/type/random/version.php b/question/type/random/version.php index d9e3ffa139e59..fbe5339b807de 100644 --- a/question/type/random/version.php +++ b/question/type/random/version.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_random'; -$plugin->version = 2024100700; +$plugin->version = 2024100701; $plugin->requires = 2024100100; From c300545e9e3d67324e8bf35a513af40100ffc6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Tue, 25 Jun 2024 22:20:24 +0200 Subject: [PATCH 02/79] MDL-82281 quiz: Amend test to circumvent modinfo cache fails. --- mod/quiz/tests/external/external_test.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index 09fb3d66a9652..22743e2751581 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -239,6 +239,11 @@ public function test_mod_quiz_get_quizzes_by_courses(): void { break; } } + + // Clear static cache and call get_fast_modinfo() again so that in the following cache should not be rebuilt. + \course_modinfo::clear_instance_cache(); + get_fast_modinfo($record->course); + $enrol->enrol_user($instance2, $this->student->id, $this->studentrole->id); self::setUser($this->student); From a45cfb7c7b84e61cee36a79f9598d51189096677 Mon Sep 17 00:00:00 2001 From: Simey Lameze Date: Mon, 8 Dec 2025 07:57:43 +0800 Subject: [PATCH 03/79] MDL-87340 mod_quiz: make test_question_shuffle more robust This commit improves test_question_shuffle by retrying if questions remains in the same order after shuffling. --- mod/quiz/tests/attempt_test.php | 47 ++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/mod/quiz/tests/attempt_test.php b/mod/quiz/tests/attempt_test.php index 83d474896a807..3622b4327324d 100644 --- a/mod/quiz/tests/attempt_test.php +++ b/mod/quiz/tests/attempt_test.php @@ -584,7 +584,7 @@ public function test_question_shuffle(): void { $user = $this->getDataGenerator()->create_user(); $this->setUser($user); - // Create quiz with two sections using create_test_quiz. + // Create quiz with two sections (shuffled, non-shuffled) using create_test_quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Shuffled section*', @@ -601,26 +601,47 @@ public function test_question_shuffle(): void { ['Q10', 2, 'shortanswer'], ]); - // Start two attempts. + // Start the reference attempt (Attempt 1). $attempt1 = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], [], $user->id); - $attempt2 = quiz_prepare_and_start_new_attempt($quizobj, 2, null, false, [], [], $user->id); $attemptobj1 = quiz_attempt::create($attempt1->id); - $attemptobj2 = quiz_attempt::create($attempt2->id); - // Get slot numbers for each section in both attempts. + // Shuffled section $slots1a = $attemptobj1->get_slots(0); - $slots1b = $attemptobj2->get_slots(0); + // Non-shuffled section $slots2a = $attemptobj1->get_slots(1); - $slots2b = $attemptobj2->get_slots(1); - - // Get question order for each section in both attempts. + // Get reference orders. $order1a = array_map(fn($slot) => $attemptobj1->get_question_attempt($slot)->get_question()->id, $slots1a); - $order1b = array_map(fn($slot) => $attemptobj2->get_question_attempt($slot)->get_question()->id, $slots1b); $order2a = array_map(fn($slot) => $attemptobj1->get_question_attempt($slot)->get_question()->id, $slots2a); - $order2b = array_map(fn($slot) => $attemptobj2->get_question_attempt($slot)->get_question()->id, $slots2b); - // Assert shuffled section is different, non-shuffled is the same. - $this->assertNotEquals($order1a, $order1b, 'Shuffled section should have different order between attempts.'); + // Start comparison attempt (Attempt 2) with a retry mechanism. + // We try up to 5 times to get a different shuffle. + // If it matches 5 times in a row, the shuffle feature is likely broken. + $maxretries = 5; + $isshuffled = false; + + for ($i = 0; $i < $maxretries; $i++) { + $attempt2 = quiz_prepare_and_start_new_attempt($quizobj, 2 + $i, null, false, [], [], $user->id); + $attemptobj2 = quiz_attempt::create($attempt2->id); + + $slots1b = $attemptobj2->get_slots(0); + $order1b = array_map(fn($slot) => $attemptobj2->get_question_attempt($slot)->get_question()->id, $slots1b); + + // If the orders are different, the shuffle is working. + if ($order1a !== $order1b) { + $isshuffled = true; + break; + } + // Otherwise try again. + } + + // Assert that we eventually found a different order. + $message = "Shuffled section should have different order between attempts after $maxretries tries."; + $this->assertTrue($isshuffled, $message); + + // Verify the non-shuffled section on the final attempt used. + // We only need to check this on the last attempt generated, as it should never change. + $slots2b = $attemptobj2->get_slots(1); + $order2b = array_map(fn($slot) => $attemptobj2->get_question_attempt($slot)->get_question()->id, $slots2b); $this->assertEquals($order2a, $order2b, 'Non-shuffled section should have same order between attempts.'); } } From 70ba060e57b91c23e33e72ee20ff8d0b05dc85e5 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 29 Oct 2025 16:43:34 -0700 Subject: [PATCH 04/79] MDL-86416 navigation: Render the more menu only when it's not empty Co-authored-by: Jun Pataleta --- lib/classes/navigation/output/more_menu.php | 4 ++ .../navigation/output/more_menu_test.php | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 lib/tests/navigation/output/more_menu_test.php diff --git a/lib/classes/navigation/output/more_menu.php b/lib/classes/navigation/output/more_menu.php index 89b9c4c8ed9c6..3b38ff060f6e6 100644 --- a/lib/classes/navigation/output/more_menu.php +++ b/lib/classes/navigation/output/more_menu.php @@ -83,6 +83,10 @@ public function export_for_template(renderer_base $output): array { $data['nodecollection'] = $this->content; } else { $data['nodearray'] = (array) $this->content; + // If there is no node array to render then return an empty array. + if (empty($data['nodearray'])) { + return []; + } } $data['moremenuid'] = uniqid(); diff --git a/lib/tests/navigation/output/more_menu_test.php b/lib/tests/navigation/output/more_menu_test.php new file mode 100644 index 0000000000000..db817f5f69758 --- /dev/null +++ b/lib/tests/navigation/output/more_menu_test.php @@ -0,0 +1,46 @@ +. + +namespace core\navigation\output; + +use basic_testcase; +use core\output\renderer_base; +use stdClass; + +/** + * More menu navigation renderable test. + * + * @package core + * @category navigation + * @copyright Stefan Topfstedt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\navigation\output\more_menu + */ +final class more_menu_test extends basic_testcase { + /** + * Checks that export_for_template() returns an empty array if the given content is empty. + * See MDL-86416. + * + * @return void + */ + public function test_export_for_template_returns_empty_array(): void { + $moremenu = new more_menu(new stdClass(), 'whatever', false, false); + $output = $this->createStub(renderer_base::class); + $data = $moremenu->export_for_template($output); + $this->assertIsArray($data); + $this->assertEmpty($data); + } +} From 367a2a7678579e1dbf1de5d46225f1af0960cd56 Mon Sep 17 00:00:00 2001 From: Andi Permana Date: Tue, 9 Dec 2025 15:20:23 +0700 Subject: [PATCH 05/79] MDL-86805 courseindex: Fix RTL keyboard navigation in tree --- lib/amd/build/tree.min.js | 2 +- lib/amd/build/tree.min.js.map | 2 +- lib/amd/src/tree.js | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/amd/build/tree.min.js b/lib/amd/build/tree.min.js index dc73db1d7f640..96b96212b1107 100644 --- a/lib/amd/build/tree.min.js +++ b/lib/amd/build/tree.min.js @@ -6,6 +6,6 @@ * @copyright 2015 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("core/tree",["jquery"],(function($){var SELECTORS_ITEM="[role=treeitem]",SELECTORS_GROUP="[role=treeitem]:has([role=group]), [role=treeitem][aria-owns], [role=treeitem][data-requires-ajax=true]",SELECTORS_CLOSED_GROUP="[role=treeitem]:has([role=group])[aria-expanded=false], [role=treeitem][aria-owns][aria-expanded=false], [role=treeitem][data-requires-ajax=true][aria-expanded=false]",SELECTORS_FIRST_ITEM="[role=treeitem]:first",SELECTORS_VISIBLE_ITEM="[role=treeitem]:visible",SELECTORS_UNLOADED_AJAX_ITEM="[role=treeitem][data-requires-ajax=true][data-loaded=false][aria-expanded=true]",Tree=function(selector,selectCallback){this.treeRoot=$(selector),this.treeRoot.data("activeItem",null),this.selectCallback=selectCallback,this.keys={tab:9,enter:13,space:32,pageup:33,pagedown:34,end:35,home:36,left:37,up:38,right:39,down:40,asterisk:106},this.initialiseNodes(this.treeRoot),this.setActiveItem(this.treeRoot.find(SELECTORS_FIRST_ITEM)),this.refreshVisibleItemsCache(),this.bindEventHandlers()};return Tree.prototype.registerEnterCallback=function(callback){this.enterCallback=callback},Tree.prototype.refreshVisibleItemsCache=function(){this.treeRoot.data("visibleItems",this.treeRoot.find(SELECTORS_VISIBLE_ITEM))},Tree.prototype.getVisibleItems=function(){return this.treeRoot.data("visibleItems")},Tree.prototype.setActiveItem=function(item){var currentActive=this.treeRoot.data("activeItem");item!==currentActive&&(currentActive&&(currentActive.attr("tabindex","-1"),currentActive.attr("aria-selected","false")),item.attr("tabindex","0"),item.attr("aria-selected","true"),this.treeRoot.data("activeItem",item),"function"==typeof this.selectCallback&&this.selectCallback(item))},Tree.prototype.isGroupItem=function(item){return item.is(SELECTORS_GROUP)},Tree.prototype.getGroupFromItem=function(item){var ariaowns=this.treeRoot.find("#"+item.attr("aria-owns")),plain=item.children("[role=group]");return ariaowns.length>plain.length?ariaowns:plain},Tree.prototype.isGroupCollapsed=function(item){return"false"===item.attr("aria-expanded")},Tree.prototype.isGroupCollapsible=function(item){return"false"!==item.attr("data-collapsible")},Tree.prototype.initialiseNodes=function(node){this.removeAllFromTabOrder(node),this.setAriaSelectedFalseOnItems(node);var thisTree=this;node.find(SELECTORS_UNLOADED_AJAX_ITEM).each((function(){var unloadedNode=$(this);thisTree.collapseGroup(unloadedNode),thisTree.expandGroup(unloadedNode)}))},Tree.prototype.removeAllFromTabOrder=function(node){node.find("*").attr("tabindex","-1"),this.getGroupFromItem($(node)).find("*").attr("tabindex","-1")},Tree.prototype.setAriaSelectedFalseOnItems=function(node){node.find(SELECTORS_ITEM).attr("aria-selected","false")},Tree.prototype.expandAllGroups=function(){var thisTree=this;this.treeRoot.find(SELECTORS_CLOSED_GROUP).each((function(){var groupNode=$(this);thisTree.expandGroup($(this)).done((function(){thisTree.expandAllChildGroups(groupNode)}))}))},Tree.prototype.expandAllChildGroups=function(item){var thisTree=this;this.getGroupFromItem(item).find(SELECTORS_CLOSED_GROUP).each((function(){var groupNode=$(this);thisTree.expandGroup($(this)).done((function(){thisTree.expandAllChildGroups(groupNode)}))}))},Tree.prototype.expandGroup=function(item){var promise=$.Deferred();if("false"!==item.attr("data-expandable")&&this.isGroupCollapsed(item))if("true"===item.attr("data-requires-ajax")&&"true"!==item.attr("data-loaded")){item.attr("data-loaded",!1);var moduleName=item.closest("[data-ajax-loader]").attr("data-ajax-loader"),thisTree=this;const p=item.find("p");p.addClass("loading"),require([moduleName],(function(loader){loader.load(item).done((function(){item.attr("data-loaded",!0),thisTree.initialiseNodes(item),thisTree.finishExpandingGroup(item),p.removeClass("loading"),promise.resolve()}))}))}else this.finishExpandingGroup(item),promise.resolve();else promise.resolve();return promise},Tree.prototype.finishExpandingGroup=function(item){this.getGroupFromItem(item).removeAttr("aria-hidden"),item.attr("aria-expanded","true"),this.refreshVisibleItemsCache()},Tree.prototype.collapseGroup=function(item){this.isGroupCollapsible(item)&&!this.isGroupCollapsed(item)&&(this.getGroupFromItem(item).attr("aria-hidden","true"),item.attr("aria-expanded","false"),this.refreshVisibleItemsCache())},Tree.prototype.toggleGroup=function(item){"true"===item.attr("aria-expanded")?this.collapseGroup(item):this.expandGroup(item)},Tree.prototype.handleKeyDown=function(e){var _this$getVisibleItems,item=$(e.target),currentIndex=null===(_this$getVisibleItems=this.getVisibleItems())||void 0===_this$getVisibleItems?void 0:_this$getVisibleItems.index(item);if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey&&e.keyCode!=this.keys.tab))switch(e.keyCode){case this.keys.home:return this.getVisibleItems().first().focus(),void e.preventDefault();case this.keys.end:return this.getVisibleItems().last().focus(),void e.preventDefault();case this.keys.enter:var links=item.children("a").length?item.children("a"):item.children().not(SELECTORS_GROUP).find("a");return links.length?links.first().data("overrides-tree-activation-key-handler")?links.first().triggerHandler(e):"function"==typeof this.enterCallback?this.enterCallback(item):window.location.href=links.first().attr("href"):this.isGroupItem(item)&&this.toggleGroup(item,!0),void e.preventDefault();case this.keys.space:if(this.isGroupItem(item))this.toggleGroup(item,!0);else if(item.children("a").length){var firstLink=item.children("a").first();firstLink.data("overrides-tree-activation-key-handler")&&firstLink.triggerHandler(e)}return void e.preventDefault();case this.keys.left:var focusParent=function(tree){tree.getVisibleItems().filter((function(){return tree.getGroupFromItem($(this)).has(item).length})).focus()};return this.isGroupItem(item)?this.isGroupCollapsed(item)?focusParent(this):this.collapseGroup(item):focusParent(this),void e.preventDefault();case this.keys.right:return this.isGroupItem(item)&&(this.isGroupCollapsed(item)?this.expandGroup(item):this.getGroupFromItem(item).find(SELECTORS_ITEM).first().focus()),void e.preventDefault();case this.keys.up:if(currentIndex>0)this.getVisibleItems().eq(currentIndex-1).focus();return void e.preventDefault();case this.keys.down:if(currentIndexplain.length?ariaowns:plain},Tree.prototype.isGroupCollapsed=function(item){return"false"===item.attr("aria-expanded")},Tree.prototype.isGroupCollapsible=function(item){return"false"!==item.attr("data-collapsible")},Tree.prototype.initialiseNodes=function(node){this.removeAllFromTabOrder(node),this.setAriaSelectedFalseOnItems(node);var thisTree=this;node.find(SELECTORS_UNLOADED_AJAX_ITEM).each((function(){var unloadedNode=$(this);thisTree.collapseGroup(unloadedNode),thisTree.expandGroup(unloadedNode)}))},Tree.prototype.removeAllFromTabOrder=function(node){node.find("*").attr("tabindex","-1"),this.getGroupFromItem($(node)).find("*").attr("tabindex","-1")},Tree.prototype.setAriaSelectedFalseOnItems=function(node){node.find(SELECTORS_ITEM).attr("aria-selected","false")},Tree.prototype.expandAllGroups=function(){var thisTree=this;this.treeRoot.find(SELECTORS_CLOSED_GROUP).each((function(){var groupNode=$(this);thisTree.expandGroup($(this)).done((function(){thisTree.expandAllChildGroups(groupNode)}))}))},Tree.prototype.expandAllChildGroups=function(item){var thisTree=this;this.getGroupFromItem(item).find(SELECTORS_CLOSED_GROUP).each((function(){var groupNode=$(this);thisTree.expandGroup($(this)).done((function(){thisTree.expandAllChildGroups(groupNode)}))}))},Tree.prototype.expandGroup=function(item){var promise=$.Deferred();if("false"!==item.attr("data-expandable")&&this.isGroupCollapsed(item))if("true"===item.attr("data-requires-ajax")&&"true"!==item.attr("data-loaded")){item.attr("data-loaded",!1);var moduleName=item.closest("[data-ajax-loader]").attr("data-ajax-loader"),thisTree=this;const p=item.find("p");p.addClass("loading"),require([moduleName],(function(loader){loader.load(item).done((function(){item.attr("data-loaded",!0),thisTree.initialiseNodes(item),thisTree.finishExpandingGroup(item),p.removeClass("loading"),promise.resolve()}))}))}else this.finishExpandingGroup(item),promise.resolve();else promise.resolve();return promise},Tree.prototype.finishExpandingGroup=function(item){this.getGroupFromItem(item).removeAttr("aria-hidden"),item.attr("aria-expanded","true"),this.refreshVisibleItemsCache()},Tree.prototype.collapseGroup=function(item){this.isGroupCollapsible(item)&&!this.isGroupCollapsed(item)&&(this.getGroupFromItem(item).attr("aria-hidden","true"),item.attr("aria-expanded","false"),this.refreshVisibleItemsCache())},Tree.prototype.toggleGroup=function(item){"true"===item.attr("aria-expanded")?this.collapseGroup(item):this.expandGroup(item)},Tree.prototype.handleKeyDown=function(e){var _this$getVisibleItems,item=$(e.target),currentIndex=null===(_this$getVisibleItems=this.getVisibleItems())||void 0===_this$getVisibleItems?void 0:_this$getVisibleItems.index(item);if(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey&&e.keyCode!=this.keys.tab)return;const rtl=window.right_to_left(),collapseKey=rtl?this.keys.right:this.keys.left,expandKey=rtl?this.keys.left:this.keys.right;switch(e.keyCode){case this.keys.home:return this.getVisibleItems().first().focus(),void e.preventDefault();case this.keys.end:return this.getVisibleItems().last().focus(),void e.preventDefault();case this.keys.enter:var links=item.children("a").length?item.children("a"):item.children().not(SELECTORS_GROUP).find("a");return links.length?links.first().data("overrides-tree-activation-key-handler")?links.first().triggerHandler(e):"function"==typeof this.enterCallback?this.enterCallback(item):window.location.href=links.first().attr("href"):this.isGroupItem(item)&&this.toggleGroup(item,!0),void e.preventDefault();case this.keys.space:if(this.isGroupItem(item))this.toggleGroup(item,!0);else if(item.children("a").length){var firstLink=item.children("a").first();firstLink.data("overrides-tree-activation-key-handler")&&firstLink.triggerHandler(e)}return void e.preventDefault();case collapseKey:var focusParent=function(tree){tree.getVisibleItems().filter((function(){return tree.getGroupFromItem($(this)).has(item).length})).focus()};return this.isGroupItem(item)?this.isGroupCollapsed(item)?focusParent(this):this.collapseGroup(item):focusParent(this),void e.preventDefault();case expandKey:return this.isGroupItem(item)&&(this.isGroupCollapsed(item)?this.expandGroup(item):this.getGroupFromItem(item).find(SELECTORS_ITEM).first().focus()),void e.preventDefault();case this.keys.up:if(currentIndex>0)this.getVisibleItems().eq(currentIndex-1).focus();return void e.preventDefault();case this.keys.down:if(currentIndex.\n\n/**\n * Implement an accessible aria tree widget, from a nested unordered list.\n * Based on http://oaa-accessibility.org/example/41/.\n *\n * @module core/tree\n * @copyright 2015 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery'], function($) {\n // Private variables and functions.\n var SELECTORS = {\n ITEM: '[role=treeitem]',\n GROUP: '[role=treeitem]:has([role=group]), [role=treeitem][aria-owns], [role=treeitem][data-requires-ajax=true]',\n CLOSED_GROUP: '[role=treeitem]:has([role=group])[aria-expanded=false], [role=treeitem][aria-owns][aria-expanded=false], ' +\n '[role=treeitem][data-requires-ajax=true][aria-expanded=false]',\n FIRST_ITEM: '[role=treeitem]:first',\n VISIBLE_ITEM: '[role=treeitem]:visible',\n UNLOADED_AJAX_ITEM: '[role=treeitem][data-requires-ajax=true][data-loaded=false][aria-expanded=true]'\n };\n\n /**\n * Constructor.\n *\n * @param {String} selector\n * @param {function} selectCallback Called when the active node is changed.\n */\n var Tree = function(selector, selectCallback) {\n this.treeRoot = $(selector);\n\n this.treeRoot.data('activeItem', null);\n this.selectCallback = selectCallback;\n this.keys = {\n tab: 9,\n enter: 13,\n space: 32,\n pageup: 33,\n pagedown: 34,\n end: 35,\n home: 36,\n left: 37,\n up: 38,\n right: 39,\n down: 40,\n asterisk: 106\n };\n\n // Apply the standard default initialisation for all nodes, starting with the tree root.\n this.initialiseNodes(this.treeRoot);\n // Make the first item the active item for the tree so that it is added to the tab order.\n this.setActiveItem(this.treeRoot.find(SELECTORS.FIRST_ITEM));\n // Create the cache of the visible items.\n this.refreshVisibleItemsCache();\n // Create the event handlers for the tree.\n this.bindEventHandlers();\n };\n\n Tree.prototype.registerEnterCallback = function(callback) {\n this.enterCallback = callback;\n };\n\n /**\n * Find all visible tree items and save a cache of them on the tree object.\n *\n * @method refreshVisibleItemsCache\n */\n Tree.prototype.refreshVisibleItemsCache = function() {\n this.treeRoot.data('visibleItems', this.treeRoot.find(SELECTORS.VISIBLE_ITEM));\n };\n\n /**\n * Get all visible tree items.\n *\n * @method getVisibleItems\n * @return {Object} visible items\n */\n Tree.prototype.getVisibleItems = function() {\n return this.treeRoot.data('visibleItems');\n };\n\n /**\n * Mark the given item as active within the tree and fire the callback for when the active item is set.\n *\n * @method setActiveItem\n * @param {object} item jquery object representing an item on the tree.\n */\n Tree.prototype.setActiveItem = function(item) {\n var currentActive = this.treeRoot.data('activeItem');\n if (item === currentActive) {\n return;\n }\n\n // Remove previous active from tab order.\n if (currentActive) {\n currentActive.attr('tabindex', '-1');\n currentActive.attr('aria-selected', 'false');\n }\n item.attr('tabindex', '0');\n item.attr('aria-selected', 'true');\n\n // Set the new active item.\n this.treeRoot.data('activeItem', item);\n\n if (typeof this.selectCallback === 'function') {\n this.selectCallback(item);\n }\n };\n\n /**\n * Determines if the given item is a group item (contains child tree items) in the tree.\n *\n * @method isGroupItem\n * @param {object} item jquery object representing an item on the tree.\n * @returns {bool}\n */\n Tree.prototype.isGroupItem = function(item) {\n return item.is(SELECTORS.GROUP);\n };\n\n /**\n * Determines if the given item is a group item (contains child tree items) in the tree.\n *\n * @method isGroupItem\n * @param {object} item jquery object representing an item on the tree.\n * @returns {bool}\n */\n Tree.prototype.getGroupFromItem = function(item) {\n var ariaowns = this.treeRoot.find('#' + item.attr('aria-owns'));\n var plain = item.children('[role=group]');\n if (ariaowns.length > plain.length) {\n return ariaowns;\n } else {\n return plain;\n }\n };\n\n /**\n * Determines if the given group item (contains child tree items) is collapsed.\n *\n * @method isGroupCollapsed\n * @param {object} item jquery object representing a group item on the tree.\n * @returns {bool}\n */\n Tree.prototype.isGroupCollapsed = function(item) {\n return item.attr('aria-expanded') === 'false';\n };\n\n /**\n * Determines if the given group item (contains child tree items) can be collapsed.\n *\n * @method isGroupCollapsible\n * @param {object} item jquery object representing a group item on the tree.\n * @returns {bool}\n */\n Tree.prototype.isGroupCollapsible = function(item) {\n return item.attr('data-collapsible') !== 'false';\n };\n\n /**\n * Performs the tree initialisation for all child items from the given node,\n * such as removing everything from the tab order and setting aria selected\n * on items.\n *\n * @method initialiseNodes\n * @param {object} node jquery object representing a node.\n */\n Tree.prototype.initialiseNodes = function(node) {\n this.removeAllFromTabOrder(node);\n this.setAriaSelectedFalseOnItems(node);\n\n // Get all ajax nodes that have been rendered as expanded but haven't loaded the child items yet.\n var thisTree = this;\n node.find(SELECTORS.UNLOADED_AJAX_ITEM).each(function() {\n var unloadedNode = $(this);\n // Collapse and then expand to trigger the ajax loading.\n thisTree.collapseGroup(unloadedNode);\n thisTree.expandGroup(unloadedNode);\n });\n };\n\n /**\n * Removes all child DOM elements of the given node from the tab order.\n *\n * @method removeAllFromTabOrder\n * @param {object} node jquery object representing a node.\n */\n Tree.prototype.removeAllFromTabOrder = function(node) {\n node.find('*').attr('tabindex', '-1');\n this.getGroupFromItem($(node)).find('*').attr('tabindex', '-1');\n };\n\n /**\n * Find all child tree items from the given node and set the aria selected attribute to false.\n *\n * @method setAriaSelectedFalseOnItems\n * @param {object} node jquery object representing a node.\n */\n Tree.prototype.setAriaSelectedFalseOnItems = function(node) {\n node.find(SELECTORS.ITEM).attr('aria-selected', 'false');\n };\n\n /**\n * Expand all group nodes within the tree.\n *\n * @method expandAllGroups\n */\n Tree.prototype.expandAllGroups = function() {\n var thisTree = this;\n\n this.treeRoot.find(SELECTORS.CLOSED_GROUP).each(function() {\n var groupNode = $(this);\n\n thisTree.expandGroup($(this)).done(function() {\n thisTree.expandAllChildGroups(groupNode);\n });\n });\n };\n\n /**\n * Find all child group nodes from the given node and expand them.\n *\n * @method expandAllChildGroups\n * @param {Object} item is the jquery id of the group.\n */\n Tree.prototype.expandAllChildGroups = function(item) {\n var thisTree = this;\n\n this.getGroupFromItem(item).find(SELECTORS.CLOSED_GROUP).each(function() {\n var groupNode = $(this);\n\n thisTree.expandGroup($(this)).done(function() {\n thisTree.expandAllChildGroups(groupNode);\n });\n });\n };\n\n /**\n * Expand a collapsed group.\n *\n * Handles expanding nodes that are ajax loaded (marked with a data-requires-ajax attribute).\n *\n * @method expandGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n * @return {Object} a promise that is resolved when the group has been expanded.\n */\n Tree.prototype.expandGroup = function(item) {\n var promise = $.Deferred();\n // Ignore nodes that are explicitly maked as not expandable or are already expanded.\n if (item.attr('data-expandable') !== 'false' && this.isGroupCollapsed(item)) {\n // If this node requires ajax load and we haven't already loaded it.\n if (item.attr('data-requires-ajax') === 'true' && item.attr('data-loaded') !== 'true') {\n item.attr('data-loaded', false);\n // Get the closes ajax loading module specificed in the tree.\n var moduleName = item.closest('[data-ajax-loader]').attr('data-ajax-loader');\n var thisTree = this;\n // Flag this node as loading.\n const p = item.find('p');\n p.addClass('loading');\n // Require the ajax module (must be AMD) and try to load the items.\n require([moduleName], function(loader) {\n // All ajax module must implement a \"load\" method.\n loader.load(item).done(function() {\n item.attr('data-loaded', true);\n\n // Set defaults on the newly constructed part of the tree.\n thisTree.initialiseNodes(item);\n thisTree.finishExpandingGroup(item);\n // Make sure no child elements of the item we just loaded are tabbable.\n p.removeClass('loading');\n promise.resolve();\n });\n });\n } else {\n this.finishExpandingGroup(item);\n promise.resolve();\n }\n } else {\n promise.resolve();\n }\n return promise;\n };\n\n /**\n * Perform the necessary DOM changes to display a group item.\n *\n * @method finishExpandingGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n */\n Tree.prototype.finishExpandingGroup = function(item) {\n // Expand the group.\n var group = this.getGroupFromItem(item);\n group.removeAttr('aria-hidden');\n item.attr('aria-expanded', 'true');\n\n // Update the list of visible items.\n this.refreshVisibleItemsCache();\n };\n\n /**\n * Collapse an expanded group.\n *\n * @method collapseGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n */\n Tree.prototype.collapseGroup = function(item) {\n // If the item is not collapsible or already collapsed then do nothing.\n if (!this.isGroupCollapsible(item) || this.isGroupCollapsed(item)) {\n return;\n }\n\n // Collapse the group.\n var group = this.getGroupFromItem(item);\n group.attr('aria-hidden', 'true');\n item.attr('aria-expanded', 'false');\n\n // Update the list of visible items.\n this.refreshVisibleItemsCache();\n };\n\n /**\n * Expand or collapse a group.\n *\n * @method toggleGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n */\n Tree.prototype.toggleGroup = function(item) {\n if (item.attr('aria-expanded') === 'true') {\n this.collapseGroup(item);\n } else {\n this.expandGroup(item);\n }\n };\n\n /**\n * Handle a key down event - ie navigate the tree.\n *\n * @method handleKeyDown\n * @param {Event} e The event.\n */\n // This function should be simplified. In the meantime..\n // eslint-disable-next-line complexity\n Tree.prototype.handleKeyDown = function(e) {\n var item = $(e.target);\n var currentIndex = this.getVisibleItems()?.index(item);\n\n if ((e.altKey || e.ctrlKey || e.metaKey) || (e.shiftKey && e.keyCode != this.keys.tab)) {\n // Do nothing.\n return;\n }\n\n switch (e.keyCode) {\n case this.keys.home: {\n // Jump to first item in tree.\n this.getVisibleItems().first().focus();\n\n e.preventDefault();\n return;\n }\n case this.keys.end: {\n // Jump to last visible item.\n this.getVisibleItems().last().focus();\n\n e.preventDefault();\n return;\n }\n case this.keys.enter: {\n var links = item.children('a').length ? item.children('a') : item.children().not(SELECTORS.GROUP).find('a');\n if (links.length) {\n if (links.first().data('overrides-tree-activation-key-handler')) {\n // If the link overrides handling of activation keys, let it do so.\n links.first().triggerHandler(e);\n } else if (typeof this.enterCallback === 'function') {\n // Use callback if there is one.\n this.enterCallback(item);\n } else {\n window.location.href = links.first().attr('href');\n }\n } else if (this.isGroupItem(item)) {\n this.toggleGroup(item, true);\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.space: {\n if (this.isGroupItem(item)) {\n this.toggleGroup(item, true);\n } else if (item.children('a').length) {\n var firstLink = item.children('a').first();\n\n if (firstLink.data('overrides-tree-activation-key-handler')) {\n firstLink.triggerHandler(e);\n }\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.left: {\n var focusParent = function(tree) {\n // Get the immediate visible parent group item that contains this element.\n tree.getVisibleItems().filter(function() {\n return tree.getGroupFromItem($(this)).has(item).length;\n }).focus();\n };\n\n // If this is a group item then collapse it and focus the parent group\n // in accordance with the aria spec.\n if (this.isGroupItem(item)) {\n if (this.isGroupCollapsed(item)) {\n focusParent(this);\n } else {\n this.collapseGroup(item);\n }\n } else {\n focusParent(this);\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.right: {\n // If this is a group item then expand it and focus the first child item\n // in accordance with the aria spec.\n if (this.isGroupItem(item)) {\n if (this.isGroupCollapsed(item)) {\n this.expandGroup(item);\n } else {\n // Move to the first item in the child group.\n this.getGroupFromItem(item).find(SELECTORS.ITEM).first().focus();\n }\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.up: {\n\n if (currentIndex > 0) {\n var prev = this.getVisibleItems().eq(currentIndex - 1);\n\n prev.focus();\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.down: {\n\n if (currentIndex < this.getVisibleItems().length - 1) {\n var next = this.getVisibleItems().eq(currentIndex + 1);\n\n next.focus();\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.asterisk: {\n // Expand all groups.\n this.expandAllGroups();\n e.preventDefault();\n return;\n }\n }\n };\n\n /**\n * Handle an item click.\n *\n * @param {Event} event the click event\n * @param {jQuery} item the item clicked\n */\n Tree.prototype.handleItemClick = function(event, item) {\n // Update the active item.\n item.focus();\n\n // If the item is a group node.\n if (this.isGroupItem(item)) {\n this.toggleGroup(item);\n }\n };\n\n /**\n * Handle a click (select).\n *\n * @method handleClick\n * @param {Event} event The event.\n */\n Tree.prototype.handleClick = function(event) {\n if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) {\n // Do nothing.\n return;\n }\n\n // Get the closest tree item from the event target.\n var item = $(event.target).closest('[role=\"treeitem\"]');\n if (!item.is(event.currentTarget)) {\n return;\n }\n\n this.handleItemClick(event, item);\n };\n\n /**\n * Handle a focus event.\n *\n * @method handleFocus\n * @param {Event} e The event.\n */\n Tree.prototype.handleFocus = function(e) {\n this.setActiveItem($(e.target));\n };\n\n /**\n * Bind the event listeners we require.\n *\n * @method bindEventHandlers\n */\n Tree.prototype.bindEventHandlers = function() {\n // Bind event handlers to the tree items. Use event delegates to allow\n // for dynamically loaded parts of the tree.\n this.treeRoot.on({\n click: this.handleClick.bind(this),\n keydown: this.handleKeyDown.bind(this),\n focus: this.handleFocus.bind(this),\n }, SELECTORS.ITEM);\n };\n\n return /** @alias module:core/tree */ Tree;\n});\n"],"names":["define","$","SELECTORS","Tree","selector","selectCallback","treeRoot","data","keys","tab","enter","space","pageup","pagedown","end","home","left","up","right","down","asterisk","initialiseNodes","this","setActiveItem","find","refreshVisibleItemsCache","bindEventHandlers","prototype","registerEnterCallback","callback","enterCallback","getVisibleItems","item","currentActive","attr","isGroupItem","is","getGroupFromItem","ariaowns","plain","children","length","isGroupCollapsed","isGroupCollapsible","node","removeAllFromTabOrder","setAriaSelectedFalseOnItems","thisTree","each","unloadedNode","collapseGroup","expandGroup","expandAllGroups","groupNode","done","expandAllChildGroups","promise","Deferred","moduleName","closest","p","addClass","require","loader","load","finishExpandingGroup","removeClass","resolve","removeAttr","toggleGroup","handleKeyDown","e","target","currentIndex","_this$getVisibleItems","index","altKey","ctrlKey","metaKey","shiftKey","keyCode","first","focus","preventDefault","last","links","not","triggerHandler","window","location","href","firstLink","focusParent","tree","filter","has","eq","handleItemClick","event","handleClick","currentTarget","handleFocus","on","click","bind","keydown"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"tree.min.js","sources":["../src/tree.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Implement an accessible aria tree widget, from a nested unordered list.\n * Based on http://oaa-accessibility.org/example/41/.\n *\n * @module core/tree\n * @copyright 2015 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery'], function($) {\n // Private variables and functions.\n var SELECTORS = {\n ITEM: '[role=treeitem]',\n GROUP: '[role=treeitem]:has([role=group]), [role=treeitem][aria-owns], [role=treeitem][data-requires-ajax=true]',\n CLOSED_GROUP: '[role=treeitem]:has([role=group])[aria-expanded=false], [role=treeitem][aria-owns][aria-expanded=false], ' +\n '[role=treeitem][data-requires-ajax=true][aria-expanded=false]',\n FIRST_ITEM: '[role=treeitem]:first',\n VISIBLE_ITEM: '[role=treeitem]:visible',\n UNLOADED_AJAX_ITEM: '[role=treeitem][data-requires-ajax=true][data-loaded=false][aria-expanded=true]'\n };\n\n /**\n * Constructor.\n *\n * @param {String} selector\n * @param {function} selectCallback Called when the active node is changed.\n */\n var Tree = function(selector, selectCallback) {\n this.treeRoot = $(selector);\n\n this.treeRoot.data('activeItem', null);\n this.selectCallback = selectCallback;\n this.keys = {\n tab: 9,\n enter: 13,\n space: 32,\n pageup: 33,\n pagedown: 34,\n end: 35,\n home: 36,\n left: 37,\n up: 38,\n right: 39,\n down: 40,\n asterisk: 106\n };\n\n // Apply the standard default initialisation for all nodes, starting with the tree root.\n this.initialiseNodes(this.treeRoot);\n // Make the first item the active item for the tree so that it is added to the tab order.\n this.setActiveItem(this.treeRoot.find(SELECTORS.FIRST_ITEM));\n // Create the cache of the visible items.\n this.refreshVisibleItemsCache();\n // Create the event handlers for the tree.\n this.bindEventHandlers();\n };\n\n Tree.prototype.registerEnterCallback = function(callback) {\n this.enterCallback = callback;\n };\n\n /**\n * Find all visible tree items and save a cache of them on the tree object.\n *\n * @method refreshVisibleItemsCache\n */\n Tree.prototype.refreshVisibleItemsCache = function() {\n this.treeRoot.data('visibleItems', this.treeRoot.find(SELECTORS.VISIBLE_ITEM));\n };\n\n /**\n * Get all visible tree items.\n *\n * @method getVisibleItems\n * @return {Object} visible items\n */\n Tree.prototype.getVisibleItems = function() {\n return this.treeRoot.data('visibleItems');\n };\n\n /**\n * Mark the given item as active within the tree and fire the callback for when the active item is set.\n *\n * @method setActiveItem\n * @param {object} item jquery object representing an item on the tree.\n */\n Tree.prototype.setActiveItem = function(item) {\n var currentActive = this.treeRoot.data('activeItem');\n if (item === currentActive) {\n return;\n }\n\n // Remove previous active from tab order.\n if (currentActive) {\n currentActive.attr('tabindex', '-1');\n currentActive.attr('aria-selected', 'false');\n }\n item.attr('tabindex', '0');\n item.attr('aria-selected', 'true');\n\n // Set the new active item.\n this.treeRoot.data('activeItem', item);\n\n if (typeof this.selectCallback === 'function') {\n this.selectCallback(item);\n }\n };\n\n /**\n * Determines if the given item is a group item (contains child tree items) in the tree.\n *\n * @method isGroupItem\n * @param {object} item jquery object representing an item on the tree.\n * @returns {bool}\n */\n Tree.prototype.isGroupItem = function(item) {\n return item.is(SELECTORS.GROUP);\n };\n\n /**\n * Determines if the given item is a group item (contains child tree items) in the tree.\n *\n * @method isGroupItem\n * @param {object} item jquery object representing an item on the tree.\n * @returns {bool}\n */\n Tree.prototype.getGroupFromItem = function(item) {\n var ariaowns = this.treeRoot.find('#' + item.attr('aria-owns'));\n var plain = item.children('[role=group]');\n if (ariaowns.length > plain.length) {\n return ariaowns;\n } else {\n return plain;\n }\n };\n\n /**\n * Determines if the given group item (contains child tree items) is collapsed.\n *\n * @method isGroupCollapsed\n * @param {object} item jquery object representing a group item on the tree.\n * @returns {bool}\n */\n Tree.prototype.isGroupCollapsed = function(item) {\n return item.attr('aria-expanded') === 'false';\n };\n\n /**\n * Determines if the given group item (contains child tree items) can be collapsed.\n *\n * @method isGroupCollapsible\n * @param {object} item jquery object representing a group item on the tree.\n * @returns {bool}\n */\n Tree.prototype.isGroupCollapsible = function(item) {\n return item.attr('data-collapsible') !== 'false';\n };\n\n /**\n * Performs the tree initialisation for all child items from the given node,\n * such as removing everything from the tab order and setting aria selected\n * on items.\n *\n * @method initialiseNodes\n * @param {object} node jquery object representing a node.\n */\n Tree.prototype.initialiseNodes = function(node) {\n this.removeAllFromTabOrder(node);\n this.setAriaSelectedFalseOnItems(node);\n\n // Get all ajax nodes that have been rendered as expanded but haven't loaded the child items yet.\n var thisTree = this;\n node.find(SELECTORS.UNLOADED_AJAX_ITEM).each(function() {\n var unloadedNode = $(this);\n // Collapse and then expand to trigger the ajax loading.\n thisTree.collapseGroup(unloadedNode);\n thisTree.expandGroup(unloadedNode);\n });\n };\n\n /**\n * Removes all child DOM elements of the given node from the tab order.\n *\n * @method removeAllFromTabOrder\n * @param {object} node jquery object representing a node.\n */\n Tree.prototype.removeAllFromTabOrder = function(node) {\n node.find('*').attr('tabindex', '-1');\n this.getGroupFromItem($(node)).find('*').attr('tabindex', '-1');\n };\n\n /**\n * Find all child tree items from the given node and set the aria selected attribute to false.\n *\n * @method setAriaSelectedFalseOnItems\n * @param {object} node jquery object representing a node.\n */\n Tree.prototype.setAriaSelectedFalseOnItems = function(node) {\n node.find(SELECTORS.ITEM).attr('aria-selected', 'false');\n };\n\n /**\n * Expand all group nodes within the tree.\n *\n * @method expandAllGroups\n */\n Tree.prototype.expandAllGroups = function() {\n var thisTree = this;\n\n this.treeRoot.find(SELECTORS.CLOSED_GROUP).each(function() {\n var groupNode = $(this);\n\n thisTree.expandGroup($(this)).done(function() {\n thisTree.expandAllChildGroups(groupNode);\n });\n });\n };\n\n /**\n * Find all child group nodes from the given node and expand them.\n *\n * @method expandAllChildGroups\n * @param {Object} item is the jquery id of the group.\n */\n Tree.prototype.expandAllChildGroups = function(item) {\n var thisTree = this;\n\n this.getGroupFromItem(item).find(SELECTORS.CLOSED_GROUP).each(function() {\n var groupNode = $(this);\n\n thisTree.expandGroup($(this)).done(function() {\n thisTree.expandAllChildGroups(groupNode);\n });\n });\n };\n\n /**\n * Expand a collapsed group.\n *\n * Handles expanding nodes that are ajax loaded (marked with a data-requires-ajax attribute).\n *\n * @method expandGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n * @return {Object} a promise that is resolved when the group has been expanded.\n */\n Tree.prototype.expandGroup = function(item) {\n var promise = $.Deferred();\n // Ignore nodes that are explicitly maked as not expandable or are already expanded.\n if (item.attr('data-expandable') !== 'false' && this.isGroupCollapsed(item)) {\n // If this node requires ajax load and we haven't already loaded it.\n if (item.attr('data-requires-ajax') === 'true' && item.attr('data-loaded') !== 'true') {\n item.attr('data-loaded', false);\n // Get the closes ajax loading module specificed in the tree.\n var moduleName = item.closest('[data-ajax-loader]').attr('data-ajax-loader');\n var thisTree = this;\n // Flag this node as loading.\n const p = item.find('p');\n p.addClass('loading');\n // Require the ajax module (must be AMD) and try to load the items.\n require([moduleName], function(loader) {\n // All ajax module must implement a \"load\" method.\n loader.load(item).done(function() {\n item.attr('data-loaded', true);\n\n // Set defaults on the newly constructed part of the tree.\n thisTree.initialiseNodes(item);\n thisTree.finishExpandingGroup(item);\n // Make sure no child elements of the item we just loaded are tabbable.\n p.removeClass('loading');\n promise.resolve();\n });\n });\n } else {\n this.finishExpandingGroup(item);\n promise.resolve();\n }\n } else {\n promise.resolve();\n }\n return promise;\n };\n\n /**\n * Perform the necessary DOM changes to display a group item.\n *\n * @method finishExpandingGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n */\n Tree.prototype.finishExpandingGroup = function(item) {\n // Expand the group.\n var group = this.getGroupFromItem(item);\n group.removeAttr('aria-hidden');\n item.attr('aria-expanded', 'true');\n\n // Update the list of visible items.\n this.refreshVisibleItemsCache();\n };\n\n /**\n * Collapse an expanded group.\n *\n * @method collapseGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n */\n Tree.prototype.collapseGroup = function(item) {\n // If the item is not collapsible or already collapsed then do nothing.\n if (!this.isGroupCollapsible(item) || this.isGroupCollapsed(item)) {\n return;\n }\n\n // Collapse the group.\n var group = this.getGroupFromItem(item);\n group.attr('aria-hidden', 'true');\n item.attr('aria-expanded', 'false');\n\n // Update the list of visible items.\n this.refreshVisibleItemsCache();\n };\n\n /**\n * Expand or collapse a group.\n *\n * @method toggleGroup\n * @param {Object} item is the jquery id of the parent item of the group.\n */\n Tree.prototype.toggleGroup = function(item) {\n if (item.attr('aria-expanded') === 'true') {\n this.collapseGroup(item);\n } else {\n this.expandGroup(item);\n }\n };\n\n /**\n * Handle a key down event - ie navigate the tree.\n *\n * @method handleKeyDown\n * @param {Event} e The event.\n */\n // This function should be simplified. In the meantime..\n // eslint-disable-next-line complexity\n Tree.prototype.handleKeyDown = function(e) {\n var item = $(e.target);\n var currentIndex = this.getVisibleItems()?.index(item);\n\n if ((e.altKey || e.ctrlKey || e.metaKey) || (e.shiftKey && e.keyCode != this.keys.tab)) {\n // Do nothing.\n return;\n }\n\n // Detect RTL mode and swap left/right arrow keys accordingly.\n const rtl = window.right_to_left();\n const collapseKey = rtl ? this.keys.right : this.keys.left;\n const expandKey = rtl ? this.keys.left : this.keys.right;\n\n switch (e.keyCode) {\n case this.keys.home: {\n // Jump to first item in tree.\n this.getVisibleItems().first().focus();\n\n e.preventDefault();\n return;\n }\n case this.keys.end: {\n // Jump to last visible item.\n this.getVisibleItems().last().focus();\n\n e.preventDefault();\n return;\n }\n case this.keys.enter: {\n var links = item.children('a').length ? item.children('a') : item.children().not(SELECTORS.GROUP).find('a');\n if (links.length) {\n if (links.first().data('overrides-tree-activation-key-handler')) {\n // If the link overrides handling of activation keys, let it do so.\n links.first().triggerHandler(e);\n } else if (typeof this.enterCallback === 'function') {\n // Use callback if there is one.\n this.enterCallback(item);\n } else {\n window.location.href = links.first().attr('href');\n }\n } else if (this.isGroupItem(item)) {\n this.toggleGroup(item, true);\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.space: {\n if (this.isGroupItem(item)) {\n this.toggleGroup(item, true);\n } else if (item.children('a').length) {\n var firstLink = item.children('a').first();\n\n if (firstLink.data('overrides-tree-activation-key-handler')) {\n firstLink.triggerHandler(e);\n }\n }\n\n e.preventDefault();\n return;\n }\n case collapseKey: {\n var focusParent = function(tree) {\n // Get the immediate visible parent group item that contains this element.\n tree.getVisibleItems().filter(function() {\n return tree.getGroupFromItem($(this)).has(item).length;\n }).focus();\n };\n\n // If this is a group item then collapse it and focus the parent group\n // in accordance with the aria spec.\n if (this.isGroupItem(item)) {\n if (this.isGroupCollapsed(item)) {\n focusParent(this);\n } else {\n this.collapseGroup(item);\n }\n } else {\n focusParent(this);\n }\n\n e.preventDefault();\n return;\n }\n case expandKey: {\n // If this is a group item then expand it and focus the first child item\n // in accordance with the aria spec.\n if (this.isGroupItem(item)) {\n if (this.isGroupCollapsed(item)) {\n this.expandGroup(item);\n } else {\n // Move to the first item in the child group.\n this.getGroupFromItem(item).find(SELECTORS.ITEM).first().focus();\n }\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.up: {\n\n if (currentIndex > 0) {\n var prev = this.getVisibleItems().eq(currentIndex - 1);\n\n prev.focus();\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.down: {\n\n if (currentIndex < this.getVisibleItems().length - 1) {\n var next = this.getVisibleItems().eq(currentIndex + 1);\n\n next.focus();\n }\n\n e.preventDefault();\n return;\n }\n case this.keys.asterisk: {\n // Expand all groups.\n this.expandAllGroups();\n e.preventDefault();\n return;\n }\n }\n };\n\n /**\n * Handle an item click.\n *\n * @param {Event} event the click event\n * @param {jQuery} item the item clicked\n */\n Tree.prototype.handleItemClick = function(event, item) {\n // Update the active item.\n item.focus();\n\n // If the item is a group node.\n if (this.isGroupItem(item)) {\n this.toggleGroup(item);\n }\n };\n\n /**\n * Handle a click (select).\n *\n * @method handleClick\n * @param {Event} event The event.\n */\n Tree.prototype.handleClick = function(event) {\n if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) {\n // Do nothing.\n return;\n }\n\n // Get the closest tree item from the event target.\n var item = $(event.target).closest('[role=\"treeitem\"]');\n if (!item.is(event.currentTarget)) {\n return;\n }\n\n this.handleItemClick(event, item);\n };\n\n /**\n * Handle a focus event.\n *\n * @method handleFocus\n * @param {Event} e The event.\n */\n Tree.prototype.handleFocus = function(e) {\n this.setActiveItem($(e.target));\n };\n\n /**\n * Bind the event listeners we require.\n *\n * @method bindEventHandlers\n */\n Tree.prototype.bindEventHandlers = function() {\n // Bind event handlers to the tree items. Use event delegates to allow\n // for dynamically loaded parts of the tree.\n this.treeRoot.on({\n click: this.handleClick.bind(this),\n keydown: this.handleKeyDown.bind(this),\n focus: this.handleFocus.bind(this),\n }, SELECTORS.ITEM);\n };\n\n return /** @alias module:core/tree */ Tree;\n});\n"],"names":["define","$","SELECTORS","Tree","selector","selectCallback","treeRoot","data","keys","tab","enter","space","pageup","pagedown","end","home","left","up","right","down","asterisk","initialiseNodes","this","setActiveItem","find","refreshVisibleItemsCache","bindEventHandlers","prototype","registerEnterCallback","callback","enterCallback","getVisibleItems","item","currentActive","attr","isGroupItem","is","getGroupFromItem","ariaowns","plain","children","length","isGroupCollapsed","isGroupCollapsible","node","removeAllFromTabOrder","setAriaSelectedFalseOnItems","thisTree","each","unloadedNode","collapseGroup","expandGroup","expandAllGroups","groupNode","done","expandAllChildGroups","promise","Deferred","moduleName","closest","p","addClass","require","loader","load","finishExpandingGroup","removeClass","resolve","removeAttr","toggleGroup","handleKeyDown","e","target","currentIndex","_this$getVisibleItems","index","altKey","ctrlKey","metaKey","shiftKey","keyCode","rtl","window","right_to_left","collapseKey","expandKey","first","focus","preventDefault","last","links","not","triggerHandler","location","href","firstLink","focusParent","tree","filter","has","eq","handleItemClick","event","handleClick","currentTarget","handleFocus","on","click","bind","keydown"],"mappings":"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"} \ No newline at end of file diff --git a/lib/amd/src/tree.js b/lib/amd/src/tree.js index 8b7b1261cc3ba..8190f56ab54b4 100644 --- a/lib/amd/src/tree.js +++ b/lib/amd/src/tree.js @@ -362,6 +362,11 @@ define(['jquery'], function($) { return; } + // Detect RTL mode and swap left/right arrow keys accordingly. + const rtl = window.right_to_left(); + const collapseKey = rtl ? this.keys.right : this.keys.left; + const expandKey = rtl ? this.keys.left : this.keys.right; + switch (e.keyCode) { case this.keys.home: { // Jump to first item in tree. @@ -410,7 +415,7 @@ define(['jquery'], function($) { e.preventDefault(); return; } - case this.keys.left: { + case collapseKey: { var focusParent = function(tree) { // Get the immediate visible parent group item that contains this element. tree.getVisibleItems().filter(function() { @@ -433,7 +438,7 @@ define(['jquery'], function($) { e.preventDefault(); return; } - case this.keys.right: { + case expandKey: { // If this is a group item then expand it and focus the first child item // in accordance with the aria spec. if (this.isGroupItem(item)) { From 3d812fe6c3fd25a9f23e23cef05c4490de01c578 Mon Sep 17 00:00:00 2001 From: Muhammad Arnaldo Date: Tue, 9 Dec 2025 15:23:21 +0700 Subject: [PATCH 06/79] MDL-86807 core_courseformat: RTL chevron in move activity modal --- course/format/templates/local/content/movecmsection.mustache | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/course/format/templates/local/content/movecmsection.mustache b/course/format/templates/local/content/movecmsection.mustache index f801ecda1345c..224c072423c47 100644 --- a/course/format/templates/local/content/movecmsection.mustache +++ b/course/format/templates/local/content/movecmsection.mustache @@ -73,7 +73,8 @@ class="collapse-list-link icons-collapse-expand collapsed" > - {{#pix}} t/collapsedchevron, core {{/pix}} + {{#pix}} t/collapsedchevron, core {{/pix}} + {{#pix}} t/collapsedchevron_rtl, core {{/pix}} {{#str}} expand, core {{/str}} From 0e0bdbdd76ab5ca6b94a440e8fb4e43160557cf9 Mon Sep 17 00:00:00 2001 From: Angelia Dela Cruz Date: Wed, 5 Mar 2025 15:37:39 +0800 Subject: [PATCH 07/79] MDL-84736 gradingform : Behat for editing guide with graded submissions --- .../behat/edit_marking_guide_detail.feature | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature diff --git a/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature b/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature new file mode 100644 index 0000000000000..375d1c948f91b --- /dev/null +++ b/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature @@ -0,0 +1,73 @@ +@gradingform @gradingform_guide +Feature: Marking guide details can be updated for activity with graded submissions + In order to update marking guide details + As a teacher + I need to be able to grade a submission + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | One | teacher1@example.com | + | student1 | Student | One | student1@example.com | + And the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | course | name | advancedgradingmethod_submissions | assignsubmission_onlinetext_enabled | assignsubmission_file_enabled | assignfeedback_comments_commentinline | assignfeedback_comments_enabled | + | assign | C1 | Assign 1 | guide | 1 | 0 | 1 | 1 | + And the following "mod_assign > submissions" exist: + | assign | user | onlinetext | + | Assign 1 | student1 | Assign 1 submission | + And I am on the "Course 1" course page logged in as teacher1 + And I change window size to "large" + And I go to "Assign 1" advanced grading definition page + And I set the following fields to these values: + | Name | Assign 1 marking guide | + | Description | Marking guide description | + And I define the following marking guide: + | Criterion name | Description for students | Description for markers | Maximum score | + | Grade criterion A | Grade 1 description for students | Grade 1 description for markers | 70 | + | Grade criterion B | Grade 2 description for students | Grade 2 description for markers | 30 | + And I define the following frequently used comments: + | Comment 1 | + | Comment 2 | + | Comment 3 | + And I press "Save marking guide and make it ready" + + @javascript + Scenario: Teacher can update marking guide details for activity with graded submissions + Given I navigate to "Assignment" in current page administration + And I go to "Student One" "Assign 1" activity advanced grading page + And I grade by filling the marking guide with: + | Grade criterion A | 25 | Needs improvement | + | Grade criterion B | 20 | Excellent! | + # Inserting frequently used comment. + And I click on "Insert frequently used comment" "button" in the "Grade criterion A" "table_row" + And I wait "1" seconds + And I press "Comment 1" + And I wait "1" seconds + And I click on "Insert frequently used comment" "button" in the "Grade criterion B" "table_row" + And I wait "1" seconds + And I press "Comment 2" + And I wait "1" seconds + And I press "Save changes" + When I am on "Course 1" course homepage + And I go to "Assign 1" advanced grading definition page +# And I set the field "Grade criterion A" to "Criteria 1" +# And I set the field "guide[criteria][477000][shortname]" to "Criteria 1" +# And I set the following fields to these values: +# | Criterion name | Description for students | Description for markers | Maximum score | +# | Criteria 1 | Criteria 1 student | Criteria 1 marker | 40 | +# | Criteria 2 | Criteria 2 student | Criteria 2 marker | 70 | + And I click on "Move down" "button" in the "Comment 1" "table_row" + And I click on "Move up" "button" in the "Comment 3" "table_row" + And I press "Save" + # Remove after testing + Then the following should exist in the "guide-comments" table: + | Comment 2 | + | Comment 3 | + | Comment 1 | From 39026940b3eed0aa8f4af0e2d1502d2b67f0e9d3 Mon Sep 17 00:00:00 2001 From: Simey Lameze Date: Wed, 19 Mar 2025 15:11:25 +0800 Subject: [PATCH 08/79] MDL-84736 behat: add custom step to edit a criterion This commit also improves the existing tests and add a new scenario to verify the 'Do not mark for regrade' behavior. --- .../tests/behat/behat_gradingform_guide.php | 49 +++++++++++ .../behat/edit_marking_guide_detail.feature | 83 +++++++++++-------- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/grade/grading/form/guide/tests/behat/behat_gradingform_guide.php b/grade/grading/form/guide/tests/behat/behat_gradingform_guide.php index 7c90524c67f4f..c122e9cd3e03e 100644 --- a/grade/grading/form/guide/tests/behat/behat_gradingform_guide.php +++ b/grade/grading/form/guide/tests/behat/behat_gradingform_guide.php @@ -97,6 +97,55 @@ public function i_define_the_following_marking_guide(TableNode $guide) { } } + /** + * Edits an existing marking guide with the provided data. + * + * This method edits the marking guide of the marking guide definition + * form; the provided TableNode should contain one row for + * each field and each cell of the row should contain: + * | Field name | New value | + * | shortname | Updated Grade criterion | + * + * @When /^I edit the marking guide criterion "([^"]*)" with the following values:$/ + * @param string $criterionname + * @param TableNode $fields + */ + public function i_edit_the_marking_guide_criterion_with_the_following_values(string $criterionname, TableNode $fields) { + if ($fieldvalues = $fields->getHash()) { + $criterionid = 0; + $locator = "//tr[contains(@class, 'criterion')]//div[@class='criterionname']" + . "//span[@class='textvalue'][text()='$criterionname']/ancestor::tr"; + if ($criterionrow = $this->find('xpath', $locator)) { + $criterionid = str_replace('guide-criteria-', '', $criterionrow->getAttribute('id')); + } + + if ($criterionid) { + $criterionroot = 'guide[criteria]' . '[' . $criterionid . ']'; + + foreach ($fieldvalues as $fieldvalue) { + // Make sure the fieldvalue array has 2 elements. + if (count($fieldvalue) != 2) { + throw new ExpectationException( + 'The field definition should contain field name and new value. ' . + 'Please follow this format: | Field name | New value |', + $this->getSession() + ); + } + + $fieldname = $fieldvalue['Field name']; + $newvalue = $fieldvalue['New value']; + + $this->set_guide_field_value($criterionroot . "[$fieldname]", $newvalue); + } + } else { + throw new ExpectationException( + 'Criterion with name "' . $criterionname . '" not found.', + $this->getSession() + ); + } + } + } + /** * Defines the marking guide with the provided data, following marking guide's definition grid cells. * diff --git a/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature b/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature index 375d1c948f91b..0c7d8dfa6d83c 100644 --- a/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature +++ b/grade/grading/form/guide/tests/behat/edit_marking_guide_detail.feature @@ -1,5 +1,5 @@ @gradingform @gradingform_guide -Feature: Marking guide details can be updated for activity with graded submissions +Feature: Editing a marking guide already used for grading updates regrade state and student visible grades In order to update marking guide details As a teacher I need to be able to grade a submission @@ -17,13 +17,12 @@ Feature: Marking guide details can be updated for activity with graded submissio | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: - | activity | course | name | advancedgradingmethod_submissions | assignsubmission_onlinetext_enabled | assignsubmission_file_enabled | assignfeedback_comments_commentinline | assignfeedback_comments_enabled | - | assign | C1 | Assign 1 | guide | 1 | 0 | 1 | 1 | + | activity | course | name | advancedgradingmethod_submissions | assignsubmission_onlinetext_enabled | + | assign | C1 | Assign 1 | guide | 1 | And the following "mod_assign > submissions" exist: | assign | user | onlinetext | | Assign 1 | student1 | Assign 1 submission | And I am on the "Course 1" course page logged in as teacher1 - And I change window size to "large" And I go to "Assign 1" advanced grading definition page And I set the following fields to these values: | Name | Assign 1 marking guide | @@ -32,42 +31,56 @@ Feature: Marking guide details can be updated for activity with graded submissio | Criterion name | Description for students | Description for markers | Maximum score | | Grade criterion A | Grade 1 description for students | Grade 1 description for markers | 70 | | Grade criterion B | Grade 2 description for students | Grade 2 description for markers | 30 | - And I define the following frequently used comments: - | Comment 1 | - | Comment 2 | - | Comment 3 | And I press "Save marking guide and make it ready" - - @javascript - Scenario: Teacher can update marking guide details for activity with graded submissions - Given I navigate to "Assignment" in current page administration + And I navigate to "Assignment" in current page administration And I go to "Student One" "Assign 1" activity advanced grading page And I grade by filling the marking guide with: | Grade criterion A | 25 | Needs improvement | | Grade criterion B | 20 | Excellent! | - # Inserting frequently used comment. - And I click on "Insert frequently used comment" "button" in the "Grade criterion A" "table_row" - And I wait "1" seconds - And I press "Comment 1" - And I wait "1" seconds - And I click on "Insert frequently used comment" "button" in the "Grade criterion B" "table_row" - And I wait "1" seconds - And I press "Comment 2" - And I wait "1" seconds And I press "Save changes" - When I am on "Course 1" course homepage + And I am on "Course 1" course homepage And I go to "Assign 1" advanced grading definition page -# And I set the field "Grade criterion A" to "Criteria 1" -# And I set the field "guide[criteria][477000][shortname]" to "Criteria 1" -# And I set the following fields to these values: -# | Criterion name | Description for students | Description for markers | Maximum score | -# | Criteria 1 | Criteria 1 student | Criteria 1 marker | 40 | -# | Criteria 2 | Criteria 2 student | Criteria 2 marker | 70 | - And I click on "Move down" "button" in the "Comment 1" "table_row" - And I click on "Move up" "button" in the "Comment 3" "table_row" + And I edit the marking guide criterion "Grade criterion A" with the following values: + | Field name | New value | + | shortname | Updated Grade criterion A | + | description | Updated description for students | + | descriptionmarkers | Updated description for markers | + | maxscore | 60 | And I press "Save" - # Remove after testing - Then the following should exist in the "guide-comments" table: - | Comment 2 | - | Comment 3 | - | Comment 1 | + + @javascript + Scenario: Teacher edits a used marking guide and mark it for regrade and student sees breakdown only after teacher regrades + # Set the marking guide to be "Mark for regrade". + Given I set the field "menuguideregrade" to "Mark for regrade" + And I should see "You are about to save changes to a marking guide that has already been used for grading. Please indicate if existing grades need to be reviewed. If you set this then the marking guide will be hidden from students until their item is regraded." + And I click on "Continue" "button" + And I am on the "Assign 1" "assign activity" page logged in as student1 + # Student should not see the grade breakdown as the activity is marked for regrade. + And I should not see "Grade breakdown" + When I am on the "Assign 1" "assign activity" page logged in as teacher1 + And I go to "Student One" "Assign 1" activity advanced grading page + And I grade by filling the marking guide with: + | Updated Grade criterion A | 50 | I changed my mind | + | Grade criterion B | 30 | It's all good now | + And I press "Save changes" + And I am on the "Assign 1" "assign activity" page logged in as student1 + # Now the student should see the updated grade breakdown. + Then I should see "Grade breakdown" + And I should see the marking guide information displayed as: + | criteria | description | remark | maxscore | criteriascore | + | Updated Grade criterion A | Updated description for students | I changed my mind | 60 | 50 / 60 | + | Grade criterion B | Grade 2 description for students | It's all good now | 30 | 30 / 30 | + + @javascript + Scenario: Teacher edits a used marking guide does not mark for regrade existing student grades remain visible and unchanged + # Set the marking guide to be "Do not mark for regrade". + Given I set the field "menuguideregrade" to "Do not mark for regrade" + And I should see "You are about to save changes to a marking guide that has already been used for grading. Please indicate if existing grades need to be reviewed. If you set this then the marking guide will be hidden from students until their item is regraded." + And I click on "Continue" "button" + When I am on the "Assign 1" "assign activity" page logged in as student1 + # Student should see the updated grade breakdown as the activity was not marked for regrading. + Then I should see "Grade breakdown" + And I should see the marking guide information displayed as: + | criteria | description | remark | maxscore | criteriascore | + | Updated Grade criterion A | Updated description for students | Needs improvement | 60 | 25 / 60 | + | Grade criterion B | Grade 2 description for students | Excellent! | 30 | 20 / 30 | From 4112aa89577923c86e33de11a8827ef088b9929a Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Thu, 11 Dec 2025 00:07:58 +0000 Subject: [PATCH 09/79] Automatically generated installer lang files --- install/lang/uz/error.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install/lang/uz/error.php b/install/lang/uz/error.php index f9a7a55e570ac..547e499d4a8f2 100644 --- a/install/lang/uz/error.php +++ b/install/lang/uz/error.php @@ -43,3 +43,5 @@ $string['componentisuptodate'] = 'Komponent yangilangan holda'; $string['dmlexceptiononinstall'] = '

MaтАЩlumotlar bazasida xatolik yuz berdi [{$a->errorcode}].
{$a->debuginfo}

'; $string['downloadedfilecheckfailed'] = 'Yuklab olingan faylni tekshirish muvaffaqiyatsiz tugadi'; +$string['remotedownloaderror'] = '

Komponentni serveringizga yuklash muvaffaqiyatsiz tugadi. Iltimos, proksi sozlamalarini tekshiring; PHP cURL kengaytmasidan foydalanish tavsiya etiladi.

+

Siz {$a->url} faylini qoтАШlda yuklab, serveringizdagi "{$a->dest}" joyiga nusxalab, u yerda arxivdan chiqarishingiz kerak.

'; From 9ebe2f8676304bd17199f77fe01e9c89fecceef9 Mon Sep 17 00:00:00 2001 From: Muhammad Arnaldo Date: Wed, 10 Dec 2025 14:53:28 +0700 Subject: [PATCH 10/79] MDL-86420 enrol_database: avoid duplicate key violations --- enrol/database/db/upgrade.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/enrol/database/db/upgrade.php b/enrol/database/db/upgrade.php index 792fbe2e29a59..d5c3c7e657b8d 100644 --- a/enrol/database/db/upgrade.php +++ b/enrol/database/db/upgrade.php @@ -58,15 +58,19 @@ function xmldb_enrol_database_upgrade($oldversion) { // Migrate enrolments where possible. // First, get the user enrolments that can be migrated. + // Only select the earliest (MIN id) user_enrolments per user to avoid + // duplicate key violations when a user has multiple enrolments across + // duplicate database enrol instances. $migrateusers = $DB->get_records_sql( - "SELECT ue.id + "SELECT MIN(ue.id) AS id FROM {user_enrolments} ue WHERE ue.enrolid $insql AND NOT EXISTS ( SELECT 1 FROM {user_enrolments} ue2 WHERE ue2.userid = ue.userid - AND ue2.enrolid = :idtokeep)", + AND ue2.enrolid = :idtokeep) + GROUP BY ue.userid", array_merge($inparams, ['idtokeep' => $idtokeep]), ); From b357491d00cbb46d964e5666475f0ad04446a4ce Mon Sep 17 00:00:00 2001 From: Shamim Rezaie Date: Fri, 12 Dec 2025 14:06:51 +1100 Subject: [PATCH 11/79] weekly release 4.5.8+ --- version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.php b/version.php index c2d75c3961313..ea9a0eaeab1df 100644 --- a/version.php +++ b/version.php @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2024100708.00; // 20241007 = branching date YYYYMMDD - do not modify! +$version = 2024100708.01; // 20241007 = branching date YYYYMMDD - do not modify! // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '4.5.8 (Build: 20251208)'; // Human-friendly version name +$release = '4.5.8+ (Build: 20251212)'; // Human-friendly version name $branch = '405'; // This version's branch. $maturity = MATURITY_STABLE; // This version's maturity level. From dd9b7e8deb4a8cc99ae7ce9434ce25866a11d089 Mon Sep 17 00:00:00 2001 From: Muhammad Arnaldo Date: Fri, 12 Dec 2025 10:58:51 +0700 Subject: [PATCH 12/79] MDL-87428 core_course: Fix RTL keyboard navigation Ensure consistent navigation experience across LTR and RTL interfaces in activity chooser. --- .../local/activitychooser/dialogue.min.js | 2 +- .../local/activitychooser/dialogue.min.js.map | 2 +- .../amd/src/local/activitychooser/dialogue.js | 102 +++++++++--------- 3 files changed, 52 insertions(+), 54 deletions(-) diff --git a/course/amd/build/local/activitychooser/dialogue.min.js b/course/amd/build/local/activitychooser/dialogue.min.js index 4945e3b9d8b64..4dfb7dd7f959d 100644 --- a/course/amd/build/local/activitychooser/dialogue.min.js +++ b/course/amd/build/local/activitychooser/dialogue.min.js @@ -1,3 +1,3 @@ -define("core_course/local/activitychooser/dialogue",["exports","jquery","core/modal_events","core_course/local/activitychooser/selectors","core/templates","core/key_codes","core/loadingicon","core_course/local/activitychooser/repository","core/notification","core/utils"],(function(_exports,_jquery,ModalEvents,_selectors,Templates,_key_codes,_loadingicon,Repository,_notification,_utils){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.displayChooser=void 0,_jquery=_interopRequireDefault(_jquery),ModalEvents=_interopRequireWildcard(ModalEvents),_selectors=_interopRequireDefault(_selectors),Templates=_interopRequireWildcard(Templates),Repository=_interopRequireWildcard(Repository),_notification=_interopRequireDefault(_notification);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};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 _interopRequireWildcard(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]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}const showModuleHelp=function(carousel,moduleData){let modal=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;null!==modal&&!0===moduleData.showFooter&&modal.setFooter(Templates.render("core_course/local/activitychooser/footer_partial",moduleData));const help=carousel.find(_selectors.default.regions.help)[0];help.innerHTML="",help.classList.add("m-auto");const spinnerPromise=(0,_loadingicon.addIconToContainer)(help);let transitionPromiseResolver=null;const transitionPromise=new Promise((resolve=>{transitionPromiseResolver=resolve})),contentPromise=Templates.renderForPromise("core_course/local/activitychooser/help",moduleData);Promise.all([contentPromise,spinnerPromise,transitionPromise]).then((_ref=>{let[{html:html,js:js}]=_ref;return Templates.replaceNodeContents(help,html,js)})).then((()=>(help.querySelector(_selectors.default.regions.chooserSummary.header).focus(),help))).catch(_notification.default.exception),carousel.one("slid.bs.carousel",(()=>{transitionPromiseResolver()})),carousel.carousel("next")},registerListenerEvents=(modal,mappedModules,partialFavourite,footerData)=>{const bodyClickListener=async e=>{if(e.target.closest(_selectors.default.actions.optionActions.showSummary)){const carousel=(0,_jquery.default)(modal.getBody()[0].querySelector(_selectors.default.regions.carousel)),moduleName=e.target.closest(_selectors.default.regions.chooserOption.container).dataset.modname,moduleData=mappedModules.get(moduleName);moduleData.showFooter=modal.hasFooterContent(),showModuleHelp(carousel,moduleData,modal)}if(e.target.closest(_selectors.default.actions.optionActions.manageFavourite)){const caller=e.target.closest(_selectors.default.actions.optionActions.manageFavourite);await(async(modalBody,caller,partialFavourite)=>{const isFavourite=caller.dataset.favourited,id=caller.dataset.id,name=caller.dataset.name,internal=caller.dataset.internal;"true"===isFavourite?(await Repository.unfavouriteModule(name,id),partialFavourite(internal,!1,modalBody)):(await Repository.favouriteModule(name,id),partialFavourite(internal,!0,modalBody))})(modal.getBody()[0],caller,partialFavourite);const activeSectionId=modal.getBody()[0].querySelector(_selectors.default.elements.activetab).getAttribute("href"),sectionChooserOptions=modal.getBody()[0].querySelector(_selectors.default.regions.getSectionChooserOptions(activeSectionId)),firstChooserOption=sectionChooserOptions.querySelector(_selectors.default.regions.chooserOption.container);toggleFocusableChooserOption(firstChooserOption,!0),initChooserOptionsKeyboardNavigation(modal.getBody()[0],mappedModules,sectionChooserOptions,modal)}if(e.target.matches(_selectors.default.actions.closeOption)){const carousel=(0,_jquery.default)(modal.getBody()[0].querySelector(_selectors.default.regions.carousel));carousel.carousel("prev"),carousel.on("slid.bs.carousel",(()=>{modal.getBody()[0].querySelector(_selectors.default.regions.modules).querySelector(_selectors.default.regions.getModuleSelector(e.target.dataset.modname)).focus()}))}if(e.target.closest(_selectors.default.actions.clearSearch)){const searchInput=modal.getBody()[0].querySelector(_selectors.default.actions.search);searchInput.value="",searchInput.focus(),toggleSearchResultsView(modal,mappedModules,searchInput.value)}},footerClickListener=async e=>{if(!0===footerData.footer){const footerjs=await(pluginName=footerData.customfooterjs,"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginName],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginName)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginName]));await footerjs.footerClickListener(e,footerData,modal)}var pluginName};modal.getBodyPromise().then((body=>body[0])).then((body=>((0,_jquery.default)(body.querySelector(_selectors.default.regions.carousel)).carousel({interval:!1,pause:!0,keyboard:!1}),body))).then((body=>(body.addEventListener("click",bodyClickListener),body))).then((body=>{const searchInput=body.querySelector(_selectors.default.actions.search);return searchInput.addEventListener("input",(0,_utils.debounce)((()=>{toggleSearchResultsView(modal,mappedModules,searchInput.value)}),300)),body})).then((body=>{const activeSectionId=body.querySelector(_selectors.default.elements.activetab).getAttribute("href"),sectionChooserOptions=body.querySelector(_selectors.default.regions.getSectionChooserOptions(activeSectionId)),firstChooserOption=sectionChooserOptions.querySelector(_selectors.default.regions.chooserOption.container);return toggleFocusableChooserOption(firstChooserOption,!0),initChooserOptionsKeyboardNavigation(body,mappedModules,sectionChooserOptions,modal),body})).catch(),modal.getFooterPromise().then((footer=>footer[0])).then((footer=>(footer.addEventListener("click",footerClickListener),footer))).catch()},initChooserOptionsKeyboardNavigation=function(body,mappedModules,chooserOptionsContainer){let modal=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null;const chooserOptions=chooserOptionsContainer.querySelectorAll(_selectors.default.regions.chooserOption.container);Array.from(chooserOptions).forEach((element=>element.addEventListener("keydown",(e=>{if((e.keyCode===_key_codes.enter||e.keyCode===_key_codes.space)&&e.target.matches(_selectors.default.actions.optionActions.showSummary)){e.preventDefault();const moduleName=e.target.closest(_selectors.default.regions.chooserOption.container).dataset.modname,moduleData=mappedModules.get(moduleName),carousel=(0,_jquery.default)(body.querySelector(_selectors.default.regions.carousel));carousel.carousel({interval:!1,pause:!0,keyboard:!1}),moduleData.showFooter=modal.hasFooterContent(),showModuleHelp(carousel,moduleData,modal)}if(e.keyCode===_key_codes.arrowRight){e.preventDefault();const currentOption=e.target.closest(_selectors.default.regions.chooserOption.container),nextOption=currentOption.nextElementSibling,firstOption=chooserOptionsContainer.firstElementChild,toFocusOption=clickErrorHandler(nextOption,firstOption);focusChooserOption(toFocusOption,currentOption)}if(e.keyCode===_key_codes.arrowLeft){e.preventDefault();const currentOption=e.target.closest(_selectors.default.regions.chooserOption.container),previousOption=currentOption.previousElementSibling,lastOption=chooserOptionsContainer.lastElementChild,toFocusOption=clickErrorHandler(previousOption,lastOption);focusChooserOption(toFocusOption,currentOption)}if(e.keyCode===_key_codes.home){e.preventDefault();const currentOption=e.target.closest(_selectors.default.regions.chooserOption.container),firstOption=chooserOptionsContainer.firstElementChild;focusChooserOption(firstOption,currentOption)}if(e.keyCode===_key_codes.end){e.preventDefault();const currentOption=e.target.closest(_selectors.default.regions.chooserOption.container),lastOption=chooserOptionsContainer.lastElementChild;focusChooserOption(lastOption,currentOption)}}))))},focusChooserOption=function(currentChooserOption){let previousChooserOption=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;null!==previousChooserOption&&toggleFocusableChooserOption(previousChooserOption,!1),toggleFocusableChooserOption(currentChooserOption,!0),currentChooserOption.focus()},toggleFocusableChooserOption=(chooserOption,isFocusable)=>{const chooserOptionLink=chooserOption.querySelector(_selectors.default.actions.addChooser),chooserOptionHelp=chooserOption.querySelector(_selectors.default.actions.optionActions.showSummary),chooserOptionFavourite=chooserOption.querySelector(_selectors.default.actions.optionActions.manageFavourite);isFocusable?(chooserOption.tabIndex=0,chooserOptionLink.tabIndex=0,chooserOptionHelp.tabIndex=0,chooserOptionFavourite.tabIndex=0):(chooserOption.tabIndex=-1,chooserOptionLink.tabIndex=-1,chooserOptionHelp.tabIndex=-1,chooserOptionFavourite.tabIndex=-1)},clickErrorHandler=(item,fallback)=>null!==item?item:fallback,toggleSearchResultsView=async(modal,mappedModules,searchQuery)=>{const modalBody=modal.getBody()[0],searchResultsContainer=modalBody.querySelector(_selectors.default.regions.searchResults),chooserContainer=modalBody.querySelector(_selectors.default.regions.chooser),clearSearchButton=modalBody.querySelector(_selectors.default.actions.clearSearch);if(searchQuery.length>0){const searchResultsData=searchModules(mappedModules,searchQuery);await(async(searchResultsContainer,searchResultsData)=>{const templateData={searchresultsnumber:searchResultsData.length,searchresults:searchResultsData},{html:html,js:js}=await Templates.renderForPromise("core_course/local/activitychooser/search_results",templateData);await Templates.replaceNodeContents(searchResultsContainer,html,js)})(searchResultsContainer,searchResultsData);const searchResultItemsContainer=searchResultsContainer.querySelector(_selectors.default.regions.searchResultItems),firstSearchResultItem=searchResultItemsContainer.querySelector(_selectors.default.regions.chooserOption.container);firstSearchResultItem&&(toggleFocusableChooserOption(firstSearchResultItem,!0),initChooserOptionsKeyboardNavigation(modalBody,mappedModules,searchResultItemsContainer,modal)),clearSearchButton.classList.remove("d-none"),chooserContainer.setAttribute("hidden","hidden"),searchResultsContainer.removeAttribute("hidden")}else clearSearchButton.classList.add("d-none"),searchResultsContainer.setAttribute("hidden","hidden"),chooserContainer.removeAttribute("hidden")},searchModules=(modules,searchTerm)=>{if(""===searchTerm)return modules;searchTerm=searchTerm.toLowerCase();const searchResults=[];return modules.forEach((activity=>{const activityName=activity.title.toLowerCase(),activityDesc=activity.help.toLowerCase();(activityName.includes(searchTerm)||activityDesc.includes(searchTerm))&&searchResults.push(activity)})),searchResults},disableFocusAllChooserOptions=sectionChooserOptions=>{sectionChooserOptions.querySelectorAll(_selectors.default.regions.chooserOption.container).forEach((chooserOption=>{toggleFocusableChooserOption(chooserOption,!1)}))};_exports.displayChooser=(modalPromise,sectionModules,partialFavourite,footerData)=>{const mappedModules=new Map;sectionModules.forEach((module=>{mappedModules.set(module.componentname+"_"+module.link,module)})),modalPromise.then((modal=>(registerListenerEvents(modal,mappedModules,partialFavourite,footerData),((modal,mappedModules)=>{modal.getModal()[0].tabIndex=-1,modal.getBodyPromise().then((body=>{(0,_jquery.default)(_selectors.default.elements.tab).on("shown.bs.tab",(e=>{const activeSectionId=e.target.getAttribute("href"),activeSectionChooserOptions=body[0].querySelector(_selectors.default.regions.getSectionChooserOptions(activeSectionId)),firstChooserOption=activeSectionChooserOptions.querySelector(_selectors.default.regions.chooserOption.container),prevActiveSectionId=e.relatedTarget.getAttribute("href"),prevActiveSectionChooserOptions=body[0].querySelector(_selectors.default.regions.getSectionChooserOptions(prevActiveSectionId));disableFocusAllChooserOptions(prevActiveSectionChooserOptions),toggleFocusableChooserOption(firstChooserOption,!0),initChooserOptionsKeyboardNavigation(body[0],mappedModules,activeSectionChooserOptions,modal)}))})).catch(_notification.default.exception)})(modal,mappedModules),modal.getRoot().on(ModalEvents.hidden,(()=>{modal.destroy()})),modal))).catch()}})); +define("core_course/local/activitychooser/dialogue",["exports","jquery","core/custom_interaction_events","core/modal_events","core_course/local/activitychooser/selectors","core/templates","core/key_codes","core/loadingicon","core_course/local/activitychooser/repository","core/notification","core/utils"],(function(_exports,_jquery,_custom_interaction_events,ModalEvents,_selectors,Templates,_key_codes,_loadingicon,Repository,_notification,_utils){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.displayChooser=void 0,_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),ModalEvents=_interopRequireWildcard(ModalEvents),_selectors=_interopRequireDefault(_selectors),Templates=_interopRequireWildcard(Templates),Repository=_interopRequireWildcard(Repository),_notification=_interopRequireDefault(_notification);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};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 _interopRequireWildcard(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]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}const showModuleHelp=function(carousel,moduleData){let modal=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;null!==modal&&!0===moduleData.showFooter&&modal.setFooter(Templates.render("core_course/local/activitychooser/footer_partial",moduleData));const help=carousel.find(_selectors.default.regions.help)[0];help.innerHTML="",help.classList.add("m-auto");const spinnerPromise=(0,_loadingicon.addIconToContainer)(help);let transitionPromiseResolver=null;const transitionPromise=new Promise((resolve=>{transitionPromiseResolver=resolve})),contentPromise=Templates.renderForPromise("core_course/local/activitychooser/help",moduleData);Promise.all([contentPromise,spinnerPromise,transitionPromise]).then((_ref=>{let[{html:html,js:js}]=_ref;return Templates.replaceNodeContents(help,html,js)})).then((()=>(help.querySelector(_selectors.default.regions.chooserSummary.header).focus(),help))).catch(_notification.default.exception),carousel.one("slid.bs.carousel",(()=>{transitionPromiseResolver()})),carousel.carousel("next")},registerListenerEvents=(modal,mappedModules,partialFavourite,footerData)=>{const bodyClickListener=async e=>{if(e.target.closest(_selectors.default.actions.optionActions.showSummary)){const carousel=(0,_jquery.default)(modal.getBody()[0].querySelector(_selectors.default.regions.carousel)),moduleName=e.target.closest(_selectors.default.regions.chooserOption.container).dataset.modname,moduleData=mappedModules.get(moduleName);moduleData.showFooter=modal.hasFooterContent(),showModuleHelp(carousel,moduleData,modal)}if(e.target.closest(_selectors.default.actions.optionActions.manageFavourite)){const caller=e.target.closest(_selectors.default.actions.optionActions.manageFavourite);await(async(modalBody,caller,partialFavourite)=>{const isFavourite=caller.dataset.favourited,id=caller.dataset.id,name=caller.dataset.name,internal=caller.dataset.internal;"true"===isFavourite?(await Repository.unfavouriteModule(name,id),partialFavourite(internal,!1,modalBody)):(await Repository.favouriteModule(name,id),partialFavourite(internal,!0,modalBody))})(modal.getBody()[0],caller,partialFavourite);const activeSectionId=modal.getBody()[0].querySelector(_selectors.default.elements.activetab).getAttribute("href"),sectionChooserOptions=modal.getBody()[0].querySelector(_selectors.default.regions.getSectionChooserOptions(activeSectionId)),firstChooserOption=sectionChooserOptions.querySelector(_selectors.default.regions.chooserOption.container);toggleFocusableChooserOption(firstChooserOption,!0),initChooserOptionsKeyboardNavigation(modal.getBody()[0],mappedModules,sectionChooserOptions,modal)}if(e.target.matches(_selectors.default.actions.closeOption)){const carousel=(0,_jquery.default)(modal.getBody()[0].querySelector(_selectors.default.regions.carousel));carousel.carousel("prev"),carousel.on("slid.bs.carousel",(()=>{modal.getBody()[0].querySelector(_selectors.default.regions.modules).querySelector(_selectors.default.regions.getModuleSelector(e.target.dataset.modname)).focus()}))}if(e.target.closest(_selectors.default.actions.clearSearch)){const searchInput=modal.getBody()[0].querySelector(_selectors.default.actions.search);searchInput.value="",searchInput.focus(),toggleSearchResultsView(modal,mappedModules,searchInput.value)}},footerClickListener=async e=>{if(!0===footerData.footer){const footerjs=await(pluginName=footerData.customfooterjs,"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginName],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginName)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginName]));await footerjs.footerClickListener(e,footerData,modal)}var pluginName};modal.getBodyPromise().then((body=>body[0])).then((body=>((0,_jquery.default)(body.querySelector(_selectors.default.regions.carousel)).carousel({interval:!1,pause:!0,keyboard:!1}),body))).then((body=>(body.addEventListener("click",bodyClickListener),body))).then((body=>{const searchInput=body.querySelector(_selectors.default.actions.search);return searchInput.addEventListener("input",(0,_utils.debounce)((()=>{toggleSearchResultsView(modal,mappedModules,searchInput.value)}),300)),body})).then((body=>{const activeSectionId=body.querySelector(_selectors.default.elements.activetab).getAttribute("href"),sectionChooserOptions=body.querySelector(_selectors.default.regions.getSectionChooserOptions(activeSectionId)),firstChooserOption=sectionChooserOptions.querySelector(_selectors.default.regions.chooserOption.container);return toggleFocusableChooserOption(firstChooserOption,!0),initChooserOptionsKeyboardNavigation(body,mappedModules,sectionChooserOptions,modal),body})).catch(),modal.getFooterPromise().then((footer=>footer[0])).then((footer=>(footer.addEventListener("click",footerClickListener),footer))).catch()},initChooserOptionsKeyboardNavigation=function(body,mappedModules,chooserOptionsContainer){let modal=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null;const chooserOptions=chooserOptionsContainer.querySelectorAll(_selectors.default.regions.chooserOption.container);Array.from(chooserOptions).forEach((element=>{const $element=(0,_jquery.default)(element);_custom_interaction_events.default.define($element,[_custom_interaction_events.default.events.next,_custom_interaction_events.default.events.previous,_custom_interaction_events.default.events.home,_custom_interaction_events.default.events.end]);const createNavHandler=resolver=>(e,data)=>{const currentOption=data.originalEvent.target.closest(_selectors.default.regions.chooserOption.container);if(null!==currentOption){const toFocusOption=resolver(currentOption);toFocusOption&&focusChooserOption(toFocusOption,currentOption)}};$element.on(_custom_interaction_events.default.events.next,createNavHandler((current=>current.nextElementSibling||chooserOptionsContainer.firstElementChild))),$element.on(_custom_interaction_events.default.events.previous,createNavHandler((current=>current.previousElementSibling||chooserOptionsContainer.lastElementChild))),$element.on(_custom_interaction_events.default.events.home,createNavHandler((()=>chooserOptionsContainer.firstElementChild))),$element.on(_custom_interaction_events.default.events.end,createNavHandler((()=>chooserOptionsContainer.lastElementChild))),element.addEventListener("keydown",(e=>{if((e.keyCode===_key_codes.enter||e.keyCode===_key_codes.space)&&e.target.matches(_selectors.default.actions.optionActions.showSummary)){e.preventDefault();const moduleName=e.target.closest(_selectors.default.regions.chooserOption.container).dataset.modname,moduleData=mappedModules.get(moduleName),carousel=(0,_jquery.default)(body.querySelector(_selectors.default.regions.carousel));carousel.carousel({interval:!1,pause:!0,keyboard:!1}),moduleData.showFooter=modal.hasFooterContent(),showModuleHelp(carousel,moduleData,modal)}}))}))},focusChooserOption=function(currentChooserOption){let previousChooserOption=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;null!==previousChooserOption&&toggleFocusableChooserOption(previousChooserOption,!1),toggleFocusableChooserOption(currentChooserOption,!0),currentChooserOption.focus()},toggleFocusableChooserOption=(chooserOption,isFocusable)=>{const chooserOptionLink=chooserOption.querySelector(_selectors.default.actions.addChooser),chooserOptionHelp=chooserOption.querySelector(_selectors.default.actions.optionActions.showSummary),chooserOptionFavourite=chooserOption.querySelector(_selectors.default.actions.optionActions.manageFavourite);isFocusable?(chooserOption.tabIndex=0,chooserOptionLink.tabIndex=0,chooserOptionHelp.tabIndex=0,chooserOptionFavourite.tabIndex=0):(chooserOption.tabIndex=-1,chooserOptionLink.tabIndex=-1,chooserOptionHelp.tabIndex=-1,chooserOptionFavourite.tabIndex=-1)},toggleSearchResultsView=async(modal,mappedModules,searchQuery)=>{const modalBody=modal.getBody()[0],searchResultsContainer=modalBody.querySelector(_selectors.default.regions.searchResults),chooserContainer=modalBody.querySelector(_selectors.default.regions.chooser),clearSearchButton=modalBody.querySelector(_selectors.default.actions.clearSearch);if(searchQuery.length>0){const searchResultsData=searchModules(mappedModules,searchQuery);await(async(searchResultsContainer,searchResultsData)=>{const templateData={searchresultsnumber:searchResultsData.length,searchresults:searchResultsData},{html:html,js:js}=await Templates.renderForPromise("core_course/local/activitychooser/search_results",templateData);await Templates.replaceNodeContents(searchResultsContainer,html,js)})(searchResultsContainer,searchResultsData);const searchResultItemsContainer=searchResultsContainer.querySelector(_selectors.default.regions.searchResultItems),firstSearchResultItem=searchResultItemsContainer.querySelector(_selectors.default.regions.chooserOption.container);firstSearchResultItem&&(toggleFocusableChooserOption(firstSearchResultItem,!0),initChooserOptionsKeyboardNavigation(modalBody,mappedModules,searchResultItemsContainer,modal)),clearSearchButton.classList.remove("d-none"),chooserContainer.setAttribute("hidden","hidden"),searchResultsContainer.removeAttribute("hidden")}else clearSearchButton.classList.add("d-none"),searchResultsContainer.setAttribute("hidden","hidden"),chooserContainer.removeAttribute("hidden")},searchModules=(modules,searchTerm)=>{if(""===searchTerm)return modules;searchTerm=searchTerm.toLowerCase();const searchResults=[];return modules.forEach((activity=>{const activityName=activity.title.toLowerCase(),activityDesc=activity.help.toLowerCase();(activityName.includes(searchTerm)||activityDesc.includes(searchTerm))&&searchResults.push(activity)})),searchResults},disableFocusAllChooserOptions=sectionChooserOptions=>{sectionChooserOptions.querySelectorAll(_selectors.default.regions.chooserOption.container).forEach((chooserOption=>{toggleFocusableChooserOption(chooserOption,!1)}))};_exports.displayChooser=(modalPromise,sectionModules,partialFavourite,footerData)=>{const mappedModules=new Map;sectionModules.forEach((module=>{mappedModules.set(module.componentname+"_"+module.link,module)})),modalPromise.then((modal=>(registerListenerEvents(modal,mappedModules,partialFavourite,footerData),((modal,mappedModules)=>{modal.getModal()[0].tabIndex=-1,modal.getBodyPromise().then((body=>{(0,_jquery.default)(_selectors.default.elements.tab).on("shown.bs.tab",(e=>{const activeSectionId=e.target.getAttribute("href"),activeSectionChooserOptions=body[0].querySelector(_selectors.default.regions.getSectionChooserOptions(activeSectionId)),firstChooserOption=activeSectionChooserOptions.querySelector(_selectors.default.regions.chooserOption.container),prevActiveSectionId=e.relatedTarget.getAttribute("href"),prevActiveSectionChooserOptions=body[0].querySelector(_selectors.default.regions.getSectionChooserOptions(prevActiveSectionId));disableFocusAllChooserOptions(prevActiveSectionChooserOptions),toggleFocusableChooserOption(firstChooserOption,!0),initChooserOptionsKeyboardNavigation(body[0],mappedModules,activeSectionChooserOptions,modal)}))})).catch(_notification.default.exception)})(modal,mappedModules),modal.getRoot().on(ModalEvents.hidden,(()=>{modal.destroy()})),modal))).catch()}})); //# sourceMappingURL=dialogue.min.js.map \ No newline at end of file diff --git a/course/amd/build/local/activitychooser/dialogue.min.js.map b/course/amd/build/local/activitychooser/dialogue.min.js.map index 311cbac79965e..4fca168ede8c3 100644 --- a/course/amd/build/local/activitychooser/dialogue.min.js.map +++ b/course/amd/build/local/activitychooser/dialogue.min.js.map @@ -1 +1 @@ -{"version":3,"file":"dialogue.min.js","sources":["../../../src/local/activitychooser/dialogue.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A type of dialogue used as for choosing options.\n *\n * @module core_course/local/activitychooser/dialogue\n * @copyright 2019 Mihail Geshoski \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport * as ModalEvents from 'core/modal_events';\nimport selectors from 'core_course/local/activitychooser/selectors';\nimport * as Templates from 'core/templates';\nimport {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';\nimport {addIconToContainer} from 'core/loadingicon';\nimport * as Repository from 'core_course/local/activitychooser/repository';\nimport Notification from 'core/notification';\nimport {debounce} from 'core/utils';\nconst getPlugin = pluginName => import(pluginName);\n\n/**\n * Given an event from the main module 'page' navigate to it's help section via a carousel.\n *\n * @method showModuleHelp\n * @param {jQuery} carousel Our initialized carousel to manipulate\n * @param {Object} moduleData Data of the module to carousel to\n * @param {jQuery} modal We need to figure out if the current modal has a footer.\n */\nconst showModuleHelp = (carousel, moduleData, modal = null) => {\n // If we have a real footer then we need to change temporarily.\n if (modal !== null && moduleData.showFooter === true) {\n modal.setFooter(Templates.render('core_course/local/activitychooser/footer_partial', moduleData));\n }\n const help = carousel.find(selectors.regions.help)[0];\n help.innerHTML = '';\n help.classList.add('m-auto');\n\n // Add a spinner.\n const spinnerPromise = addIconToContainer(help);\n\n // Used later...\n let transitionPromiseResolver = null;\n const transitionPromise = new Promise(resolve => {\n transitionPromiseResolver = resolve;\n });\n\n // Build up the html & js ready to place into the help section.\n const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);\n\n // Wait for the content to be ready, and for the transition to be complet.\n Promise.all([contentPromise, spinnerPromise, transitionPromise])\n .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))\n .then(() => {\n help.querySelector(selectors.regions.chooserSummary.header).focus();\n return help;\n })\n .catch(Notification.exception);\n\n // Move to the next slide, and resolve the transition promise when it's done.\n carousel.one('slid.bs.carousel', () => {\n transitionPromiseResolver();\n });\n // Trigger the transition between 'pages'.\n carousel.carousel('next');\n};\n\n/**\n * Given a user wants to change the favourite state of a module we either add or remove the status.\n * We also propergate this change across our map of modals.\n *\n * @method manageFavouriteState\n * @param {HTMLElement} modalBody The DOM node of the modal to manipulate\n * @param {HTMLElement} caller\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n */\nconst manageFavouriteState = async(modalBody, caller, partialFavourite) => {\n const isFavourite = caller.dataset.favourited;\n const id = caller.dataset.id;\n const name = caller.dataset.name;\n const internal = caller.dataset.internal;\n // Switch on fave or not.\n if (isFavourite === 'true') {\n await Repository.unfavouriteModule(name, id);\n\n partialFavourite(internal, false, modalBody);\n } else {\n await Repository.favouriteModule(name, id);\n\n partialFavourite(internal, true, modalBody);\n }\n\n};\n\n/**\n * Register chooser related event listeners.\n *\n * @method registerListenerEvents\n * @param {Promise} modal Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n * @param {Object} footerData Our base footer object.\n */\nconst registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => {\n const bodyClickListener = async(e) => {\n if (e.target.closest(selectors.actions.optionActions.showSummary)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.\n moduleData.showFooter = modal.hasFooterContent();\n showModuleHelp(carousel, moduleData, modal);\n }\n\n if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {\n const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);\n await manageFavouriteState(modal.getBody()[0], caller, partialFavourite);\n const activeSectionId = modal.getBody()[0].querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = modal.getBody()[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions, modal);\n }\n\n // From the help screen go back to the module overview.\n if (e.target.matches(selectors.actions.closeOption)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n // Trigger the transition between 'pages'.\n carousel.carousel('prev');\n carousel.on('slid.bs.carousel', () => {\n const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);\n const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));\n caller.focus();\n });\n }\n\n // The \"clear search\" button is triggered.\n if (e.target.closest(selectors.actions.clearSearch)) {\n // Clear the entered search query in the search bar and hide the search results container.\n const searchInput = modal.getBody()[0].querySelector(selectors.actions.search);\n searchInput.value = \"\";\n searchInput.focus();\n toggleSearchResultsView(modal, mappedModules, searchInput.value);\n }\n };\n\n // We essentially have two types of footer.\n // A fake one that is handled within the template for chooser_help and then all of the stuff for\n // modal.footer. We need to ensure we know exactly what type of footer we are using so we know what we\n // need to manage. The below code handles a real footer going to a mnet carousel item.\n const footerClickListener = async(e) => {\n if (footerData.footer === true) {\n const footerjs = await getPlugin(footerData.customfooterjs);\n await footerjs.footerClickListener(e, footerData, modal);\n }\n };\n\n modal.getBodyPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(body => body[0])\n\n // Set up the carousel.\n .then(body => {\n $(body.querySelector(selectors.regions.carousel))\n .carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n return body;\n })\n\n // Add the listener for clicks on the body.\n .then(body => {\n body.addEventListener('click', bodyClickListener);\n return body;\n })\n\n // Add a listener for an input change in the activity chooser's search bar.\n .then(body => {\n const searchInput = body.querySelector(selectors.actions.search);\n // The search input is triggered.\n searchInput.addEventListener('input', debounce(() => {\n // Display the search results.\n toggleSearchResultsView(modal, mappedModules, searchInput.value);\n }, 300));\n return body;\n })\n\n // Register event listeners related to the keyboard navigation controls.\n .then(body => {\n // Get the active chooser options section.\n const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);\n\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);\n\n return body;\n })\n .catch();\n\n modal.getFooterPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(footer => footer[0])\n // Add the listener for clicks on the footer.\n .then(footer => {\n footer.addEventListener('click', footerClickListener);\n return footer;\n })\n .catch();\n};\n\n/**\n * Initialise the keyboard navigation controls for the chooser options.\n *\n * @method initChooserOptionsKeyboardNavigation\n * @param {HTMLElement} body Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items\n * @param {Object} modal Our created modal for the section\n */\nconst initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => {\n const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);\n\n Array.from(chooserOptions).forEach((element) => {\n return element.addEventListener('keydown', (e) => {\n\n // Check for enter/ space triggers for showing the help.\n if (e.keyCode === enter || e.keyCode === space) {\n if (e.target.matches(selectors.actions.optionActions.showSummary)) {\n e.preventDefault();\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n const carousel = $(body.querySelector(selectors.regions.carousel));\n carousel.carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.\n moduleData.showFooter = modal.hasFooterContent();\n showModuleHelp(carousel, moduleData, modal);\n }\n }\n\n // Next.\n if (e.keyCode === arrowRight) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const nextOption = currentOption.nextElementSibling;\n const firstOption = chooserOptionsContainer.firstElementChild;\n const toFocusOption = clickErrorHandler(nextOption, firstOption);\n focusChooserOption(toFocusOption, currentOption);\n }\n\n // Previous.\n if (e.keyCode === arrowLeft) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const previousOption = currentOption.previousElementSibling;\n const lastOption = chooserOptionsContainer.lastElementChild;\n const toFocusOption = clickErrorHandler(previousOption, lastOption);\n focusChooserOption(toFocusOption, currentOption);\n }\n\n if (e.keyCode === home) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const firstOption = chooserOptionsContainer.firstElementChild;\n focusChooserOption(firstOption, currentOption);\n }\n\n if (e.keyCode === end) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const lastOption = chooserOptionsContainer.lastElementChild;\n focusChooserOption(lastOption, currentOption);\n }\n });\n });\n};\n\n/**\n * Focus on a chooser option element and remove the previous chooser element from the focus order\n *\n * @method focusChooserOption\n * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus\n * @param {HTMLElement|null} previousChooserOption The previous focused option element\n */\nconst focusChooserOption = (currentChooserOption, previousChooserOption = null) => {\n if (previousChooserOption !== null) {\n toggleFocusableChooserOption(previousChooserOption, false);\n }\n\n toggleFocusableChooserOption(currentChooserOption, true);\n currentChooserOption.focus();\n};\n\n/**\n * Add or remove a chooser option from the focus order.\n *\n * @method toggleFocusableChooserOption\n * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order\n * @param {Boolean} isFocusable Whether the chooser element is focusable or not\n */\nconst toggleFocusableChooserOption = (chooserOption, isFocusable) => {\n const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);\n const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);\n const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);\n\n if (isFocusable) {\n // Set tabindex to 0 to add current chooser option element to the focus order.\n chooserOption.tabIndex = 0;\n chooserOptionLink.tabIndex = 0;\n chooserOptionHelp.tabIndex = 0;\n chooserOptionFavourite.tabIndex = 0;\n } else {\n // Set tabindex to -1 to remove the previous chooser option element from the focus order.\n chooserOption.tabIndex = -1;\n chooserOptionLink.tabIndex = -1;\n chooserOptionHelp.tabIndex = -1;\n chooserOptionFavourite.tabIndex = -1;\n }\n};\n\n/**\n * Small error handling function to make sure the navigated to object exists\n *\n * @method clickErrorHandler\n * @param {HTMLElement} item What we want to check exists\n * @param {HTMLElement} fallback If we dont match anything fallback the focus\n * @return {HTMLElement}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Render the search results in a defined container\n *\n * @method renderSearchResults\n * @param {HTMLElement} searchResultsContainer The container where the data should be rendered\n * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria\n */\nconst renderSearchResults = async(searchResultsContainer, searchResultsData) => {\n const templateData = {\n 'searchresultsnumber': searchResultsData.length,\n 'searchresults': searchResultsData\n };\n // Build up the html & js ready to place into the help section.\n const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);\n await Templates.replaceNodeContents(searchResultsContainer, html, js);\n};\n\n/**\n * Toggle (display/hide) the search results depending on the value of the search query\n *\n * @method toggleSearchResultsView\n * @param {Object} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {String} searchQuery The search query\n */\nconst toggleSearchResultsView = async(modal, mappedModules, searchQuery) => {\n const modalBody = modal.getBody()[0];\n const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);\n const chooserContainer = modalBody.querySelector(selectors.regions.chooser);\n const clearSearchButton = modalBody.querySelector(selectors.actions.clearSearch);\n\n if (searchQuery.length > 0) { // Search query is present.\n const searchResultsData = searchModules(mappedModules, searchQuery);\n await renderSearchResults(searchResultsContainer, searchResultsData);\n const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);\n const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);\n if (firstSearchResultItem) {\n // Set the first result item to be focusable.\n toggleFocusableChooserOption(firstSearchResultItem, true);\n // Register keyboard events on the created search result items.\n initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal);\n }\n // Display the \"clear\" search button in the activity chooser search bar.\n clearSearchButton.classList.remove('d-none');\n // Hide the default chooser options container.\n chooserContainer.setAttribute('hidden', 'hidden');\n // Display the search results container.\n searchResultsContainer.removeAttribute('hidden');\n } else { // Search query is not present.\n // Hide the \"clear\" search button in the activity chooser search bar.\n clearSearchButton.classList.add('d-none');\n // Hide the search results container.\n searchResultsContainer.setAttribute('hidden', 'hidden');\n // Display the default chooser options container.\n chooserContainer.removeAttribute('hidden');\n }\n};\n\n/**\n * Return the list of modules which have a name or description that matches the given search term.\n *\n * @method searchModules\n * @param {Array} modules List of available modules\n * @param {String} searchTerm The search term to match\n * @return {Array}\n */\nconst searchModules = (modules, searchTerm) => {\n if (searchTerm === '') {\n return modules;\n }\n searchTerm = searchTerm.toLowerCase();\n const searchResults = [];\n modules.forEach((activity) => {\n const activityName = activity.title.toLowerCase();\n const activityDesc = activity.help.toLowerCase();\n if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {\n searchResults.push(activity);\n }\n });\n\n return searchResults;\n};\n\n/**\n * Set up our tabindex information across the chooser.\n *\n * @method setupKeyboardAccessibility\n * @param {Promise} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the built module information\n */\nconst setupKeyboardAccessibility = (modal, mappedModules) => {\n modal.getModal()[0].tabIndex = -1;\n\n modal.getBodyPromise().then(body => {\n $(selectors.elements.tab).on('shown.bs.tab', (e) => {\n const activeSectionId = e.target.getAttribute(\"href\");\n const activeSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = activeSectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n const prevActiveSectionId = e.relatedTarget.getAttribute(\"href\");\n const prevActiveSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));\n\n // Disable the focus of every chooser option in the previous active section.\n disableFocusAllChooserOptions(prevActiveSectionChooserOptions);\n // Enable the focus of the first chooser option in the current active section.\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal);\n });\n return;\n }).catch(Notification.exception);\n};\n\n/**\n * Disable the focus of all chooser options in a specific container (section).\n *\n * @method disableFocusAllChooserOptions\n * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items\n */\nconst disableFocusAllChooserOptions = (sectionChooserOptions) => {\n const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);\n allChooserOptions.forEach((chooserOption) => {\n toggleFocusableChooserOption(chooserOption, false);\n });\n};\n\n/**\n * Display the module chooser.\n *\n * @method displayChooser\n * @param {Promise} modalPromise Our created modal for the section\n * @param {Array} sectionModules An array of all of the built module information\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n * @param {Object} footerData Our base footer object.\n */\nexport const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {\n // Make a map so we can quickly fetch a specific module's object for either rendering or searching.\n const mappedModules = new Map();\n sectionModules.forEach((module) => {\n mappedModules.set(module.componentname + '_' + module.link, module);\n });\n\n // Register event listeners.\n modalPromise.then(modal => {\n registerListenerEvents(modal, mappedModules, partialFavourite, footerData);\n\n // We want to focus on the first chooser option element as soon as the modal is opened.\n setupKeyboardAccessibility(modal, mappedModules);\n\n // We want to focus on the action select when the dialog is closed.\n modal.getRoot().on(ModalEvents.hidden, () => {\n modal.destroy();\n });\n\n return modal;\n }).catch();\n};\n"],"names":["showModuleHelp","carousel","moduleData","modal","showFooter","setFooter","Templates","render","help","find","selectors","regions","innerHTML","classList","add","spinnerPromise","transitionPromiseResolver","transitionPromise","Promise","resolve","contentPromise","renderForPromise","all","then","_ref","html","js","replaceNodeContents","querySelector","chooserSummary","header","focus","catch","Notification","exception","one","registerListenerEvents","mappedModules","partialFavourite","footerData","bodyClickListener","async","e","target","closest","actions","optionActions","showSummary","getBody","moduleName","chooserOption","container","dataset","modname","get","hasFooterContent","manageFavourite","caller","modalBody","isFavourite","favourited","id","name","internal","Repository","unfavouriteModule","favouriteModule","manageFavouriteState","activeSectionId","elements","activetab","getAttribute","sectionChooserOptions","getSectionChooserOptions","firstChooserOption","toggleFocusableChooserOption","initChooserOptionsKeyboardNavigation","matches","closeOption","on","modules","getModuleSelector","clearSearch","searchInput","search","value","toggleSearchResultsView","footerClickListener","footer","footerjs","pluginName","customfooterjs","getBodyPromise","body","interval","pause","keyboard","addEventListener","getFooterPromise","chooserOptionsContainer","chooserOptions","querySelectorAll","Array","from","forEach","element","keyCode","enter","space","preventDefault","arrowRight","currentOption","nextOption","nextElementSibling","firstOption","firstElementChild","toFocusOption","clickErrorHandler","focusChooserOption","arrowLeft","previousOption","previousElementSibling","lastOption","lastElementChild","home","end","currentChooserOption","previousChooserOption","isFocusable","chooserOptionLink","addChooser","chooserOptionHelp","chooserOptionFavourite","tabIndex","item","fallback","searchQuery","searchResultsContainer","searchResults","chooserContainer","chooser","clearSearchButton","length","searchResultsData","searchModules","templateData","renderSearchResults","searchResultItemsContainer","searchResultItems","firstSearchResultItem","remove","setAttribute","removeAttribute","searchTerm","toLowerCase","activity","activityName","title","activityDesc","includes","push","disableFocusAllChooserOptions","modalPromise","sectionModules","Map","module","set","componentname","link","getModal","tab","activeSectionChooserOptions","prevActiveSectionId","relatedTarget","prevActiveSectionChooserOptions","setupKeyboardAccessibility","getRoot","ModalEvents","hidden","destroy"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"dialogue.min.js","sources":["../../../src/local/activitychooser/dialogue.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A type of dialogue used as for choosing options.\n *\n * @module core_course/local/activitychooser/dialogue\n * @copyright 2019 Mihail Geshoski \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport CustomEvents from 'core/custom_interaction_events';\nimport * as ModalEvents from 'core/modal_events';\nimport selectors from 'core_course/local/activitychooser/selectors';\nimport * as Templates from 'core/templates';\nimport {enter, space} from 'core/key_codes';\nimport {addIconToContainer} from 'core/loadingicon';\nimport * as Repository from 'core_course/local/activitychooser/repository';\nimport Notification from 'core/notification';\nimport {debounce} from 'core/utils';\nconst getPlugin = pluginName => import(pluginName);\n\n/**\n * Given an event from the main module 'page' navigate to it's help section via a carousel.\n *\n * @method showModuleHelp\n * @param {jQuery} carousel Our initialized carousel to manipulate\n * @param {Object} moduleData Data of the module to carousel to\n * @param {jQuery} modal We need to figure out if the current modal has a footer.\n */\nconst showModuleHelp = (carousel, moduleData, modal = null) => {\n // If we have a real footer then we need to change temporarily.\n if (modal !== null && moduleData.showFooter === true) {\n modal.setFooter(Templates.render('core_course/local/activitychooser/footer_partial', moduleData));\n }\n const help = carousel.find(selectors.regions.help)[0];\n help.innerHTML = '';\n help.classList.add('m-auto');\n\n // Add a spinner.\n const spinnerPromise = addIconToContainer(help);\n\n // Used later...\n let transitionPromiseResolver = null;\n const transitionPromise = new Promise(resolve => {\n transitionPromiseResolver = resolve;\n });\n\n // Build up the html & js ready to place into the help section.\n const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);\n\n // Wait for the content to be ready, and for the transition to be complet.\n Promise.all([contentPromise, spinnerPromise, transitionPromise])\n .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))\n .then(() => {\n help.querySelector(selectors.regions.chooserSummary.header).focus();\n return help;\n })\n .catch(Notification.exception);\n\n // Move to the next slide, and resolve the transition promise when it's done.\n carousel.one('slid.bs.carousel', () => {\n transitionPromiseResolver();\n });\n // Trigger the transition between 'pages'.\n carousel.carousel('next');\n};\n\n/**\n * Given a user wants to change the favourite state of a module we either add or remove the status.\n * We also propergate this change across our map of modals.\n *\n * @method manageFavouriteState\n * @param {HTMLElement} modalBody The DOM node of the modal to manipulate\n * @param {HTMLElement} caller\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n */\nconst manageFavouriteState = async(modalBody, caller, partialFavourite) => {\n const isFavourite = caller.dataset.favourited;\n const id = caller.dataset.id;\n const name = caller.dataset.name;\n const internal = caller.dataset.internal;\n // Switch on fave or not.\n if (isFavourite === 'true') {\n await Repository.unfavouriteModule(name, id);\n\n partialFavourite(internal, false, modalBody);\n } else {\n await Repository.favouriteModule(name, id);\n\n partialFavourite(internal, true, modalBody);\n }\n\n};\n\n/**\n * Register chooser related event listeners.\n *\n * @method registerListenerEvents\n * @param {Promise} modal Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n * @param {Object} footerData Our base footer object.\n */\nconst registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => {\n const bodyClickListener = async(e) => {\n if (e.target.closest(selectors.actions.optionActions.showSummary)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.\n moduleData.showFooter = modal.hasFooterContent();\n showModuleHelp(carousel, moduleData, modal);\n }\n\n if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {\n const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);\n await manageFavouriteState(modal.getBody()[0], caller, partialFavourite);\n const activeSectionId = modal.getBody()[0].querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = modal.getBody()[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions, modal);\n }\n\n // From the help screen go back to the module overview.\n if (e.target.matches(selectors.actions.closeOption)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n // Trigger the transition between 'pages'.\n carousel.carousel('prev');\n carousel.on('slid.bs.carousel', () => {\n const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);\n const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));\n caller.focus();\n });\n }\n\n // The \"clear search\" button is triggered.\n if (e.target.closest(selectors.actions.clearSearch)) {\n // Clear the entered search query in the search bar and hide the search results container.\n const searchInput = modal.getBody()[0].querySelector(selectors.actions.search);\n searchInput.value = \"\";\n searchInput.focus();\n toggleSearchResultsView(modal, mappedModules, searchInput.value);\n }\n };\n\n // We essentially have two types of footer.\n // A fake one that is handled within the template for chooser_help and then all of the stuff for\n // modal.footer. We need to ensure we know exactly what type of footer we are using so we know what we\n // need to manage. The below code handles a real footer going to a mnet carousel item.\n const footerClickListener = async(e) => {\n if (footerData.footer === true) {\n const footerjs = await getPlugin(footerData.customfooterjs);\n await footerjs.footerClickListener(e, footerData, modal);\n }\n };\n\n modal.getBodyPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(body => body[0])\n\n // Set up the carousel.\n .then(body => {\n $(body.querySelector(selectors.regions.carousel))\n .carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n return body;\n })\n\n // Add the listener for clicks on the body.\n .then(body => {\n body.addEventListener('click', bodyClickListener);\n return body;\n })\n\n // Add a listener for an input change in the activity chooser's search bar.\n .then(body => {\n const searchInput = body.querySelector(selectors.actions.search);\n // The search input is triggered.\n searchInput.addEventListener('input', debounce(() => {\n // Display the search results.\n toggleSearchResultsView(modal, mappedModules, searchInput.value);\n }, 300));\n return body;\n })\n\n // Register event listeners related to the keyboard navigation controls.\n .then(body => {\n // Get the active chooser options section.\n const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);\n\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);\n\n return body;\n })\n .catch();\n\n modal.getFooterPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(footer => footer[0])\n // Add the listener for clicks on the footer.\n .then(footer => {\n footer.addEventListener('click', footerClickListener);\n return footer;\n })\n .catch();\n};\n\n/**\n * Initialise the keyboard navigation controls for the chooser options.\n *\n * @method initChooserOptionsKeyboardNavigation\n * @param {HTMLElement} body Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items\n * @param {Object} modal Our created modal for the section\n */\nconst initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => {\n const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);\n\n Array.from(chooserOptions).forEach((element) => {\n const $element = $(element);\n\n // Set up custom interaction events for RTL-aware keyboard navigation.\n CustomEvents.define($element, [\n CustomEvents.events.next,\n CustomEvents.events.previous,\n CustomEvents.events.home,\n CustomEvents.events.end,\n ]);\n\n // Handle focus move (automatically handles RTL).\n const createNavHandler = (resolver) => (e, data) => {\n const currentOption = data.originalEvent.target.closest(\n selectors.regions.chooserOption.container\n );\n if (currentOption !== null) {\n const toFocusOption = resolver(currentOption);\n if (toFocusOption) {\n focusChooserOption(toFocusOption, currentOption);\n }\n }\n };\n\n $element.on(\n CustomEvents.events.next,\n createNavHandler(\n (current) => current.nextElementSibling || chooserOptionsContainer.firstElementChild\n )\n );\n\n $element.on(\n CustomEvents.events.previous,\n createNavHandler(\n (current) => current.previousElementSibling || chooserOptionsContainer.lastElementChild\n )\n );\n\n $element.on(\n CustomEvents.events.home,\n createNavHandler(() => chooserOptionsContainer.firstElementChild)\n );\n\n $element.on(\n CustomEvents.events.end,\n createNavHandler(() => chooserOptionsContainer.lastElementChild)\n );\n\n element.addEventListener('keydown', (e) => {\n\n // Check for enter/ space triggers for showing the help.\n if (e.keyCode === enter || e.keyCode === space) {\n if (e.target.matches(selectors.actions.optionActions.showSummary)) {\n e.preventDefault();\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n const carousel = $(body.querySelector(selectors.regions.carousel));\n carousel.carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.\n moduleData.showFooter = modal.hasFooterContent();\n showModuleHelp(carousel, moduleData, modal);\n }\n }\n });\n });\n};\n\n/**\n * Focus on a chooser option element and remove the previous chooser element from the focus order\n *\n * @method focusChooserOption\n * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus\n * @param {HTMLElement|null} previousChooserOption The previous focused option element\n */\nconst focusChooserOption = (currentChooserOption, previousChooserOption = null) => {\n if (previousChooserOption !== null) {\n toggleFocusableChooserOption(previousChooserOption, false);\n }\n\n toggleFocusableChooserOption(currentChooserOption, true);\n currentChooserOption.focus();\n};\n\n/**\n * Add or remove a chooser option from the focus order.\n *\n * @method toggleFocusableChooserOption\n * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order\n * @param {Boolean} isFocusable Whether the chooser element is focusable or not\n */\nconst toggleFocusableChooserOption = (chooserOption, isFocusable) => {\n const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);\n const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);\n const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);\n\n if (isFocusable) {\n // Set tabindex to 0 to add current chooser option element to the focus order.\n chooserOption.tabIndex = 0;\n chooserOptionLink.tabIndex = 0;\n chooserOptionHelp.tabIndex = 0;\n chooserOptionFavourite.tabIndex = 0;\n } else {\n // Set tabindex to -1 to remove the previous chooser option element from the focus order.\n chooserOption.tabIndex = -1;\n chooserOptionLink.tabIndex = -1;\n chooserOptionHelp.tabIndex = -1;\n chooserOptionFavourite.tabIndex = -1;\n }\n};\n\n/**\n * Render the search results in a defined container\n *\n * @method renderSearchResults\n * @param {HTMLElement} searchResultsContainer The container where the data should be rendered\n * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria\n */\nconst renderSearchResults = async(searchResultsContainer, searchResultsData) => {\n const templateData = {\n 'searchresultsnumber': searchResultsData.length,\n 'searchresults': searchResultsData\n };\n // Build up the html & js ready to place into the help section.\n const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);\n await Templates.replaceNodeContents(searchResultsContainer, html, js);\n};\n\n/**\n * Toggle (display/hide) the search results depending on the value of the search query\n *\n * @method toggleSearchResultsView\n * @param {Object} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {String} searchQuery The search query\n */\nconst toggleSearchResultsView = async(modal, mappedModules, searchQuery) => {\n const modalBody = modal.getBody()[0];\n const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);\n const chooserContainer = modalBody.querySelector(selectors.regions.chooser);\n const clearSearchButton = modalBody.querySelector(selectors.actions.clearSearch);\n\n if (searchQuery.length > 0) { // Search query is present.\n const searchResultsData = searchModules(mappedModules, searchQuery);\n await renderSearchResults(searchResultsContainer, searchResultsData);\n const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);\n const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);\n if (firstSearchResultItem) {\n // Set the first result item to be focusable.\n toggleFocusableChooserOption(firstSearchResultItem, true);\n // Register keyboard events on the created search result items.\n initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal);\n }\n // Display the \"clear\" search button in the activity chooser search bar.\n clearSearchButton.classList.remove('d-none');\n // Hide the default chooser options container.\n chooserContainer.setAttribute('hidden', 'hidden');\n // Display the search results container.\n searchResultsContainer.removeAttribute('hidden');\n } else { // Search query is not present.\n // Hide the \"clear\" search button in the activity chooser search bar.\n clearSearchButton.classList.add('d-none');\n // Hide the search results container.\n searchResultsContainer.setAttribute('hidden', 'hidden');\n // Display the default chooser options container.\n chooserContainer.removeAttribute('hidden');\n }\n};\n\n/**\n * Return the list of modules which have a name or description that matches the given search term.\n *\n * @method searchModules\n * @param {Array} modules List of available modules\n * @param {String} searchTerm The search term to match\n * @return {Array}\n */\nconst searchModules = (modules, searchTerm) => {\n if (searchTerm === '') {\n return modules;\n }\n searchTerm = searchTerm.toLowerCase();\n const searchResults = [];\n modules.forEach((activity) => {\n const activityName = activity.title.toLowerCase();\n const activityDesc = activity.help.toLowerCase();\n if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {\n searchResults.push(activity);\n }\n });\n\n return searchResults;\n};\n\n/**\n * Set up our tabindex information across the chooser.\n *\n * @method setupKeyboardAccessibility\n * @param {Promise} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the built module information\n */\nconst setupKeyboardAccessibility = (modal, mappedModules) => {\n modal.getModal()[0].tabIndex = -1;\n\n modal.getBodyPromise().then(body => {\n $(selectors.elements.tab).on('shown.bs.tab', (e) => {\n const activeSectionId = e.target.getAttribute(\"href\");\n const activeSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = activeSectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n const prevActiveSectionId = e.relatedTarget.getAttribute(\"href\");\n const prevActiveSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));\n\n // Disable the focus of every chooser option in the previous active section.\n disableFocusAllChooserOptions(prevActiveSectionChooserOptions);\n // Enable the focus of the first chooser option in the current active section.\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal);\n });\n return;\n }).catch(Notification.exception);\n};\n\n/**\n * Disable the focus of all chooser options in a specific container (section).\n *\n * @method disableFocusAllChooserOptions\n * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items\n */\nconst disableFocusAllChooserOptions = (sectionChooserOptions) => {\n const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);\n allChooserOptions.forEach((chooserOption) => {\n toggleFocusableChooserOption(chooserOption, false);\n });\n};\n\n/**\n * Display the module chooser.\n *\n * @method displayChooser\n * @param {Promise} modalPromise Our created modal for the section\n * @param {Array} sectionModules An array of all of the built module information\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n * @param {Object} footerData Our base footer object.\n */\nexport const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {\n // Make a map so we can quickly fetch a specific module's object for either rendering or searching.\n const mappedModules = new Map();\n sectionModules.forEach((module) => {\n mappedModules.set(module.componentname + '_' + module.link, module);\n });\n\n // Register event listeners.\n modalPromise.then(modal => {\n registerListenerEvents(modal, mappedModules, partialFavourite, footerData);\n\n // We want to focus on the first chooser option element as soon as the modal is opened.\n setupKeyboardAccessibility(modal, mappedModules);\n\n // We want to focus on the action select when the dialog is closed.\n modal.getRoot().on(ModalEvents.hidden, () => {\n modal.destroy();\n });\n\n return modal;\n }).catch();\n};\n"],"names":["showModuleHelp","carousel","moduleData","modal","showFooter","setFooter","Templates","render","help","find","selectors","regions","innerHTML","classList","add","spinnerPromise","transitionPromiseResolver","transitionPromise","Promise","resolve","contentPromise","renderForPromise","all","then","_ref","html","js","replaceNodeContents","querySelector","chooserSummary","header","focus","catch","Notification","exception","one","registerListenerEvents","mappedModules","partialFavourite","footerData","bodyClickListener","async","e","target","closest","actions","optionActions","showSummary","getBody","moduleName","chooserOption","container","dataset","modname","get","hasFooterContent","manageFavourite","caller","modalBody","isFavourite","favourited","id","name","internal","Repository","unfavouriteModule","favouriteModule","manageFavouriteState","activeSectionId","elements","activetab","getAttribute","sectionChooserOptions","getSectionChooserOptions","firstChooserOption","toggleFocusableChooserOption","initChooserOptionsKeyboardNavigation","matches","closeOption","on","modules","getModuleSelector","clearSearch","searchInput","search","value","toggleSearchResultsView","footerClickListener","footer","footerjs","pluginName","customfooterjs","getBodyPromise","body","interval","pause","keyboard","addEventListener","getFooterPromise","chooserOptionsContainer","chooserOptions","querySelectorAll","Array","from","forEach","element","$element","define","CustomEvents","events","next","previous","home","end","createNavHandler","resolver","data","currentOption","originalEvent","toFocusOption","focusChooserOption","current","nextElementSibling","firstElementChild","previousElementSibling","lastElementChild","keyCode","enter","space","preventDefault","currentChooserOption","previousChooserOption","isFocusable","chooserOptionLink","addChooser","chooserOptionHelp","chooserOptionFavourite","tabIndex","searchQuery","searchResultsContainer","searchResults","chooserContainer","chooser","clearSearchButton","length","searchResultsData","searchModules","templateData","renderSearchResults","searchResultItemsContainer","searchResultItems","firstSearchResultItem","remove","setAttribute","removeAttribute","searchTerm","toLowerCase","activity","activityName","title","activityDesc","includes","push","disableFocusAllChooserOptions","modalPromise","sectionModules","Map","module","set","componentname","link","getModal","tab","activeSectionChooserOptions","prevActiveSectionId","relatedTarget","prevActiveSectionChooserOptions","setupKeyboardAccessibility","getRoot","ModalEvents","hidden","destroy"],"mappings":"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"} \ No newline at end of file diff --git a/course/amd/src/local/activitychooser/dialogue.js b/course/amd/src/local/activitychooser/dialogue.js index 433469a1387a1..b90638f04dd93 100644 --- a/course/amd/src/local/activitychooser/dialogue.js +++ b/course/amd/src/local/activitychooser/dialogue.js @@ -22,10 +22,11 @@ */ import $ from 'jquery'; +import CustomEvents from 'core/custom_interaction_events'; import * as ModalEvents from 'core/modal_events'; import selectors from 'core_course/local/activitychooser/selectors'; import * as Templates from 'core/templates'; -import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes'; +import {enter, space} from 'core/key_codes'; import {addIconToContainer} from 'core/loadingicon'; import * as Repository from 'core_course/local/activitychooser/repository'; import Notification from 'core/notification'; @@ -246,7 +247,54 @@ const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOption const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container); Array.from(chooserOptions).forEach((element) => { - return element.addEventListener('keydown', (e) => { + const $element = $(element); + + // Set up custom interaction events for RTL-aware keyboard navigation. + CustomEvents.define($element, [ + CustomEvents.events.next, + CustomEvents.events.previous, + CustomEvents.events.home, + CustomEvents.events.end, + ]); + + // Handle focus move (automatically handles RTL). + const createNavHandler = (resolver) => (e, data) => { + const currentOption = data.originalEvent.target.closest( + selectors.regions.chooserOption.container + ); + if (currentOption !== null) { + const toFocusOption = resolver(currentOption); + if (toFocusOption) { + focusChooserOption(toFocusOption, currentOption); + } + } + }; + + $element.on( + CustomEvents.events.next, + createNavHandler( + (current) => current.nextElementSibling || chooserOptionsContainer.firstElementChild + ) + ); + + $element.on( + CustomEvents.events.previous, + createNavHandler( + (current) => current.previousElementSibling || chooserOptionsContainer.lastElementChild + ) + ); + + $element.on( + CustomEvents.events.home, + createNavHandler(() => chooserOptionsContainer.firstElementChild) + ); + + $element.on( + CustomEvents.events.end, + createNavHandler(() => chooserOptionsContainer.lastElementChild) + ); + + element.addEventListener('keydown', (e) => { // Check for enter/ space triggers for showing the help. if (e.keyCode === enter || e.keyCode === space) { @@ -267,40 +315,6 @@ const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOption showModuleHelp(carousel, moduleData, modal); } } - - // Next. - if (e.keyCode === arrowRight) { - e.preventDefault(); - const currentOption = e.target.closest(selectors.regions.chooserOption.container); - const nextOption = currentOption.nextElementSibling; - const firstOption = chooserOptionsContainer.firstElementChild; - const toFocusOption = clickErrorHandler(nextOption, firstOption); - focusChooserOption(toFocusOption, currentOption); - } - - // Previous. - if (e.keyCode === arrowLeft) { - e.preventDefault(); - const currentOption = e.target.closest(selectors.regions.chooserOption.container); - const previousOption = currentOption.previousElementSibling; - const lastOption = chooserOptionsContainer.lastElementChild; - const toFocusOption = clickErrorHandler(previousOption, lastOption); - focusChooserOption(toFocusOption, currentOption); - } - - if (e.keyCode === home) { - e.preventDefault(); - const currentOption = e.target.closest(selectors.regions.chooserOption.container); - const firstOption = chooserOptionsContainer.firstElementChild; - focusChooserOption(firstOption, currentOption); - } - - if (e.keyCode === end) { - e.preventDefault(); - const currentOption = e.target.closest(selectors.regions.chooserOption.container); - const lastOption = chooserOptionsContainer.lastElementChild; - focusChooserOption(lastOption, currentOption); - } }); }); }; @@ -348,22 +362,6 @@ const toggleFocusableChooserOption = (chooserOption, isFocusable) => { } }; -/** - * Small error handling function to make sure the navigated to object exists - * - * @method clickErrorHandler - * @param {HTMLElement} item What we want to check exists - * @param {HTMLElement} fallback If we dont match anything fallback the focus - * @return {HTMLElement} - */ -const clickErrorHandler = (item, fallback) => { - if (item !== null) { - return item; - } else { - return fallback; - } -}; - /** * Render the search results in a defined container * From d404adf5002d57171d64a5da3ea177bdd8e7e5bc Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Thu, 11 Dec 2025 17:31:11 +0000 Subject: [PATCH 13/79] MDL-87443 behat: define new Toast message named selector. --- MDL-87443-2025121117305654.yml | 7 +++++++ lib/behat/classes/partial_named_selector.php | 4 ++++ 2 files changed, 11 insertions(+) create mode 100644 MDL-87443-2025121117305654.yml diff --git a/MDL-87443-2025121117305654.yml b/MDL-87443-2025121117305654.yml new file mode 100644 index 0000000000000..0456305686b83 --- /dev/null +++ b/MDL-87443-2025121117305654.yml @@ -0,0 +1,7 @@ +issueNumber: MDL-87443 +notes: + core: + - message: >- + There is a new Behat `toast_message` named selector to more easily + assert the presence of Toast messages on the page + type: improved diff --git a/lib/behat/classes/partial_named_selector.php b/lib/behat/classes/partial_named_selector.php index 6363d443bcd60..44e4882c2ca4c 100644 --- a/lib/behat/classes/partial_named_selector.php +++ b/lib/behat/classes/partial_named_selector.php @@ -127,6 +127,7 @@ public function __construct() { 'table' => 'table', 'table_row' => 'table_row', 'text' => 'text', + 'toast_message' => 'toast_message', 'xpath_element' => 'xpath_element', 'form_row' => 'form_row', 'autocomplete_selection' => 'autocomplete_selection', @@ -279,6 +280,9 @@ public function __construct() { XPATH , 'text' => << << << Date: Thu, 11 Dec 2025 17:51:06 +0000 Subject: [PATCH 14/79] MDL-87443 reportbuilder: use Toast selector in Behat scenarios. --- reportbuilder/tests/behat/audience.feature | 26 +++++++++---------- reportbuilder/tests/behat/cardview.feature | 4 +-- .../behat/columnaggregationeditor.feature | 14 +++++----- .../tests/behat/columneditor.feature | 10 +++---- .../tests/behat/columnsortingeditor.feature | 19 ++++++++------ .../tests/behat/conditioneditor.feature | 14 +++++----- .../tests/behat/customreports.feature | 11 ++++---- .../tests/behat/filtereditor.feature | 15 ++++++----- reportbuilder/tests/behat/schedules.feature | 8 +++--- 9 files changed, 63 insertions(+), 58 deletions(-) diff --git a/reportbuilder/tests/behat/audience.feature b/reportbuilder/tests/behat/audience.feature index 887d58bec36db..8787c6a0e91ba 100644 --- a/reportbuilder/tests/behat/audience.feature +++ b/reportbuilder/tests/behat/audience.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Configure access to reports based on intended audience As an admin I want to restrict which users have access to a report @@ -27,12 +27,12 @@ Feature: Configure access to reports based on intended audience And I click on the "Audience" dynamic tab And I should see "There are no audiences for this report" And I click on "Add audience 'Manually added users'" "link" - And I should see "Added audience 'Manually added users'" + And "Added audience 'Manually added users'" "toast_message" should be visible And I should see "Audience not saved" in the "Manually added users" "core_reportbuilder > Audience" And I set the following fields in the "Manually added users" "core_reportbuilder > Audience" to these values: | Add users manually | User 1,User 3 | And I press "Save changes" - And I should see "Audience saved" + And "Audience saved" "toast_message" should be visible And I should not see "Audience not saved" in the "Manually added users" "core_reportbuilder > Audience" And I should see "User 1, User 3" in the "Manually added users" "core_reportbuilder > Audience" And I should not see "There are no audiences for this report" @@ -52,7 +52,7 @@ Feature: Configure access to reports based on intended audience And I click on the "Audience" dynamic tab When I click on "Add audience 'Site administrators'" "link" And I press "Save changes" - Then I should see "Audience saved" + Then "Audience saved" "toast_message" should be visible And I click on the "Access" dynamic tab And I should see "Admin User" in the "reportbuilder-table" "table" And I should not see "User 1" in the "reportbuilder-table" "table" @@ -66,11 +66,11 @@ Feature: Configure access to reports based on intended audience And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I click on the "Audience" dynamic tab When I click on "Add audience 'Assigned system role'" "link" - And I should see "Added audience 'Assigned system role'" + And "Added audience 'Assigned system role'" "toast_message" should be visible And I set the following fields in the "Assigned system role" "core_reportbuilder > Audience" to these values: | Select a role | Manager | And I press "Save changes" - Then I should see "Audience saved" + Then "Audience saved" "toast_message" should be visible And I should see "Manager" in the "Assigned system role" "core_reportbuilder > Audience" And I click on the "Access" dynamic tab And I should not see "User 1" in the "reportbuilder-table" "table" @@ -90,11 +90,11 @@ Feature: Configure access to reports based on intended audience And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I click on the "Audience" dynamic tab When I click on "Add audience 'Member of cohort'" "link" - And I should see "Added audience 'Member of cohort'" + And "Added audience 'Member of cohort'" "toast_message" should be visible And I set the following fields in the "Member of cohort" "core_reportbuilder > Audience" to these values: | Select members from cohort | Cohort1 | And I press "Save changes" - Then I should see "Audience saved" + Then "Audience saved" "toast_message" should be visible And I should see "Cohort1" in the "Member of cohort" "core_reportbuilder > Audience" And I click on the "Access" dynamic tab And I should not see "User 1" in the "reportbuilder-table" "table" @@ -134,9 +134,9 @@ Feature: Configure access to reports based on intended audience Then I should see "All users" in the "[data-region=sidebar-menu]" "css_element" And I should not see "Member of cohort" in the "[data-region=sidebar-menu]" "css_element" And I click on "Add audience 'All users'" "link" - And I should see "Added audience 'All users'" + And "Added audience 'All users'" "toast_message" should be visible And I press "Save changes" - And I should see "Audience saved" + And "Audience saved" "toast_message" should be visible Scenario: Rename report audience Given I am on the "My report" "reportbuilder > Editor" page logged in as "admin" @@ -167,7 +167,7 @@ Feature: Configure access to reports based on intended audience When I click on "Delete audience 'All users'" "button" And I should see "Are you sure you want to delete the audience 'All users'?" in the "Delete audience 'All users'" "dialogue" And I click on "Delete" "button" in the "Delete audience 'All users'" "dialogue" - Then I should see "Deleted audience 'All users'" + Then "Deleted audience 'All users'" "toast_message" should be visible And "All users" "core_reportbuilder > Audience" should not exist And I should see "There are no audiences for this report" @@ -190,7 +190,7 @@ Feature: Configure access to reports based on intended audience And I click on "Delete audience 'All users'" "button" And I should see "This audience is used in a schedule for this report" in the "Delete audience 'All users'" "dialogue" And I click on "Delete" "button" in the "Delete audience 'All users'" "dialogue" - And I should see "Deleted audience 'All users'" + And "Deleted audience 'All users'" "toast_message" should be visible And "All users" "core_reportbuilder > Audience" should not exist And I should see "There are no audiences for this report" @@ -209,7 +209,7 @@ Feature: Configure access to reports based on intended audience And I set the following fields in the "Manually added users" "core_reportbuilder > Audience" to these values: | Add users manually | User 2 | And I press "Save changes" - Then I should see "Audience saved" + Then "Audience saved" "toast_message" should be visible And I should see "User 2" in the "Manually added users" "core_reportbuilder > Audience" And I click on the "Access" dynamic tab And I should not see "User 1" in the "reportbuilder-table" "table" diff --git a/reportbuilder/tests/behat/cardview.feature b/reportbuilder/tests/behat/cardview.feature index a2ccbe6a2d3a7..c75e060969884 100644 --- a/reportbuilder/tests/behat/cardview.feature +++ b/reportbuilder/tests/behat/cardview.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Manage card view settings in the report editor In order to manage a report card view settings As an admin @@ -29,7 +29,7 @@ Feature: Manage card view settings in the report editor | Columns visible | 3 | | First column title | Yes | And I press "Save changes" - And I should see "Card view settings saved" + And "Card view settings saved" "toast_message" should be visible # Let's check that after switching to preview mode card view form gets rendered again. And I click on "Switch to preview mode" "button" And I click on "Switch to edit mode" "button" diff --git a/reportbuilder/tests/behat/columnaggregationeditor.feature b/reportbuilder/tests/behat/columnaggregationeditor.feature index b0129d4be6e1b..cb536f07cebd0 100644 --- a/reportbuilder/tests/behat/columnaggregationeditor.feature +++ b/reportbuilder/tests/behat/columnaggregationeditor.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Manage custom report columns aggregation In order to manage the aggregation for columns of custom reports As an admin @@ -22,7 +22,7 @@ Feature: Manage custom report columns aggregation And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I change window size to "large" When I set the "First name" column aggregation to "" - Then I should see "Aggregated column 'First name'" + Then "Aggregated column 'First name'" "toast_message" should be visible And I should see "" in the "Richie" "table_row" Examples: | aggregation | output | @@ -42,7 +42,7 @@ Feature: Manage custom report columns aggregation And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I change window size to "large" When I set the "Full name" column aggregation to "" - Then I should see "Aggregated column 'Full name'" + Then "Aggregated column 'Full name'" "toast_message" should be visible And I should see "" in the "Richie" "table_row" Examples: | aggregation | output | @@ -62,7 +62,7 @@ Feature: Manage custom report columns aggregation And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I change window size to "large" When I set the "Last access" column aggregation to "" - Then I should see "Aggregated column 'Last access'" + Then "Aggregated column 'Last access'" "toast_message" should be visible And I should see "" in the "Richie" "table_row" Examples: | aggregation | output | @@ -82,7 +82,7 @@ Feature: Manage custom report columns aggregation And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I change window size to "large" When I set the "Confirmed" column aggregation to "" - Then I should see "Aggregated column 'Confirmed'" + Then "Aggregated column 'Confirmed'" "toast_message" should be visible And I should see "" in the "Richie" "table_row" Examples: | aggregation | output | @@ -110,7 +110,7 @@ Feature: Manage custom report columns aggregation And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I change window size to "large" When I set the "Confirmed" column aggregation to "" - Then I should see "Aggregated column 'Confirmed'" + Then "Aggregated column 'Confirmed'" "toast_message" should be visible And I should see "" in the "Richie" "table_row" Examples: | aggregation | output | @@ -131,7 +131,7 @@ Feature: Manage custom report columns aggregation And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" And I change window size to "large" When I set the "First name" column aggregation to "" - Then I should see "Aggregated column 'First name'" + Then "Aggregated column 'First name'" "toast_message" should be visible And I should see "" in the "Richie" "table_row" Examples: | aggregation | output | diff --git a/reportbuilder/tests/behat/columneditor.feature b/reportbuilder/tests/behat/columneditor.feature index 82fb7bc31e4fb..94968508864bd 100644 --- a/reportbuilder/tests/behat/columneditor.feature +++ b/reportbuilder/tests/behat/columneditor.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Manage custom report columns In order to manage the columns of custom reports As an admin @@ -10,7 +10,7 @@ Feature: Manage custom report columns | My report | core_user\reportbuilder\datasource\users | 0 | And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" When I click on "Add column 'Full name'" "link" - Then I should see "Added column 'Full name'" + Then "Added column 'Full name'" "toast_message" should be visible And I should see "Full name" in the "reportbuilder-table" "table" Scenario: Search for and add column to report @@ -23,7 +23,7 @@ Feature: Manage custom report columns Then I should see "Last name" in the "[data-region=sidebar-menu]" "css_element" And I should not see "Email address" in the "[data-region=sidebar-menu]" "css_element" And I click on "Add column 'Last name'" "link" - And I should see "Added column 'Last name'" + And "Added column 'Last name'" "toast_message" should be visible And I should see "Last name" in the "reportbuilder-table" "table" Scenario: Rename column in report @@ -66,7 +66,7 @@ Feature: Manage custom report columns And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" When I click on "Move column 'Last access'" "button" And I click on "After \"Full name\"" "link" in the "Move column 'Last access'" "dialogue" - Then I should see "Moved column 'Last access'" + Then "Moved column 'Last access'" "toast_message" should be visible And "Last access" "text" should appear before "Email address" "text" Scenario: Delete column from report @@ -79,5 +79,5 @@ Feature: Manage custom report columns And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" When I click on "Delete column 'Full name'" "button" And I click on "Delete" "button" in the "Delete column 'Full name'" "dialogue" - Then I should see "Deleted column 'Full name'" + Then "Deleted column 'Full name'" "toast_message" should be visible And I should see "Nothing to display" diff --git a/reportbuilder/tests/behat/columnsortingeditor.feature b/reportbuilder/tests/behat/columnsortingeditor.feature index e07eedcdd8f05..b3ced9e9c8063 100644 --- a/reportbuilder/tests/behat/columnsortingeditor.feature +++ b/reportbuilder/tests/behat/columnsortingeditor.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Manage custom report columns sorting In order to manage the sorting for columns of custom reports As an admin @@ -26,10 +26,11 @@ Feature: Manage custom report columns sorting # This will be the fallback sort after toggling lastname sorting. And I click on "Enable initial sorting for column 'First name'" "checkbox" When I click on "Enable initial sorting for column 'Last name'" "checkbox" - Then I should see "Updated sorting for column 'Last name'" + Then "Updated sorting for column 'Last name'" "toast_message" should be visible And "user02" "table_row" should appear before "user01" "table_row" + And I wait until "Updated sorting for column 'Last name'" "toast_message" does not exist And I click on "Disable initial sorting for column 'Last name'" "checkbox" - And I should see "Updated sorting for column 'Last name'" + And "Updated sorting for column 'Last name'" "toast_message" should be visible And "user01" "table_row" should appear before "user02" "table_row" Scenario: Change column sort direction in report @@ -37,10 +38,11 @@ Feature: Manage custom report columns sorting And I click on "Show/hide 'Sorting'" "button" When I click on "Enable initial sorting for column 'Last name'" "checkbox" And I click on "Change initial sorting for column 'Last name' to descending" "button" - Then I should see "Updated sorting for column 'Last name'" + Then "Updated sorting for column 'Last name'" "toast_message" should be visible And "user01" "table_row" should appear before "user02" "table_row" + And I wait until "Updated sorting for column 'Last name'" "toast_message" does not exist And I click on "Change initial sorting for column 'Last name' to ascending" "button" - And I should see "Updated sorting for column 'Last name'" + And "Updated sorting for column 'Last name'" "toast_message" should be visible And "user02" "table_row" should appear before "user01" "table_row" Scenario: Change column sort order in report @@ -50,7 +52,7 @@ Feature: Manage custom report columns sorting And I click on "Enable initial sorting for column 'First name'" "checkbox" And I click on "Move sorting for column 'First name'" "button" And I click on "To the top of the list" "link" in the "Move sorting for column 'First name'" "dialogue" - Then I should see "Updated sorting for column 'First name'" + Then "Updated sorting for column 'First name'" "toast_message" should be visible And "First name" "text" should appear before "Last name" "text" in the "#settingssorting" "css_element" And "user01" "table_row" should appear before "user02" "table_row" @@ -59,12 +61,13 @@ Feature: Manage custom report columns sorting And I click on "Add column 'Full name'" "link" And I click on "Show/hide 'Sorting'" "button" When I click on "Enable initial sorting for column 'Full name'" "checkbox" - Then I should see "Updated sorting for column 'Full name'" + Then "Updated sorting for column 'Full name'" "toast_message" should be visible # User1 = Alice Zebra; User2=Zoe Aardvark; User3 = Alice Badger. And "user03" "table_row" should appear before "user01" "table_row" And "user01" "table_row" should appear before "user02" "table_row" + And I wait until "Updated sorting for column 'Full name'" "toast_message" does not exist And I click on "Change initial sorting for column 'Full name' to descending" "button" - And I should see "Updated sorting for column 'Full name'" + And "Updated sorting for column 'Full name'" "toast_message" should be visible And "user02" "table_row" should appear before "user01" "table_row" And "user01" "table_row" should appear before "user03" "table_row" diff --git a/reportbuilder/tests/behat/conditioneditor.feature b/reportbuilder/tests/behat/conditioneditor.feature index 2295ca5daf965..30207aae75769 100644 --- a/reportbuilder/tests/behat/conditioneditor.feature +++ b/reportbuilder/tests/behat/conditioneditor.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Manage custom report conditions In order to manage the conditions of custom reports As an admin @@ -23,13 +23,13 @@ Feature: Manage custom report conditions When I click on "Show/hide 'Conditions'" "button" Then I should see "There are no conditions selected" in the "[data-region='settings-conditions']" "css_element" And I set the field "Select a condition" to "Email address" - And I should see "Added condition 'Email address'" + And "Added condition 'Email address'" "toast_message" should be visible And I should not see "There are no conditions selected" in the "[data-region='settings-conditions']" "css_element" And I set the following fields in the "Email address" "core_reportbuilder > Condition" to these values: | Email address operator | Does not contain | | Email address value | user02 | And I click on "Apply" "button" in the "[data-region='settings-conditions']" "css_element" - And I should see "Conditions applied" + And "Conditions applied" "toast_message" should be visible And I should see "User One" in the "reportbuilder-table" "table" And I should not see "User Two" in the "reportbuilder-table" "table" @@ -43,7 +43,7 @@ Feature: Manage custom report conditions | Tag name operator | Is equal to | | Tag name value | dancing | And I click on "Apply" "button" in the "[data-region='settings-conditions']" "css_element" - Then I should see "Conditions applied" + Then "Conditions applied" "toast_message" should be visible And I should see "User One" in the "reportbuilder-table" "table" And I should not see "User Two" in the "reportbuilder-table" "table" @@ -57,7 +57,7 @@ Feature: Manage custom report conditions When I click on "Show/hide 'Conditions'" "button" And I click on "Move condition 'Country'" "button" And I click on "After \"Full name\"" "link" in the "Move condition 'Country'" "dialogue" - Then I should see "Moved condition 'Country'" + Then "Moved condition 'Country'" "toast_message" should be visible And "Country" "text" should appear before "Email address" "text" Scenario: Delete condition from report @@ -73,7 +73,7 @@ Feature: Manage custom report conditions And I click on "Apply" "button" in the "[data-region='settings-conditions']" "css_element" And I click on "Delete condition 'Email address'" "button" And I click on "Delete" "button" in the "Delete condition 'Email address'" "dialogue" - Then I should see "Deleted condition 'Email address'" + Then "Deleted condition 'Email address'" "toast_message" should be visible And I should see "There are no conditions selected" in the "[data-region='settings-conditions']" "css_element" And "[data-region='active-conditions']" "css_element" should not exist And I should see "User One" in the "reportbuilder-table" "table" @@ -93,7 +93,7 @@ Feature: Manage custom report conditions And I should see "Nothing to display" And I click on "Reset all" "button" in the "[data-region='settings-conditions']" "css_element" And I click on "Reset all" "button" in the "Reset conditions" "dialogue" - Then I should see "Conditions reset" + Then "Conditions reset" "toast_message" should be visible And the following fields in the "Email address" "core_reportbuilder > Condition" match these values: | Email address operator | Is any value | And I should see "User One" in the "reportbuilder-table" "table" diff --git a/reportbuilder/tests/behat/customreports.feature b/reportbuilder/tests/behat/customreports.feature index 88285d76879de..646f3ffffbffe 100644 --- a/reportbuilder/tests/behat/customreports.feature +++ b/reportbuilder/tests/behat/customreports.feature @@ -146,7 +146,7 @@ Feature: Manage custom reports | Name | My renamed report | | Tags | Cat, Dog | And I click on "Save" "button" in the "Edit report details" "dialogue" - Then I should see "Report updated" + Then "Report updated" "toast_message" should be visible And the following should exist in the "Reports list" table: | Name | Tags | Report source | | My renamed report | Cat, Dog | Users | @@ -167,7 +167,7 @@ Feature: Manage custom reports | operator | Is equal to | | value | | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" - Then I should see "Filters applied" + Then "Filters applied" "toast_message" should be visible And the following should exist in the "Reports list" table: | Name | Tags | Report source | | My users | Cat, Dog | Users | @@ -190,7 +190,7 @@ Feature: Manage custom reports | Time created from | ##2 days ago## | | Time created to | ##tomorrow## | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" - Then I should see "Filters applied" + Then "Filters applied" "toast_message" should be visible And I should see "My report" in the "Reports list" "table" And I set the field "Time created to" in the "Time created" "core_reportbuilder > Filter" to "##yesterday##" And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" @@ -208,13 +208,14 @@ Feature: Manage custom reports | Name operator | Contains | | Name value | Lionel | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" + Then "Filters applied" "toast_message" should be visible And I should see "Filters (1)" in the "#dropdownFiltersButton" "css_element" And I should see "Nothing to display" And I click on "Reset all" "button" in the "[data-region='report-filters']" "css_element" And I should not see "Filters (1)" in the "#dropdownFiltersButton" "css_element" And I should see "Filters" in the "#dropdownFiltersButton" "css_element" And "[data-region='report-filters']" "css_element" should be visible - Then I should see "Filters reset" + Then "Filters reset" "toast_message" should be visible And the following fields in the "Name" "core_reportbuilder > Filter" match these values: | Name operator | Is any value | And I should see "My report" in the "Reports list" "table" @@ -242,7 +243,7 @@ Feature: Manage custom reports When I navigate to "Reports > Report builder > Custom reports" in site administration And I press "Delete report" action in the "My report" report row And I click on "Delete" "button" in the "Delete report" "dialogue" - Then I should see "Report deleted" + Then "Report deleted" "toast_message" should be visible And I should see "Nothing to display" Scenario: Switch between Preview and Edit mode diff --git a/reportbuilder/tests/behat/filtereditor.feature b/reportbuilder/tests/behat/filtereditor.feature index 0a23e22b37c6c..e5e48c17b6e2d 100644 --- a/reportbuilder/tests/behat/filtereditor.feature +++ b/reportbuilder/tests/behat/filtereditor.feature @@ -1,4 +1,4 @@ -@core_reportbuilder @javascript +@core @core_reportbuilder @javascript Feature: Manage custom report filters In order to manage the filters of custom reports As an admin @@ -12,7 +12,7 @@ Feature: Manage custom report filters When I click on "Show/hide 'Filters'" "button" Then I should see "There are no filters selected" in the "[data-region='active-filters']" "css_element" And I set the field "Select a filter" to "Email address" - And I should see "Added filter 'Email address'" + And "Added filter 'Email address'" "toast_message" should be visible And I should not see "There are no filters selected" in the "[data-region='active-filters']" "css_element" And I should see "Email address" in the "[data-region='active-filters']" "css_element" @@ -74,7 +74,7 @@ Feature: Manage custom report filters When I click on "Show/hide 'Filters'" "button" And I click on "Move filter 'Country'" "button" And I click on "After \"Full name\"" "link" in the "Move filter 'Country'" "dialogue" - Then I should see "Moved filter 'Country'" + Then "Moved filter 'Country'" "toast_message" should be visible And "Country" "text" should appear before "Email address" "text" Scenario: Delete filter from report @@ -88,7 +88,7 @@ Feature: Manage custom report filters And I click on "Show/hide 'Filters'" "button" And I click on "Delete filter 'Email address'" "button" And I click on "Delete" "button" in the "Delete filter 'Email address'" "dialogue" - Then I should see "Deleted filter 'Email address'" + Then "Deleted filter 'Email address'" "toast_message" should be visible And I should see "There are no filters selected" in the "[data-region='active-filters']" "css_element" And I should not see "Email address" in the "[data-region='active-filters']" "css_element" @@ -102,13 +102,14 @@ Feature: Manage custom report filters | Full name operator | Contains | | Full name value | Lionel | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" + And "Filters applied" "toast_message" should be visible And I should see "Filters (1)" in the "#dropdownFiltersButton" "css_element" And I should see "Nothing to display" And I click on "Reset all" "button" in the "[data-region='report-filters']" "css_element" And I should not see "Filters (1)" in the "#dropdownFiltersButton" "css_element" And I should see "Filters" in the "#dropdownFiltersButton" "css_element" And "[data-region='report-filters']" "css_element" should be visible - Then I should see "Filters reset" + Then "Filters reset" "toast_message" should be visible And the following fields in the "Full name" "core_reportbuilder > Filter" match these values: | Full name operator | Is any value | And I should see "Admin User" in the "Users" "table" @@ -140,7 +141,7 @@ Feature: Manage custom report filters | Full name operator | Does not contain | | Full name value | User 2 | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" - Then I should see "Filters applied" + Then "Filters applied" "toast_message" should be visible And I should see "Filters (1)" in the "#dropdownFiltersButton" "css_element" And the following should exist in the "reportbuilder-table" table: | Full name | Email address | @@ -191,7 +192,7 @@ Feature: Manage custom report filters | Email address operator | Is not equal to | | Email address value | user2@example.com | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" - Then I should see "Filters applied" + Then "Filters applied" "toast_message" should be visible And I should see "Filters (1)" in the "#dropdownFiltersButton" "css_element" # Assert we haven't overridden the condition and user3 is still not showing in the report. And the following should exist in the "reportbuilder-table" table: diff --git a/reportbuilder/tests/behat/schedules.feature b/reportbuilder/tests/behat/schedules.feature index 1b9c5bc1593d7..644cb1a0516b7 100644 --- a/reportbuilder/tests/behat/schedules.feature +++ b/reportbuilder/tests/behat/schedules.feature @@ -39,7 +39,7 @@ Feature: Manage custom report schedules And I should see "All my lovely users" in the "New schedule" "dialogue" And I set the field "Manually added users: User One, User Two" to "1" And I click on "Save" "button" in the "New schedule" "dialogue" - Then I should see "Schedule created" + Then "Schedule created" "toast_message" should be visible And the following should exist in the "Report schedules" table: | Name | Starting from | Time last sent | Modified by | | My schedule | ##tomorrow 11:00##%A, %d %B %Y, %H:%M## | Never | Admin User | @@ -108,7 +108,7 @@ Feature: Manage custom report schedules | Starting from | ##tomorrow 11:00## | | All users: All site users | 1 | And I click on "Save" "button" in the "Edit schedule details" "dialogue" - Then I should see "Schedule updated" + Then "Schedule updated" "toast_message" should be visible And the following should exist in the "Report schedules" table: | Name | Starting from | | My updated schedule | ##tomorrow 11:00##%A, %d %B %Y, %H:%M## | @@ -121,7 +121,7 @@ Feature: Manage custom report schedules And I click on the "Schedules" dynamic tab When I press "Send schedule" action in the "My schedule" report row And I click on "Confirm" "button" in the "Send schedule" "dialogue" - Then I should see "Schedule sent" + Then "Schedule sent" "toast_message" should be visible Scenario: Delete report schedule Given the following "core_reportbuilder > Schedules" exist: @@ -131,5 +131,5 @@ Feature: Manage custom report schedules And I click on the "Schedules" dynamic tab When I press "Delete schedule" action in the "My schedule" report row And I click on "Delete" "button" in the "Delete schedule" "dialogue" - Then I should see "Schedule deleted" + Then "Schedule deleted" "toast_message" should be visible And I should see "Nothing to display" From 0620c07639320718ba79b8435aecd7bc5b4a4a77 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 10 Dec 2025 11:53:17 -0800 Subject: [PATCH 15/79] MDL-87415 profile: set page title when accessed as guest user. --- lang/en/moodle.php | 1 + user/view.php | 1 + 2 files changed, 2 insertions(+) diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 2088a1ceaacdd..26564594241d4 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1245,6 +1245,7 @@

If someone else has already chosen your username then you\'ll have to try again using a different username.

'; $string['loginto'] = 'Log in to {$a}'; $string['loginagain'] = 'Log in again'; +$string['loginrequired'] = 'Login required'; $string['logoof'] = 'Logo of {$a}'; $string['logout'] = 'Log out'; $string['logoutconfirm'] = 'Do you really want to log out?'; diff --git a/user/view.php b/user/view.php index 2390bc590225f..f724a95d65d62 100644 --- a/user/view.php +++ b/user/view.php @@ -66,6 +66,7 @@ // Guests do not have permissions to view anyone's profile if forceloginforprofiles is set. if (isguestuser()) { $PAGE->set_secondary_navigation(false); + $PAGE->set_title(get_string('loginrequired')); echo $OUTPUT->header(); echo $OUTPUT->confirm(get_string('guestcantaccessprofiles', 'error'), get_login_url(), From 20251783434a783c74a44ffc039f26b116e262ca Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Fri, 12 Dec 2025 18:00:50 -0800 Subject: [PATCH 16/79] MDL-86436 navigation: hide empty page heading menu. Co-authored-by: Jun Pataleta --- theme/boost/templates/navbar.mustache | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/theme/boost/templates/navbar.mustache b/theme/boost/templates/navbar.mustache index 281288d7a5bc5..28f7848db16f7 100644 --- a/theme/boost/templates/navbar.mustache +++ b/theme/boost/templates/navbar.mustache @@ -77,10 +77,12 @@ {{/primarymoremenu}} - + {{# output.page_heading_menu}} + + {{/output.page_heading_menu}}