{{/isfirstunread}}
@@ -193,7 +193,7 @@
class="post-actions d-flex align-self-end justify-content-end flex-wrap ms-auto p-1"
data-region="post-actions-container"
role="menubar"
- aria-label='{{#str}} postbyuser, mod_forum, {"post": "{{subject}}", "user": "{{author.fullname}}"} {{/str}}'
+ aria-label="{{label}}"
aria-controls="p{{id}}"
>
{{#capabilities}}
diff --git a/public/mod/forum/tests/behat/h5p_inline_editing_content.feature b/public/mod/forum/tests/behat/h5p_inline_editing_content.feature
index c84d49bd9772c..3646fa8360bb0 100644
--- a/public/mod/forum/tests/behat/h5p_inline_editing_content.feature
+++ b/public/mod/forum/tests/behat/h5p_inline_editing_content.feature
@@ -43,8 +43,8 @@ Feature: Inline editing H5P content in mod_forum
And I click on "Select this file" "button"
And I click on "Insert H5P" "button" in the "Insert H5P content" "dialogue"
And I click on "Save and display" "button"
- And I switch to "h5p-iframe" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Hello world!"
And I switch to the main frame
# The Edit button is only displayed when editing mode is on.
@@ -89,8 +89,8 @@ Feature: Inline editing H5P content in mod_forum
And I click on "Select this file" "button"
And I click on "Insert H5P" "button" in the "Insert H5P content" "dialogue"
And I press "Save changes"
- And I switch to "h5p-iframe" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Hello world!"
And I switch to the main frame
# The Edit button is only displayed when editing mode is on.
diff --git a/public/mod/forum/tests/custom_completion_test.php b/public/mod/forum/tests/custom_completion_test.php
index 342f1b013dbcf..45f4bc2ae80cc 100644
--- a/public/mod/forum/tests/custom_completion_test.php
+++ b/public/mod/forum/tests/custom_completion_test.php
@@ -258,13 +258,12 @@ public function test_get_available_custom_rules(int $status, array $expected): v
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of magic getter for the customdata attribute.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->with('customdata')
+ ->method('get_custom_data')
->willReturn($customdataval);
$customcompletion = new custom_completion($mockcminfo, 1);
diff --git a/public/mod/forum/tests/exporters_post_test.php b/public/mod/forum/tests/exporters_post_test.php
index 920cf72d19503..fcade1f49d796 100644
--- a/public/mod/forum/tests/exporters_post_test.php
+++ b/public/mod/forum/tests/exporters_post_test.php
@@ -159,6 +159,7 @@ public function test_export_post($istimed = false, $addtime = 0): void {
$this->assertEquals('This is the subject', $exportedpost->subject);
$this->assertEquals('This is the message', $exportedpost->message);
+ $this->assertEquals('This is the subject by ' . $exportedpost->author->fullname, $exportedpost->label);
$this->assertEquals($user->id, $exportedpost->author->id);
$this->assertEquals($discussion->get_id(), $exportedpost->discussionid);
$this->assertEquals(false, $exportedpost->hasparent);
@@ -314,6 +315,7 @@ public function test_export_deleted_post(): void {
$this->assertNotEquals('This is the subject', $exportedpost->subject);
$this->assertNotEquals('This is the message', $exportedpost->message);
+ $this->assertEquals('This forum post has been removed', $exportedpost->label);
$this->assertEquals(null, $exportedpost->timecreated);
$this->assertEquals(null, $exportedpost->unread);
$this->assertEquals(true, $exportedpost->isdeleted);
@@ -422,6 +424,7 @@ public function test_export_post_no_view_capability(): void {
$this->assertNotEquals('This is the subject', $exportedpost->subject);
$this->assertNotEquals('This is the message', $exportedpost->message);
+ $this->assertEquals('Subject (hidden)', $exportedpost->label);
$this->assertEquals(null, $exportedpost->timecreated);
$this->assertEquals(null, $exportedpost->unread);
$this->assertEquals(false, $exportedpost->isdeleted);
diff --git a/public/mod/forum/tests/externallib_test.php b/public/mod/forum/tests/externallib_test.php
index 90d0d7b856a8e..4eb596bc0aaed 100644
--- a/public/mod/forum/tests/externallib_test.php
+++ b/public/mod/forum/tests/externallib_test.php
@@ -535,6 +535,10 @@ public function test_mod_forum_get_discussion_posts(): void {
'markasunread' => null,
'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id($discussion1reply2->discussion),
],
+ 'label' => get_string(
+ 'postbyuser', 'mod_forum',
+ ['post' => $discussion1reply2->subject, 'user' => $exporteduser3['fullname']]
+ ),
);
@@ -596,6 +600,10 @@ public function test_mod_forum_get_discussion_posts(): void {
'markasunread' => null,
'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id($discussion1reply1->discussion),
],
+ 'label' => get_string(
+ 'postbyuser', 'mod_forum',
+ ['post' => $discussion1reply1->subject, 'user' => $exporteduser2['fullname']]
+ ),
);
// Test a discussion with two additional posts (total 3 posts).
@@ -2524,6 +2532,10 @@ public function test_mod_forum_get_discussion_posts_by_userid(): void {
'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id(
$discussion1reply1->discussion)->out(false),
],
+ 'label' => get_string(
+ 'postbyuser', 'mod_forum',
+ ['post' => $discussion1reply1->subject, 'user' => $exporteduser2['fullname']]
+ ),
]
],
'parentposts' => [
@@ -2587,6 +2599,10 @@ public function test_mod_forum_get_discussion_posts_by_userid(): void {
'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id(
$discussion1firstpostobject->discussion)->out(false),
],
+ 'label' => get_string(
+ 'postbyuser', 'mod_forum',
+ ['post' => $discussion1firstpostobject->subject, 'user' => $exporteduser1['fullname']]
+ ),
]
],
],
@@ -2667,6 +2683,10 @@ public function test_mod_forum_get_discussion_posts_by_userid(): void {
'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id(
$discussion2reply1->discussion)->out(false),
],
+ 'label' => get_string(
+ 'postbyuser', 'mod_forum',
+ ['post' => $discussion2reply1->subject, 'user' => $exporteduser2['fullname']]
+ ),
]
],
'parentposts' => [
@@ -2730,7 +2750,11 @@ public function test_mod_forum_get_discussion_posts_by_userid(): void {
'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id(
$discussion2firstpostobject->discussion)->out(false),
- ]
+ ],
+ 'label' => get_string(
+ 'postbyuser', 'mod_forum',
+ ['post' => $discussion2firstpostobject->subject, 'user' => $exporteduser1['fullname']]
+ ),
],
]
],
diff --git a/public/mod/glossary/classes/output/standard_action_bar.php b/public/mod/glossary/classes/output/standard_action_bar.php
index a5ae6f083b7b1..d50136f6f35a5 100644
--- a/public/mod/glossary/classes/output/standard_action_bar.php
+++ b/public/mod/glossary/classes/output/standard_action_bar.php
@@ -194,15 +194,16 @@ private function get_additional_tools(renderer_base $output): array {
}
if (has_capability('mod/glossary:manageentries', $this->context) or $this->module->allowprintview) {
- $params = array(
+ $params = [
'id' => $this->cm->id,
'mode' => $this->mode,
'hook' => $this->hook,
'sortkey' => $this->sortkey,
'sortorder' => $this->sortorder,
'offset' => $this->offset,
- 'pagelimit' => $this->pagelimit
- );
+ 'pagelimit' => $this->pagelimit,
+ 'fullsearch' => $this->fullsearch,
+ ];
$printurl = new moodle_url('/mod/glossary/print.php', $params);
$buttons[get_string('printerfriendly', 'glossary')] = $printurl->out(false);
$openinnewwindow[] = $printurl->out(false);
diff --git a/public/mod/glossary/lib.php b/public/mod/glossary/lib.php
index ba366627da7de..19deaa6eb3835 100644
--- a/public/mod/glossary/lib.php
+++ b/public/mod/glossary/lib.php
@@ -2684,8 +2684,8 @@ function glossary_get_paging_bar($totalcount, $page, $perpage, $baseurl, $maxpag
$specialselected = true;
}
- //If there are results (more than 1 page)
- if ($totalcount > $perpage) {
+ // Build pagination code if there is more than 1 page and we are not viewing ALL entries.
+ if ($totalcount > $perpage && $page !== -1) {
$code .= "
";
$code .= "
".get_string("page").":";
@@ -3553,12 +3553,11 @@ function glossary_get_entries_by_letter($glossary, $context, $letter, $from, $li
$count = count($entries);
// Now applying limit.
- if (isset($limit)) {
- if (isset($from)) {
- $entries = array_slice($filteredentries, $from, $limit);
- } else {
- $entries = array_slice($filteredentries);
- }
+ $from = $from ?? 0;
+ if ($limit > 0) {
+ $entries = array_slice($filteredentries, $from, $limit);
+ } else if ($from > 0) {
+ $entries = array_slice($filteredentries, $from);
} else {
$entries = $filteredentries;
}
@@ -3958,14 +3957,21 @@ function glossary_get_entries_by_search($glossary, $context, $query, $fullsearch
$params['myid'] = $USER->id;
}
+ $sortbyconcept = true;
if ($order == 'CREATION') {
+ $sortbyconcept = false;
$sqlorderby = "ORDER BY ge.timecreated $sort";
} else if ($order == 'UPDATE') {
+ $sortbyconcept = false;
$sqlorderby = "ORDER BY ge.timemodified $sort";
} else {
- $sqlorderby = "ORDER BY ge.concept $sort";
+ // Sort by ID at DB level and perform locale-aware concept sorting below.
+ $sqlorderby = "ORDER BY ge.id ASC";
+ }
+ // Sort on ID to avoid random ordering when entries share an ordering value (only add if not already sorting by ID).
+ if ($sortbyconcept === false) {
+ $sqlorderby .= " , ge.id ASC";
}
- $sqlorderby .= " , ge.id ASC"; // Sort on ID to avoid random ordering when entries share an ordering value.
$sqlwhere = "WHERE ($searchcond) $approvedsql";
@@ -3973,7 +3979,38 @@ function glossary_get_entries_by_search($glossary, $context, $query, $fullsearch
$count = $DB->count_records_sql("SELECT COUNT(DISTINCT(ge.id)) $sqlfrom $sqlwhere", $params);
$query = "$sqlwrapheader $sqlselect $sqlfrom $sqlwhere $sqlwrapfooter $sqlorderby";
- $entries = $DB->get_records_sql($query, $params, $from, $limit);
+
+ if ($sortbyconcept) {
+ // Load all matching entries, apply locale-aware concept sorting, and only then paginate.
+ $entries = $DB->get_records_sql($query, $params);
+
+ $sortkeys = [];
+ foreach ($entries as $key => $entry) {
+ // Normalize concept values first so ordering is consistently case-insensitive across DBs/locales.
+ $sortkeys[$key] = core_text::strtolower(format_string($entry->concept));
+ }
+
+ core_collator::asort($sortkeys, core_collator::SORT_STRING);
+
+ if (strcasecmp($sort, 'DESC') === 0) {
+ $sortkeys = array_reverse($sortkeys, true);
+ }
+
+ $sortedentries = [];
+ foreach (array_keys($sortkeys) as $key) {
+ $sortedentries[$key] = $entries[$key];
+ }
+ $entries = $sortedentries;
+
+ $from = $from ?? 0;
+ if ($limit > 0) {
+ $entries = array_slice($entries, $from, $limit);
+ } else if ($from > 0) {
+ $entries = array_slice($entries, $from);
+ }
+ } else {
+ $entries = $DB->get_records_sql($query, $params, $from, $limit);
+ }
return array($entries, $count);
}
@@ -4052,12 +4089,11 @@ function glossary_get_entries_by_term($glossary, $context, $term, $from, $limit,
$count = count($entries);
// Now applying limit.
- if (isset($limit)) {
- if (isset($from)) {
- $entries = array_slice($filteredentries, $from, $limit);
- } else {
- $entries = array_slice($filteredentries);
- }
+ $from = $from ?? 0;
+ if ($limit > 0) {
+ $entries = array_slice($filteredentries, $from, $limit);
+ } else if ($from > 0) {
+ $entries = array_slice($filteredentries, $from);
} else {
$entries = $filteredentries;
}
@@ -4160,13 +4196,12 @@ function glossary_get_entries_to_approve($glossary, $context, $letter, $order, $
}
// Now applying limit.
- if (isset($limit)) {
- $count = count($filteredentries);
- if (isset($from)) {
- $filteredentries = array_slice($filteredentries, $from, $limit);
- } else {
- $filteredentries = array_slice($filteredentries, 0, $limit);
- }
+ $count = count($filteredentries);
+ $from = $from ?? 0;
+ if ($limit > 0) {
+ $filteredentries = array_slice($filteredentries, $from, $limit);
+ } else if ($from > 0) {
+ $filteredentries = array_slice($filteredentries, $from);
}
return [$filteredentries, $count];
diff --git a/public/mod/glossary/print.php b/public/mod/glossary/print.php
index ac82e5030620d..38c49d8087642 100644
--- a/public/mod/glossary/print.php
+++ b/public/mod/glossary/print.php
@@ -14,6 +14,7 @@
$mode = required_param('mode', PARAM_ALPHA); // mode to show the entries
$hook = optional_param('hook','ALL', PARAM_CLEAN); // what to show
$sortkey = optional_param('sortkey','UPDATE', PARAM_ALPHA); // Sorting key
+$fullsearch = optional_param('fullsearch', 0, PARAM_INT); // Search concept and definition.
$url = new moodle_url('/mod/glossary/print.php', array('id'=>$id));
if ($sortorder !== 'asc') {
diff --git a/public/mod/glossary/tests/behat/pagination_all_entries.feature b/public/mod/glossary/tests/behat/pagination_all_entries.feature
new file mode 100644
index 0000000000000..0818a0f09d84f
--- /dev/null
+++ b/public/mod/glossary/tests/behat/pagination_all_entries.feature
@@ -0,0 +1,137 @@
+@mod @mod_glossary
+Feature: Glossary pagination and alphabet filter display entries correctly
+ In order to view glossary entries correctly
+ As a user
+ I need entries to display properly across alphabet filter, pagination and print views
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "activities" exist:
+ | activity | name | intro | course | idnumber | entbypage | showall | showspecial | allowprintview |
+ | glossary | Test glossary | Test glossary entries | C1 | g1 | 2 | 1 | 1 | 1 |
+ And the following "mod_glossary > entries" exist:
+ | glossary | concept | definition | user |
+ | g1 | Apple | A type of fruit | teacher1 |
+ | g1 | apricot | A small fruit | teacher1 |
+ | g1 | Banana | A yellow fruit | teacher1 |
+ | g1 | Cherry | A small red fruit | teacher1 |
+ | g1 | Date | A sweet fruit | teacher1 |
+ | g1 | Eggplant | A purple vegetable | teacher1 |
+ | g1 | 1st item | Entry starting with one | teacher1 |
+
+ Scenario: Clicking ALL in alphabet filter shows entries from all letters
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ And I click on "A" "link" in the ".entrybox" "css_element"
+ # After clicking A, only A entries are shown. Click ALL to show all letters again.
+ When I click on "ALL" "link" in the ".entrybox" "css_element"
+ # ALL alphabet filter still has pagination (entbypage=2), so page 1 shows first 2 entries.
+ Then I should see "1st item"
+ And I should see "Apple"
+ And I should not see "No entries found in this section"
+ # Pagination bar should be visible when viewing subset of glossary entries.
+ And I should see "ALL" in the ".paging" "css_element"
+
+ Scenario: Clicking ALL in paging bar shows all glossary entries
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ # By default we should see paginated results with the paging bar.
+ When I click on "ALL" "link" in the ".paging" "css_element"
+ Then I should see "Apple"
+ And I should see "apricot"
+ And I should see "Banana"
+ And I should see "Cherry"
+ And I should see "Date"
+ And I should see "Eggplant"
+ And I should not see "No entries found in this section"
+ # Pagination bar should be hidden when viewing all glossary entries.
+ And I should not see "ALL" in the ".paging" "css_element"
+
+ @javascript
+ Scenario: Print view from ALL alphabet filter and ALL paging shows all entries
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ And I click on "A" "link" in the ".entrybox" "css_element"
+ And I click on "ALL" "link" in the ".entrybox" "css_element"
+ # Also click ALL in paging bar to remove pagination, so print shows all entries.
+ And I click on "ALL" "link" in the ".paging" "css_element"
+ When I click on "Export entries" "button"
+ And I click on "Printer-friendly version" "link"
+ Then I should see "Apple"
+ And I should see "apricot"
+ And I should see "Banana"
+ And I should see "Cherry"
+ And I should see "Date"
+ And I should see "Eggplant"
+
+ @javascript
+ Scenario: Print view from a specific letter shows only matching entries
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ And I click on "A" "link" in the ".entrybox" "css_element"
+ When I click on "Export entries" "button"
+ And I click on "Printer-friendly version" "link"
+ Then I should see "Apple"
+ And I should see "apricot"
+ And I should not see "Banana"
+ And I should not see "Cherry"
+
+ @javascript
+ Scenario: Print view from Special shows only non A-Z entries
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ And I click on "Special" "link" in the ".entrybox" "css_element"
+ When I click on "Export entries" "button"
+ And I click on "Printer-friendly version" "link"
+ Then I should see "1st item"
+ And I should not see "Apple"
+ And I should not see "Banana"
+
+ @javascript
+ Scenario: Print view from a specific pagination page shows only that page entries
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ # entbypage=2, default hook=ALL so all entries shown.
+ # Sort order is case-insensitive: 1st item, Apple, apricot, Banana, Cherry, Date, Eggplant.
+ # Page 1: 1st item, Apple. Page 2: apricot, Banana.
+ When I click on "2" "link" in the ".paging" "css_element"
+ And I click on "Export entries" "button"
+ And I click on "Printer-friendly version" "link"
+ # Page 2 should show apricot and Banana only.
+ Then I should not see "1st item"
+ And I should not see "Apple"
+ And I should see "apricot"
+ And I should see "Banana"
+ And I should not see "Cherry"
+ And I should not see "Date"
+
+ @javascript
+ Scenario: Print view after clicking ALL in paging bar shows all entries
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ When I click on "ALL" "link" in the ".paging" "css_element"
+ And I click on "Export entries" "button"
+ And I click on "Printer-friendly version" "link"
+ Then I should see "Apple"
+ And I should see "apricot"
+ And I should see "Banana"
+ And I should see "Cherry"
+ And I should see "Date"
+ And I should see "Eggplant"
+
+ @javascript
+ Scenario: Print view from full text search shows entries matching in definition
+ Given I am on the "Test glossary" "glossary activity" page logged in as "teacher1"
+ # "fruit" appears in definitions (Apple, apricot, Banana, Cherry, Date) but not in any concept.
+ # Search full text is enabled by default.
+ When I set the field "hook" to "fruit"
+ And I press "Search"
+ And I click on "ALL" "link" in the ".paging" "css_element"
+ And I click on "Export entries" "button"
+ And I click on "Printer-friendly version" "link"
+ Then I should see "Apple"
+ And I should see "apricot"
+ And I should see "Banana"
+ And I should not see "Eggplant"
+ And I should not see "1st item"
diff --git a/public/mod/glossary/tests/custom_completion_test.php b/public/mod/glossary/tests/custom_completion_test.php
index fee593001806d..de970f9adc5ca 100644
--- a/public/mod/glossary/tests/custom_completion_test.php
+++ b/public/mod/glossary/tests/custom_completion_test.php
@@ -95,16 +95,13 @@ public function test_get_state(string $rule, int $available, int $entries, ?int
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of the magic getter method when fetching the cm_info object's customdata and instance values.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->will($this->returnValueMap([
- ['customdata', $customdataval],
- ['instance', 1],
- ]));
+ ->method('get_custom_data')
+ ->willReturn($customdataval);
// Mock the DB calls.
$DB = $this->createMock(get_class($DB));
@@ -202,13 +199,12 @@ public function test_get_available_custom_rules(int $status, array $expected): v
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of magic getter for the customdata attribute.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->with('customdata')
+ ->method('get_custom_data')
->willReturn($customdataval);
$customcompletion = new custom_completion($mockcminfo, 1);
diff --git a/public/mod/glossary/tests/lib_test.php b/public/mod/glossary/tests/lib_test.php
index 114f97bbf5ef0..ce36ff41def18 100644
--- a/public/mod/glossary/tests/lib_test.php
+++ b/public/mod/glossary/tests/lib_test.php
@@ -505,6 +505,47 @@ public function test_glossary_get_entries_search(): void {
$this->assertCount(0, $search);
}
+ /**
+ * Test that glossary_get_entries_by_search orders concepts case-insensitively.
+ *
+ * @covers ::glossary_get_entries_by_search
+ */
+ public function test_glossary_get_entries_by_search_orders_concepts_case_insensitive(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $glossarygenerator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+ $course = $this->getDataGenerator()->create_course();
+ $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+ $context = \context_module::instance($glossary->cmid);
+
+ // The concepts are intentionally mixed-case to validate case-insensitive alphabetical sorting.
+ $glossarygenerator->create_content($glossary, ['concept' => 'Apple']);
+ $glossarygenerator->create_content($glossary, ['concept' => 'Banana']);
+ $glossarygenerator->create_content($glossary, ['concept' => 'apricot']);
+
+ [$entries, $count] = glossary_get_entries_by_search(
+ $glossary,
+ $context,
+ 'a',
+ false,
+ 'CONCEPT',
+ 'ASC',
+ 0,
+ 0
+ );
+
+ $expected = ['Apple', 'apricot', 'Banana'];
+ $actual = array_values(array_map(
+ static function ($entry) {
+ return $entry->concept;
+ },
+ $entries
+ ));
+ $this->assertEquals(3, $count);
+ $this->assertSame($expected, $actual);
+ }
+
public function test_mod_glossary_can_delete_entry_users(): void {
$this->resetAfterTest();
diff --git a/public/mod/h5pactivity/classes/local/manager.php b/public/mod/h5pactivity/classes/local/manager.php
index acb0a0638020a..fc58ee2817df0 100644
--- a/public/mod/h5pactivity/classes/local/manager.php
+++ b/public/mod/h5pactivity/classes/local/manager.php
@@ -318,7 +318,7 @@ public function count_attempts(?int $userid = null, array $groups = []): int {
}
$usersjoin = $this->get_active_users_join();
- $sql = "SELECT COUNT(*)
+ $sql = "SELECT COUNT(DISTINCT ha.id)
FROM {user} u $usersjoin->joins
WHERE $usersjoin->wheres";
$params = array_merge($usersjoin->params);
diff --git a/public/mod/h5pactivity/tests/behat/add_h5pactivity.feature b/public/mod/h5pactivity/tests/behat/add_h5pactivity.feature
index c4c8bec2d83af..5dbb78810d167 100644
--- a/public/mod/h5pactivity/tests/behat/add_h5pactivity.feature
+++ b/public/mod/h5pactivity/tests/behat/add_h5pactivity.feature
@@ -30,8 +30,8 @@ Feature: Add H5P activity
| packagefilepath | h5p/tests/fixtures/ipsums.h5p |
When I am on the "Awesome H5P package" "h5pactivity activity" page
Then I should see "H5P activity Description"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Lorum ipsum"
And I should not see "Reuse"
And I should not see "Rights of use"
@@ -46,8 +46,8 @@ Feature: Add H5P activity
| displayoptions | 12 |
| packagefilepath | h5p/tests/fixtures/ipsums.h5p |
When I am on the "Awesome H5P package" "h5pactivity activity" page
- Then I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ Then I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And "Reuse" "text" should exist in the ".h5p-actions" "css_element"
And I should not see "Rights of use"
And I should not see "Embed"
@@ -61,8 +61,8 @@ Feature: Add H5P activity
| displayoptions | 10 |
| packagefilepath | h5p/tests/fixtures/ipsums.h5p |
When I am on the "Awesome H5P package" "h5pactivity activity" page
- Then I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ Then I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And "Reuse" "text" should not exist in the ".h5p-actions" "css_element"
And I should not see "Rights of use"
And I should see "Embed"
@@ -77,8 +77,8 @@ Feature: Add H5P activity
| packagefilepath | h5p/tests/fixtures/guess-the-answer.h5p |
And I change window size to "large"
When I am on the "Awesome H5P package" "h5pactivity activity" page
- Then I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ Then I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And "Reuse" "text" should not exist in the ".h5p-actions" "css_element"
And I should see "Rights of use"
And I should not see "Embed"
@@ -100,8 +100,8 @@ Feature: Add H5P activity
| displayoptions | 6 |
| packagefilepath | h5p/tests/fixtures/ipsums.h5p |
When I am on the "Awesome H5P package" "h5pactivity activity" page
- Then I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ Then I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And "Reuse" "text" should not exist in the ".h5p-actions" "css_element"
And I should not see "Rights of use"
And I should not see "Embed"
@@ -115,8 +115,8 @@ Feature: Add H5P activity
| displayoptions | 0 |
| packagefilepath | h5p/tests/fixtures/guess-the-answer.h5p |
When I am on the "Awesome H5P package" "h5pactivity activity" page
- Then I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ Then I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And "Reuse" "text" should exist in the ".h5p-actions" "css_element"
And I should see "Rights of use"
And I should see "Embed"
diff --git a/public/mod/h5pactivity/tests/behat/contentbank_link.feature b/public/mod/h5pactivity/tests/behat/contentbank_link.feature
index 2f544081d3e48..8e8975c95f816 100644
--- a/public/mod/h5pactivity/tests/behat/contentbank_link.feature
+++ b/public/mod/h5pactivity/tests/behat/contentbank_link.feature
@@ -51,8 +51,8 @@ Feature: Content bank link in the activity settings form
And I click on "Link to the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Of which countries are Berlin, Washington, Beijing, Canberra and Brasilia the capitals?"
And I switch to the main frame
When I navigate to "Settings" in current page administration
@@ -73,8 +73,8 @@ Feature: Content bank link in the activity settings form
And I click on "Make a copy of the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Of which countries are Berlin,"
And I switch to the main frame
When I navigate to "Settings" in current page administration
@@ -96,8 +96,8 @@ Feature: Content bank link in the activity settings form
And I click on "Link to the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Which fruit is this?"
And I switch to the main frame
And I navigate to "Settings" in current page administration
diff --git a/public/mod/h5pactivity/tests/behat/h5pactivity_completion_pass_grade.feature b/public/mod/h5pactivity/tests/behat/h5pactivity_completion_pass_grade.feature
index 4f1d9a8809d5c..357e6fdb3086e 100644
--- a/public/mod/h5pactivity/tests/behat/h5pactivity_completion_pass_grade.feature
+++ b/public/mod/h5pactivity/tests/behat/h5pactivity_completion_pass_grade.feature
@@ -42,8 +42,8 @@ Feature: Completion of H5P activity by achieving a passing grade
Scenario: Verify that students can complete an H5P activity by achieving a passing grade
# Student 1 attempt the H5P and fills the blanks with the wrong answers... needs more geography lessons!
Given I am on the "Music history" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Of which countries are Berlin, Washington, Beijing, Canberra and Brasilia the capitals?"
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Rio de Janeiro"
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 2 of 4\")]" to "New York"
@@ -58,8 +58,8 @@ Feature: Completion of H5P activity by achieving a passing grade
# Student 2 attempts the H5P and fills the blanks with the correct answers.
And I am on the "Music history" "h5pactivity activity" page logged in as student2
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Of which countries are Berlin, Washington, Beijing, Canberra and Brasilia the capitals?"
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Brasilia"
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 2 of 4\")]" to "Washington"
diff --git a/public/mod/h5pactivity/tests/behat/h5pactivity_deployment.feature b/public/mod/h5pactivity/tests/behat/h5pactivity_deployment.feature
index 425feaeeb3189..0b21d4595064c 100644
--- a/public/mod/h5pactivity/tests/behat/h5pactivity_deployment.feature
+++ b/public/mod/h5pactivity/tests/behat/h5pactivity_deployment.feature
@@ -33,19 +33,19 @@ Feature: Undeployed H5P activities packages should be available only to any user
Scenario: In an H5P activity, as student I should not be able to deploy the package if not deployed by the teacher
beforehand. Then if a second teacher deploys the package, I can see it.
Given I am on the "Music history" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
And "This file can't be displayed because it has been uploaded by a user without the required capability to deploy H5P content" "text" should exist
And I switch to the main frame
And I log out
# Then teacher2 will be allowed to deploy the package.
And I am on the "Music history" "h5pactivity activity" page logged in as teacher2
- And I switch to "h5p-player" class iframe
- When I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ When I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "Of which countries are Berlin"
And I switch to the main frame
And I log out
# Now student1 should be able to see the package.
And I am on the "Music history" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- When I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ When I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "Of which countries are Berlin"
diff --git a/public/mod/h5pactivity/tests/behat/inline_editing_content.feature b/public/mod/h5pactivity/tests/behat/inline_editing_content.feature
index fc2473e09f16a..d0deb1c43e510 100644
--- a/public/mod/h5pactivity/tests/behat/inline_editing_content.feature
+++ b/public/mod/h5pactivity/tests/behat/inline_editing_content.feature
@@ -44,19 +44,19 @@ Feature: Inline editing H5P content
And I click on "Link to the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Hello world!"
And I switch to the main frame
# Modify the H5P content using the edit button (which opens the H5P editor).
And I follow "Edit H5P content"
And I should see "This content may be in use in other places."
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Greeting text" to "It's a Wonderful Life!"
And I switch to the main frame
And I click on "Save changes" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
# Check the H5P content has changed.
And I should not see "Hello world!"
And I should see "It's a Wonderful Life!"
@@ -66,8 +66,8 @@ Feature: Inline editing H5P content
And I click on "Site pages" "list_item" in the "Navigation" "block"
And I click on "Content bank" "link" in the "Navigation" "block"
And I click on "Greeting card" "link"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should not see "Hello world!"
And I should see "It's a Wonderful Life!"
And I switch to the main frame
@@ -106,20 +106,20 @@ Feature: Inline editing H5P content
And I click on "Make a copy of the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Hello world!"
And I switch to the main frame
# Modify the H5P content using the edit button (which opens the H5P editor).
And I follow "Edit H5P content"
And I should not see "This content may be in use in other places."
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Greeting text" to "The nightmare before Christmas"
And I switch to the main frame
And I click on "Save changes" "button"
# Check the H5P content has changed.
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should not see "Hello world!"
And I should see "The nightmare before Christmas"
And I switch to the main frame
@@ -128,8 +128,8 @@ Feature: Inline editing H5P content
And I click on "Site pages" "list_item" in the "Navigation" "block"
And I click on "Content bank" "link" in the "Navigation" "block"
And I click on "Greeting card" "link"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Hello world!"
And I should not see "The nightmare before Christmas"
And I switch to the main frame
@@ -163,20 +163,20 @@ Feature: Inline editing H5P content
And I click on "Link to the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Hello world!"
And I switch to the main frame
# Modify the H5P content using the edit button (which opens the H5P editor).
And I follow "Edit H5P content"
And I should see "This content may be in use in other places."
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Greeting text" to "Little women"
And I switch to the main frame
And I click on "Save changes" "button"
# Check the H5P content has changed.
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should not see "Hello world!"
And I should see "Little women"
And I switch to the main frame
diff --git a/public/mod/h5pactivity/tests/behat/result_fillin.feature b/public/mod/h5pactivity/tests/behat/result_fillin.feature
index 44458204774cb..c9ce992de02f5 100644
--- a/public/mod/h5pactivity/tests/behat/result_fillin.feature
+++ b/public/mod/h5pactivity/tests/behat/result_fillin.feature
@@ -27,8 +27,8 @@ Feature: View fill the blanks attempt report
Scenario: View attempt in a fill the blanks content
Given I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
# Do an attempt.
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Brigadoon"
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 2 of 4\")]" to "Emerald city"
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 3 of 4\")]" to "Narnia"
diff --git a/public/mod/h5pactivity/tests/behat/result_longfillin.feature b/public/mod/h5pactivity/tests/behat/result_longfillin.feature
index 043836c9b8186..1650aa843530a 100644
--- a/public/mod/h5pactivity/tests/behat/result_longfillin.feature
+++ b/public/mod/h5pactivity/tests/behat/result_longfillin.feature
@@ -28,8 +28,8 @@ Feature: View essay attempt report
# Do an attempt.
Given I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
And I change window size to "large"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//textarea" to "This is a smurfing smurf"
And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
And I switch to the main frame
diff --git a/public/mod/h5pactivity/tests/behat/save_content_state.feature b/public/mod/h5pactivity/tests/behat/save_content_state.feature
index 3645bb0a739ca..8bf16da8de133 100644
--- a/public/mod/h5pactivity/tests/behat/save_content_state.feature
+++ b/public/mod/h5pactivity/tests/behat/save_content_state.feature
@@ -31,67 +31,67 @@ Feature: Users can save the current state of an H5P activity
Given the following config values are set as admin:
| enablesavestate | 0 | mod_h5pactivity|
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia"
And I switch to the main frame
And I am on the "Course 1" course page
When I am on the "Awesome H5P package" "h5pactivity activity" page
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia"
Scenario: Content state is saved when enablesavestate is enabled
Given the following config values are set as admin:
| enablesavestate | 1 | mod_h5pactivity|
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia"
And I switch to the main frame
And I am on the "Course 1" course page
When I am on the "Awesome H5P package" "h5pactivity activity" page
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia"
Scenario: Content state is not saved for teachers when enablesavestate is enabled
Given the following config values are set as admin:
| enablesavestate | 1 | mod_h5pactivity|
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as teacher1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia"
And I switch to the main frame
And I am on the "Course 1" course page
When I am on the "Awesome H5P package" "h5pactivity activity" page
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia"
Scenario: Content state is reseted when content changes
Given the following config values are set as admin:
| enablesavestate | 1 | mod_h5pactivity|
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia"
And I switch to the main frame
And I am on the "Course 1" course page
When I am on the "Awesome H5P package" "h5pactivity activity" page logged in as admin
# Change the content.
And I follow "Edit H5P content"
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Title" to "Capitals"
And I switch to the main frame
And I click on "Save changes" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Check"
# Check the content state has been reseted.
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "Data Reset"
And I should see "This content has changed since you last used it."
And I click on "OK" "button"
@@ -101,26 +101,26 @@ Feature: Users can save the current state of an H5P activity
Given the following config values are set as admin:
| enablesavestate | 1 | mod_h5pactivity|
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia"
And I switch to the main frame
And I am on the "Course 1" course page
When I am on the "Awesome H5P package" "h5pactivity activity" page logged in as admin
# Start content edition.
And I follow "Edit H5P content"
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Title" to "Capitals"
And I switch to the main frame
And I click on "Cancel" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I should see "Check"
# Check the content state hasn't been reseted.
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
And I should see "Awesome H5P package"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should not see "Data Reset"
And I should not see "This content has changed since you last used it."
And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia"
@@ -130,31 +130,31 @@ Feature: Users can save the current state of an H5P activity
| enablesavestate | 1 | mod_h5pactivity|
# Save state content for student2, to check this data is not removed when student1 finishes their attempt.
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student2
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Vallhonesta"
# Confirm the content state has been saved properly.
And I reload the page
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Vallhonesta"
# Create an attempt for student1.
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1
And I should not see "Attempts report"
- When I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ When I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia"
And I click on "Check" "button"
# Check the state content has been removed.
And I reload the page
Then I should see "Attempts report"
And I am on the "Awesome H5P package" "h5pactivity activity" page
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia"
And I switch to the main frame
# Check the state content for student2 is still there.
And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student2
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Vallhonesta"
diff --git a/public/mod/h5pactivity/tests/behat/sending_attempt.feature b/public/mod/h5pactivity/tests/behat/sending_attempt.feature
index 8e756944cb0b3..53defdab7a4cd 100644
--- a/public/mod/h5pactivity/tests/behat/sending_attempt.feature
+++ b/public/mod/h5pactivity/tests/behat/sending_attempt.feature
@@ -39,8 +39,7 @@ Feature: Do a H5P attempt
And I click on "Correct one" "text" in the ".h5p-question-content" "css_element"
And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
And I switch to the main frame
- And I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
Then I follow "Today's logs"
And I should see "xAPI statement received"
diff --git a/public/mod/h5pactivity/tests/local/manager_test.php b/public/mod/h5pactivity/tests/local/manager_test.php
index 77a99eb3af6bd..0c31e0f1e3e38 100644
--- a/public/mod/h5pactivity/tests/local/manager_test.php
+++ b/public/mod/h5pactivity/tests/local/manager_test.php
@@ -649,6 +649,30 @@ public function test_count_attempts_all(bool $canview, bool $cansubmit, bool $ex
$this->assertEquals($result, $manager->count_attempts());
}
+ /**
+ * Test count_attempts is not inflated when a user is enrolled via multiple enrolment methods.
+ */
+ public function test_count_attempts_with_multiple_enrolments(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+
+ $manager = manager::create_from_instance($activity);
+
+ // User enrolled via a single enrolment method with 3 completed attempts.
+ $user1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+ $this->generate_fake_attempts($activity, $user1, 1);
+
+ // User enrolled via two different enrolment methods with 3 completed attempts.
+ $user2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+ $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student', 'self');
+ $this->generate_fake_attempts($activity, $user2, 2);
+
+ $this->assertEquals(6, $manager->count_attempts());
+ }
+
/**
* Data provider for test_count_attempts_all.
*
diff --git a/public/mod/lesson/locallib.php b/public/mod/lesson/locallib.php
index ac88d080537b3..77f9fa84a3188 100644
--- a/public/mod/lesson/locallib.php
+++ b/public/mod/lesson/locallib.php
@@ -2979,11 +2979,12 @@ public function get_last_page_seen($retriescount) {
$attempt = end($allattempts);
$attemptpage = $this->load_page($attempt->pageid);
$jumpto = $DB->get_field('lesson_answers', 'jumpto', array('id' => $attempt->answerid));
+ $maxattempts = $this->properties->maxattempts;
// Convert the jumpto to a proper page id.
if ($jumpto == 0) {
// Check if a question has been incorrectly answered AND no more attempts at it are left.
$nattempts = $this->get_attempts($attempt->retry, false, $attempt->pageid, $USER->id);
- if (count($nattempts) >= $this->properties->maxattempts) {
+ if (count($nattempts) >= $maxattempts && $maxattempts > 0) { // If maxattempts is 0, unlimited attempts are allowed.
$lastpageseen = $this->get_next_page($attemptpage->nextpageid);
} else {
$lastpageseen = $attempt->pageid;
diff --git a/public/mod/lesson/pagetypes/shortanswer.php b/public/mod/lesson/pagetypes/shortanswer.php
index 4dc8c0680d388..4ed3c4b79ec8d 100644
--- a/public/mod/lesson/pagetypes/shortanswer.php
+++ b/public/mod/lesson/pagetypes/shortanswer.php
@@ -462,6 +462,7 @@ public function definition() {
$placeholder = $matches[0];
$contentsparts = explode( $placeholder, $contents, 2);
$attrs['size'] = round(strlen($placeholder) * 1.1);
+ $attrs['class'] = 'd-inline-block w-auto';
}
// Disable shortforms.
@@ -476,9 +477,9 @@ public function definition() {
if ($placeholder) {
$contentsgroup = array();
- $contentsgroup[] = $mform->createElement('static', '', '', $contentsparts[0]);
+ $contentsgroup[] = $mform->createElement('static', '', '', str_replace(['
'], '', $contentsparts[0]));
$contentsgroup[] = $mform->createElement('text', 'answer', '', $attrs);
- $contentsgroup[] = $mform->createElement('static', '', '', $contentsparts[1]);
+ $contentsgroup[] = $mform->createElement('static', '', '', str_replace(['
'], '', $contentsparts[1]));
$mform->addGroup($contentsgroup, '', '', '', false);
} else {
$mform->addElement('html', $OUTPUT->container($contents, 'contents'));
diff --git a/public/mod/lesson/tests/behat/lesson_complete_report.feature b/public/mod/lesson/tests/behat/lesson_complete_report.feature
index e9b7d81cff5ad..5ac1625caa50e 100644
--- a/public/mod/lesson/tests/behat/lesson_complete_report.feature
+++ b/public/mod/lesson/tests/behat/lesson_complete_report.feature
@@ -29,9 +29,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
| First page name | Next page | | Next page | 0 |
| True/false question 1 | True | Correct | Next page | 1 |
| True/false question 1 | False | Wrong | This page | 0 |
- And I log in as "teacher1"
- When I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I follow "Student 1"
And I follow "Complete report"
Then I should see "No attempts have been made on this lesson"
@@ -51,8 +49,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
When I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "First page contents"
And I press "Next page"
- Then I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Then I am on the "Course 1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
And I follow "Complete report"
And I should see "Lesson has been started, but not yet completed"
@@ -91,8 +88,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
And I press "Submit"
And I press "Continue"
And I should see "Congratulations - end of lesson reached"
- Then I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Then I am on the "Course 1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
And I follow "Complete report"
And I should see "Grade: 50.00 / 100.00"
@@ -115,8 +111,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
And I press "Next page"
And I should see "Second page contents"
And I press "End of lesson"
- Then I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Then I am on the "Course 1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
And I follow "Complete report"
And I should see "Completed"
diff --git a/public/mod/lesson/tests/behat/lesson_course_reset.feature b/public/mod/lesson/tests/behat/lesson_course_reset.feature
index 032214b11d726..8e02491c2ad84 100644
--- a/public/mod/lesson/tests/behat/lesson_course_reset.feature
+++ b/public/mod/lesson/tests/behat/lesson_course_reset.feature
@@ -34,14 +34,10 @@ Feature: Lesson reset
| True/false question 1 | True | Wrong | This page | 0 |
Scenario: Use course reset to clear all attempt data
- When I am on the "Test lesson name" "lesson activity" page logged in as student1
- And I should see "Cat is an amphibian"
- And I set the following fields to these values:
- | False | 1 |
- And I press "Submit"
- And I press "Continue"
- And I should see "Congratulations - end of lesson reached"
- And I am on the "Test lesson name" "lesson activity" page logged in as teacher1
+ Given the following "mod_lesson > attempts" exist:
+ | lesson | user | page | answer | correct |
+ | Test lesson name | student1 | True/false question 1 | False | 1 |
+ When I am on the "Test lesson name" "lesson activity" page logged in as teacher1
And I navigate to "Reports" in current page administration
And I should see "Sam1 Student1"
And I am on the "Course 1" "reset" page
@@ -53,44 +49,31 @@ Feature: Lesson reset
And I navigate to "Reports" in current page administration
Then I should see "No attempts have been made on this lesson"
- @javascript
Scenario: Use course reset to remove user overrides
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | retake |
+ | Test lesson name | student1 | 1 |
When I am on the "Test lesson name" "lesson activity" page logged in as teacher1
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | Allow multiple attempts | 1 |
- And I press "Save"
- And I should see "Sam1 Student1"
And I am on the "Course 1" "reset" page
And I press "Deselect all"
And I set the following fields to these values:
| All user overrides | 1 |
And I press "Reset course"
- And I click on "Reset course" "button" in the "Reset course?" "dialogue"
And I press "Continue"
And I am on the "Test lesson name" "lesson activity" page
And I navigate to "Overrides" in current page administration
Then I should not see "Sam1 Student1"
- @javascript
Scenario: Use course reset to remove group overrides
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | retake |
+ | Test lesson name | G1 | 1 |
When I am on the "Test lesson name" "lesson activity" page logged in as teacher1
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | Allow multiple attempts | 1 |
- And I press "Save"
- And I should see "Group 1"
And I am on the "Course 1" "reset" page
And I press "Deselect all"
And I set the following fields to these values:
| All group overrides | 1 |
And I press "Reset course"
- And I click on "Reset course" "button" in the "Reset course?" "dialogue"
And I press "Continue"
And I am on the "Test lesson name" "lesson activity" page
And I navigate to "Overrides" in current page administration
diff --git a/public/mod/lesson/tests/behat/lesson_group_override.feature b/public/mod/lesson/tests/behat/lesson_group_override.feature
index 319a4577f5a8b..0b8037cdcf293 100644
--- a/public/mod/lesson/tests/behat/lesson_group_override.feature
+++ b/public/mod/lesson/tests/behat/lesson_group_override.feature
@@ -65,20 +65,12 @@ Feature: Lesson group override
And I should not see "Group 1"
Scenario: Duplicate a user override
- Given I am on the "Test lesson name" "lesson activity" page logged in as teacher1
- When I navigate to "Overrides" in current page administration
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | deadline |
+ | Test lesson name | G1 | ##1 Jan 2020 08:00## |
+ When I am on the "Test lesson name" "lesson activity" page logged in as teacher1
+ And I navigate to "Overrides" in current page administration
And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | id_deadline_enabled | 1 |
- | deadline[day] | 1 |
- | deadline[month] | January |
- | deadline[year] | 2020 |
- | deadline[hour] | 08 |
- | deadline[minute] | 00 |
- And I press "Save"
- And I should see "Wednesday, 1 January 2020, 8:00"
Then I click on "copy" "link"
And I set the following fields to these values:
| Override group | Group 2 |
@@ -88,52 +80,36 @@ Feature: Lesson group override
And I should see "Group 2"
Scenario: Allow a single group to have re-take the lesson
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | retake |
+ | Test lesson name | G1 | 1 |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| Allow multiple attempts | 0 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | Allow multiple attempts | 1 |
- And I press "Save"
- And I should see "Allow multiple attempts"
- Given I am on the "Test lesson name" "lesson activity" page logged in as student1
- And I should see "Cat is an amphibian"
- And I set the following fields to these values:
- | False | 1 |
- And I press "Submit"
- And I press "Continue"
- And I should see "Congratulations - end of lesson reached"
+ And the following "mod_lesson > attempts" exist:
+ | lesson | user | page | answer | correct |
+ | Test lesson name | student1 | True/false question 1 | False | 1 |
+ | Test lesson name | student2 | True/false question 1 | False | 1 |
+ When I am on the "Course 1" course page logged in as student1
+ And I follow "Grades" in the user menu
+ And I click on "Course 1" "link" in the "Course 1" "table_row"
+ Then I should see "100" in the "Test lesson name" "table_row"
And I am on the "Test lesson name" "lesson activity" page
- Then I should not see "You are not allowed to retake this lesson."
- And I should see "Cat is an amphibian"
- Given I am on the "Test lesson name" "lesson activity" page logged in as student2
+ And I should not see "You are not allowed to retake this lesson."
And I should see "Cat is an amphibian"
- And I set the following fields to these values:
- | False | 1 |
- And I press "Submit"
- And I press "Continue"
- And I should see "Congratulations - end of lesson reached"
- And I am on the "Test lesson name" "lesson activity" page
+ And I am on the "Test lesson name" "lesson activity" page logged in as student2
And I should see "You are not allowed to retake this lesson."
Scenario: Allow a single group to have a different password
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | password |
+ | Test lesson name | G1 | 12345 |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| Password protected lesson | Yes |
| id_password | moodle_rules |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | Password protected lesson | 12345 |
- And I press "Save"
- And I should see "Password protected lesson"
And I am on the "Test lesson name" "lesson activity" page logged in as student1
Then I should see "Test lesson name is a password protected lesson"
And I should not see "Cat is an amphibian"
@@ -160,7 +136,10 @@ Feature: Lesson group override
And I press "Continue"
Scenario: Allow a group to have a different due date
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | deadline |
+ | Test lesson name | G1 | ##1 Jan 2030 08:00## |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| id_deadline_enabled | 1 |
| deadline[day] | 1 |
@@ -169,19 +148,6 @@ Feature: Lesson group override
| deadline[hour] | 08 |
| deadline[minute] | 00 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | id_deadline_enabled | 1 |
- | deadline[day] | 1 |
- | deadline[month] | January |
- | deadline[year] | 2030 |
- | deadline[hour] | 08 |
- | deadline[minute] | 00 |
- And I press "Save"
- And I should see "Lesson closes"
And I am on the "Test lesson name" "lesson activity" page logged in as student2
Then the activity date in "Test lesson name" should contain "Closed: Saturday, 1 January 2000, 8:00"
And I should not see "Cat is an amphibian"
@@ -189,7 +155,10 @@ Feature: Lesson group override
And I should see "Cat is an amphibian"
Scenario: Allow a group to have a different start date
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | available |
+ | Test lesson name | G1 | ##1 Jan 2015 08:00## |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| id_available_enabled | 1 |
| available[day] | 1 |
@@ -198,19 +167,6 @@ Feature: Lesson group override
| available[hour] | 08 |
| available[minute] | 00 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | id_available_enabled | 1 |
- | available[day] | 1 |
- | available[month] | January |
- | available[year] | 2015 |
- | available[hour] | 08 |
- | available[minute] | 00 |
- And I press "Save"
- And I should see "Lesson opens"
And I am on the "Test lesson name" "lesson activity" page logged in as student2
Then the activity date in "Test lesson name" should contain "Opens: Tuesday, 1 January 2030, 8:00"
And I should not see "Cat is an amphibian"
@@ -218,18 +174,13 @@ Feature: Lesson group override
And I should see "Cat is an amphibian"
Scenario: Allow a single group to have multiple attempts at each question
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > group overrides" exist:
+ | lesson | group | maxattempts |
+ | Test lesson name | G1 | 2 |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| Allow multiple attempts | 1 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | Maximum number of tries per question | 2 |
- And I press "Save"
- And I should see "Maximum number of tries per question"
And I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "Cat is an amphibian"
And I set the following fields to these values:
@@ -335,28 +286,10 @@ Feature: Lesson group override
And the following "group members" exist:
| user | group |
| teacher1 | G1 |
- And I am on the "Lesson 2" "lesson activity" page logged in as admin
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | id_available_enabled | 1 |
- | available[day] | 1 |
- | available[month] | January |
- | available[year] | 2020 |
- | available[hour] | 08 |
- | available[minute] | 00 |
- And I press "Save and enter another override"
- And I set the following fields to these values:
- | Override group | Group 2 |
- | id_available_enabled | 1 |
- | available[day] | 1 |
- | available[month] | January |
- | available[year] | 2020 |
- | available[hour] | 08 |
- | available[minute] | 00 |
- And I press "Save"
+ And the following "mod_lesson > group overrides" exist:
+ | lesson | group | available |
+ | Lesson 2 | G1 | ##1 Jan 2020 08:00## |
+ | Lesson 2 | G2 | ##1 Jan 2020 08:00## |
When I am on the "Lesson 2" "lesson activity" page logged in as teacher1
And I navigate to "Overrides" in current page administration
And I select "Group overrides" from the "jump" singleselect
@@ -397,27 +330,15 @@ Feature: Lesson group override
And I press "Save and display"
When I log in as "student1"
Then I should see "##tomorrow##%A, %d %B %Y##" in the "Timeline" "block"
- And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | Available from | ##tomorrow## |
- | Deadline | ##tomorrow +1day## |
- And I press "Save"
- And I log in as "student1"
+ And the following "mod_lesson > group overrides" exist:
+ | lesson | group | available | deadline |
+ | Test lesson name | G1 | ##tomorrow## | ##tomorrow +1day## |
+ And I reload the page
And I should see "##tomorrow +1day##%A, %d %B %Y##" in the "Timeline" "block"
- And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 2 |
- | Available from | ##tomorrow +1day## |
- | Deadline | ##tomorrow +3days## |
- And I press "Save"
- And I log in as "student1"
+ And the following "mod_lesson > group overrides" exist:
+ | lesson | group | available | deadline |
+ | Test lesson name | G1 | ##tomorrow +1## | ##tomorrow +3day## |
+ And I reload the page
And I should see "##tomorrow +3days##%A, %d %B %Y##" in the "Timeline" "block"
@javascript
@@ -432,26 +353,13 @@ Feature: Lesson group override
| available | ##today## |
| deadline | ##tomorrow## |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 1 |
- | Available from | ##tomorrow## |
- | Deadline | ##tomorrow +1day## |
- And I press "Save"
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 2 |
- | Available from | ##tomorrow +1day## |
- | Deadline | ##tomorrow +3days## |
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Sam1 Student1 |
- | Available from | ##tomorrow## |
- | Deadline | ##tomorrow noon## |
- And I press "Save"
+ And the following "mod_lesson > group overrides" exist:
+ | lesson | group | available | deadline |
+ | Test lesson name | G1 | ##tomorrow## | ##tomorrow +1day## |
+ | Test lesson name | G2 | ##tomorrow +1## | ##tomorrow +3day## |
+ And the following "mod_lesson > user overrides" exist:
+ | lesson | user | available | deadline |
+ | Test lesson name | student1 | ##tomorrow## | ##tomorrow noon## |
When I log in as "student1"
Then I should see "##tomorrow noon##%A, %d %B %Y##" in the "Timeline" "block"
@@ -460,23 +368,13 @@ Feature: Lesson group override
Given the following "group members" exist:
| user | group |
| student1 | G2 |
- And I am on the "Test lesson name" "lesson activity" page logged in as teacher1
- And I navigate to "Overrides" in current page administration
- And I select "Group overrides" from the "jump" singleselect
- And I follow "Add group override"
- And I set the following fields to these values:
- | Override group | Group 2 |
- | Available from | ##tomorrow +1day## |
- | Deadline | ##tomorrow +3days## |
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Sam1 Student1 |
- | Available from | ##tomorrow## |
- | Deadline | ##tomorrow noon## |
- And I press "Save"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And the following "mod_lesson > group overrides" exist:
+ | lesson | group | available | deadline |
+ | Test lesson name | G2 | ##tomorrow +1## | ##tomorrow +3day## |
+ And the following "mod_lesson > user overrides" exist:
+ | lesson | user | available | deadline |
+ | Test lesson name | student1 | ##tomorrow## | ##tomorrow noon## |
+ And I am on the "C1" "enrolled users" page logged in as teacher1
And I click on "Unenrol" "icon" in the "student1" "table_row"
And I click on "Unenrol" "button" in the "Unenrol" "dialogue"
When I log in as "student1"
diff --git a/public/mod/lesson/tests/behat/lesson_outline_report.feature b/public/mod/lesson/tests/behat/lesson_outline_report.feature
index fd922442b7cdb..2359418756201 100644
--- a/public/mod/lesson/tests/behat/lesson_outline_report.feature
+++ b/public/mod/lesson/tests/behat/lesson_outline_report.feature
@@ -34,8 +34,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
| True/false question 1 | True | Correct | Next page | 1 |
| True/false question 1 | False | Wrong | This page | 0 |
When I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I follow "Student 1"
And I follow "Outline report"
Then I should see "No attempts have been made on this lesson"
@@ -56,8 +55,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
When I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "First page contents"
And I press "Next page"
- Then I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Then I am on the "Course 1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
And I follow "Outline report"
And I should see "Lesson has been started, but not yet completed"
@@ -86,8 +84,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
And I press "Submit"
And I press "Continue"
And I should see "Congratulations - end of lesson reached"
- Then I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Then I am on the "Course 1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
And I follow "Outline report"
And I should see "Grade: 100.00 / 100.00"
@@ -107,8 +104,7 @@ Feature: Teachers can review student progress on all lessons in a course by view
And I press "Next page"
And I should see "Second page contents"
And I press "End of lesson"
- Then I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Then I am on the "C1" "enrolled users" page logged in as teacher1
And I follow "Student 1"
And I follow "Outline report"
And I should see "Completed"
diff --git a/public/mod/lesson/tests/behat/lesson_practice.feature b/public/mod/lesson/tests/behat/lesson_practice.feature
index 9d7a8e2b5280d..673af843519b0 100644
--- a/public/mod/lesson/tests/behat/lesson_practice.feature
+++ b/public/mod/lesson/tests/behat/lesson_practice.feature
@@ -36,14 +36,13 @@ Feature: Practice mode in a lesson activity
| Description | This lesson will affect your course grade |
| Practice lesson | No |
And I press "Save and display"
+ And the following "mod_lesson > attempts" exist:
+ | lesson | user | page | answer | correct |
+ | Non-practice lesson | student1 | True or False | True | 1 |
When I am on the "Non-practice lesson" "lesson activity" page logged in as student1
- And I set the following fields to these values:
- | True | 1 |
- And I press "Submit"
- Then I should see "View grades"
And I follow "Grades" in the user menu
- And I am on "Course 1" course homepage
- And I should see "Non-practice lesson"
+ And I click on "Course 1" "link" in the "Course 1" "table_row"
+ Then I should see "100.00" in the "Non-practice lesson" "table_row"
Scenario: Practice lesson doesn't record grades in the gradebook
Given I set the following fields to these values:
@@ -51,10 +50,10 @@ Feature: Practice mode in a lesson activity
| Description | This lesson will NOT affect your course grade |
| Practice lesson | Yes |
And I press "Save and display"
+ And the following "mod_lesson > attempts" exist:
+ | lesson | user | page | answer | correct |
+ | Practice lesson | student1 | True or False | True | 1 |
When I am on the "Practice lesson" "lesson activity" page logged in as student1
- And I set the following fields to these values:
- | True | 1 |
- And I press "Submit"
Then I should not see "View grades"
And I follow "Grades" in the user menu
And I click on "Course 1" "link" in the "Course 1" "table_row"
@@ -67,10 +66,10 @@ Feature: Practice mode in a lesson activity
| Practice lesson | Yes |
| Type | Scale |
And I press "Save and display"
+ And the following "mod_lesson > attempts" exist:
+ | lesson | user | page | answer | correct |
+ | Practice lesson with scale | student1 | True or False | True | 1 |
When I am on the "Practice lesson with scale" "lesson activity" page logged in as student1
- And I set the following fields to these values:
- | True | 1 |
- And I press "Submit"
Then I should not see "View grades"
And I follow "Grades" in the user menu
And I click on "Course 1" "link" in the "Course 1" "table_row"
diff --git a/public/mod/lesson/tests/behat/lesson_retake.feature b/public/mod/lesson/tests/behat/lesson_retake.feature
index 9992de1395c6b..9a35bd1e16e9a 100644
--- a/public/mod/lesson/tests/behat/lesson_retake.feature
+++ b/public/mod/lesson/tests/behat/lesson_retake.feature
@@ -37,19 +37,14 @@ Feature: Retake lesson activity
| Question 3 | Lavender | Next page | 1 |
Scenario: A student can retake a lesson
- # First attempt - all correct
- Given I am on the "Test lesson name" "lesson activity" page logged in as student1
- And I set the following fields to these values:
- | Brown | 1 |
- And I press "Submit"
- And I set the following fields to these values:
- | Lavender | 1 |
- And I press "Submit"
- And I set the following fields to these values:
- | Lavender | 1 |
- And I press "Submit"
- # Confirm that lesson can be retaken
- When I am on the "Test lesson name" "lesson activity" page
+ # First attempt - all correct (using data generators)
+ Given the following "mod_lesson > attempts" exist:
+ | lesson | user | page | answer | correct |
+ | Test lesson name | student1 | Question 1 | Brown | 1 |
+ | Test lesson name | student1 | Question 2 | Lavender | 1 |
+ | Test lesson name | student1 | Question 3 | Lavender | 1 |
+ # Log in as the student to start the manual second attempt
+ When I am on the "Test lesson name" "lesson activity" page logged in as student1
Then I should see "Which is not a plant?"
# Second attempt - only 1 correct
And I set the following fields to these values:
diff --git a/public/mod/lesson/tests/behat/lesson_user_override.feature b/public/mod/lesson/tests/behat/lesson_user_override.feature
index 9211b9d9cae34..46f0f0e3f0d76 100644
--- a/public/mod/lesson/tests/behat/lesson_user_override.feature
+++ b/public/mod/lesson/tests/behat/lesson_user_override.feature
@@ -55,19 +55,11 @@ Feature: Lesson user override
@javascript
Scenario: Duplicate a user override
- Given I am on the "Test lesson name" "lesson activity" page logged in as teacher1
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | deadline |
+ | Test lesson name | student1 | ##1 Jan 2020 08:00## |
+ When I am on the "Test lesson name" "lesson activity" page logged in as teacher1
And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | id_deadline_enabled | 1 |
- | deadline[day] | 1 |
- | deadline[month] | January |
- | deadline[year] | 2020 |
- | deadline[hour] | 08 |
- | deadline[minute] | 00 |
- And I press "Save"
- And I should see "Wednesday, 1 January 2020, 8:00"
Then I click on "copy" "link"
And I set the following fields to these values:
| Override user | Student2 |
@@ -76,19 +68,14 @@ Feature: Lesson user override
And I should see "Tuesday, 1 January 2030, 8:00"
And I should see "Sam2 Student2"
- @javascript
Scenario: Allow a single user to have re-take the lesson
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | retake |
+ | Test lesson name | student1 | 1 |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| Allow multiple attempts | 0 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | Allow multiple attempts | 1 |
- And I press "Save"
- And I should see "Allow multiple attempts"
And I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "Cat is an amphibian"
And I set the following fields to these values:
@@ -109,20 +96,15 @@ Feature: Lesson user override
And I am on the "Test lesson name" "lesson activity" page
And I should see "You are not allowed to retake this lesson."
- @javascript
Scenario: Allow a single user to have a different password
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | password |
+ | Test lesson name | student1 | 12345 |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| Password protected lesson | Yes |
| id_password | moodle_rules |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | Password protected lesson | 12345 |
- And I press "Save"
- And I should see "Password protected lesson"
And I am on the "Test lesson name" "lesson activity" page logged in as student1
Then I should see "Test lesson name is a password protected lesson"
And I should not see "Cat is an amphibian"
@@ -148,9 +130,11 @@ Feature: Lesson user override
And I set the field "userpassword" to "moodle_rules"
And I press "Continue"
- @javascript
Scenario: Allow a user to have a different due date
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | deadline |
+ | Test lesson name | student1 | ##1 Jan 2030 08:00## |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| id_deadline_enabled | 1 |
| deadline[day] | 1 |
@@ -159,28 +143,18 @@ Feature: Lesson user override
| deadline[hour] | 08 |
| deadline[minute] | 00 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | id_deadline_enabled | 1 |
- | deadline[day] | 1 |
- | deadline[month] | January |
- | deadline[year] | 2030 |
- | deadline[hour] | 08 |
- | deadline[minute] | 00 |
- And I press "Save"
- And I should see "Lesson closes"
- And I am on the "Test lesson name" "lesson activity" page logged in as student2
+ When I am on the "Test lesson name" "lesson activity" page logged in as student2
And I wait until the page is ready
Then the activity date in "Test lesson name" should contain "Closed: Saturday, 1 January 2000, 8:00"
And I should not see "Cat is an amphibian"
And I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "Cat is an amphibian"
- @javascript
Scenario: Allow a user to have a different start date
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | available |
+ | Test lesson name | student1 | ##1 Jan 2015 08:00## |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| id_available_enabled | 1 |
| available[day] | 1 |
@@ -189,39 +163,22 @@ Feature: Lesson user override
| available[hour] | 08 |
| available[minute] | 00 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | id_available_enabled | 1 |
- | available[day] | 1 |
- | available[month] | January |
- | available[year] | 2015 |
- | available[hour] | 08 |
- | available[minute] | 00 |
- And I press "Save"
- And I should see "Lesson opens"
- And I am on the "Test lesson name" "lesson activity" page logged in as student2
+ When I am on the "Test lesson name" "lesson activity" page logged in as student2
And I wait until the page is ready
Then the activity date in "Test lesson name" should contain "Opens: Tuesday, 1 January 2030, 8:00"
And I should not see "Cat is an amphibian"
And I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "Cat is an amphibian"
- @javascript
Scenario: Allow a single user to have multiple attempts at each question
- Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | maxattempts |
+ | Test lesson name | student1 | 2 |
+ And I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I set the following fields to these values:
| Allow multiple attempts | 1 |
And I press "Save and display"
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | Maximum number of tries per question | 2 |
- And I press "Save"
- And I should see "Maximum number of tries per question"
- And I am on the "Test lesson name" "lesson activity" page logged in as student1
+ When I am on the "Test lesson name" "lesson activity" page logged in as student1
And I should see "Cat is an amphibian"
And I set the following fields to these values:
| True | 1 |
@@ -275,7 +232,6 @@ Feature: Lesson user override
Then the "Override user" select box should contain "Sam1 Student1, student1@example.com"
And the "Override user" select box should not contain "Sam2 Student2, student2@example.com"
- @javascript
Scenario: A teacher without accessallgroups permission should only be able to see the user override for their group-mates, when the activity's group mode is 'separate groups'
Given the following "permission overrides" exist:
| capability | permission | role | contextlevel | reference |
@@ -292,44 +248,24 @@ Feature: Lesson user override
| teacher1 | G1 |
| student1 | G1 |
| student2 | G2 |
- And I am on the "Lesson 2" "lesson activity" page logged in as admin
- And I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | id_deadline_enabled | 1 |
- | deadline[day] | 1 |
- | deadline[month] | January |
- | deadline[year] | 2020 |
- | deadline[hour] | 08 |
- | deadline[minute] | 00 |
- And I press "Save and enter another override"
- And I set the following fields to these values:
- | Override user | Student2 |
- | id_deadline_enabled | 1 |
- | deadline[day] | 1 |
- | deadline[month] | January |
- | deadline[year] | 2020 |
- | deadline[hour] | 08 |
- | deadline[minute] | 00 |
- And I press "Save"
+ And the following "mod_lesson > user overrides" exist:
+ | lesson | user | deadline |
+ | Lesson 2 | student1 | ##1 Jan 2020 08:00## |
+ | Lesson 2 | student2 | ##1 Jan 2020 08:00## |
When I am on the "Lesson 2" "lesson activity" page logged in as teacher1
And I navigate to "Overrides" in current page administration
Then I should see "Student1" in the ".generaltable" "css_element"
And I should not see "Student2" in the ".generaltable" "css_element"
- @javascript
Scenario: Create a user override when the lesson is not available to the student
+ Given the following "mod_lesson > user overrides" exist:
+ | lesson | user | maxattempts |
+ | Test lesson name | student1 | 2 |
Given I am on the "Test lesson name" "lesson activity editing" page logged in as teacher1
And I expand all fieldsets
And I set the field "Availability" to "Hide on course page"
And I click on "Save and display" "button"
When I navigate to "Overrides" in current page administration
- And I follow "Add user override"
- And I set the following fields to these values:
- | Override user | Student1 |
- | Maximum number of tries per question | 2 |
- And I press "Save"
Then I should see "This override is inactive"
And "Edit" "icon" should exist in the "Sam1 Student1" "table_row"
And "copy" "icon" should exist in the "Sam1 Student1" "table_row"
diff --git a/public/mod/lesson/tests/behat/overview_report.feature b/public/mod/lesson/tests/behat/overview_report.feature
index 003f31ab2923f..427a92f461bef 100644
--- a/public/mod/lesson/tests/behat/overview_report.feature
+++ b/public/mod/lesson/tests/behat/overview_report.feature
@@ -31,12 +31,12 @@ Feature: Testing overview_report in mod_lesson
| page | answer | jumpto | score |
| Question 1 | True | End of lesson | 1 |
| Question 1 | False | End of lesson | 0 |
- And the following "mod_lesson > submissions" exist:
- | lesson | user | grade |
- | Lesson 1 | student1 | 50 |
- | Lesson 1 | student1 | 60 |
- | Lesson 1 | student1 | 100 |
- | Lesson 1 | student2 | 90 |
+ And the following "mod_lesson > attempts" exist:
+ | lesson | user | grade | retry |
+ | Lesson 1 | student1 | 50 | 0 |
+ | Lesson 1 | student1 | 60 | 1 |
+ | Lesson 1 | student1 | 100 | 2 |
+ | Lesson 1 | student2 | 90 | 0 |
@javascript
Scenario: Teacher can see the lesson relevant information in the lesson overview
diff --git a/public/mod/lesson/tests/courseformat/overview_test.php b/public/mod/lesson/tests/courseformat/overview_test.php
index 84a5aca311af5..3ad725c4ebc87 100644
--- a/public/mod/lesson/tests/courseformat/overview_test.php
+++ b/public/mod/lesson/tests/courseformat/overview_test.php
@@ -198,25 +198,26 @@ public function test_get_extra_totalattempts_overview(
$this->create_lesson_pages($lesson, 2);
if ($hasentries) {
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $student1->id,
'grade' => 50,
]);
if ($hasretakes) {
// If we can retake, create another attempt for student1.
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $student1->id,
'grade' => 100,
+ 'retry' => 1,
]);
}
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $student2->id,
'grade' => 100,
]);
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $currentuser->id,
'grade' => 100,
@@ -388,17 +389,17 @@ public function test_get_extra_attemptedstudents_overview(
$this->create_lesson_pages($lesson, 2);
if ($hasentries) {
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $student1->id,
'grade' => 100,
]);
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $student2->id,
'grade' => 100,
]);
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $currentuser->id,
'grade' => 100,
diff --git a/public/mod/lesson/tests/custom_completion_test.php b/public/mod/lesson/tests/custom_completion_test.php
index f0e1a14dd6616..b3e9acf08cc1f 100644
--- a/public/mod/lesson/tests/custom_completion_test.php
+++ b/public/mod/lesson/tests/custom_completion_test.php
@@ -99,17 +99,13 @@ public function test_get_state(string $rule, int $rulevalue, $uservalue, ?int $s
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of the magic getter method when fetching the cm_info object's
- // customdata and instance values.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->will($this->returnValueMap([
- ['customdata', $customdataval],
- ['instance', 1],
- ]));
+ ->method('get_custom_data')
+ ->willReturn($customdataval);
if ($rule === 'completiontimespent') {
// Mock the DB call fetching user's lesson time spent.
@@ -245,13 +241,12 @@ public function test_get_available_custom_rules(array $completionrulesvalues, ar
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of magic getter for the customdata attribute.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->with('customdata')
+ ->method('get_custom_data')
->willReturn($customcompletionrules);
$customcompletion = new custom_completion($mockcminfo, 1);
diff --git a/public/mod/lesson/tests/generator/behat_mod_lesson_generator.php b/public/mod/lesson/tests/generator/behat_mod_lesson_generator.php
index 8909a2b5f7a95..8ddebbeb77158 100644
--- a/public/mod/lesson/tests/generator/behat_mod_lesson_generator.php
+++ b/public/mod/lesson/tests/generator/behat_mod_lesson_generator.php
@@ -49,12 +49,24 @@ protected function get_creatable_entities(): array {
'datagenerator' => 'answer',
'required' => ['page'],
],
- 'submissions' => [
- 'singular' => 'submission',
- 'datagenerator' => 'submission',
+ 'attempts' => [
+ 'singular' => 'attempt',
+ 'datagenerator' => 'attempt',
'required' => ['lesson', 'user'],
'switchids' => ['lesson' => 'lessonid', 'user' => 'userid'],
],
+ 'user overrides' => [
+ 'singular' => 'user override',
+ 'datagenerator' => 'override',
+ 'required' => ['lesson', 'user'],
+ 'switchids' => ['lesson' => 'lessonid', 'user' => 'userid'],
+ ],
+ 'group overrides' => [
+ 'singular' => 'group override',
+ 'datagenerator' => 'override',
+ 'required' => ['lesson', 'group'],
+ 'switchids' => ['lesson' => 'lessonid', 'group' => 'groupid'],
+ ],
];
}
@@ -68,4 +80,40 @@ protected function get_lesson_id(string $idnumberorname): int {
return $this->get_cm_by_activity_name('lesson', $idnumberorname)->instance;
}
+ /**
+ * Preprocess attempt data.
+ *
+ * @param array $data
+ * @return array
+ */
+ protected function preprocess_attempt(array $data): array {
+ global $DB;
+
+ // The 'lesson' and 'user' fields are already resolved to 'lessonid' and 'userid' by the
+ // 'switchids' declaration, which runs before this method. Here we only need to resolve the
+ // 'page' and 'answer' fields, which depend on those ids having been resolved first.
+ if (isset($data['page']) && isset($data['lessonid'])) {
+ $data['pageid'] = $DB->get_field(
+ 'lesson_pages',
+ 'id',
+ ['title' => $data['page'], 'lessonid' => $data['lessonid']],
+ MUST_EXIST
+ );
+ unset($data['page']);
+ }
+
+ if (isset($data['answer']) && isset($data['pageid'])) {
+ // The 'answer' field is a TEXT column, so we must use sql_compare_text to query it.
+ $select = $DB->sql_compare_text('answer') . ' = ' . $DB->sql_compare_text(':answer') .
+ ' AND pageid = :pageid';
+ $params = [
+ 'answer' => $data['answer'],
+ 'pageid' => $data['pageid'],
+ ];
+ $data['answerid'] = $DB->get_field_select('lesson_answers', 'id', $select, $params, MUST_EXIST);
+ unset($data['answer']);
+ }
+
+ return $data;
+ }
}
diff --git a/public/mod/lesson/tests/generator/lib.php b/public/mod/lesson/tests/generator/lib.php
index 7adfd02c78fee..a223576c5bfb8 100644
--- a/public/mod/lesson/tests/generator/lib.php
+++ b/public/mod/lesson/tests/generator/lib.php
@@ -646,6 +646,8 @@ public function create_override(array $data): void {
}
$DB->insert_record('lesson_overrides', (object) $data);
+
+ lesson_update_events($DB->get_record('lesson', ['id' => $data['lessonid']], '*', MUST_EXIST));
}
/**
@@ -804,64 +806,114 @@ protected function convert_page_jumpto(int $lessonid, ?array $jumptolist): ?arra
}
/**
- * Creates a lesson submission for testing purposes.
+ * Create a lesson attempt.
+ *
+ * Inserts a lesson_attempts record and keeps the corresponding lesson_grades record and
+ * gradebook in sync, without requiring a separate 'submissions' generator.
+ *
+ * The 'retry' value (0-indexed, defaulting to 0) identifies which attempt/retake this record
+ * belongs to. All attempt rows created for the same lessonid/userid/retry combination are
+ * treated as belonging to the same retake and are aggregated into a single lesson_grades
+ * record for that retake, mirroring how lesson::process_page() maintains one lesson_grades
+ * row per retake (see mod/lesson/locallib.php). Retry values for a given user should be
+ * created in increasing order (0, 1, 2, ...), matching how retakes are actually generated,
+ * so that grade records line up with the correct retake.
+ *
+ * If 'grade' is not supplied, the grade for the retake is derived as the percentage of
+ * 'correct' attempts recorded so far for that lessonid/userid/retry, recalculated on every
+ * call so that multi-page attempts within the same retake average correctly instead of the
+ * final page silently overwriting earlier results.
*
- * @param mixed $record
- * @throws \coding_exception
- * @return bool|int
+ * @param array|stdClass $record data for the attempt.
+ * @return stdClass attempt record.
+ * @throws coding_exception
*/
- public function create_submission($record = null) {
+ public function create_attempt($record) {
+ global $CFG;
$db = \core\di::get(\moodle_database::class);
- [
- 'lessonid' => $lessonid,
- 'userid' => $userid,
- 'grade' => $grade,
- ] = $record;
-
- [, $cm] = get_course_and_cm_from_instance($lessonid, 'lesson');
- $lesson = new lesson($cm->get_instance_record());
-
- // Check if the lesson exists and retakes are allowed.
- if (!$lesson->retake && $db->record_exists('lesson_grades', ['lessonid' => $lessonid, 'userid' => $userid])) {
- throw new coding_exception("Grade for user $userid in lesson $lessonid already exists and retakes are not allowed.");
- }
-
- // Get the highest score answer for each page in the lesson.
- $sql = "SELECT la.id AS answerid, la.answer, la.pageid FROM {lesson_answers} la
- JOIN (
- SELECT lessonid, pageid, MAX(score) AS maxscore FROM {lesson_answers}
- GROUP BY lessonid, pageid
- ) mla ON mla.lessonid = la.lessonid AND mla.pageid = la.pageid AND mla.maxscore = la.score
- WHERE (
- SELECT COUNT(*)
- FROM {lesson_answers} lac
- WHERE lac.lessonid = la.lessonid
- AND lac.pageid = la.pageid
- AND lac.score = la.score
- ) = 1
- AND la.lessonid = :lessonid";
-
- $answers = $db->get_records_sql($sql, ['lessonid' => $lessonid]);
- foreach ($answers as $answer) {
- // Create an attempt for each answer.
- $db->insert_record('lesson_attempts', [
- 'lessonid' => $lessonid,
- 'userid' => $userid,
- 'pageid' => $answer->pageid,
- 'answerid' => $answer->answerid,
- 'retry' => 0,
- 'useranswer' => $answer->answer,
- 'timeseen' => time(),
- ]);
- }
-
- return $db->insert_record('lesson_grades', [
- 'lessonid' => $lessonid,
- 'userid' => $userid,
- 'grade' => $grade,
- 'late' => 0,
- 'completed' => time(),
+ $data = (array)$record;
+
+ if (!isset($data['lessonid'])) {
+ throw new coding_exception('Must specify lessonid when creating a lesson attempt.');
+ }
+
+ if (!isset($data['userid'])) {
+ throw new coding_exception('Must specify userid when creating a lesson attempt.');
+ }
+
+ $attempt = new stdClass();
+ $attempt->lessonid = (int)$data['lessonid'];
+ $attempt->userid = (int)$data['userid'];
+ $attempt->pageid = isset($data['pageid']) ? (int)$data['pageid'] : 0;
+ $attempt->answerid = isset($data['answerid']) ? (int)$data['answerid'] : 0;
+ $attempt->retry = isset($data['retry']) ? (int)$data['retry'] : 0;
+ $attempt->correct = isset($data['correct']) ? (int)$data['correct'] : 0;
+ $attempt->useranswer = isset($data['useranswer']) ? $data['useranswer'] : '';
+ $attempt->timeseen = isset($data['timeseen']) ? (int)$data['timeseen'] : time();
+
+ $attempt->id = $db->insert_record('lesson_attempts', $attempt);
+
+ $lesson = $db->get_record('lesson', ['id' => $attempt->lessonid], '*', MUST_EXIST);
+
+ // Fetch all attempt rows recorded so far for this retake (including the one just
+ // inserted above), so we can both derive an automatic grade and detect whether this
+ // retake already has a lesson_grades record.
+ $retryattempts = $db->get_records('lesson_attempts', [
+ 'lessonid' => $attempt->lessonid,
+ 'userid' => $attempt->userid,
+ 'retry' => $attempt->retry,
]);
+
+ // Determine the grade for this retake. Use the explicit value if supplied, otherwise
+ // derive it from the percentage of correct attempts recorded so far for this retake.
+ if (isset($data['grade'])) {
+ $gradeval = (float)$data['grade'];
+ } else {
+ $total = count($retryattempts);
+ $correct = 0;
+ foreach ($retryattempts as $retryattempt) {
+ if ($retryattempt->correct) {
+ $correct++;
+ }
+ }
+ $gradeval = $total > 0 ? ($correct / $total) * 100 : 0;
+ }
+
+ // Was this retake already graded by an earlier call (i.e. do other attempt rows already
+ // exist for this lessonid/userid/retry)? If so, update the existing lesson_grades record
+ // for this user's most recent retake, mirroring how core updates the last grade record
+ // when continuing an in-progress attempt. Otherwise, this is a new retake and needs its
+ // own lesson_grades record.
+ $othersinretry = count($retryattempts) - 1;
+
+ if ($othersinretry > 0) {
+ $lastgrade = $db->get_records('lesson_grades', [
+ 'lessonid' => $attempt->lessonid,
+ 'userid' => $attempt->userid,
+ ], 'id DESC', '*', 0, 1);
+ $graderecord = reset($lastgrade);
+ } else {
+ $graderecord = false;
+ }
+
+ if ($graderecord) {
+ $graderecord->grade = $gradeval;
+ $db->update_record('lesson_grades', $graderecord);
+ } else {
+ $newgrade = new stdClass();
+ $newgrade->lessonid = $attempt->lessonid;
+ $newgrade->userid = $attempt->userid;
+ $newgrade->grade = $gradeval;
+ $newgrade->late = 0;
+ $newgrade->completed = time();
+ $db->insert_record('lesson_grades', $newgrade);
+ }
+
+ // Trigger Moodle core API to update the official gradebook.
+ require_once($CFG->dirroot . '/mod/lesson/lib.php');
+ lesson_update_grades($lesson, $attempt->userid);
+
+ return $db->get_record('lesson_attempts', ['id' => $attempt->id], '*', MUST_EXIST);
}
}
diff --git a/public/mod/lesson/tests/generator_test.php b/public/mod/lesson/tests/generator_test.php
index 5cff1f6e911e7..998794762784a 100644
--- a/public/mod/lesson/tests/generator_test.php
+++ b/public/mod/lesson/tests/generator_test.php
@@ -750,11 +750,11 @@ public function test_create_answer_jumpto_circular_dependency(): void {
}
/**
- * Test create a submission and the related attempts.
+ * Test create an attempt and the related grade.
*
- * @covers ::create_submission
+ * @covers ::create_attempt
*/
- public function test_create_submission(): void {
+ public function test_create_attempt(): void {
$db = \core\di::get(\moodle_database::class);
$this->resetAfterTest();
$this->setAdminUser();
@@ -785,20 +785,20 @@ public function test_create_submission(): void {
$lessongenerator->create_answer(['page' => 'Multichoice question 2', 'answer' => 'spider']);
$lessongenerator->finish_generate_answer();
- // Create a submission.
- $submissionid = $lessongenerator->create_submission([
+ // Create an attempt.
+ $attempt = $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $student1->id,
'grade' => 100,
]);
- // Check that the submission was created.
- $submission = $db->get_record('lesson_grades', ['lessonid' => $lesson->id, 'userid' => $student1->id]);
- $this->assertNotEmpty($submission);
- $this->assertEquals($submissionid, $submission->id);
+ // Check that the grade was successfully auto-created.
+ $grade = $db->get_record('lesson_grades', ['lessonid' => $lesson->id, 'userid' => $student1->id]);
+ $this->assertNotEmpty($grade);
+ $this->assertEquals(100, $grade->grade);
// Check that the attempts were created.
$attempts = $db->get_records('lesson_attempts', ['lessonid' => $lesson->id, 'userid' => $student1->id]);
- $this->assertCount(2, $attempts);
+ $this->assertCount(1, $attempts);
}
}
diff --git a/public/mod/lesson/tests/locallib_test.php b/public/mod/lesson/tests/locallib_test.php
index 29a2dc0f2b70d..410344dc584b5 100644
--- a/public/mod/lesson/tests/locallib_test.php
+++ b/public/mod/lesson/tests/locallib_test.php
@@ -295,15 +295,16 @@ public function test_get_last_attempt($maxattempts, $attempts, $expected): void
* @param int $userid The user ID for whom the attempts are created.
* @param int $count The number of attempts to create.
*/
- private function create_user_submissions(lesson $lesson, int $userid, int $count): void {
+ private function create_user_attempts(lesson $lesson, int $userid, int $count): void {
/** @var \mod_lesson_generator $lessongenerator */
$lessongenerator = $this->getDataGenerator()->get_plugin_generator('mod_lesson');
for ($i = 0; $i < $count; $i++) {
- $lessongenerator->create_submission([
+ $lessongenerator->create_attempt([
'lessonid' => $lesson->id,
'userid' => $userid,
'grade' => 100,
+ 'retry' => $i,
]);
}
}
@@ -359,9 +360,9 @@ public function test_count_attempts_and_participants(): void {
$lesson = new lesson($lessonrecord);
$this->create_lesson_pages($lesson, 2);
- $this->create_user_submissions($lesson, $student1->id, 1);
- $this->create_user_submissions($lesson, $student2->id, 2);
- $this->create_user_submissions($lesson, $student3->id, 2);
+ $this->create_user_attempts($lesson, $student1->id, 1);
+ $this->create_user_attempts($lesson, $student2->id, 2);
+ $this->create_user_attempts($lesson, $student3->id, 2);
$this->setUser($teacher->id);
@@ -414,9 +415,9 @@ public function test_count_attempts_and_participants_with_groups(): void {
$lesson = new lesson($lessonrecord);
$this->create_lesson_pages($lesson, 2);
- $this->create_user_submissions($lesson, $student1->id, 1);
- $this->create_user_submissions($lesson, $student2->id, 2);
- $this->create_user_submissions($lesson, $student3->id, 2);
+ $this->create_user_attempts($lesson, $student1->id, 1);
+ $this->create_user_attempts($lesson, $student2->id, 2);
+ $this->create_user_attempts($lesson, $student3->id, 2);
$group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
$group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
@@ -443,4 +444,182 @@ public function test_count_attempts_and_participants_with_groups(): void {
$this->getDataGenerator()->create_group_member(['userid' => $teacher->id, 'groupid' => $group1->id]);
$this->assertEquals(2, $lesson->count_all_participants([$group1->id]));
}
+
+ /**
+ * Data provider for test_get_last_page_seen_with_this_page_jumpto.
+ *
+ * Each case defines:
+ * - maxattempts: Lesson setting (0 = unlimited).
+ * - attemptcount: Number of wrong attempts on the question page.
+ * - expectnextpage Whether we expect get_last_page_seen() to return the next page.
+ *
+ * @return array
+ */
+ public static function get_last_page_seen_dataprovider(): array {
+ return [
+ // Unlimited attempts: even with multiple wrong attempts, we should stay on the same page.
+ 'unlimited_attempts_one_attempt' => [0, 1, false],
+ 'unlimited_attempts_two_attempts' => [0, 2, false],
+
+ // Maxattempts = 1, with one wrong attempt: attempts are exhausted, move to next page.
+ 'single_attempt_limit_reached' => [1, 1, true],
+
+ // Maxattempts = 2, with one wrong attempt: not exhausted yet, stay on same page.
+ 'two_attempt_limit_not_reached' => [2, 1, false],
+
+ // Maxattempts = 2, with two wrong attempts: exhausted, move to next page.
+ 'two_attempt_limit_reached' => [2, 2, true],
+ ];
+ }
+
+ /**
+ * Helper to create a lesson with a question page followed by a content page,
+ * where the wrong answer on the question jumps to "This page".
+ *
+ * Returns:
+ * - course => course record
+ * - lesson => lesson object
+ * - questionpage => question page record
+ * - nextpage => next page record
+ * - wronganswer => wrong answer record (jumpto = LESSON_THISPAGE)
+ *
+ * @param int $maxattempts The lesson maxattempts setting (0 = unlimited).
+ * @return array
+ */
+ private function create_lesson_with_this_page_wrong_answer(int $maxattempts): array {
+ global $DB;
+
+ // Course and lesson.
+ $course = $this->getDataGenerator()->create_course();
+ $lessonrecord = $this->getDataGenerator()->create_module('lesson', [
+ 'course' => $course->id,
+ 'maxattempts' => $maxattempts,
+ ]);
+ $lesson = new lesson($lessonrecord);
+
+ /** @var \mod_lesson_generator $lessongenerator */
+ $lessongenerator = $this->getDataGenerator()->get_plugin_generator('mod_lesson');
+
+ // Create a multichoice question page followed by a content page.
+ $questionpage = [
+ 'title' => 'Question page',
+ 'content' => 'What animal is an amphibian?',
+ 'qtype' => 'multichoice',
+ 'lessonid' => $lesson->id,
+ ];
+ $nextpage = [
+ 'title' => 'Next content page',
+ 'content' => 'This is the next page.',
+ 'qtype' => 'content',
+ 'lessonid' => $lesson->id,
+ ];
+
+ $lessongenerator->create_page($questionpage);
+ $lessongenerator->create_page($nextpage);
+
+ // Add a correct answer (Next page) and an incorrect answer (This page).
+ $lessongenerator->create_answer([
+ 'page' => $questionpage['title'],
+ 'answer' => 'Frog',
+ 'response' => 'Correct',
+ 'jumpto' => 'Next page',
+ 'score' => 1,
+ ]);
+ $lessongenerator->create_answer([
+ 'page' => $questionpage['title'],
+ 'answer' => 'Cat',
+ 'response' => 'Incorrect',
+ 'jumpto' => 'This page',
+ 'score' => 0,
+ ]);
+
+ $lessongenerator->finish_generate_answer();
+
+ $questionpagedb = $DB->get_record('lesson_pages', [
+ 'lessonid' => $lesson->id,
+ 'qtype' => LESSON_PAGE_MULTICHOICE,
+ ], '*', MUST_EXIST);
+
+ $nextpagedb = $DB->get_record('lesson_pages', [
+ 'lessonid' => $lesson->id,
+ 'qtype' => LESSON_PAGE_BRANCHTABLE,
+ ], '*', MUST_EXIST);
+
+ // Fetch the wrong answer record.
+ $wronganswerdb = $DB->get_record('lesson_answers', [
+ 'lessonid' => $lesson->id,
+ 'pageid' => $questionpagedb->id,
+ 'score' => 0,
+ ], '*', MUST_EXIST);
+
+ return [
+ 'course' => $course,
+ 'lesson' => $lesson,
+ 'questionpage' => $questionpagedb,
+ 'nextpage' => $nextpagedb,
+ 'wronganswer' => $wronganswerdb,
+ ];
+ }
+
+ /**
+ * Test get_last_page_seen() when the last attempts were wrong answers with a
+ * "This page" jump, for different maxattempts and attempt counts.
+ *
+ * Scenario:
+ * - Lesson with a multichoice question followed by a content page.
+ * - Wrong answer on the question jumps to "This page".
+ * - We record wrong attempts on that question.
+ * - We call get_last_page_seen(0) to determine where resuming should start.
+ *
+ * For unlimited attempts (maxattempts = 0) we should always stay on the question page.
+ * For limited attempts, once count(attempts) >= maxattempts, we should go to the next page.
+ *
+ * @dataProvider get_last_page_seen_dataprovider
+ * @param int $maxattempts Lesson setting (0 = unlimited).
+ * @param int $attemptcount Number of wrong attempts to create on the question page.
+ * @param bool $expectnextpage Whether we expect the next page to be returned.
+ * @covers ::get_last_page_seen
+ */
+ public function test_get_last_page_seen_with_this_page_jumpto(int $maxattempts, int $attemptcount, bool $expectnextpage): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ // Build the lesson scenario.
+ [
+ 'course' => $course,
+ 'lesson' => $lesson,
+ 'questionpage' => $questionpage,
+ 'nextpage' => $nextpage,
+ 'wronganswer' => $wronganswer,
+ ] = $this->create_lesson_with_this_page_wrong_answer($maxattempts);
+ // Create and enrol a student.
+ $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+ // Act as the student.
+ $this->setUser($student);
+
+ // Create the required number of wrong attempts on the question page.
+ for ($i = 0; $i < $attemptcount; $i++) {
+ $attempt = (object) [
+ 'lessonid' => $lesson->id,
+ 'pageid' => $questionpage->id,
+ 'userid' => $student->id,
+ 'answerid' => $wronganswer->id,
+ 'retry' => 0,
+ 'correct' => 0,
+ 'useranswer' => 'Cat', // Cat is the incorrect answer.
+ 'timeseen' => time(),
+ ];
+ $DB->insert_record('lesson_attempts', $attempt);
+ }
+
+ // Call the method under test.
+ $lastpageid = $lesson->get_last_page_seen(0);
+
+ // Decide which page we expect based on the data provider.
+ $expectedpageid = $expectnextpage ? $nextpage->id : $questionpage->id;
+ $this->assertEquals($expectedpageid, $lastpageid);
+ }
}
diff --git a/public/mod/lti/styles.css b/public/mod/lti/styles.css
index f7c701a056a96..0a9c7009112ed 100644
--- a/public/mod/lti/styles.css
+++ b/public/mod/lti/styles.css
@@ -433,7 +433,7 @@
}
/* Strip the caret from the action menu toggle, as the ... ellipsis icon is used. */
-#page-mod-lti-coursetools a.dropdown-toggle::after {
+#page-mod-lti-coursetools table.reportbuilder-table a.dropdown-toggle::after {
display: none;
}
diff --git a/public/mod/page/tests/behat/page_editing.feature b/public/mod/page/tests/behat/page_editing.feature
new file mode 100644
index 0000000000000..a4fc9e251844e
--- /dev/null
+++ b/public/mod/page/tests/behat/page_editing.feature
@@ -0,0 +1,18 @@
+@mod @mod_page
+Feature: Edit page resource settings
+ In order to configure the page resource
+ As a teacher
+ I need to be able to edit its settings
+
+ Background:
+ Given the following "courses" exist:
+ | shortname | fullname |
+ | C1 | Course 1 |
+ And the following "activities" exist:
+ | activity | name | intro | course | idnumber |
+ | page | PageName1 | PageDesc1 | C1 | PAGE1 |
+
+ @javascript @accessibility
+ Scenario: Check the accessibility of the page activity editing page
+ Given I am on the "PageName1" "page activity editing" page logged in as admin
+ Then the page should meet accessibility standards
diff --git a/public/mod/qbank/classes/task/transfer_question_categories.php b/public/mod/qbank/classes/task/transfer_question_categories.php
index a64b3abeb0aad..20f4f898bd654 100644
--- a/public/mod/qbank/classes/task/transfer_question_categories.php
+++ b/public/mod/qbank/classes/task/transfer_question_categories.php
@@ -64,7 +64,7 @@ public function execute(): void {
$this->fix_wrong_parents();
- $recordset = $DB->get_recordset('question_categories', ['parent' => 0]);
+ $recordset = $DB->get_recordset('question_categories', ['parent' => 0], 'id ASC');
foreach ($recordset as $oldtopcategory) {
diff --git a/public/mod/qbank/tests/fixtures/testable_transfer_question_categories.php b/public/mod/qbank/tests/fixtures/testable_transfer_question_categories.php
index 8b576430ecc78..969c83f5a9814 100644
--- a/public/mod/qbank/tests/fixtures/testable_transfer_question_categories.php
+++ b/public/mod/qbank/tests/fixtures/testable_transfer_question_categories.php
@@ -40,8 +40,7 @@ class testable_transfer_question_categories extends transfer_question_categories
#[\Override]
protected function move_question_category(\stdClass $oldtopcategory, module $newcontext): array {
if ($this->testcounter >= 1) {
- // We simulate a failure after successfully transferring two question categories
- // and creating two corresponding transfer_questions tasks.
+ // Simulate a failure after one successful top-level category transfer.
throw new moodle_exception('This is a mocked exception for testing purposes.');
}
$this->testcounter++;
diff --git a/public/mod/qbank/tests/task/transfer_question_categories_test.php b/public/mod/qbank/tests/task/transfer_question_categories_test.php
index fe6c87415e67e..21d70a430069d 100644
--- a/public/mod/qbank/tests/task/transfer_question_categories_test.php
+++ b/public/mod/qbank/tests/task/transfer_question_categories_test.php
@@ -80,7 +80,7 @@ protected function get_question_data(array $categoryids): array {
[$insql, $inparams] = $DB->get_in_or_equal($categoryids);
- $sql = "SELECT q.id, qbe.questioncategoryid AS categoryid, qv.status
+ $sql = "SELECT q.id, q.qtype, qbe.questioncategoryid AS categoryid, qv.status
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
@@ -390,7 +390,9 @@ public function test_setup_pre_install_data(): void {
// Make sure we have 2 questions in the above course category level question category.
$questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecatcats));
$this->assertCount(2, $questions);
- $question = reset($questions);
+ $essayquestions = array_filter($questions, static fn($question) => $question->qtype === 'essay');
+ $this->assertCount(1, $essayquestions);
+ $question = reset($essayquestions);
$this->assertEquals($parentcat->id, $question->categoryid);
// Make sure there are files in the expected fileareas for this question.
$fs = get_file_storage();
@@ -1130,6 +1132,13 @@ public function test_qbank_install_resilience(): void {
$this->resetAfterTest();
$this->setup_pre_install_data();
+ $sitecontext = context_system::instance();
+ $expectedcategoryids = [
+ $DB->get_field('question_categories', 'id', ['contextid' => $sitecontext->id, 'name' => 'Site Parent Cat'], MUST_EXIST),
+ $DB->get_field('question_categories', 'id', ['contextid' => $sitecontext->id, 'name' => 'Site Child Cat'], MUST_EXIST),
+ ];
+ sort($expectedcategoryids);
+
require_once(__DIR__ . '/../fixtures/testable_transfer_question_categories.php');
$task = new testable_transfer_question_categories();
try {
@@ -1139,8 +1148,19 @@ public function test_qbank_install_resilience(): void {
$this->assertStringContainsString('This is a mocked exception for testing purposes.', $e->getMessage());
}
// We want to verify a failure does not prevent the creation of tasks with hitherto transferred categories and their data.
- // We should have a transfer_questions task for two of the categories that were moved.
$questiontasks = manager::get_adhoc_tasks(transfer_questions::class);
- $this->assertCount(2, $questiontasks);
+ $this->assertCount(count($expectedcategoryids), $questiontasks);
+
+ // Check the queued tasks are for the categories moved before the simulated failure.
+ $actualcategoryids = array_map(
+ static fn(transfer_questions $task): int => $task->get_custom_data()->categoryid,
+ $questiontasks,
+ );
+ sort($actualcategoryids);
+ $this->assertEquals($expectedcategoryids, $actualcategoryids);
+
+ foreach ($questiontasks as $questiontask) {
+ $this->assertEquals($sitecontext->id, $questiontask->get_custom_data()->contextid);
+ }
}
}
diff --git a/public/mod/quiz/classes/local/override_manager.php b/public/mod/quiz/classes/local/override_manager.php
index 827cdeac5e11a..9f515f2f00126 100644
--- a/public/mod/quiz/classes/local/override_manager.php
+++ b/public/mod/quiz/classes/local/override_manager.php
@@ -35,6 +35,9 @@ class override_manager {
/** @var array quiz setting keys that can be overwritten **/
private const OVERRIDEABLE_QUIZ_SETTINGS = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password'];
+ /** @var array override fields that are numeric and can validly be 0 **/
+ private const OVERRIDE_NUMERIC_FIELDS = ['attempts', 'timelimit', 'timeopen', 'timeclose'];
+
/**
* Create override manager
*
@@ -583,12 +586,13 @@ private function clear_unused_values(array $formdata): array {
// If the formdata is empty, set it to null.
// This avoids putting 0, false, or '' into the DB since the override logic expects null.
- // Attempts is the exception, it can have a integer value of '0', so we use is_numeric instead.
- if ($key != 'attempts' && empty($formdata[$key])) {
+ if (!in_array($key, self::OVERRIDE_NUMERIC_FIELDS, true) && empty($formdata[$key])) {
$formdata[$key] = null;
}
- if ($key == 'attempts' && !is_numeric($formdata[$key])) {
+ // Few fields like attempts, timelimit, timeopen and timeclose are exceptions, they can have an integer value of '0',
+ // so we use is_numeric instead.
+ if (in_array($key, self::OVERRIDE_NUMERIC_FIELDS, true) && !is_numeric($formdata[$key])) {
$formdata[$key] = null;
}
}
diff --git a/public/mod/quiz/classes/notification_helper.php b/public/mod/quiz/classes/notification_helper.php
index 1e499adad9163..ab6e7318e3a93 100644
--- a/public/mod/quiz/classes/notification_helper.php
+++ b/public/mod/quiz/classes/notification_helper.php
@@ -74,27 +74,31 @@ public static function get_users_within_quiz(int $quizid): array {
// Get quiz data.
$quizobj = quiz_settings::create($quizid);
$quiz = $quizobj->get_quiz();
+ $coursemodule = $quizobj->get_cm();
// Get our users.
$users = get_enrolled_users(
- context: \context_module::instance($quizobj->get_cm()->id),
+ context: \context_module::instance($coursemodule->id),
withcapability: 'mod/quiz:attempt',
userfields: 'u.id, u.firstname, u.suspended, u.auth',
onlyactive: true,
);
// Filter a list of users who meet the availability conditions.
- $info = new \core_availability\info_module($quizobj->get_cm());
+ $info = new \core_availability\info_module($coursemodule);
$users = $info->filter_user_list($users);
// Check for any override dates.
$overrides = $quizobj->get_override_manager()->get_all_overrides();
foreach ($users as $key => $user) {
- if ($user->suspended || ($user->auth == 'nologin')) {
+ // Skip users who are suspended, use nologin auth, or cannot access the quiz.
+ $isuservisible = \core_availability\info_module::is_user_visible($coursemodule, $user->id, false);
+ if ($user->suspended || $user->auth == 'nologin' || !$isuservisible) {
unset($users[$key]);
continue;
}
+
// Time open and time close dates can be user specific with an override.
// We begin by assuming it is the same as recorded in the quiz.
$user->timeopen = $quiz->timeopen;
diff --git a/public/mod/quiz/classes/output/edit_renderer.php b/public/mod/quiz/classes/output/edit_renderer.php
index 3114eab361115..d405b8a38b30e 100644
--- a/public/mod/quiz/classes/output/edit_renderer.php
+++ b/public/mod/quiz/classes/output/edit_renderer.php
@@ -711,7 +711,7 @@ public function edit_menu_actions(structure $structure, $page,
// Add a new section to the add_menu if possible. This is always added to the HTML
// then hidden with CSS when no needed, so that as things are re-ordered, etc. with
// Ajax it can be relevaled again when necessary.
- $params = ['cmid' => $structure->get_cmid(), 'addsectionatpage' => $page];
+ $params = ['cmid' => $structure->get_cmid(), 'addsectionatpage' => $page, 'sesskey' => sesskey()];
$actions['addasection'] = new \action_menu_link_secondary(
new \moodle_url($pageurl, $params),
diff --git a/public/mod/quiz/classes/quiz_settings.php b/public/mod/quiz/classes/quiz_settings.php
index 7216ff69b2f77..63396cd8870e6 100644
--- a/public/mod/quiz/classes/quiz_settings.php
+++ b/public/mod/quiz/classes/quiz_settings.php
@@ -21,6 +21,7 @@
use context;
use context_module;
use core_question\local\bank\question_version_status;
+use core_question\local\bank\random_question_loader;
use mod_quiz\question\bank\qbank_helper;
use mod_quiz\question\display_options;
use moodle_exception;
@@ -579,51 +580,38 @@ protected function ensure_question_loaded($id) {
*
* @param boolean $includepotential if the quiz include random questions,
* setting this flag to true will make the function to return all the
- * possible question types in the random questions category.
+ * possible question types matching random question filters.
* @return array a sorted array including the different question types.
* @since Moodle 3.1
*/
public function get_all_question_types_used($includepotential = false) {
$questiontypes = [];
-
- // To control if we need to look in categories for questions.
- $qcategories = [];
+ $loadedconditions = [];
foreach ($this->get_questions(null, false) as $questiondata) {
if ($questiondata->status == question_version_status::QUESTION_STATUS_DRAFT) {
// Skip questions where all versions are draft.
continue;
}
- if ($questiondata->qtype === 'random' && $includepotential) {
- $filtercondition = $questiondata->filtercondition;
- if (!empty($filtercondition)) {
- $filter = $filtercondition['filter'];
- if (isset($filter['category'])) {
- foreach ($filter['category']['values'] as $catid) {
- $qcategories[$catid] = $filter['category']['filteroptions']['includesubcategories'];
+ if ($questiondata->qtype === 'random') {
+ if ($includepotential) {
+ $filtercondition = $questiondata->filtercondition;
+ if (!empty($filtercondition) && !in_array($filtercondition, $loadedconditions)) {
+ $loader = new random_question_loader(new \qubaid_list([]));
+ $potentials = $loader->get_filtered_questions($filtercondition['filter'], 0);
+ foreach ($potentials as $potential) {
+ if (!in_array($potential->qtype, $questiontypes)) {
+ $questiontypes[] = $potential->qtype;
+ }
}
+ $loadedconditions[] = $filtercondition; // Save re-loading the same pool of questions used multiple times.
}
}
- } else {
- if (!in_array($questiondata->qtype, $questiontypes)) {
- $questiontypes[] = $questiondata->qtype;
- }
+ } else if (!in_array($questiondata->qtype, $questiontypes)) {
+ $questiontypes[] = $questiondata->qtype;
}
}
- if (!empty($qcategories)) {
- // We have to look for all the question types in these categories.
- $categoriestolook = [];
- foreach ($qcategories as $cat => $includesubcats) {
- if ($includesubcats) {
- $categoriestolook = array_merge($categoriestolook, question_categorylist($cat));
- } else {
- $categoriestolook[] = $cat;
- }
- }
- $questiontypesincategories = question_bank::get_all_question_types_in_categories($categoriestolook);
- $questiontypes = array_merge($questiontypes, $questiontypesincategories);
- }
$questiontypes = array_unique($questiontypes);
sort($questiontypes);
diff --git a/public/mod/quiz/edit.php b/public/mod/quiz/edit.php
index 3258abefa8a5a..691f69b439484 100644
--- a/public/mod/quiz/edit.php
+++ b/public/mod/quiz/edit.php
@@ -124,7 +124,7 @@
redirect($afteractionurl);
}
-if ($addsectionatpage = optional_param('addsectionatpage', false, PARAM_INT)) {
+if (($addsectionatpage = optional_param('addsectionatpage', false, PARAM_INT)) && confirm_sesskey()) {
// Add a section to the quiz.
$structure->check_can_be_edited();
$structure->add_section_heading($addsectionatpage);
diff --git a/public/mod/quiz/report/overview/report.php b/public/mod/quiz/report/overview/report.php
index fb944f5480557..68c79257b2493 100644
--- a/public/mod/quiz/report/overview/report.php
+++ b/public/mod/quiz/report/overview/report.php
@@ -262,7 +262,7 @@ protected function process_regrade_actions($quiz, $cm, $currentgroup,
}
$dryrun = optional_param('dryrunregrade', 0, PARAM_BOOL);
- if ($dryrun || optional_param('regrade', 0, PARAM_BOOL)) {
+ if (($dryrun || optional_param('regrade', 0, PARAM_BOOL)) && confirm_sesskey()) {
$attemptids = [];
if (optional_param('regradeselectedattempts', 0, PARAM_BOOL)) {
diff --git a/public/mod/quiz/tests/behat/add_quiz.feature b/public/mod/quiz/tests/behat/add_quiz.feature
index af5c0cdda8940..6767e04905870 100644
--- a/public/mod/quiz/tests/behat/add_quiz.feature
+++ b/public/mod/quiz/tests/behat/add_quiz.feature
@@ -44,7 +44,7 @@ Feature: Add a quiz
And I should see "Answer saved"
And I press "Submit all and finish"
- @javascript @skip_chrome_zerosize
+ @javascript
Scenario: Add and configure small quiz and perform an attempt as a student with Javascript enabled
Then I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue"
And I should see "So you think it is true"
diff --git a/public/mod/quiz/tests/behat/behat_mod_quiz.php b/public/mod/quiz/tests/behat/behat_mod_quiz.php
index d89898583a28b..acc11ec967731 100644
--- a/public/mod/quiz/tests/behat/behat_mod_quiz.php
+++ b/public/mod/quiz/tests/behat/behat_mod_quiz.php
@@ -717,8 +717,11 @@ public function i_delete_question_by_clicking_the_delete_icon($questionname) {
$this->execute("behat_general::i_click_on", [$slotxpath . $deletexpath, "xpath_element"]);
+ // Wait for the dialogue to exist before clicking on the 'Yes' button to avoid random failures.
+ $this->execute('behat_general::wait_until_exists', [".modal-content", "css_element"]);
+
$this->execute('behat_general::i_click_on_in_the',
- ['Yes', "button", "Confirm", "dialogue"]
+ ["Yes", "button", "Confirm", "dialogue"]
);
}
diff --git a/public/mod/quiz/tests/behat/editing_add_from_question_bank.feature b/public/mod/quiz/tests/behat/editing_add_from_question_bank.feature
index a79bc0eee8ca3..0889a87e7c487 100644
--- a/public/mod/quiz/tests/behat/editing_add_from_question_bank.feature
+++ b/public/mod/quiz/tests/behat/editing_add_from_question_bank.feature
@@ -21,30 +21,22 @@ Feature: Adding questions to a quiz from the question bank
| qbank | Question Bank A | Question Bank A for testing qbank name | C1 | qbankA |
| qbank | Question Bank B | Question Bank B for testing qbank name | C1 | qbankB |
And the following "question categories" exist:
- | contextlevel | reference | name |
- | Activity module | quiz1 | Test questions |
- | Activity module | qbank1 | Qbank questions |
- | Activity module | qbankA | Qbank Questions 1 |
- | Activity module | qbankB | Qbank Questions 2 |
+ | contextlevel | reference | name |
+ | Activity module | quiz1 | Test questions |
+ | Activity module | qbank1 | Qbank questions |
+ | Activity module | qbankA | Qbank Questions 1 |
+ | Activity module | qbankB | Qbank Questions 2 |
And the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext | idnumber |
- | Test questions | essay | question 01 name | admin | Question 01 text | |
- | Test questions | essay | question 02 name | teacher1 | Question 02 text | qidnum |
- | Qbank questions | essay | question 03 name | teacher1 | Question 03 text | q3idnum |
- | Qbank questions | essay | question 04 name | teacher1 | Question 04 text | q4idnum |
- | Qbank Questions 1 | truefalse | TF1 | admin | Qbank 1 question | |
- | Qbank Questions 2 | truefalse | TF2 | admin | Qbank 2 question | |
+ | questioncategory | qtype | name | user | questiontext | idnumber | tags |
+ | Test questions | essay | question 01 name | admin | Question 01 text | | foo |
+ | Test questions | essay | question 02 name | teacher1 | Question 02 text | qidnum | bar |
+ | Qbank questions | essay | question 03 name | teacher1 | Question 03 text | q3idnum | qbanktag1 |
+ | Qbank questions | essay | question 04 name | teacher1 | Question 04 text | q4idnum | qbanktag2 |
+ | Qbank Questions 1 | truefalse | TF1 | admin | Qbank 1 question | | |
+ | Qbank Questions 2 | truefalse | TF2 | admin | Qbank 2 question | | |
Scenario: The questions can be filtered by tag
- Given I am on the "question 01 name" "core_question > edit" page logged in as teacher1
- And I set the following fields to these values:
- | Tags | foo |
- And I press "id_submitbutton"
- And I choose "Edit question" action for "question 02 name" in the question bank
- And I set the following fields to these values:
- | Tags | bar |
- And I press "id_submitbutton"
- When I am on the "Quiz 1" "mod_quiz > Edit" page
+ Given I am on the "Quiz 1" "mod_quiz > edit" page logged in as teacher1
And I open the "last" add to quiz menu
And I follow "from question bank"
And I apply question bank filter "Category" with value "Test questions"
@@ -58,15 +50,7 @@ Feature: Adding questions to a quiz from the question bank
Scenario: The questions can be filtered by tag on a shared question bank
Given the "multilang" filter is "on"
And the "multilang" filter applies to "content and headings"
- And I am on the "question 03 name" "core_question > edit" page logged in as teacher1
- And I set the following fields to these values:
- | Tags | qbanktag1 |
- And I press "Save changes"
- And I am on the "question 04 name" "core_question > edit" page logged in as teacher1
- And I set the following fields to these values:
- | Tags | qbanktag2 |
- And I press "Save changes"
- When I am on the "Quiz 1" "mod_quiz > Edit" page
+ When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
And I open the "last" add to quiz menu
And I follow "from question bank"
And I click on "Switch bank" "button"
@@ -80,8 +64,8 @@ Feature: Adding questions to a quiz from the question bank
Scenario: The question modal can be paginated
Given the following "question categories" exist:
- | contextlevel | reference | name |
- | Activity module | quiz1 | My collection |
+ | contextlevel | reference | name |
+ | Activity module | quiz1 | My collection |
And 45 "questions" exist with the following data:
| questioncategory | My collection |
| qtype | essay |
@@ -118,8 +102,8 @@ Feature: Adding questions to a quiz from the question bank
Scenario: After closing and reopening the modal, it still works
Given the following "question categories" exist:
- | contextlevel | reference | name |
- | Activity module | quiz1 | My collection |
+ | contextlevel | reference | name |
+ | Activity module | quiz1 | My collection |
And the following "question" exists:
| questioncategory | My collection |
| qtype | essay |
@@ -190,8 +174,8 @@ Feature: Adding questions to a quiz from the question bank
@javascript
Scenario: Validate the sorting while adding questions from question bank
Given the following "questions" exist:
- | questioncategory | qtype | name | questiontext |
- | Test questions | multichoice | question 03 name | question 03 name text |
+ | questioncategory | qtype | name | questiontext |
+ | Test questions | multichoice | question 03 name | question 03 name text |
And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
When I open the "last" add to quiz menu
And I follow "from question bank"
@@ -262,8 +246,8 @@ Feature: Adding questions to a quiz from the question bank
| fullname | shortname | category |
| Course 2 | C2 | 0 |
And the following "activities" exist:
- | activity | name | course | idnumber |
- | qbank | Question Bank C | C2 | qbankC |
+ | activity | name | course | idnumber |
+ | qbank | Question Bank C | C2 | qbankC |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | qbankC | Qbank Questions 3 |
diff --git a/public/mod/quiz/tests/behat/editing_add_random.feature b/public/mod/quiz/tests/behat/editing_add_random.feature
index 82b8f6292fe40..c57bac288c382 100644
--- a/public/mod/quiz/tests/behat/editing_add_random.feature
+++ b/public/mod/quiz/tests/behat/editing_add_random.feature
@@ -27,21 +27,13 @@ Feature: Adding random questions to a quiz based on category and tags
| contextlevel | reference | name | questioncategory |
| Activity module | quiz1 | Subcategory | Questions Category 1 |
And the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext |
- | Questions Category 1 | essay | question 1 name | admin | Question 1 text |
- | Questions Category 1 | essay | question 2 name | teacher1 | Question 2 text |
- | Subcategory | essay | question 3 name | teacher1 | Question 3 text |
- | Subcategory | essay | question 4 name | teacher1 | Question 4 text |
- | Questions Category 1 | essay | "listen" & "answer" | teacher1 | Question 5 text |
- | Qbank questions | essay | Qbank question 1 | teacher1 | Qbank question |
- And the following "core_question > Tags" exist:
- | question | tag |
- | question 1 name | foo |
- | question 2 name | bar |
- | question 3 name | foo |
- | question 4 name | bar |
- | "listen" & "answer" | foo |
- | Qbank question 1 | qbanktag |
+ | questioncategory | qtype | name | user | questiontext | tags |
+ | Questions Category 1 | essay | question 1 name | admin | Question 1 text | foo |
+ | Questions Category 1 | essay | question 2 name | teacher1 | Question 2 text | bar |
+ | Subcategory | essay | question 3 name | teacher1 | Question 3 text | foo |
+ | Subcategory | essay | question 4 name | teacher1 | Question 4 text | bar |
+ | Questions Category 1 | essay | "listen" & "answer" | teacher1 | Question 5 text | foo |
+ | Qbank questions | essay | Qbank question 1 | teacher1 | Qbank question | qbanktag |
Scenario: Available tags are shown in the autocomplete tag field
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
@@ -128,12 +120,9 @@ Feature: Adding random questions to a quiz based on category and tags
| contextlevel | reference | name |
| Activity module | quiz1 | Quiz 1 category |
And the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext |
- | Quiz 1 category | essay | quiz 1 question 1 name | teacher1 | Quiz 1 question 1 text |
- | Quiz 1 category | essay | quiz 1 question 2 name | teacher1 | Quiz 1 question 2 text |
- And the following "core_question > Tags" exist:
- | question | tag |
- | quiz 1 question 1 name | foo |
+ | questioncategory | qtype | name | user | questiontext | tags |
+ | Quiz 1 category | essay | quiz 1 question 1 name | teacher1 | Quiz 1 question 1 text | foo |
+ | Quiz 1 category | essay | quiz 1 question 2 name | teacher1 | Quiz 1 question 2 text | |
And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
And I open the "last" add to quiz menu
And I follow "a random question"
diff --git a/public/mod/quiz/tests/behat/editing_edit_random.feature b/public/mod/quiz/tests/behat/editing_edit_random.feature
index cf8c7a3209de2..bc33bdf015460 100644
--- a/public/mod/quiz/tests/behat/editing_edit_random.feature
+++ b/public/mod/quiz/tests/behat/editing_edit_random.feature
@@ -24,17 +24,10 @@ Feature: Editing random questions already in a quiz based on category and tags
| Activity module | quiz1 | Questions Category 2|
| Activity module | qbank1 | Questions Category 3|
And the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext |
- | Questions Category 1 | essay | question 1 name | admin | Question 1 text |
- | Questions Category 1 | essay | question 2 name | teacher1 | Question 2 text |
- | Questions Category 3 | essay | question 3 name | teacher1 | Question 3 text |
- And the following "core_question > Tags" exist:
- | question | tag |
- | question 1 name | easy |
- | question 1 name | essay |
- | question 2 name | hard |
- | question 2 name | essay |
- | question 3 name | essay |
+ | questioncategory | qtype | name | user | questiontext | tags |
+ | Questions Category 1 | essay | question 1 name | admin | Question 1 text | easy, essay |
+ | Questions Category 1 | essay | question 2 name | teacher1 | Question 2 text | hard, essay |
+ | Questions Category 3 | essay | question 3 name | teacher1 | Question 3 text | essay |
Scenario: Editing tags on one slot does not delete the rest
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
diff --git a/public/mod/quiz/tests/behat/editing_remove_multiple_questions.feature b/public/mod/quiz/tests/behat/editing_remove_multiple_questions.feature
index 6cad48e12c95f..fd1b423afbb9a 100644
--- a/public/mod/quiz/tests/behat/editing_remove_multiple_questions.feature
+++ b/public/mod/quiz/tests/behat/editing_remove_multiple_questions.feature
@@ -94,7 +94,7 @@ Feature: Edit quiz page - remove multiple questions
And I should see "Questions: 2"
@javascript
- Scenario: Can delete the last question in a quiz.
+ Scenario: Can delete the last question in a quiz using bulk selection.
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | Question A | This is question 01 |
diff --git a/public/mod/quiz/tests/behat/editing_remove_question.feature b/public/mod/quiz/tests/behat/editing_remove_question.feature
index 05091ed136951..037a44a18418f 100644
--- a/public/mod/quiz/tests/behat/editing_remove_question.feature
+++ b/public/mod/quiz/tests/behat/editing_remove_question.feature
@@ -85,7 +85,7 @@ Feature: Edit quiz page - remove questions
Then "Delete" "link" in the "Question C" "list_item" should be visible
@javascript
- Scenario: Can delete the last question in a quiz.
+ Scenario: Can delete the last question in a quiz using delete icon.
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | Question A | This is question 01 |
diff --git a/public/mod/quiz/tests/behat/quiz_group_override.feature b/public/mod/quiz/tests/behat/quiz_group_override.feature
index 28d386f24d9c1..1e5878118d426 100644
--- a/public/mod/quiz/tests/behat/quiz_group_override.feature
+++ b/public/mod/quiz/tests/behat/quiz_group_override.feature
@@ -204,8 +204,7 @@ Feature: Quiz group override
| Override user | Sam 1 Student 1 |
| timeopen | ##tomorrow## |
| timeclose | ##tomorrow noon## |
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I click on "Unenrol" "icon" in the "student1" "table_row"
And I click on "Unenrol" "button" in the "Unenrol" "dialogue"
When I log in as "student1"
diff --git a/public/mod/quiz/tests/behat/view_grade_recover_grades.feature b/public/mod/quiz/tests/behat/view_grade_recover_grades.feature
index 7b21d16667792..12b124e191ee2 100644
--- a/public/mod/quiz/tests/behat/view_grade_recover_grades.feature
+++ b/public/mod/quiz/tests/behat/view_grade_recover_grades.feature
@@ -65,9 +65,7 @@ Feature: Testing view quiz grade feedback with recover grades setting
Scenario Outline: View quiz after unenrolling and re-enrolling user
Given the following config values are set as admin:
| recovergradesdefault |
|
- And I log in as "teacher"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "teacher"
And I click on "Unenrol" "icon" in the "Student One" "table_row"
And I click on "Unenrol" "button" in the "Unenrol" "dialogue"
And the following "course enrolments" exist:
diff --git a/public/mod/quiz/tests/local/override_manager_test.php b/public/mod/quiz/tests/local/override_manager_test.php
index 5376d2ba045a4..debdafc4c2062 100644
--- a/public/mod/quiz/tests/local/override_manager_test.php
+++ b/public/mod/quiz/tests/local/override_manager_test.php
@@ -255,6 +255,52 @@ public static function save_and_get_override_provider(): array {
'expectedrecordscreated' => 1,
'expectedeventclass' => user_override_created::class,
],
+ 'update user override - unlimited timelimit' => [
+ 'existingdata' => [
+ 'userid' => ':userid',
+ 'groupid' => null,
+ 'timeopen' => null,
+ 'timeclose' => null,
+ 'timelimit' => 2,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'formdata' => [
+ 'id' => ':existingid',
+ 'userid' => ':userid',
+ 'groupid' => null,
+ 'timeopen' => null,
+ 'timeclose' => null,
+ 'timelimit' => 0,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'expectedrecordscreated' => 0,
+ 'expectedeventclass' => user_override_updated::class,
+ ],
+ 'update user override - disabled open and close dates' => [
+ 'existingdata' => [
+ 'userid' => ':userid',
+ 'groupid' => null,
+ 'timeopen' => 50,
+ 'timeclose' => 51,
+ 'timelimit' => null,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'formdata' => [
+ 'id' => ':existingid',
+ 'userid' => ':userid',
+ 'groupid' => null,
+ 'timeopen' => 0,
+ 'timeclose' => 0,
+ 'timelimit' => null,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'expectedrecordscreated' => 0,
+ 'expectedeventclass' => user_override_updated::class,
+ ],
'create group override - no existing data' => [
'existingdata' => [],
'formdata' => [
@@ -297,6 +343,52 @@ public static function save_and_get_override_provider(): array {
'expectedrecordscreated' => 1,
'expectedeventclass' => group_override_created::class,
],
+ 'update group override - unlimited timelimit' => [
+ 'existingdata' => [
+ 'userid' => null,
+ 'groupid' => ':groupid',
+ 'timeopen' => null,
+ 'timeclose' => null,
+ 'timelimit' => 2,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'formdata' => [
+ 'id' => ':existingid',
+ 'userid' => null,
+ 'groupid' => ':groupid',
+ 'timeopen' => null,
+ 'timeclose' => null,
+ 'timelimit' => 0,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'expectedrecordscreated' => 0,
+ 'expectedeventclass' => group_override_updated::class,
+ ],
+ 'update group override - disabled open and close dates' => [
+ 'existingdata' => [
+ 'userid' => null,
+ 'groupid' => ':groupid',
+ 'timeopen' => 50,
+ 'timeclose' => 51,
+ 'timelimit' => null,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'formdata' => [
+ 'id' => ':existingid',
+ 'userid' => null,
+ 'groupid' => ':groupid',
+ 'timeopen' => 0,
+ 'timeclose' => 0,
+ 'timelimit' => null,
+ 'attempts' => null,
+ 'password' => null,
+ ],
+ 'expectedrecordscreated' => 0,
+ 'expectedeventclass' => group_override_updated::class,
+ ],
'update user override - updating existing data' => [
'existingdata' => [
'userid' => ':userid',
@@ -641,6 +733,22 @@ public static function validate_data_provider(): array {
'general' => get_string('nooverridedata', 'quiz'),
],
],
+ 'empty password results in no override' => [
+ 'existingdata' => [],
+ 'formdata' => [
+ 'userid' => ':userid',
+ 'groupid' => null,
+ 'timeopen' => null,
+ 'timeclose' => null,
+ 'timelimit' => null,
+ 'attempts' => null,
+ // Empty string is normalised to null.
+ 'password' => '',
+ ],
+ 'expectedreturn' => [
+ 'general' => get_string('nooverridedata', 'quiz'),
+ ],
+ ],
'all submitted data was the same as the existing quiz' => [
'existingdata' => [],
'formdata' => [
diff --git a/public/mod/quiz/tests/notification_helper_test.php b/public/mod/quiz/tests/notification_helper_test.php
index a5b6c7439ad5a..8718a2c2222bc 100644
--- a/public/mod/quiz/tests/notification_helper_test.php
+++ b/public/mod/quiz/tests/notification_helper_test.php
@@ -199,6 +199,62 @@ public function test_get_users_within_quiz(): void {
$this->assertEquals([$user2->id, $user3->id], array_keys($users));
}
+ /**
+ * Test users failing a grade condition are excluded from open-soon notifications.
+ */
+ public function test_get_users_within_quiz_with_grade_restriction(): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+ $clock = $this->mock_clock_with_frozen();
+
+ $course = $generator->create_course();
+ $user1 = $generator->create_user(); // Scores 90% — should NOT receive notification.
+ $user2 = $generator->create_user(); // Scores 50% — SHOULD receive notification.
+ $generator->enrol_user($user1->id, $course->id, 'student');
+ $generator->enrol_user($user2->id, $course->id, 'student');
+
+ $quizgenerator = $generator->get_plugin_generator('mod_quiz');
+
+ // Create a graded quiz and assign scores: user1 passes (>=60%), user2 fails (<60%).
+ $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100]);
+ grade_update('mod/quiz', $course->id, 'mod', 'quiz', $quiz->id, 0, ['userid' => $user1->id, 'rawgrade' => 90]);
+ grade_update('mod/quiz', $course->id, 'mod', 'quiz', $quiz->id, 0, ['userid' => $user2->id, 'rawgrade' => 50]);
+ $gradeitem = \grade_item::fetch(
+ [
+ 'itemtype' => 'mod',
+ 'itemmodule' => 'quiz',
+ 'iteminstance' => $quiz->id,
+ 'courseid' => $course->id,
+ 'itemnumber' => 0,
+ ]
+ );
+
+ // Create a remedial quiz opening within 48 hours, restricted to students who scored <60%.
+ $remedialquiz = $quizgenerator->create_instance(['course' => $course->id, 'timeopen' => $clock->time() + DAYSECS]);
+ $cm = get_coursemodule_from_instance('quiz', $remedialquiz->id, $course->id);
+ $DB->set_field('course_modules', 'availability', json_encode([
+ 'op' => '&',
+ 'showc' => [true],
+ 'c' => [
+ [
+ 'type' => 'grade',
+ 'id' => (int) $gradeitem->id,
+ 'max' => 60.0,
+ ],
+ ],
+ ]), ['id' => $cm->id]);
+
+ rebuild_course_cache($course->id, true);
+
+ $users = notification_helper::get_users_within_quiz($remedialquiz->id);
+
+ // Only user2 should be returned because user1 does not meet the grade condition.
+ $this->assertCount(1, $users);
+ $this->assertArrayHasKey($user2->id, $users);
+ }
+
/**
* Test sending the quiz open soon notification to a user.
*/
diff --git a/public/mod/quiz/tests/quizobj_test.php b/public/mod/quiz/tests/quizobj_test.php
index b86704b108f4f..12f995489e621 100644
--- a/public/mod/quiz/tests/quizobj_test.php
+++ b/public/mod/quiz/tests/quizobj_test.php
@@ -16,15 +16,19 @@
namespace mod_quiz;
-use basic_testcase;
+use advanced_testcase;
+use core\output\datafilter;
+use core_tag_area;
use mod_quiz\question\display_options;
-use mod_quiz\quiz_settings;
+use mod_quiz\tests\question_helper_test_trait;
+use PHPUnit\Framework\Attributes\DataProvider;
use stdClass;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
+require_once($CFG->dirroot . '/mod/quiz/tests/classes/question_helper_test_trait.php');
/**
* Unit tests for the quiz class
@@ -34,7 +38,9 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\quiz_settings
*/
-final class quizobj_test extends basic_testcase {
+final class quizobj_test extends advanced_testcase {
+ use question_helper_test_trait;
+
/**
* Test cases for {@see test_cannot_review_message()}.
*
@@ -127,4 +133,88 @@ public function test_cannot_review_message(
$this->assertEquals($expectation,
$quizobj->cannot_review_message($attemptstate, false, $submittime));
}
+
+ /**
+ * Data provider for testing the correct question types are returned.
+ *
+ * @return array[]
+ */
+ public static function question_types(): array {
+ return [
+ 'only direct questions' => [
+ 'potential' => false,
+ 'types' => ['numerical', 'shortanswer'],
+ ],
+ 'include potential questions' => [
+ 'potential' => true,
+ 'types' => ['essay', 'numerical', 'shortanswer', 'truefalse'],
+ ],
+ ];
+ }
+
+ /**
+ * Return the question types used by a quiz.
+ *
+ * @param bool $potential Include potential types from random questions.
+ * @param array $types List of types to expect, in alphabetical order.
+ */
+ #[DataProvider('question_types')]
+ public function test_get_all_question_types_used(bool $potential, array $types): void {
+ $this->setAdminUser();
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questiongenerator = $generator->get_plugin_generator('core_question');
+ $quiz = $this->create_test_quiz($course);
+ [, $cm] = get_course_and_cm_from_cmid($quiz->cmid, 'quiz');
+ $quizobj = new quiz_settings($quiz, $cm, $course);
+ // Add shortanswer and numerical questions.
+ $this->add_two_regular_questions($questiongenerator, $quiz);
+ // Add truefalse and essay as potential random questions.
+ $this->add_one_random_question($questiongenerator, $quiz);
+ $quizobj->preload_questions();
+ $usedtypes = $quizobj->get_all_question_types_used($potential);
+ $this->assertEquals($types, $usedtypes);
+ }
+
+ /**
+ * Return the question types based on all filters in a random question.
+ */
+ public function test_get_all_question_types_used_with_tag(): void {
+ $this->setAdminUser();
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questiongenerator = $generator->get_plugin_generator('core_question');
+ $quiz = $this->create_test_quiz($course);
+ [, $cm] = get_course_and_cm_from_cmid($quiz->cmid, 'quiz');
+ $quizobj = new quiz_settings($quiz, $cm, $course);
+ // Add shortanswer and numerical questions.
+ $this->add_two_regular_questions($questiongenerator, $quiz);
+ // Add essay as a potential random question with a tag, and truefalse as another question in the category.
+ $randomcategory = $questiongenerator->create_question_category();
+ $questiongenerator->create_question('truefalse', null, ['category' => $randomcategory->id]);
+ $taggedquestion = $questiongenerator->create_question('essay', null, ['category' => $randomcategory->id]);
+ $questiongenerator->create_question_tag(['questionid' => $taggedquestion->id, 'tag' => 'test']);
+ $tagcollid = core_tag_area::get_collection('core_question', 'question');
+ $tag = \core_tag_tag::get_by_name($tagcollid, 'test');
+ $filtercondition = [
+ 'filter' => [
+ 'category' => [
+ 'jointype' => datafilter::JOINTYPE_ALL,
+ 'values' => [$randomcategory->id],
+ 'filteroptions' => ['includesubcategories' => false],
+ ],
+ 'qtagids' => [
+ 'jointype' => datafilter::JOINTYPE_ALL,
+ 'values' => [$tag->id],
+ ],
+ ],
+ ];
+ $quizobj->get_structure()->add_random_questions(1, 1, $filtercondition);
+ $quizobj->preload_questions();
+ $usedtypes = $quizobj->get_all_question_types_used(true);
+ $this->assertCount(3, $usedtypes);
+ $this->assertEquals(['essay', 'numerical', 'shortanswer'], $usedtypes);
+ }
}
diff --git a/public/mod/scorm/player.php b/public/mod/scorm/player.php
index 28b5fe66056b1..01d1699990db0 100644
--- a/public/mod/scorm/player.php
+++ b/public/mod/scorm/player.php
@@ -167,15 +167,24 @@
$completion = new completion_info($course);
$completion->set_module_viewed($cm);
-// Generate the exit button.
+// Generate the exit button URL depending on our course format and display options.
+$format = course_get_format($course);
+$formatoptions = $format->get_format_options();
+$coursedisplay = $formatoptions['coursedisplay'] ?? null;
$exiturl = "";
if (empty($scorm->popup) || $displaymode == 'popup') {
- if ($course->format == 'singleactivity' && $scorm->skipview == SCORM_SKIPVIEW_ALWAYS
- && !has_capability('mod/scorm:viewreport', context_module::instance($cm->id))) {
+ if (
+ $format->get_format() == 'singleactivity' &&
+ $scorm->skipview == SCORM_SKIPVIEW_ALWAYS &&
+ !has_capability('mod/scorm:viewreport', context_module::instance($cm->id))
+ ) {
// Redirect students back to site home to avoid redirect loop.
$exiturl = $CFG->wwwroot;
+ } else if ($coursedisplay == COURSE_DISPLAY_MULTIPAGE) {
+ // Redirect back to the current section if one section per page is being used.
+ $exiturl = course_get_url($course, $cm->sectionnum, ['sr' => $cm->sectionnum])->out();
} else {
- // Redirect back to the correct section if one section per page is being used.
+ // Redirect back to the current section anchor on the course page.
$exiturl = course_get_url($course, $cm->sectionnum)->out();
}
}
diff --git a/public/mod/scorm/tests/behat/scorm_display_options.feature b/public/mod/scorm/tests/behat/scorm_display_options.feature
index eb7a01d5b142d..e2682e01989b8 100644
--- a/public/mod/scorm/tests/behat/scorm_display_options.feature
+++ b/public/mod/scorm/tests/behat/scorm_display_options.feature
@@ -10,15 +10,17 @@ Feature: Scorm display options
| teacher1 | Teacher | One | teacher1@example.com |
| student1 | Student | One | student1@example.com |
And the following "courses" exist:
- | fullname | shortname | format | activitytype |
- | Course 1 | C1 | topics | |
- | Course 2 | C2 | singleactivity | scorm |
+ | fullname | shortname | format | activitytype | coursedisplay |
+ | Course 1 | C1 | topics | | 0 |
+ | Course 2 | C2 | singleactivity | scorm | 0 |
+ | Course 3 | C3 | topics | | 1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| teacher1 | C2 | editingteacher |
| student1 | C2 | student |
+ | student1 | C3 | student |
@javascript
Scenario Outline: Teacher can change to various Scorm package display options
@@ -56,3 +58,27 @@ Feature: Scorm display options
And "Enter" "button" should exist
And "Exit activity" "link" should not exist
And I should not see "Golf Explained - Minimum Run-time Calls"
+
+ Scenario: Student returns to the correct section after exiting Scorm activity (one section per page)
+ Given the following "activities" exist:
+ | activity | course | name | packagefilepath | section |
+ | scorm | C3 | C3 Scorm 1 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 1 |
+ | scorm | C3 | C3 Scorm 2 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 2 |
+ And I am on the "C3 Scorm 1" "scorm activity" page logged in as student1
+ And I press "Enter"
+ When I click on "Exit activity" "link"
+ # Student should be returned to the section they were in, and not the course overview.
+ Then I should see "C3 Scorm 1"
+ And I should not see "C3 Scorm 2"
+
+ Scenario: Student returns to the course overview after exiting Scorm activity (all sections on one page)
+ Given the following "activities" exist:
+ | activity | course | name | packagefilepath | section |
+ | scorm | C1 | C1 Scorm 1 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 1 |
+ | scorm | C1 | C1 Scorm 2 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 2 |
+ And I am on the "C1 Scorm 1" "scorm activity" page logged in as student1
+ And I press "Enter"
+ When I click on "Exit activity" "link"
+ # Student should be returned to the course overview page displaying both activities.
+ Then I should see "C1 Scorm 1"
+ And I should see "C1 Scorm 2"
diff --git a/public/mod/scorm/tests/custom_completion_test.php b/public/mod/scorm/tests/custom_completion_test.php
index 3947f7f6f7015..fc1a78763b124 100644
--- a/public/mod/scorm/tests/custom_completion_test.php
+++ b/public/mod/scorm/tests/custom_completion_test.php
@@ -178,17 +178,13 @@ public function test_get_state(string $rule, int $rulevalue, array $uservalue, i
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of the magic getter method when fetching the cm_info object's
- // customdata and instance values.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->will($this->returnValueMap([
- ['customdata', $customdataval],
- ['instance', 1],
- ]));
+ ->method('get_custom_data')
+ ->willReturn($customdataval);
// Mock the DB call fetching user's SCORM track data.
$DB = $this->createMock(get_class($DB));
@@ -361,13 +357,12 @@ public function test_get_available_custom_rules(array $completionrulesvalues, ar
// Build a mock cm_info instance.
$mockcminfo = $this->getMockBuilder(cm_info::class)
->disableOriginalConstructor()
- ->onlyMethods(['__get'])
+ ->onlyMethods(['get_custom_data'])
->getMock();
- // Mock the return of magic getter for the customdata attribute.
+ // Mock the return of the get_custom_data method when fetching the cm_info object's customdata.
$mockcminfo->expects($this->any())
- ->method('__get')
- ->with('customdata')
+ ->method('get_custom_data')
->willReturn($customcompletionrules);
$customcompletion = new custom_completion($mockcminfo, 1);
diff --git a/public/mod/workshop/renderer.php b/public/mod/workshop/renderer.php
index cb3661ded8369..f702e26395a15 100644
--- a/public/mod/workshop/renderer.php
+++ b/public/mod/workshop/renderer.php
@@ -441,7 +441,7 @@ protected function render_workshop_grading_report(workshop_grading_report $gradi
}
$table = new html_table();
- $table->attributes['class'] = 'grading-report table-striped table-hover';
+ $table->attributes['class'] = 'grading-report table table-striped table-hover';
$sortbyfirstname = $this->helper_sortable_heading(get_string('firstname'), 'firstname', $options->sortby, $options->sorthow);
$sortbylastname = $this->helper_sortable_heading(get_string('lastname'), 'lastname', $options->sortby, $options->sorthow);
diff --git a/public/my/index.php b/public/my/index.php
index 6921ffccf536b..e50a4d17f18a8 100644
--- a/public/my/index.php
+++ b/public/my/index.php
@@ -104,13 +104,17 @@
if (!isguestuser()) { // Skip default home page for guests
if (get_home_page() != HOMEPAGE_MY) {
- if (optional_param('setdefaulthome', false, PARAM_BOOL)) {
+ if (optional_param('setdefaulthome', false, PARAM_BOOL) && confirm_sesskey()) {
set_user_preference('user_home_page_preference', HOMEPAGE_MY);
+ redirect($PAGE->url);
} else if (!empty($CFG->defaulthomepage) && $CFG->defaulthomepage == HOMEPAGE_USER) {
$frontpagenode = $PAGE->settingsnav->add(get_string('frontpagesettings'), null, navigation_node::TYPE_SETTING, null);
$frontpagenode->force_open();
- $frontpagenode->add(get_string('makethismyhome'), new moodle_url('/my/', array('setdefaulthome' => true)),
- navigation_node::TYPE_SETTING);
+ $frontpagenode->add(
+ get_string('makethismyhome'),
+ new moodle_url('/my/', ['setdefaulthome' => 1, 'sesskey' => sesskey()]),
+ navigation_node::TYPE_SETTING,
+ );
}
}
}
diff --git a/public/my/tests/behat/add_blocks.feature b/public/my/tests/behat/add_blocks.feature
index d00158675cf88..0e70bee06db1a 100644
--- a/public/my/tests/behat/add_blocks.feature
+++ b/public/my/tests/behat/add_blocks.feature
@@ -18,9 +18,11 @@ Feature: Add blocks to dashboard page
| student2 | C1 | student |
And I log in as "student1"
+ @javascript @accessibility
Scenario: Add blocks to page
When I turn editing mode on
And I add the "Latest announcements" block
+ And the "Latest announcements" "block" should meet accessibility standards with "best-practice" extra tests
And I turn editing mode off
Then I should see "Latest announcements" in the "Latest announcements" "block"
And I should see "Timeline" in the "Timeline" "block"
diff --git a/public/my/tests/behat/preferences_navigation.feature b/public/my/tests/behat/preferences_navigation.feature
index 102b8971a9559..7ce773b880768 100644
--- a/public/my/tests/behat/preferences_navigation.feature
+++ b/public/my/tests/behat/preferences_navigation.feature
@@ -51,8 +51,7 @@ Feature: Navigate and use preferences page
| Register an external blog |
Scenario Outline: Navigating through course participant preferences
- Given I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page
And I follow "Sam Student"
When I click on "Preferences" "link" in the "#region-main-box" "css_element"
Then I should see "Sam Student" in the ".page-header-headings" "css_element"
@@ -98,8 +97,7 @@ Feature: Navigate and use preferences page
And I follow "Event monitoring"
# Confirm that user can subscribe to new rule.
And "Subscribe to rule \"Testing1\"" "link" should exist
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I follow "Sam Student"
And I click on "Preferences" "link" in the "#region-main-box" "css_element"
# Confirm that admin cannot change student's event monitor subscription.
diff --git a/public/question/bank/deletequestion/tests/behat/delete_question_column.feature b/public/question/bank/deletequestion/tests/behat/delete_question_column.feature
index 23620e8f1c62e..978962ce4e74b 100644
--- a/public/question/bank/deletequestion/tests/behat/delete_question_column.feature
+++ b/public/question/bank/deletequestion/tests/behat/delete_question_column.feature
@@ -19,10 +19,10 @@ Feature: Use the qbank plugin manager page for deletequestion
| contextlevel | reference | name |
| Activity module | quiz1 | Test questions |
And the following "questions" exist:
- | questioncategory | qtype | name | questiontext |
- | Test questions | truefalse | Question 1 | Answer the first question |
- | Test questions | truefalse | Question 2 | Answer the second question |
- | Test questions | truefalse | Question 3 | Answer the third question |
+ | questioncategory | qtype | name | questiontext | tags |
+ | Test questions | truefalse | Question 1 | Answer the first question | foo |
+ | Test questions | truefalse | Question 2 | Answer the second question | |
+ | Test questions | truefalse | Question 3 | Answer the third question | |
@javascript
Scenario: Enable/disable delete question column from the base view
@@ -70,12 +70,7 @@ Feature: Use the qbank plugin manager page for deletequestion
@javascript
Scenario: I should be able to delete a question when filtered using tags
- Given I am on the "Question 1" "core_question > edit" page logged in as "admin"
- And I change window size to "large"
- And I set the following fields to these values:
- | Tags | foo |
- And I click on "Save changes" "button"
- And I am on the "Test quiz" "mod_quiz > question bank" page
+ Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin"
And I apply question bank filter "Category" with value "Test questions"
And I apply question bank filter "Tag" with value "foo"
And I click on "Question 1" "checkbox"
diff --git a/public/question/bank/history/classes/question_history_view.php b/public/question/bank/history/classes/question_history_view.php
index 0f121cfbeb7be..1b0b19329e779 100644
--- a/public/question/bank/history/classes/question_history_view.php
+++ b/public/question/bank/history/classes/question_history_view.php
@@ -67,7 +67,8 @@ public function __construct(
debugging('$cm is now a required field', DEBUG_DEVELOPER);
}
- $this->entryid = $extraparams['entryid'];
+ // The extra params can come straight from a web service request, so the entry id must be cleaned here.
+ $this->entryid = clean_param($extraparams['entryid'] ?? 0, PARAM_INT);
$this->basereturnurl = new \moodle_url($extraparams['returnurl']);
parent::__construct($contexts, $pageurl, $course, $cm, $params, $extraparams);
}
@@ -120,9 +121,8 @@ protected function build_query(): void {
}
// Build the where clause.
- $entryid = "qbe.id = $this->entryid";
// Changes done here to get the questions only for the passed entryid.
- $tests = ['q.parent = 0', $entryid];
+ $tests = ['q.parent = 0', 'qbe.id = :entryid'];
$this->sqlparams = [];
foreach ($this->searchconditions as $searchcondition) {
if ($searchcondition->where()) {
@@ -132,6 +132,8 @@ protected function build_query(): void {
$this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
}
}
+ // Merged last so that a search condition cannot replace the entry id value.
+ $this->sqlparams = array_merge($this->sqlparams, ['entryid' => $this->entryid]);
// Build the SQL.
$sql = ' FROM {question} q ' . implode(' ', $joins);
$sql .= ' WHERE ' . implode(' AND ', $tests);
diff --git a/public/question/classes/category_manager.php b/public/question/classes/category_manager.php
index 9fba85e21f772..10fe2368c69ab 100644
--- a/public/question/classes/category_manager.php
+++ b/public/question/classes/category_manager.php
@@ -403,30 +403,4 @@ public static function fix_restored_category_parents(): void {
$DB->update_record('question_categories', $categorytofix, true);
}
}
-
- /**
- * Upgrade step to find questions with no category and delete them.
- *
- * Due to MDL-86154, there may be questions left in the database after a restore, whose category has been deleted. This will
- * find any questions like that and delete them. These questions will always be unused.
- *
- * Now that we have prevented this occurring, this function is used by the upgrade process to clean up these questions.
- *
- * @return int A count of deleted questions.
- */
- public static function cleanup_questions_without_categories(): int {
- global $DB;
- $questionids = $DB->get_fieldset_sql("
- SELECT q.id
- FROM {question_bank_entries} qbe
- JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
- JOIN {question} q ON qv.questionid = q.id
- LEFT JOIN {question_categories} qc ON qbe.questioncategoryid = qc.id
- WHERE qc.id IS NULL
- ");
- foreach ($questionids as $questionid) {
- question_delete_question($questionid);
- }
- return count($questionids);
- }
}
diff --git a/public/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php b/public/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php
index 8cf86522f0c40..4ca28f4299ca3 100644
--- a/public/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php
+++ b/public/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php
@@ -212,6 +212,7 @@ public function get_cached($qubaids) {
debugging('Statistics found for slot ' . $fromdb->slot .
' in stats ' . json_encode($qubaids->from_where_params()) .
' which is not an analysable question.', DEBUG_DEVELOPER);
+ continue;
}
$this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
} else {
@@ -241,6 +242,13 @@ public function get_cached($qubaids) {
$this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
$newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
} else {
+ if (!isset($this->subquestionstats[$fromdb->questionid])) {
+ debugging('Statistics found for subquestion ID ' . $fromdb->questionid .
+ ' (variant ' . $fromdb->variant . ') in stats ' .
+ json_encode($qubaids->from_where_params()) .
+ ' which is not an analysable subquestion.', DEBUG_DEVELOPER);
+ continue;
+ }
$newcalcinstance = new calculated_for_subquestion();
$this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
if (isset($this->subquestions[$fromdb->questionid])) {
diff --git a/public/question/templates/question_banks_list.mustache b/public/question/templates/question_banks_list.mustache
index 30a5b4dc6bb9f..d58ee3c5b66a0 100644
--- a/public/question/templates/question_banks_list.mustache
+++ b/public/question/templates/question_banks_list.mustache
@@ -205,9 +205,7 @@
-
{{#managequestions}}
diff --git a/public/question/tests/behat/bank_add_default_shared.feature b/public/question/tests/behat/bank_add_default_shared.feature
index 5878ebf99cde5..dcdfa95d6184c 100644
--- a/public/question/tests/behat/bank_add_default_shared.feature
+++ b/public/question/tests/behat/bank_add_default_shared.feature
@@ -18,9 +18,9 @@ Feature: Add a default question bank
Scenario: Add a default question bank to a course
Given I am on the "C1" "Course" page logged in as "teacher1"
When I navigate to "Question banks" in current page administration
- Then I should see "This course doesn't have any question banks yet."
+ Then I should see "This course doesn't have any shared question banks yet."
And I should see "Add"
And I click on "Create default question bank" "button"
- But I should not see "This course doesn't have any question banks yet."
+ But I should not see "This course doesn't have any shared question banks yet."
And I should see "Default question bank created."
And I should see "Course 1 course question bank"
diff --git a/public/question/tests/behat/bank_manage.feature b/public/question/tests/behat/bank_manage.feature
index cca9f025139a5..de495b74d05b0 100644
--- a/public/question/tests/behat/bank_manage.feature
+++ b/public/question/tests/behat/bank_manage.feature
@@ -49,12 +49,19 @@ Feature: Manage question banks
@javascript
Scenario: Delete a question bank
- Given I am on the "C1" "Course" page logged in as "teacher1"
+ Given the "multilang" filter is "on"
+ And the "multilang" filter applies to "content and headings"
+ And I am on the "C1" "Course" page logged in as "teacher1"
When I navigate to "Question banks" in current page administration
- And I open the action menu in "bank1" "list_item"
- And I choose "Delete" in the open action menu
+ And I choose the "Edit settings" item in the "Edit" action menu of the "bank1" "list_item"
+ And I set the following fields to these values:
+ | Question bank name |
BankBanque 1 |
+ And I press "Save and return to question bank list"
+ Then I should see "Bank 1"
+ And I choose the "Delete" item in the "Edit" action menu of the "Bank 1" "list_item"
+ And I should see "This will delete Bank 1 and any user data it contains"
And I click on "Delete" "button"
- Then I should not see "bank1"
+ And I should not see "Bank 1"
But I should see "bank2"
Scenario: A student without permissions to access a bank cannot access the question banks page
diff --git a/public/question/tests/behat/behat_core_question.php b/public/question/tests/behat/behat_core_question.php
index e69a09054b0f5..a829b35b3c498 100644
--- a/public/question/tests/behat/behat_core_question.php
+++ b/public/question/tests/behat/behat_core_question.php
@@ -402,7 +402,7 @@ public function i_apply_question_bank_filter(string $filtertype, string $value)
// Set the filter value.
$this->execute('behat_forms::i_set_the_field_to', [
$filtertype,
- $value
+ $value,
]);
// Apply filters.
diff --git a/public/question/tests/behat/filter_questions_by_tag.feature b/public/question/tests/behat/filter_questions_by_tag.feature
index 5de2b7a7d7420..38960a5bce2a7 100644
--- a/public/question/tests/behat/filter_questions_by_tag.feature
+++ b/public/question/tests/behat/filter_questions_by_tag.feature
@@ -1,49 +1,43 @@
@core @core_question
Feature: The questions in the question bank can be filtered by tags
- In order to find the questions I need
- As a teacher
- I want to filter the questions by tags
+ In order to find the questions I need
+ As a teacher
+ I want to filter the questions by tags
Background:
- Given the following "users" exist:
- | username | firstname | lastname | email |
- | teacher1 | Teacher | 1 | teacher1@example.com |
- And the following "courses" exist:
+ Given the following "courses" exist:
| fullname | shortname | format |
- | Course 1 | C1 | weeks |
+ | Course 1 | C1 | weeks |
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
- | activity | name | intro | course | idnumber |
- | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 |
+ | activity | name | intro | course | idnumber |
+ | qbank | Qbank 2 | Question bank 2 | C1 | qbank2 |
+ | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | qbank1 | Test questions |
And the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext |
- | Test questions | essay | question 1 name | admin | Question 1 text |
- | Test questions | essay | question 2 name | teacher1 | Question 2 text |
- And I am on the "question 1 name" "core_question > edit" page logged in as "teacher1"
- And I set the following fields to these values:
- | Tags | foo |
- And I press "id_submitbutton"
- And I am on the "question 2 name" "core_question > edit" page
- And I change window size to "large"
- And I set the following fields to these values:
- | Tags | bar |
- And I press "id_submitbutton"
+ | questioncategory | qtype | name | user | questiontext | tags |
+ | Test questions | essay | question 1 name | admin | Question 1 text | foo |
+ | Test questions | essay | question 2 name | teacher1 | Question 2 text | bar |
+ And I am on the "qbank1" "core_question > question bank" page logged in as "teacher1"
@javascript
Scenario: The questions can be filtered by tag
- When I apply question bank filter "Tag" with value "foo"
+ When I apply question bank filter "Category" with value "Test questions"
+ And I apply question bank filter "Tag" with value "foo"
Then I should see "question 1 name" in the "categoryquestions" "table"
And I should not see "question 2 name" in the "categoryquestions" "table"
@javascript
Scenario: Empty condition should not result in exception
- When I am on the "Qbank 1" "core_question > question bank" page
+ When I apply question bank filter "Category" with value "Test questions"
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Test questions"
- When I click on "Add condition" "button"
+ Then I click on "Add condition" "button"
And I set the field "type" in the "Filter 2" "fieldset" to "Tag"
And I click on "Apply filters" "button"
diff --git a/public/question/tests/behat/filter_questions_combined_conditions.feature b/public/question/tests/behat/filter_questions_combined_conditions.feature
index 4ac46ea990aa6..29bf4defc127e 100644
--- a/public/question/tests/behat/filter_questions_combined_conditions.feature
+++ b/public/question/tests/behat/filter_questions_combined_conditions.feature
@@ -23,15 +23,11 @@ Feature: The questions in the question bank can be filtered by combine various c
| Activity module | qbank1 | Test questions 2 |
| Activity module | qbank1 | Test questions 3 |
And the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext |
- | Test questions 1 | essay | question 1 name | teacher1 | Question 1 text |
- | Test questions 1 | essay | question 2 name | teacher1 | Question 2 text |
- | Test questions 2 | essay | question 3 name | teacher1 | Question 3 text |
- | Test questions 2 | essay | question 4 name | teacher1 | Question 4 text |
- And the following "core_question > Tags" exist:
- | question | tag |
- | question 1 name | foo |
- | question 3 name | foo |
+ | questioncategory | qtype | name | user | questiontext | tags |
+ | Test questions 1 | essay | question 1 name | teacher1 | Question 1 text | foo |
+ | Test questions 1 | essay | question 2 name | teacher1 | Question 2 text | |
+ | Test questions 2 | essay | question 3 name | teacher1 | Question 3 text | foo |
+ | Test questions 2 | essay | question 4 name | teacher1 | Question 4 text | |
And I am on the "Qbank 1" "core_question > question bank" page logged in as "teacher1"
@javascript
@@ -47,11 +43,8 @@ Feature: The questions in the question bank can be filtered by combine various c
@javascript
Scenario: Filters persist when the page is reloaded
Given the following "questions" exist:
- | questioncategory | qtype | name | user | questiontext | status |
- | Test questions 1 | essay | hidden question name | teacher1 | Hidden text | hidden |
- And the following "core_question > Tags" exist:
- | question | tag |
- | hidden question name | foo |
+ | questioncategory | qtype | name | user | questiontext | status | tags |
+ | Test questions 1 | essay | hidden question name | teacher1 | Hidden text | hidden | foo |
And I apply question bank filter "Category" with value "Test questions 1"
And I apply question bank filter "Tag" with value "foo"
And I apply question bank filter "Show hidden questions" with value "Yes"
diff --git a/public/question/tests/category_manager_test.php b/public/question/tests/category_manager_test.php
index 18870f0cefc04..a8354ccdb829e 100644
--- a/public/question/tests/category_manager_test.php
+++ b/public/question/tests/category_manager_test.php
@@ -664,47 +664,4 @@ public function test_fix_restored_category_parents(): void {
$this->assertEquals($quiz2top->id, $DB->get_field('question_categories', 'parent', ['id' => $quiz2nontop->id]));
$this->assertEquals($qbank2top->id, $DB->get_field('question_categories', 'parent', ['id' => $qbank2nontop->id]));
}
-
- /**
- * A question with no category should be deleted, while other questions remain as-is.
- */
- public function test_cleanup_questions_without_categories(): void {
- global $DB;
- $this->setAdminUser();
- $this->resetAfterTest();
-
- $course = $this->getDataGenerator()->create_course();
- $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
- $context = \context_module::instance($quiz->cmid);
- $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
- $topcategory = question_get_top_category($context->id, true);
- $defaultcategory = question_get_default_category($context->id);
- $deletedcategory = $questiongenerator->create_question_category(
- ['contextid' => $context->id, 'parent' => $topcategory->id],
- );
- // Create 2 questions. One in the default category, and in the category being deleted.
- $question = $questiongenerator->create_question('truefalse', overrides: ['category' => $defaultcategory->id]);
- $orphan = $questiongenerator->create_question('truefalse', overrides: ['category' => $deletedcategory->id]);
-
- $DB->delete_records('question_categories', ['id' => $deletedcategory->id]);
-
- $this->assertEquals(1, category_manager::cleanup_questions_without_categories());
-
- // The default category question is unchanged.
- $this->assertTrue(
- $DB->record_exists_sql(
- "SELECT *
- FROM {question} q
- JOIN {question_versions} qv on qv.questionid = q.id
- JOIN {question_bank_entries} qbe ON qv.questionbankentryid = qbe.id
- WHERE q.id = :questionid AND qbe.questioncategoryid = :categoryid",
- [
- 'questionid' => $question->id,
- 'categoryid' => $defaultcategory->id,
- ],
- ),
- );
- // The orphaned question has been deleted.
- $this->assertFalse($DB->record_exists('question', ['id' => $orphan->id]));
- }
}
diff --git a/public/question/tests/cleanup_questions_without_categories_task_test.php b/public/question/tests/cleanup_questions_without_categories_task_test.php
new file mode 100644
index 0000000000000..98bbc78f3f005
--- /dev/null
+++ b/public/question/tests/cleanup_questions_without_categories_task_test.php
@@ -0,0 +1,73 @@
+.
+
+namespace core_question;
+
+/**
+ * Unit test for the cleanup_questions_without_categories_task class.
+ *
+ * @package core_question
+ * @copyright 2026 Martin Gauk
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\task\cleanup_questions_without_categories_task
+ */
+final class cleanup_questions_without_categories_task_test extends \advanced_testcase {
+ /**
+ * A question with no category should be deleted, while other questions remain as-is.
+ */
+ public function test_cleanup_questions_without_categories(): void {
+ global $DB;
+ $this->setAdminUser();
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
+ $context = \context_module::instance($quiz->cmid);
+ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $topcategory = question_get_top_category($context->id, true);
+ $defaultcategory = question_get_default_category($context->id);
+ $deletedcategory = $questiongenerator->create_question_category(
+ ['contextid' => $context->id, 'parent' => $topcategory->id],
+ );
+ // Create 2 questions. One in the default category, and in the category being deleted.
+ $question = $questiongenerator->create_question('truefalse', overrides: ['category' => $defaultcategory->id]);
+ $orphan = $questiongenerator->create_question('truefalse', overrides: ['category' => $deletedcategory->id]);
+
+ $DB->delete_records('question_categories', ['id' => $deletedcategory->id]);
+
+ $task = new \core\task\cleanup_questions_without_categories_task();
+ $task->execute();
+ $this->expectOutputRegex('/Cleaned up 1 questions left over from restores./');
+ $this->resetDebugging();
+
+ // The default category question is unchanged.
+ $this->assertTrue(
+ $DB->record_exists_sql(
+ "SELECT *
+ FROM {question} q
+ JOIN {question_versions} qv on qv.questionid = q.id
+ JOIN {question_bank_entries} qbe ON qv.questionbankentryid = qbe.id
+ WHERE q.id = :questionid AND qbe.questioncategoryid = :categoryid",
+ [
+ 'questionid' => $question->id,
+ 'categoryid' => $defaultcategory->id,
+ ],
+ ),
+ );
+ // The orphaned question has been deleted.
+ $this->assertFalse($DB->record_exists('question', ['id' => $orphan->id]));
+ }
+}
diff --git a/public/question/tests/generator/lib.php b/public/question/tests/generator/lib.php
index f955190cbaf85..a6443c9398ad0 100644
--- a/public/question/tests/generator/lib.php
+++ b/public/question/tests/generator/lib.php
@@ -179,6 +179,20 @@ public function update_question($question, $which = null, $overrides = null) {
$question->version = $questionversion->version;
$question->status = $questionversion->status;
+ // Add any tags if they are provided in the overrides.
+ if (array_key_exists('tags', (array)$overrides)) {
+ $tags = array_filter(
+ array_map(
+ 'trim',
+ explode(',', $overrides['tags']),
+ ),
+ );
+ foreach ($tags as $tag) {
+ $tag = trim($tag);
+ $this->create_question_tag(['questionid' => $question->id, 'tag' => $tag]);
+ }
+ }
+
return $question;
}
diff --git a/public/question/tests/generator_test.php b/public/question/tests/generator_test.php
index 3baeaa4395426..b56d7e73cce01 100644
--- a/public/question/tests/generator_test.php
+++ b/public/question/tests/generator_test.php
@@ -78,4 +78,40 @@ public function test_idnumbers_in_categories_and_questions(): void {
$quest4 = $generator->create_question('shortanswer', null, ['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => '0']);
$this->assertSame('0', $quest4->idnumber);
}
+
+ /**
+ * Tests for create_question() to correctly applies tags and stores them.
+ *
+ * @covers \core_question_generator::create_question
+ * @covers \core_question_generator::update_question
+ */
+ public function test_update_question_with_tags(): void {
+ $this->resetAfterTest();
+
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+
+ // Create a question category.
+ $category = $generator->create_question_category();
+
+ // Create a question with tags.
+ $question = $generator->create_question('multichoice', null, [
+ 'category' => $category->id,
+ 'name' => 'Tag test question',
+ 'questiontext' => 'Some question text',
+ 'tags' => 'tag1, tag2 ,tag3',
+ ]);
+
+ // Assert.
+ $tags = \core_tag_tag::get_item_tags(
+ 'core_question',
+ 'question',
+ $question->id
+ );
+
+ $tagnames = array_map(fn($t) => $t->name, $tags);
+ sort($tagnames);
+
+ $this->assertCount(3, $tags);
+ $this->assertEquals(['tag1', 'tag2', 'tag3'], $tagnames);
+ }
}
diff --git a/public/question/tests/statistics/questions/all_calculated_for_qubaid_condition_test.php b/public/question/tests/statistics/questions/all_calculated_for_qubaid_condition_test.php
new file mode 100644
index 0000000000000..f98c0d28f1bde
--- /dev/null
+++ b/public/question/tests/statistics/questions/all_calculated_for_qubaid_condition_test.php
@@ -0,0 +1,143 @@
+.
+
+namespace core_question\statistics\questions;
+
+use advanced_testcase;
+use qubaid_list;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/engine/lib.php');
+
+/**
+ * Tests for all_calculated_for_qubaid_condition.
+ *
+ * @package core_question
+ * @copyright 2026 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core_question\statistics\questions\all_calculated_for_qubaid_condition::get_cached
+ */
+final class all_calculated_for_qubaid_condition_test extends advanced_testcase {
+ /**
+ * Test that get_cached() gracefully skips a DB row whose slot is not in questionstats.
+ */
+ public function test_get_cached_skips_stale_slot_row(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Create a question so the FK constraint on question_statistics.questionid is satisfied.
+ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $cat = $questiongenerator->create_question_category();
+ $q = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
+
+ // Build a qubaid_list and derive the hashcode that get_cached() will query.
+ $qubaids = new qubaid_list([]);
+ $hashcode = $qubaids->get_hash_code();
+ $now = time();
+
+ // Insert a valid slot row (slot 1 — will be pre-populated in the stats object).
+ $DB->insert_record('question_statistics', (object)[
+ 'hashcode' => $hashcode,
+ 'timemodified' => $now,
+ 'questionid' => $q->id,
+ 'subquestion' => 0,
+ 'slot' => 1,
+ 'variant' => null,
+ 's' => 0,
+ 'negcovar' => 0,
+ ]);
+
+ // Insert a stale slot row (slot 99 — NOT pre-populated in the stats object).
+ // This simulates a row left behind after an admin deleted specific stats rows.
+ $DB->insert_record('question_statistics', (object)[
+ 'hashcode' => $hashcode,
+ 'timemodified' => $now,
+ 'questionid' => $q->id,
+ 'subquestion' => 0,
+ 'slot' => 99,
+ 'variant' => null,
+ 's' => 0,
+ 'negcovar' => 0,
+ ]);
+
+ // Prepare the stats object: pre-populate slot 1 only.
+ $stats = new all_calculated_for_qubaid_condition();
+ // The initialise_for_slot() method needs maxmark and number properties on the question object.
+ $q->maxmark = 1.0;
+ $q->number = 1;
+ $stats->initialise_for_slot(1, $q);
+
+ // The get_cached() call must complete without a fatal error.
+ $stats->get_cached($qubaids);
+
+ // The fix emits a debugging() notice for the stale slot — assert it was called.
+ $this->assertDebuggingCalled();
+
+ // Slot 1 should have been loaded from the DB row.
+ $this->assertArrayHasKey(1, $stats->questionstats, 'Valid slot 1 should be present in questionstats');
+ // The stale row should be skipped.
+ $this->assertArrayNotHasKey(99, $stats->questionstats, 'Stale slot 99 must not be in questionstats');
+ }
+
+ /**
+ * Test that get_cached() gracefully skips an orphan variant row whose parent
+ * sub-question entry is missing from subquestionstats.
+ */
+ public function test_get_cached_skips_orphan_variant_row(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Create a question so the FK constraint on question_statistics.questionid is satisfied.
+ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $cat = $questiongenerator->create_question_category();
+ $q = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
+
+ // Build a qubaid_list and derive the hashcode that get_cached() will query.
+ $qubaids = new qubaid_list([]);
+ $hashcode = $qubaids->get_hash_code();
+ $now = time();
+
+ // Insert ONLY a variant row for $q->id (subquestion=1, variant=1, slot=null).
+ // The parent non-variant row is intentionally omitted to simulate the orphan condition.
+ $DB->insert_record('question_statistics', (object)[
+ 'hashcode' => $hashcode,
+ 'timemodified' => $now,
+ 'questionid' => $q->id,
+ 'subquestion' => 1,
+ 'slot' => null,
+ 'variant' => 1,
+ 's' => 0,
+ 'negcovar' => 0,
+ ]);
+
+ // Prepare a stats object with no pre-populated sub-questions (parent never set).
+ $stats = new all_calculated_for_qubaid_condition();
+
+ // The get_cached() call must complete without a fatal error.
+ $stats->get_cached($qubaids);
+
+ // The orphan variant should not create a subquestionstats entry.
+ $this->assertArrayNotHasKey(
+ $q->id,
+ $stats->subquestionstats,
+ 'Orphan variant must not create a subquestionstats entry'
+ );
+ // The fix emits a debugging() notice for the stale subquestion.
+ $this->assertDebuggingCalled();
+ }
+}
diff --git a/public/question/type/ddwtos/amd/build/ddwtos.min.js b/public/question/type/ddwtos/amd/build/ddwtos.min.js
index 2d5fdccec9702..acc3bc8a57b10 100644
--- a/public/question/type/ddwtos/amd/build/ddwtos.min.js
+++ b/public/question/type/ddwtos/amd/build/ddwtos.min.js
@@ -22,6 +22,6 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.6
*/
-define("qtype_ddwtos/ddwtos",["jquery","core/dragdrop","core/key_codes","core_form/changechecker","core_filters/events"],(function($,dragDrop,keys,FormChangeChecker,filterEvent){function DragDropToTextQuestion(containerId,readOnly){const thisQ=this;this.containerId=containerId,this.questionAnswer={},this.questionDragDropWidthHeight=[],readOnly&&this.getRoot().addClass("qtype_ddwtos-readonly"),this.resizeAllDragsAndDrops(),this.cloneDrags(),this.positionDrags(),document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete,(elements=>{elements.detail.nodes.forEach((element=>{thisQ.changeAllDragsAndDropsToFilteredContent(element)}))}))}DragDropToTextQuestion.prototype.resizeAllDragsAndDrops=function(){var thisQ=this;this.getRoot().find(".answercontainer > div").each((function(i,node){thisQ.resizeAllDragsAndDropsInGroup(thisQ.getClassnameNumericSuffix($(node),"draggrouphomes"))}))},DragDropToTextQuestion.prototype.resizeAllDragsAndDropsInGroup=function(group){var thisQ=this,dragDropItems=this.getRoot().find("span.group"+group),maxWidth=0,maxHeight=0;dragDropItems.each((function(i,drag){maxWidth=Math.max(maxWidth,Math.ceil(drag.offsetWidth)),maxHeight=Math.max(maxHeight,Math.ceil(0+drag.offsetHeight))})),maxWidth+=8,maxHeight+=2,thisQ.questionDragDropWidthHeight[group]={maxWidth:maxWidth,maxHeight:maxHeight},dragDropItems.each((function(i,drag){thisQ.setElementSize(drag,maxWidth,maxHeight)}))},DragDropToTextQuestion.prototype.changeAllDragsAndDropsToFilteredContent=function(filteredElement){let currentFilteredItem=$(filteredElement);const parentIsDD=currentFilteredItem.parent().closest("span").hasClass("placed")||currentFilteredItem.parent().closest("span").hasClass("draghome"),isDD=currentFilteredItem.hasClass("placed")||currentFilteredItem.hasClass("draghome");if(!parentIsDD&&!isDD)return;parentIsDD&&(currentFilteredItem=currentFilteredItem.parent().closest("span"));const thisQ=this;if(thisQ.getRoot().find(currentFilteredItem).length<=0)return;const group=thisQ.getGroup(currentFilteredItem),choice=thisQ.getChoice(currentFilteredItem);let listOfModifiedDragDrop=[];this.getRoot().find(".group"+group+".choice"+choice).each((function(i,node){if($(node).get(0)===currentFilteredItem.get(0))return;const originalClass=$(node).attr("class"),originalStyle=$(node).attr("style"),filteredDragDropClone=currentFilteredItem.clone();filteredDragDropClone.attr("class",originalClass),filteredDragDropClone.attr("style",originalStyle),$(node).before(filteredDragDropClone),listOfModifiedDragDrop.push(node)})),listOfModifiedDragDrop.forEach((function(node){$(node).remove()}));const currentHeight=currentFilteredItem.height(),currentWidth=currentFilteredItem.width();currentFilteredItem.height("auto"),currentFilteredItem.width("auto"),filteredElement.offsetWidth&&filteredElement.offsetHeight||filteredElement.classList.add("d-block"),thisQ.questionDragDropWidthHeight[group].maxWidth{result[inputNode.id]=inputNode.value})),result},DragDropToTextQuestion.prototype.isQuestionInteracted=function(){const oldAnswer=this.questionAnswer,newAnswer=this.getQuestionAnsweredValues();let isInteracted=!1;return JSON.stringify(newAnswer)!==JSON.stringify(oldAnswer)?(isInteracted=!0,isInteracted):(Object.keys(newAnswer).forEach((key=>{newAnswer[key]!==oldAnswer[key]&&(isInteracted=!0)})),isInteracted)},DragDropToTextQuestion.prototype.handleDragStart=function(e){var thisQ=this,drag=$(e.target).closest(".draghome");if(dragDrop.prepare(e).start&&!drag.hasClass("beingdragged")){drag.addClass("beingdragged");var currentPlace=this.getClassnameNumericSuffix(drag,"inplace");if(null!==currentPlace){this.setInputValue(currentPlace,0),drag.removeClass("inplace"+currentPlace);var hiddenDrop=thisQ.getDrop(drag,currentPlace);hiddenDrop.length&&(hiddenDrop.addClass("active"),drag.offset(hiddenDrop.offset()))}else{var hiddenDrag=thisQ.getDragClone(drag);if(hiddenDrag.length)if(drag.hasClass("infinite")){var noOfDrags=this.noOfDropsInGroup(this.getGroup(drag));if(this.getInfiniteDragClones(drag,!1).length1;)choice--,previous=this.getUnplacedChoice(group,choice);return previous},DragDropToTextQuestion.prototype.animateTo=function(drag,target){var currentPos=drag.offset(),targetPos=target.offset(),thisQ=this;M.util.js_pending("qtype_ddwtos-animate-"+thisQ.containerId),drag.animate({left:parseInt(drag.css("left"))+targetPos.left-currentPos.left,top:parseInt(drag.css("top"))+targetPos.top-currentPos.top},{duration:"fast",done:function(){$("body").trigger("qtype_ddwtos-dragmoved",[drag,target,thisQ]),M.util.js_complete("qtype_ddwtos-animate-"+thisQ.containerId)}})},DragDropToTextQuestion.prototype.isPointInDrop=function(pageX,pageY,drop){var position=drop.offset();return pageX>=position.left&&pageX=position.top&&pageY1&&thisQ.getInfiniteDragClones(drag,!0).first().remove()),void 0!==drag.data("isfocus")&&!0===drag.data("isfocus")&&(drag.focus(),drag.removeData("isfocus")),void 0!==target.data("isfocus")&&!0===target.data("isfocus")&&target.removeData("isfocus"),questionManager.isKeyboardNavigation&&(questionManager.isKeyboardNavigation=!1),thisQ.isQuestionInteracted()&&(questionManager.handleFormDirty(),thisQ.questionAnswer=thisQ.getQuestionAnsweredValues())},handleFormDirty:function(){const responseForm=document.getElementById("responseform");FormChangeChecker.markFormAsDirty(responseForm)}};return{init:questionManager.init}}));
+define("qtype_ddwtos/ddwtos",["jquery","core/dragdrop","core/key_codes","core_form/changechecker","core_filters/events"],(function($,dragDrop,keys,FormChangeChecker,filterEvent){function DragDropToTextQuestion(containerId,readOnly){const thisQ=this;this.containerId=containerId,this.questionAnswer={},this.questionDragDropWidthHeight=[],readOnly&&this.getRoot().addClass("qtype_ddwtos-readonly"),this.resizeAllDragsAndDrops(),this.cloneDrags(),this.positionDrags(),document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete,(elements=>{elements.detail.nodes.forEach((element=>{thisQ.changeAllDragsAndDropsToFilteredContent(element)}))}))}DragDropToTextQuestion.prototype.resizeAllDragsAndDrops=function(){var thisQ=this;this.getRoot().find(".answercontainer > div").each((function(i,node){thisQ.resizeAllDragsAndDropsInGroup(thisQ.getClassnameNumericSuffix($(node),"draggrouphomes"))}))},DragDropToTextQuestion.prototype.resizeAllDragsAndDropsInGroup=function(group){var thisQ=this,dragDropItems=this.getRoot().find("span.group"+group),maxWidth=0,maxHeight=0;dragDropItems.each((function(i,drag){$(drag).css({width:"",height:"",lineHeight:""})})),dragDropItems.each((function(i,drag){maxWidth=Math.max(maxWidth,Math.ceil(drag.offsetWidth))})),maxWidth+=8,dragDropItems.each((function(i,drag){$(drag).width(maxWidth),maxHeight=Math.max(maxHeight,drag.offsetHeight)})),maxHeight+=2,thisQ.questionDragDropWidthHeight[group]={maxWidth:maxWidth,maxHeight:maxHeight},dragDropItems.each((function(i,drag){thisQ.setElementSize(drag,maxWidth,maxHeight)}))},DragDropToTextQuestion.prototype.changeAllDragsAndDropsToFilteredContent=function(filteredElement){let currentFilteredItem=$(filteredElement);const parentIsDD=currentFilteredItem.parent().closest("span").hasClass("placed")||currentFilteredItem.parent().closest("span").hasClass("draghome"),isDD=currentFilteredItem.hasClass("placed")||currentFilteredItem.hasClass("draghome");if(!parentIsDD&&!isDD)return;parentIsDD&&(currentFilteredItem=currentFilteredItem.parent().closest("span"));const thisQ=this;if(thisQ.getRoot().find(currentFilteredItem).length<=0)return;const group=thisQ.getGroup(currentFilteredItem),choice=thisQ.getChoice(currentFilteredItem);let listOfModifiedDragDrop=[];this.getRoot().find(".group"+group+".choice"+choice).each((function(i,node){if($(node).get(0)===currentFilteredItem.get(0))return;const originalClass=$(node).attr("class"),originalStyle=$(node).attr("style"),filteredDragDropClone=currentFilteredItem.clone();filteredDragDropClone.attr("class",originalClass),filteredDragDropClone.attr("style",originalStyle),$(node).before(filteredDragDropClone),listOfModifiedDragDrop.push(node)})),listOfModifiedDragDrop.forEach((function(node){$(node).remove()}));const currentHeight=currentFilteredItem.height(),currentWidth=currentFilteredItem.width();currentFilteredItem.height("auto"),currentFilteredItem.width("auto"),filteredElement.offsetWidth&&filteredElement.offsetHeight||filteredElement.classList.add("d-block"),thisQ.questionDragDropWidthHeight[group].maxWidth{result[inputNode.id]=inputNode.value})),result},DragDropToTextQuestion.prototype.isQuestionInteracted=function(){const oldAnswer=this.questionAnswer,newAnswer=this.getQuestionAnsweredValues();let isInteracted=!1;return JSON.stringify(newAnswer)!==JSON.stringify(oldAnswer)?(isInteracted=!0,isInteracted):(Object.keys(newAnswer).forEach((key=>{newAnswer[key]!==oldAnswer[key]&&(isInteracted=!0)})),isInteracted)},DragDropToTextQuestion.prototype.handleDragStart=function(e){var thisQ=this,drag=$(e.target).closest(".draghome");if(dragDrop.prepare(e).start&&!drag.hasClass("beingdragged")){drag.addClass("beingdragged");var currentPlace=this.getClassnameNumericSuffix(drag,"inplace");if(null!==currentPlace){this.setInputValue(currentPlace,0),drag.removeClass("inplace"+currentPlace);var hiddenDrop=thisQ.getDrop(drag,currentPlace);hiddenDrop.length&&(hiddenDrop.addClass("active"),drag.offset(hiddenDrop.offset()))}else{var hiddenDrag=thisQ.getDragClone(drag);if(hiddenDrag.length)if(drag.hasClass("infinite")){var noOfDrags=this.noOfDropsInGroup(this.getGroup(drag));if(this.getInfiniteDragClones(drag,!1).length1;)choice--,previous=this.getUnplacedChoice(group,choice);return previous},DragDropToTextQuestion.prototype.animateTo=function(drag,target){var currentPos=drag.offset(),targetPos=target.offset(),thisQ=this;M.util.js_pending("qtype_ddwtos-animate-"+thisQ.containerId),drag.animate({left:parseInt(drag.css("left"))+targetPos.left-currentPos.left,top:parseInt(drag.css("top"))+targetPos.top-currentPos.top},{duration:"fast",done:function(){$("body").trigger("qtype_ddwtos-dragmoved",[drag,target,thisQ]),M.util.js_complete("qtype_ddwtos-animate-"+thisQ.containerId)}})},DragDropToTextQuestion.prototype.isPointInDrop=function(pageX,pageY,drop){var position=drop.offset();return pageX>=position.left&&pageX=position.top&&pageY1&&thisQ.getInfiniteDragClones(drag,!0).first().remove()),void 0!==drag.data("isfocus")&&!0===drag.data("isfocus")&&(drag.focus(),drag.removeData("isfocus")),void 0!==target.data("isfocus")&&!0===target.data("isfocus")&&target.removeData("isfocus"),questionManager.isKeyboardNavigation&&(questionManager.isKeyboardNavigation=!1),thisQ.isQuestionInteracted()&&(questionManager.handleFormDirty(),thisQ.questionAnswer=thisQ.getQuestionAnsweredValues())},handleFormDirty:function(){const responseForm=document.getElementById("responseform");FormChangeChecker.markFormAsDirty(responseForm)}};return{init:questionManager.init}}));
//# sourceMappingURL=ddwtos.min.js.map
\ No newline at end of file
diff --git a/public/question/type/ddwtos/amd/build/ddwtos.min.js.map b/public/question/type/ddwtos/amd/build/ddwtos.min.js.map
index 2918776c33eca..05a15122d05ec 100644
--- a/public/question/type/ddwtos/amd/build/ddwtos.min.js.map
+++ b/public/question/type/ddwtos/amd/build/ddwtos.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"ddwtos.min.js","sources":["../src/ddwtos.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 * JavaScript to make drag-drop into text questions work.\n *\n * Some vocabulary to help understand this code:\n *\n * The question text contains 'drops' - blanks into which the 'drags', the missing\n * words, can be put.\n *\n * The thing that can be moved into the drops are called 'drags'. There may be\n * multiple copies of the 'same' drag which does not really cause problems.\n * Each drag has a 'choice' number which is the value set on the drop's hidden\n * input when this drag is placed in a drop.\n *\n * These may be in separate 'groups', distinguished by colour.\n * Things can only interact with other things in the same group.\n * The groups are numbered from 1.\n *\n * The place where a given drag started from is called its 'home'.\n *\n * @module qtype_ddwtos/ddwtos\n * @copyright 2018 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.6\n */\ndefine([\n 'jquery',\n 'core/dragdrop',\n 'core/key_codes',\n 'core_form/changechecker',\n 'core_filters/events',\n], function(\n $,\n dragDrop,\n keys,\n FormChangeChecker,\n filterEvent\n) {\n\n \"use strict\";\n\n /**\n * Object to handle one drag-drop into text question.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n * @constructor\n */\n function DragDropToTextQuestion(containerId, readOnly) {\n const thisQ = this;\n this.containerId = containerId;\n this.questionAnswer = {};\n this.questionDragDropWidthHeight = [];\n if (readOnly) {\n this.getRoot().addClass('qtype_ddwtos-readonly');\n }\n this.resizeAllDragsAndDrops();\n this.cloneDrags();\n this.positionDrags();\n // Wait for all dynamic content loaded by filter to be completed.\n document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete, (elements) => {\n elements.detail.nodes.forEach((element) => {\n thisQ.changeAllDragsAndDropsToFilteredContent(element);\n });\n });\n }\n\n /**\n * In each group, resize all the items to be the same size.\n */\n DragDropToTextQuestion.prototype.resizeAllDragsAndDrops = function() {\n var thisQ = this;\n this.getRoot().find('.answercontainer > div').each(function(i, node) {\n thisQ.resizeAllDragsAndDropsInGroup(\n thisQ.getClassnameNumericSuffix($(node), 'draggrouphomes'));\n });\n };\n\n /**\n * In a given group, set all the drags and drops to be the same size.\n *\n * @param {int} group the group number.\n */\n DragDropToTextQuestion.prototype.resizeAllDragsAndDropsInGroup = function(group) {\n var thisQ = this,\n dragDropItems = this.getRoot().find('span.group' + group),\n maxWidth = 0,\n maxHeight = 0;\n\n // Find the maximum size of any drag in this groups.\n dragDropItems.each(function(i, drag) {\n maxWidth = Math.max(maxWidth, Math.ceil(drag.offsetWidth));\n maxHeight = Math.max(maxHeight, Math.ceil(0 + drag.offsetHeight));\n });\n\n // The size we will want to set is a bit bigger than this.\n maxWidth += 8;\n maxHeight += 2;\n thisQ.questionDragDropWidthHeight[group] = {maxWidth: maxWidth, maxHeight: maxHeight};\n // Set each drag home to that size.\n dragDropItems.each(function(i, drag) {\n thisQ.setElementSize(drag, maxWidth, maxHeight);\n });\n };\n\n /**\n * Change all the drags and drops related to the item that has been changed by filter to correct size and content.\n *\n * @param {object} filteredElement the element has been modified by filter.\n */\n DragDropToTextQuestion.prototype.changeAllDragsAndDropsToFilteredContent = function(filteredElement) {\n let currentFilteredItem = $(filteredElement);\n const parentIsDD = currentFilteredItem.parent().closest('span').hasClass('placed') ||\n currentFilteredItem.parent().closest('span').hasClass('draghome');\n const isDD = currentFilteredItem.hasClass('placed') || currentFilteredItem.hasClass('draghome');\n // The filtered element or parent element should a drag or drop item.\n if (!parentIsDD && !isDD) {\n return;\n }\n if (parentIsDD) {\n currentFilteredItem = currentFilteredItem.parent().closest('span');\n }\n const thisQ = this;\n if (thisQ.getRoot().find(currentFilteredItem).length <= 0) {\n // If the DD item doesn't belong to this question\n // In case we have multiple questions in the same page.\n return;\n }\n const group = thisQ.getGroup(currentFilteredItem),\n choice = thisQ.getChoice(currentFilteredItem);\n let listOfModifiedDragDrop = [];\n // Get the list of drag and drop item within the same group and choice.\n this.getRoot().find('.group' + group + '.choice' + choice).each(function(i, node) {\n // Same modified item, skip it.\n if ($(node).get(0) === currentFilteredItem.get(0)) {\n return;\n }\n const originalClass = $(node).attr('class');\n const originalStyle = $(node).attr('style');\n // We want to keep all the handler and event for filtered item, so using clone is the only choice.\n const filteredDragDropClone = currentFilteredItem.clone();\n // Replace the class and style of the drag drop item we want to replace for the clone.\n filteredDragDropClone.attr('class', originalClass);\n filteredDragDropClone.attr('style', originalStyle);\n // Insert into DOM.\n $(node).before(filteredDragDropClone);\n // Add the item has been replaced to a list so we can remove it later.\n listOfModifiedDragDrop.push(node);\n });\n\n listOfModifiedDragDrop.forEach(function(node) {\n $(node).remove();\n });\n // Save the current height and width.\n const currentHeight = currentFilteredItem.height();\n const currentWidth = currentFilteredItem.width();\n // Set to auto so we can get the real height and width of the filtered item.\n currentFilteredItem.height('auto');\n currentFilteredItem.width('auto');\n // We need to set display block so we can get height and width.\n // Some browser can't get the offsetWidth/Height if they are an inline element like span tag.\n if (!filteredElement.offsetWidth || !filteredElement.offsetHeight) {\n filteredElement.classList.add('d-block');\n }\n if (thisQ.questionDragDropWidthHeight[group].maxWidth < Math.ceil(filteredElement.offsetWidth) ||\n thisQ.questionDragDropWidthHeight[group].maxHeight < Math.ceil(0 + filteredElement.offsetHeight)) {\n // Remove the d-block class before calculation.\n filteredElement.classList.remove('d-block');\n // Now resize all the items in the same group if we have new maximum width or height.\n thisQ.resizeAllDragsAndDropsInGroup(group);\n } else {\n // Return the original height and width in case the real height and width is not the maximum.\n currentFilteredItem.height(currentHeight);\n currentFilteredItem.width(currentWidth);\n }\n // Remove the d-block class after resize.\n filteredElement.classList.remove('d-block');\n };\n\n /**\n * Set a given DOM element to be a particular size.\n *\n * @param {HTMLElement} element\n * @param {int} width\n * @param {int} height\n */\n DragDropToTextQuestion.prototype.setElementSize = function(element, width, height) {\n $(element).width(width).height(height).css('lineHeight', height + 'px');\n };\n\n /**\n * Invisible 'drag homes' are output by the renderer. These have the same properties\n * as the drag items but are invisible. We clone these invisible elements to make the\n * actual drag items.\n */\n DragDropToTextQuestion.prototype.cloneDrags = function() {\n var thisQ = this;\n thisQ.getRoot().find('span.draghome').each(function(index, draghome) {\n var drag = $(draghome);\n var placeHolder = drag.clone();\n placeHolder.removeClass();\n placeHolder.addClass('draghome choice' +\n thisQ.getChoice(drag) + ' group' +\n thisQ.getGroup(drag) + ' dragplaceholder');\n drag.before(placeHolder);\n });\n };\n\n /**\n * Update the position of drags.\n */\n DragDropToTextQuestion.prototype.positionDrags = function() {\n var thisQ = this,\n root = this.getRoot();\n\n // First move all items back home.\n root.find('span.draghome').not('.dragplaceholder').each(function(i, dragNode) {\n var drag = $(dragNode),\n currentPlace = thisQ.getClassnameNumericSuffix(drag, 'inplace');\n drag.addClass('unplaced')\n .removeClass('placed');\n drag.removeAttr('tabindex');\n if (currentPlace !== null) {\n drag.removeClass('inplace' + currentPlace);\n }\n });\n\n // Then place the once that should be placed.\n root.find('input.placeinput').each(function(i, inputNode) {\n var input = $(inputNode),\n choice = input.val(),\n place = thisQ.getPlace(input);\n\n // Record the last known position of the drop.\n var drop = root.find('.drop.place' + place),\n dropPosition = drop.offset();\n drop.data('prev-top', dropPosition.top).data('prev-left', dropPosition.left);\n\n if (choice === '0') {\n // No item in this place.\n return;\n }\n\n // Get the unplaced drag.\n var unplacedDrag = thisQ.getUnplacedChoice(thisQ.getGroup(input), choice);\n // Get the clone of the drag.\n var hiddenDrag = thisQ.getDragClone(unplacedDrag);\n if (hiddenDrag.length) {\n if (unplacedDrag.hasClass('infinite')) {\n var noOfDrags = thisQ.noOfDropsInGroup(thisQ.getGroup(unplacedDrag));\n var cloneDrags = thisQ.getInfiniteDragClones(unplacedDrag, false);\n if (cloneDrags.length < noOfDrags) {\n var cloneDrag = unplacedDrag.clone();\n hiddenDrag.after(cloneDrag);\n questionManager.addEventHandlersToDrag(cloneDrag);\n } else {\n hiddenDrag.addClass('active');\n }\n } else {\n hiddenDrag.addClass('active');\n }\n }\n // Send the drag to drop.\n thisQ.sendDragToDrop(thisQ.getUnplacedChoice(thisQ.getGroup(input), choice), drop);\n });\n\n // Save the question answer.\n thisQ.questionAnswer = thisQ.getQuestionAnsweredValues();\n };\n\n /**\n * Get the question answered values.\n *\n * @return {Object} Contain key-value with key is the input id and value is the input value.\n */\n DragDropToTextQuestion.prototype.getQuestionAnsweredValues = function() {\n let result = {};\n this.getRoot().find('input.placeinput').each((i, inputNode) => {\n result[inputNode.id] = inputNode.value;\n });\n\n return result;\n };\n\n /**\n * Check if the question is being interacted or not.\n *\n * @return {boolean} Return true if the user has changed the question-answer.\n */\n DragDropToTextQuestion.prototype.isQuestionInteracted = function() {\n const oldAnswer = this.questionAnswer;\n const newAnswer = this.getQuestionAnsweredValues();\n let isInteracted = false;\n\n // First, check both answers have the same structure or not.\n if (JSON.stringify(newAnswer) !== JSON.stringify(oldAnswer)) {\n isInteracted = true;\n return isInteracted;\n }\n // Check the values.\n Object.keys(newAnswer).forEach(key => {\n if (newAnswer[key] !== oldAnswer[key]) {\n isInteracted = true;\n }\n });\n\n return isInteracted;\n };\n\n /**\n * Handles the start of dragging an item.\n *\n * @param {Event} e the touch start or mouse down event.\n */\n DragDropToTextQuestion.prototype.handleDragStart = function(e) {\n var thisQ = this,\n drag = $(e.target).closest('.draghome');\n\n var info = dragDrop.prepare(e);\n if (!info.start || drag.hasClass('beingdragged')) {\n return;\n }\n\n drag.addClass('beingdragged');\n var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace');\n if (currentPlace !== null) {\n this.setInputValue(currentPlace, 0);\n drag.removeClass('inplace' + currentPlace);\n var hiddenDrop = thisQ.getDrop(drag, currentPlace);\n if (hiddenDrop.length) {\n hiddenDrop.addClass('active');\n drag.offset(hiddenDrop.offset());\n }\n } else {\n var hiddenDrag = thisQ.getDragClone(drag);\n if (hiddenDrag.length) {\n if (drag.hasClass('infinite')) {\n var noOfDrags = this.noOfDropsInGroup(this.getGroup(drag));\n var cloneDrags = this.getInfiniteDragClones(drag, false);\n if (cloneDrags.length < noOfDrags) {\n var cloneDrag = drag.clone();\n cloneDrag.removeClass('beingdragged');\n hiddenDrag.after(cloneDrag);\n questionManager.addEventHandlersToDrag(cloneDrag);\n drag.offset(cloneDrag.offset());\n } else {\n hiddenDrag.addClass('active');\n drag.offset(hiddenDrag.offset());\n }\n } else {\n hiddenDrag.addClass('active');\n drag.offset(hiddenDrag.offset());\n }\n }\n }\n\n dragDrop.start(e, drag, function(x, y, drag) {\n thisQ.dragMove(x, y, drag);\n }, function(x, y, drag) {\n thisQ.dragEnd(x, y, drag);\n });\n };\n\n /**\n * Called whenever the currently dragged items moves.\n *\n * @param {Number} pageX the x position.\n * @param {Number} pageY the y position.\n * @param {jQuery} drag the item being moved.\n */\n DragDropToTextQuestion.prototype.dragMove = function(pageX, pageY, drag) {\n var thisQ = this;\n this.getRoot().find('span.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, dropNode) {\n var drop = $(dropNode);\n if (thisQ.isPointInDrop(pageX, pageY, drop)) {\n drop.addClass('valid-drag-over-drop');\n } else {\n drop.removeClass('valid-drag-over-drop');\n }\n });\n };\n\n /**\n * Called when user drops a drag item.\n *\n * @param {Number} pageX the x position.\n * @param {Number} pageY the y position.\n * @param {jQuery} drag the item being moved.\n */\n DragDropToTextQuestion.prototype.dragEnd = function(pageX, pageY, drag) {\n var thisQ = this,\n root = this.getRoot(),\n placed = false;\n root.find('span.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, dropNode) {\n if (placed) {\n return false;\n }\n const dropZone = $(dropNode);\n if (!thisQ.isPointInDrop(pageX, pageY, dropZone)) {\n // Not this drop zone.\n return true;\n }\n let drop = null;\n if (dropZone.hasClass('placed')) {\n // This is an placed drag item in a drop.\n dropZone.removeClass('valid-drag-over-drop');\n // Get the correct drop.\n drop = thisQ.getDrop(drag, thisQ.getClassnameNumericSuffix(dropZone, 'inplace'));\n } else {\n // Empty drop.\n drop = dropZone;\n }\n // Now put this drag into the drop.\n drop.removeClass('valid-drag-over-drop');\n thisQ.sendDragToDrop(drag, drop);\n placed = true;\n return false; // Stop the each() here.\n });\n if (!placed) {\n this.sendDragHome(drag);\n }\n };\n\n /**\n * Animate a drag item into a given place (or back home).\n *\n * @param {jQuery|null} drag the item to place. If null, clear the place.\n * @param {jQuery} drop the place to put it.\n */\n DragDropToTextQuestion.prototype.sendDragToDrop = function(drag, drop) {\n // Send drag home if there is no place in drop.\n if (this.getPlace(drop) === null) {\n this.sendDragHome(drag);\n return;\n }\n\n // Is there already a drag in this drop? if so, evict it.\n var oldDrag = this.getCurrentDragInPlace(this.getPlace(drop));\n if (oldDrag.length !== 0) {\n var currentPlace = this.getClassnameNumericSuffix(oldDrag, 'inplace');\n // When infinite group and there is already a drag in a drop, reject the exact clone in the same drop.\n if (this.hasDropSameDrag(currentPlace, drop, oldDrag, drag)) {\n this.sendDragHome(drag);\n return;\n }\n var hiddenDrop = this.getDrop(oldDrag, currentPlace);\n hiddenDrop.addClass('active');\n oldDrag.addClass('beingdragged');\n oldDrag.offset(hiddenDrop.offset());\n this.sendDragHome(oldDrag);\n }\n\n if (drag.length === 0) {\n this.setInputValue(this.getPlace(drop), 0);\n if (drop.data('isfocus')) {\n drop.focus();\n }\n } else {\n // Prevent the drag item drop into two drop-zone.\n if (this.getClassnameNumericSuffix(drag, 'inplace')) {\n return;\n }\n\n this.setInputValue(this.getPlace(drop), this.getChoice(drag));\n drag.removeClass('unplaced')\n .addClass('placed inplace' + this.getPlace(drop));\n drag.attr('tabindex', 0);\n this.animateTo(drag, drop);\n }\n };\n\n /**\n * When infinite group and there is already a drag in a drop, reject the exact clone in the same drop.\n *\n * @param {int} currentPlace the position of the current drop.\n * @param {jQuery} drop the drop containing a drag.\n * @param {jQuery} oldDrag the drag already placed in drop.\n * @param {jQuery} drag the new drag which is exactly the same (clone) as oldDrag .\n * @returns {boolean}\n */\n DragDropToTextQuestion.prototype.hasDropSameDrag = function(currentPlace, drop, oldDrag, drag) {\n if (drag.hasClass('infinite')) {\n return drop.hasClass('place' + currentPlace) &&\n this.getGroup(drag) === this.getGroup(drop) &&\n this.getChoice(drag) === this.getChoice(oldDrag) &&\n this.getGroup(drag) === this.getGroup(oldDrag);\n }\n return false;\n };\n\n /**\n * Animate a drag back to its home.\n *\n * @param {jQuery} drag the item being moved.\n */\n DragDropToTextQuestion.prototype.sendDragHome = function(drag) {\n var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace');\n if (currentPlace !== null) {\n drag.removeClass('inplace' + currentPlace);\n }\n drag.data('unplaced', true);\n\n this.animateTo(drag, this.getDragHome(this.getGroup(drag), this.getChoice(drag)));\n };\n\n /**\n * Handles keyboard events on drops.\n *\n * Drops are focusable. Once focused, right/down/space switches to the next choice, and\n * left/up switches to the previous. Escape clear.\n *\n * @param {KeyboardEvent} e\n */\n DragDropToTextQuestion.prototype.handleKeyPress = function(e) {\n var drop = $(e.target).closest('.drop');\n if (drop.length === 0) {\n var placedDrag = $(e.target);\n var currentPlace = this.getClassnameNumericSuffix(placedDrag, 'inplace');\n if (currentPlace !== null) {\n drop = this.getDrop(placedDrag, currentPlace);\n }\n }\n var currentDrag = this.getCurrentDragInPlace(this.getPlace(drop)),\n nextDrag = $();\n\n switch (e.keyCode) {\n case keys.space:\n case keys.arrowRight:\n case keys.arrowDown:\n nextDrag = this.getNextDrag(this.getGroup(drop), currentDrag);\n break;\n\n case keys.arrowLeft:\n case keys.arrowUp:\n nextDrag = this.getPreviousDrag(this.getGroup(drop), currentDrag);\n break;\n\n case keys.escape:\n break;\n\n default:\n questionManager.isKeyboardNavigation = false;\n return; // To avoid the preventDefault below.\n }\n\n if (nextDrag.length) {\n nextDrag.data('isfocus', true);\n nextDrag.addClass('beingdragged');\n var hiddenDrag = this.getDragClone(nextDrag);\n if (hiddenDrag.length) {\n if (nextDrag.hasClass('infinite')) {\n var noOfDrags = this.noOfDropsInGroup(this.getGroup(nextDrag));\n var cloneDrags = this.getInfiniteDragClones(nextDrag, false);\n if (cloneDrags.length < noOfDrags) {\n var cloneDrag = nextDrag.clone();\n cloneDrag.removeClass('beingdragged');\n cloneDrag.removeAttr('tabindex');\n hiddenDrag.after(cloneDrag);\n questionManager.addEventHandlersToDrag(cloneDrag);\n nextDrag.offset(cloneDrag.offset());\n } else {\n hiddenDrag.addClass('active');\n nextDrag.offset(hiddenDrag.offset());\n }\n } else {\n hiddenDrag.addClass('active');\n nextDrag.offset(hiddenDrag.offset());\n }\n }\n } else {\n drop.data('isfocus', true);\n }\n\n e.preventDefault();\n this.sendDragToDrop(nextDrag, drop);\n };\n\n /**\n * Choose the next drag in a group.\n *\n * @param {int} group which group.\n * @param {jQuery} drag current choice (empty jQuery if there isn't one).\n * @return {jQuery} the next drag in that group, or null if there wasn't one.\n */\n DragDropToTextQuestion.prototype.getNextDrag = function(group, drag) {\n var choice,\n numChoices = this.noOfChoicesInGroup(group);\n\n if (drag.length === 0) {\n choice = 1; // Was empty, so we want to select the first choice.\n } else {\n choice = this.getChoice(drag) + 1;\n }\n\n var next = this.getUnplacedChoice(group, choice);\n while (next.length === 0 && choice < numChoices) {\n choice++;\n next = this.getUnplacedChoice(group, choice);\n }\n\n return next;\n };\n\n /**\n * Choose the previous drag in a group.\n *\n * @param {int} group which group.\n * @param {jQuery} drag current choice (empty jQuery if there isn't one).\n * @return {jQuery} the next drag in that group, or null if there wasn't one.\n */\n DragDropToTextQuestion.prototype.getPreviousDrag = function(group, drag) {\n var choice;\n\n if (drag.length === 0) {\n choice = this.noOfChoicesInGroup(group);\n } else {\n choice = this.getChoice(drag) - 1;\n }\n\n var previous = this.getUnplacedChoice(group, choice);\n while (previous.length === 0 && choice > 1) {\n choice--;\n previous = this.getUnplacedChoice(group, choice);\n }\n\n // Does this choice exist?\n return previous;\n };\n\n /**\n * Animate an object to the given destination.\n *\n * @param {jQuery} drag the element to be animated.\n * @param {jQuery} target element marking the place to move it to.\n */\n DragDropToTextQuestion.prototype.animateTo = function(drag, target) {\n var currentPos = drag.offset(),\n targetPos = target.offset(),\n thisQ = this;\n\n M.util.js_pending('qtype_ddwtos-animate-' + thisQ.containerId);\n // Animate works in terms of CSS position, whereas locating an object\n // on the page works best with jQuery offset() function. So, to get\n // the right target position, we work out the required change in\n // offset() and then add that to the current CSS position.\n drag.animate(\n {\n left: parseInt(drag.css('left')) + targetPos.left - currentPos.left,\n top: parseInt(drag.css('top')) + targetPos.top - currentPos.top\n },\n {\n duration: 'fast',\n done: function() {\n $('body').trigger('qtype_ddwtos-dragmoved', [drag, target, thisQ]);\n M.util.js_complete('qtype_ddwtos-animate-' + thisQ.containerId);\n }\n }\n );\n };\n\n /**\n * Detect if a point is inside a given DOM node.\n *\n * @param {Number} pageX the x position.\n * @param {Number} pageY the y position.\n * @param {jQuery} drop the node to check (typically a drop).\n * @return {boolean} whether the point is inside the node.\n */\n DragDropToTextQuestion.prototype.isPointInDrop = function(pageX, pageY, drop) {\n var position = drop.offset();\n return pageX >= position.left && pageX < position.left + drop.width()\n && pageY >= position.top && pageY < position.top + drop.height();\n };\n\n /**\n * Set the value of the hidden input for a place, to record what is currently there.\n *\n * @param {int} place which place to set the input value for.\n * @param {int} choice the value to set.\n */\n DragDropToTextQuestion.prototype.setInputValue = function(place, choice) {\n this.getRoot().find('input.placeinput.place' + place).val(choice);\n };\n\n /**\n * Get the outer div for this question.\n *\n * @returns {jQuery} containing that div.\n */\n DragDropToTextQuestion.prototype.getRoot = function() {\n return $(document.getElementById(this.containerId));\n };\n\n /**\n * Get drag home for a given choice.\n *\n * @param {int} group the group.\n * @param {int} choice the choice number.\n * @returns {jQuery} containing that div.\n */\n DragDropToTextQuestion.prototype.getDragHome = function(group, choice) {\n if (!this.getRoot().find('.draghome.dragplaceholder.group' + group + '.choice' + choice).is(':visible')) {\n return this.getRoot().find('.draggrouphomes' + group +\n ' span.draghome.infinite' +\n '.choice' + choice +\n '.group' + group);\n }\n return this.getRoot().find('.draghome.dragplaceholder.group' + group + '.choice' + choice);\n };\n\n /**\n * Get an unplaced choice for a particular group.\n *\n * @param {int} group the group.\n * @param {int} choice the choice number.\n * @returns {jQuery} jQuery wrapping the unplaced choice. If there isn't one, the jQuery will be empty.\n */\n DragDropToTextQuestion.prototype.getUnplacedChoice = function(group, choice) {\n return this.getRoot().find('.draghome.group' + group + '.choice' + choice + '.unplaced').slice(0, 1);\n };\n\n /**\n * Get the drag that is currently in a given place.\n *\n * @param {int} place the place number.\n * @return {jQuery} the current drag (or an empty jQuery if none).\n */\n DragDropToTextQuestion.prototype.getCurrentDragInPlace = function(place) {\n return this.getRoot().find('span.draghome.inplace' + place);\n };\n\n /**\n * Return the number of blanks in a given group.\n *\n * @param {int} group the group number.\n * @returns {int} the number of drops.\n */\n DragDropToTextQuestion.prototype.noOfDropsInGroup = function(group) {\n return this.getRoot().find('.drop.group' + group).length;\n };\n\n /**\n * Return the number of choices in a given group.\n *\n * @param {int} group the group number.\n * @returns {int} the number of choices.\n */\n DragDropToTextQuestion.prototype.noOfChoicesInGroup = function(group) {\n return this.getRoot().find('.draghome.group' + group).length;\n };\n\n /**\n * Return the number at the end of the CSS class name with the given prefix.\n *\n * @param {jQuery} node\n * @param {String} prefix name prefix\n * @returns {Number|null} the suffix if found, else null.\n */\n DragDropToTextQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) {\n var classes = node.attr('class');\n if (classes !== undefined && classes !== '') {\n var classesArr = classes.split(' ');\n for (var index = 0; index < classesArr.length; index++) {\n var patt1 = new RegExp('^' + prefix + '([0-9])+$');\n if (patt1.test(classesArr[index])) {\n var patt2 = new RegExp('([0-9])+$');\n var match = patt2.exec(classesArr[index]);\n return Number(match[0]);\n }\n }\n }\n return null;\n };\n\n /**\n * Get the choice number of a drag.\n *\n * @param {jQuery} drag the drag.\n * @returns {Number} the choice number.\n */\n DragDropToTextQuestion.prototype.getChoice = function(drag) {\n return this.getClassnameNumericSuffix(drag, 'choice');\n };\n\n /**\n * Given a DOM node that is significant to this question\n * (drag, drop, ...) get the group it belongs to.\n *\n * @param {jQuery} node a DOM node.\n * @returns {Number} the group it belongs to.\n */\n DragDropToTextQuestion.prototype.getGroup = function(node) {\n return this.getClassnameNumericSuffix(node, 'group');\n };\n\n /**\n * Get the place number of a drop, or its corresponding hidden input.\n *\n * @param {jQuery} node the DOM node.\n * @returns {Number} the place number.\n */\n DragDropToTextQuestion.prototype.getPlace = function(node) {\n return this.getClassnameNumericSuffix(node, 'place');\n };\n\n /**\n * Get drag clone for a given drag.\n *\n * @param {jQuery} drag the drag.\n * @returns {jQuery} the drag's clone.\n */\n DragDropToTextQuestion.prototype.getDragClone = function(drag) {\n return this.getRoot().find('.draggrouphomes' +\n this.getGroup(drag) +\n ' span.draghome' +\n '.choice' + this.getChoice(drag) +\n '.group' + this.getGroup(drag) +\n '.dragplaceholder');\n };\n\n /**\n * Get infinite drag clones for given drag.\n *\n * @param {jQuery} drag the drag.\n * @param {Boolean} inHome in the home area or not.\n * @returns {jQuery} the drag's clones.\n */\n DragDropToTextQuestion.prototype.getInfiniteDragClones = function(drag, inHome) {\n if (inHome) {\n return this.getRoot().find('.draggrouphomes' +\n this.getGroup(drag) +\n ' span.draghome' +\n '.choice' + this.getChoice(drag) +\n '.group' + this.getGroup(drag) +\n '.infinite').not('.dragplaceholder');\n }\n return this.getRoot().find('span.draghome' +\n '.choice' + this.getChoice(drag) +\n '.group' + this.getGroup(drag) +\n '.infinite').not('.dragplaceholder');\n };\n\n /**\n * Get drop for a given drag and place.\n *\n * @param {jQuery} drag the drag.\n * @param {Integer} currentPlace the current place of drag.\n * @returns {jQuery} the drop's clone.\n */\n DragDropToTextQuestion.prototype.getDrop = function(drag, currentPlace) {\n return this.getRoot().find('.drop.group' + this.getGroup(drag) + '.place' + currentPlace);\n };\n\n /**\n * Singleton that tracks all the DragDropToTextQuestions on this page, and deals\n * with event dispatching.\n *\n * @type {Object}\n */\n var questionManager = {\n /**\n * {boolean} used to ensure the event handlers are only initialised once per page.\n */\n eventHandlersInitialised: false,\n\n /**\n * {Object} ensures that the drag event handlers are only initialised once per question,\n * indexed by containerId (id on the .que div).\n */\n dragEventHandlersInitialised: {},\n\n /**\n * {boolean} is keyboard navigation or not.\n */\n isKeyboardNavigation: false,\n\n /**\n * {DragDropToTextQuestion[]} all the questions on this page, indexed by containerId (id on the .que div).\n */\n questions: {},\n\n /**\n * Initialise questions.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n */\n init: function(containerId, readOnly) {\n questionManager.questions[containerId] = new DragDropToTextQuestion(containerId, readOnly);\n if (!questionManager.eventHandlersInitialised) {\n questionManager.setupEventHandlers();\n questionManager.eventHandlersInitialised = true;\n }\n if (!questionManager.dragEventHandlersInitialised.hasOwnProperty(containerId)) {\n questionManager.dragEventHandlersInitialised[containerId] = true;\n // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.\n var questionContainer = document.getElementById(containerId);\n if (questionContainer.classList.contains('ddwtos') &&\n !questionContainer.classList.contains('qtype_ddwtos-readonly')) {\n // TODO: Convert all the jQuery selectors and events to native Javascript.\n questionManager.addEventHandlersToDrag($(questionContainer).find('span.draghome'));\n }\n }\n },\n\n /**\n * Set up the event handlers that make this question type work. (Done once per page.)\n */\n setupEventHandlers: function() {\n $('body')\n .on('keydown',\n '.que.ddwtos:not(.qtype_ddwtos-readonly) span.drop',\n questionManager.handleKeyPress)\n .on('keydown',\n '.que.ddwtos:not(.qtype_ddwtos-readonly) span.draghome.placed:not(.beingdragged)',\n questionManager.handleKeyPress)\n .on('qtype_ddwtos-dragmoved', questionManager.handleDragMoved);\n },\n\n /**\n * Binding the drag/touch event again for newly created element.\n *\n * @param {jQuery} element Element to bind the event\n */\n addEventHandlersToDrag: function(element) {\n // Unbind all the mousedown and touchstart events to prevent double binding.\n element.unbind('mousedown touchstart');\n element.on('mousedown touchstart', questionManager.handleDragStart);\n },\n\n /**\n * Handle mouse down / touch start on drags.\n * @param {Event} e the DOM event.\n */\n handleDragStart: function(e) {\n e.preventDefault();\n var question = questionManager.getQuestionForEvent(e);\n if (question) {\n question.handleDragStart(e);\n }\n },\n\n /**\n * Handle key down / press on drops.\n * @param {KeyboardEvent} e\n */\n handleKeyPress: function(e) {\n if (questionManager.isKeyboardNavigation) {\n return;\n }\n questionManager.isKeyboardNavigation = true;\n var question = questionManager.getQuestionForEvent(e);\n if (question) {\n question.handleKeyPress(e);\n }\n },\n\n /**\n * Given an event, work out which question it affects.\n *\n * @param {Event} e the event.\n * @returns {DragDropToTextQuestion|undefined} The question, or undefined.\n */\n getQuestionForEvent: function(e) {\n var containerId = $(e.currentTarget).closest('.que.ddwtos').attr('id');\n return questionManager.questions[containerId];\n },\n\n /**\n * Handle when drag moved.\n *\n * @param {Event} e the event.\n * @param {jQuery} drag the drag\n * @param {jQuery} target the target\n * @param {DragDropToTextQuestion} thisQ the question.\n */\n handleDragMoved: function(e, drag, target, thisQ) {\n drag.removeClass('beingdragged');\n drag.css('top', '').css('left', '');\n target.after(drag);\n target.removeClass('active');\n if (typeof drag.data('unplaced') !== 'undefined' && drag.data('unplaced') === true) {\n drag.removeClass('placed').addClass('unplaced');\n drag.removeAttr('tabindex');\n drag.removeData('unplaced');\n if (drag.hasClass('infinite') && thisQ.getInfiniteDragClones(drag, true).length > 1) {\n thisQ.getInfiniteDragClones(drag, true).first().remove();\n }\n }\n if (typeof drag.data('isfocus') !== 'undefined' && drag.data('isfocus') === true) {\n drag.focus();\n drag.removeData('isfocus');\n }\n if (typeof target.data('isfocus') !== 'undefined' && target.data('isfocus') === true) {\n target.removeData('isfocus');\n }\n if (questionManager.isKeyboardNavigation) {\n questionManager.isKeyboardNavigation = false;\n }\n if (thisQ.isQuestionInteracted()) {\n // The user has interacted with the draggable items. We need to mark the form as dirty.\n questionManager.handleFormDirty();\n // Save the new answered value.\n thisQ.questionAnswer = thisQ.getQuestionAnsweredValues();\n }\n },\n\n /**\n * Handle when the form is dirty.\n */\n handleFormDirty: function() {\n const responseForm = document.getElementById('responseform');\n FormChangeChecker.markFormAsDirty(responseForm);\n }\n };\n\n /**\n * @alias module:qtype_ddwtos/ddwtos\n */\n return {\n /**\n * Initialise one drag-drop into text question.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n */\n init: questionManager.init\n };\n});\n"],"names":["define","$","dragDrop","keys","FormChangeChecker","filterEvent","DragDropToTextQuestion","containerId","readOnly","thisQ","this","questionAnswer","questionDragDropWidthHeight","getRoot","addClass","resizeAllDragsAndDrops","cloneDrags","positionDrags","document","addEventListener","eventTypes","filterContentRenderingComplete","elements","detail","nodes","forEach","element","changeAllDragsAndDropsToFilteredContent","prototype","find","each","i","node","resizeAllDragsAndDropsInGroup","getClassnameNumericSuffix","group","dragDropItems","maxWidth","maxHeight","drag","Math","max","ceil","offsetWidth","offsetHeight","setElementSize","filteredElement","currentFilteredItem","parentIsDD","parent","closest","hasClass","isDD","length","getGroup","choice","getChoice","listOfModifiedDragDrop","get","originalClass","attr","originalStyle","filteredDragDropClone","clone","before","push","remove","currentHeight","height","currentWidth","width","classList","add","css","index","draghome","placeHolder","removeClass","root","not","dragNode","currentPlace","removeAttr","inputNode","input","val","place","getPlace","drop","dropPosition","offset","data","top","left","unplacedDrag","getUnplacedChoice","hiddenDrag","getDragClone","noOfDrags","noOfDropsInGroup","getInfiniteDragClones","cloneDrag","after","questionManager","addEventHandlersToDrag","sendDragToDrop","getQuestionAnsweredValues","result","id","value","isQuestionInteracted","oldAnswer","newAnswer","isInteracted","JSON","stringify","Object","key","handleDragStart","e","target","prepare","start","setInputValue","hiddenDrop","getDrop","x","y","dragMove","dragEnd","pageX","pageY","dropNode","isPointInDrop","placed","dropZone","sendDragHome","oldDrag","getCurrentDragInPlace","hasDropSameDrag","focus","animateTo","getDragHome","handleKeyPress","placedDrag","currentDrag","nextDrag","keyCode","space","arrowRight","arrowDown","getNextDrag","arrowLeft","arrowUp","getPreviousDrag","escape","isKeyboardNavigation","preventDefault","numChoices","noOfChoicesInGroup","next","previous","currentPos","targetPos","M","util","js_pending","animate","parseInt","duration","done","trigger","js_complete","position","getElementById","is","slice","prefix","classes","undefined","classesArr","split","RegExp","test","match","exec","Number","inHome","eventHandlersInitialised","dragEventHandlersInitialised","questions","init","setupEventHandlers","hasOwnProperty","questionContainer","contains","on","handleDragMoved","unbind","question","getQuestionForEvent","currentTarget","removeData","first","handleFormDirty","responseForm","markFormAsDirty"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"ddwtos.min.js","sources":["../src/ddwtos.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 * JavaScript to make drag-drop into text questions work.\n *\n * Some vocabulary to help understand this code:\n *\n * The question text contains 'drops' - blanks into which the 'drags', the missing\n * words, can be put.\n *\n * The thing that can be moved into the drops are called 'drags'. There may be\n * multiple copies of the 'same' drag which does not really cause problems.\n * Each drag has a 'choice' number which is the value set on the drop's hidden\n * input when this drag is placed in a drop.\n *\n * These may be in separate 'groups', distinguished by colour.\n * Things can only interact with other things in the same group.\n * The groups are numbered from 1.\n *\n * The place where a given drag started from is called its 'home'.\n *\n * @module qtype_ddwtos/ddwtos\n * @copyright 2018 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.6\n */\ndefine([\n 'jquery',\n 'core/dragdrop',\n 'core/key_codes',\n 'core_form/changechecker',\n 'core_filters/events',\n], function(\n $,\n dragDrop,\n keys,\n FormChangeChecker,\n filterEvent\n) {\n\n \"use strict\";\n\n /**\n * Object to handle one drag-drop into text question.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n * @constructor\n */\n function DragDropToTextQuestion(containerId, readOnly) {\n const thisQ = this;\n this.containerId = containerId;\n this.questionAnswer = {};\n this.questionDragDropWidthHeight = [];\n if (readOnly) {\n this.getRoot().addClass('qtype_ddwtos-readonly');\n }\n this.resizeAllDragsAndDrops();\n this.cloneDrags();\n this.positionDrags();\n // Wait for all dynamic content loaded by filter to be completed.\n document.addEventListener(filterEvent.eventTypes.filterContentRenderingComplete, (elements) => {\n elements.detail.nodes.forEach((element) => {\n thisQ.changeAllDragsAndDropsToFilteredContent(element);\n });\n });\n }\n\n /**\n * In each group, resize all the items to be the same size.\n */\n DragDropToTextQuestion.prototype.resizeAllDragsAndDrops = function() {\n var thisQ = this;\n this.getRoot().find('.answercontainer > div').each(function(i, node) {\n thisQ.resizeAllDragsAndDropsInGroup(\n thisQ.getClassnameNumericSuffix($(node), 'draggrouphomes'));\n });\n };\n\n /**\n * In a given group, set all the drags and drops to be the same size.\n *\n * @param {int} group the group number.\n */\n DragDropToTextQuestion.prototype.resizeAllDragsAndDropsInGroup = function(group) {\n var thisQ = this,\n dragDropItems = this.getRoot().find('span.group' + group),\n maxWidth = 0,\n maxHeight = 0;\n\n // Reset all items to natural sizing and find max width.\n dragDropItems.each(function(i, drag) {\n $(drag).css({'width': '', 'height': '', 'lineHeight': ''});\n });\n\n dragDropItems.each(function(i, drag) {\n maxWidth = Math.max(maxWidth, Math.ceil(drag.offsetWidth));\n });\n\n // The width we will want to set is a bit bigger than this.\n maxWidth += 8;\n\n // Set width, then measure wrapped heights.\n dragDropItems.each(function(i, drag) {\n $(drag).width(maxWidth);\n maxHeight = Math.max(maxHeight, drag.offsetHeight);\n });\n maxHeight += 2;\n\n thisQ.questionDragDropWidthHeight[group] = {maxWidth: maxWidth, maxHeight: maxHeight};\n // Set each drag and drop to the final size.\n dragDropItems.each(function(i, drag) {\n thisQ.setElementSize(drag, maxWidth, maxHeight);\n });\n };\n\n /**\n * Change all the drags and drops related to the item that has been changed by filter to correct size and content.\n *\n * @param {object} filteredElement the element has been modified by filter.\n */\n DragDropToTextQuestion.prototype.changeAllDragsAndDropsToFilteredContent = function(filteredElement) {\n let currentFilteredItem = $(filteredElement);\n const parentIsDD = currentFilteredItem.parent().closest('span').hasClass('placed') ||\n currentFilteredItem.parent().closest('span').hasClass('draghome');\n const isDD = currentFilteredItem.hasClass('placed') || currentFilteredItem.hasClass('draghome');\n // The filtered element or parent element should a drag or drop item.\n if (!parentIsDD && !isDD) {\n return;\n }\n if (parentIsDD) {\n currentFilteredItem = currentFilteredItem.parent().closest('span');\n }\n const thisQ = this;\n if (thisQ.getRoot().find(currentFilteredItem).length <= 0) {\n // If the DD item doesn't belong to this question\n // In case we have multiple questions in the same page.\n return;\n }\n const group = thisQ.getGroup(currentFilteredItem),\n choice = thisQ.getChoice(currentFilteredItem);\n let listOfModifiedDragDrop = [];\n // Get the list of drag and drop item within the same group and choice.\n this.getRoot().find('.group' + group + '.choice' + choice).each(function(i, node) {\n // Same modified item, skip it.\n if ($(node).get(0) === currentFilteredItem.get(0)) {\n return;\n }\n const originalClass = $(node).attr('class');\n const originalStyle = $(node).attr('style');\n // We want to keep all the handler and event for filtered item, so using clone is the only choice.\n const filteredDragDropClone = currentFilteredItem.clone();\n // Replace the class and style of the drag drop item we want to replace for the clone.\n filteredDragDropClone.attr('class', originalClass);\n filteredDragDropClone.attr('style', originalStyle);\n // Insert into DOM.\n $(node).before(filteredDragDropClone);\n // Add the item has been replaced to a list so we can remove it later.\n listOfModifiedDragDrop.push(node);\n });\n\n listOfModifiedDragDrop.forEach(function(node) {\n $(node).remove();\n });\n // Save the current height and width.\n const currentHeight = currentFilteredItem.height();\n const currentWidth = currentFilteredItem.width();\n // Set to auto so we can get the real height and width of the filtered item.\n currentFilteredItem.height('auto');\n currentFilteredItem.width('auto');\n // We need to set display block so we can get height and width.\n // Some browser can't get the offsetWidth/Height if they are an inline element like span tag.\n if (!filteredElement.offsetWidth || !filteredElement.offsetHeight) {\n filteredElement.classList.add('d-block');\n }\n if (thisQ.questionDragDropWidthHeight[group].maxWidth < Math.ceil(filteredElement.offsetWidth) ||\n thisQ.questionDragDropWidthHeight[group].maxHeight < Math.ceil(0 + filteredElement.offsetHeight)) {\n // Remove the d-block class before calculation.\n filteredElement.classList.remove('d-block');\n // Now resize all the items in the same group if we have new maximum width or height.\n thisQ.resizeAllDragsAndDropsInGroup(group);\n } else {\n // Return the original height and width in case the real height and width is not the maximum.\n currentFilteredItem.height(currentHeight);\n currentFilteredItem.width(currentWidth);\n }\n // Remove the d-block class after resize.\n filteredElement.classList.remove('d-block');\n };\n\n /**\n * Set a given DOM element to be a particular size.\n *\n * @param {HTMLElement} element\n * @param {int} width\n * @param {int} height\n */\n DragDropToTextQuestion.prototype.setElementSize = function(element, width, height) {\n $(element).width(width).height(height);\n };\n\n /**\n * Invisible 'drag homes' are output by the renderer. These have the same properties\n * as the drag items but are invisible. We clone these invisible elements to make the\n * actual drag items.\n */\n DragDropToTextQuestion.prototype.cloneDrags = function() {\n var thisQ = this;\n thisQ.getRoot().find('span.draghome').each(function(index, draghome) {\n var drag = $(draghome);\n var placeHolder = drag.clone();\n placeHolder.removeClass();\n placeHolder.addClass('draghome choice' +\n thisQ.getChoice(drag) + ' group' +\n thisQ.getGroup(drag) + ' dragplaceholder');\n drag.before(placeHolder);\n });\n };\n\n /**\n * Update the position of drags.\n */\n DragDropToTextQuestion.prototype.positionDrags = function() {\n var thisQ = this,\n root = this.getRoot();\n\n // First move all items back home.\n root.find('span.draghome').not('.dragplaceholder').each(function(i, dragNode) {\n var drag = $(dragNode),\n currentPlace = thisQ.getClassnameNumericSuffix(drag, 'inplace');\n drag.addClass('unplaced')\n .removeClass('placed');\n drag.removeAttr('tabindex');\n if (currentPlace !== null) {\n drag.removeClass('inplace' + currentPlace);\n }\n });\n\n // Then place the once that should be placed.\n root.find('input.placeinput').each(function(i, inputNode) {\n var input = $(inputNode),\n choice = input.val(),\n place = thisQ.getPlace(input);\n\n // Record the last known position of the drop.\n var drop = root.find('.drop.place' + place),\n dropPosition = drop.offset();\n drop.data('prev-top', dropPosition.top).data('prev-left', dropPosition.left);\n\n if (choice === '0') {\n // No item in this place.\n return;\n }\n\n // Get the unplaced drag.\n var unplacedDrag = thisQ.getUnplacedChoice(thisQ.getGroup(input), choice);\n // Get the clone of the drag.\n var hiddenDrag = thisQ.getDragClone(unplacedDrag);\n if (hiddenDrag.length) {\n if (unplacedDrag.hasClass('infinite')) {\n var noOfDrags = thisQ.noOfDropsInGroup(thisQ.getGroup(unplacedDrag));\n var cloneDrags = thisQ.getInfiniteDragClones(unplacedDrag, false);\n if (cloneDrags.length < noOfDrags) {\n var cloneDrag = unplacedDrag.clone();\n hiddenDrag.after(cloneDrag);\n questionManager.addEventHandlersToDrag(cloneDrag);\n } else {\n hiddenDrag.addClass('active');\n }\n } else {\n hiddenDrag.addClass('active');\n }\n }\n // Send the drag to drop.\n thisQ.sendDragToDrop(thisQ.getUnplacedChoice(thisQ.getGroup(input), choice), drop);\n });\n\n // Save the question answer.\n thisQ.questionAnswer = thisQ.getQuestionAnsweredValues();\n };\n\n /**\n * Get the question answered values.\n *\n * @return {Object} Contain key-value with key is the input id and value is the input value.\n */\n DragDropToTextQuestion.prototype.getQuestionAnsweredValues = function() {\n let result = {};\n this.getRoot().find('input.placeinput').each((i, inputNode) => {\n result[inputNode.id] = inputNode.value;\n });\n\n return result;\n };\n\n /**\n * Check if the question is being interacted or not.\n *\n * @return {boolean} Return true if the user has changed the question-answer.\n */\n DragDropToTextQuestion.prototype.isQuestionInteracted = function() {\n const oldAnswer = this.questionAnswer;\n const newAnswer = this.getQuestionAnsweredValues();\n let isInteracted = false;\n\n // First, check both answers have the same structure or not.\n if (JSON.stringify(newAnswer) !== JSON.stringify(oldAnswer)) {\n isInteracted = true;\n return isInteracted;\n }\n // Check the values.\n Object.keys(newAnswer).forEach(key => {\n if (newAnswer[key] !== oldAnswer[key]) {\n isInteracted = true;\n }\n });\n\n return isInteracted;\n };\n\n /**\n * Handles the start of dragging an item.\n *\n * @param {Event} e the touch start or mouse down event.\n */\n DragDropToTextQuestion.prototype.handleDragStart = function(e) {\n var thisQ = this,\n drag = $(e.target).closest('.draghome');\n\n var info = dragDrop.prepare(e);\n if (!info.start || drag.hasClass('beingdragged')) {\n return;\n }\n\n drag.addClass('beingdragged');\n var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace');\n if (currentPlace !== null) {\n this.setInputValue(currentPlace, 0);\n drag.removeClass('inplace' + currentPlace);\n var hiddenDrop = thisQ.getDrop(drag, currentPlace);\n if (hiddenDrop.length) {\n hiddenDrop.addClass('active');\n drag.offset(hiddenDrop.offset());\n }\n } else {\n var hiddenDrag = thisQ.getDragClone(drag);\n if (hiddenDrag.length) {\n if (drag.hasClass('infinite')) {\n var noOfDrags = this.noOfDropsInGroup(this.getGroup(drag));\n var cloneDrags = this.getInfiniteDragClones(drag, false);\n if (cloneDrags.length < noOfDrags) {\n var cloneDrag = drag.clone();\n cloneDrag.removeClass('beingdragged');\n hiddenDrag.after(cloneDrag);\n questionManager.addEventHandlersToDrag(cloneDrag);\n drag.offset(cloneDrag.offset());\n } else {\n hiddenDrag.addClass('active');\n drag.offset(hiddenDrag.offset());\n }\n } else {\n hiddenDrag.addClass('active');\n drag.offset(hiddenDrag.offset());\n }\n }\n }\n\n dragDrop.start(e, drag, function(x, y, drag) {\n thisQ.dragMove(x, y, drag);\n }, function(x, y, drag) {\n thisQ.dragEnd(x, y, drag);\n });\n };\n\n /**\n * Called whenever the currently dragged items moves.\n *\n * @param {Number} pageX the x position.\n * @param {Number} pageY the y position.\n * @param {jQuery} drag the item being moved.\n */\n DragDropToTextQuestion.prototype.dragMove = function(pageX, pageY, drag) {\n var thisQ = this;\n this.getRoot().find('span.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, dropNode) {\n var drop = $(dropNode);\n if (thisQ.isPointInDrop(pageX, pageY, drop)) {\n drop.addClass('valid-drag-over-drop');\n } else {\n drop.removeClass('valid-drag-over-drop');\n }\n });\n };\n\n /**\n * Called when user drops a drag item.\n *\n * @param {Number} pageX the x position.\n * @param {Number} pageY the y position.\n * @param {jQuery} drag the item being moved.\n */\n DragDropToTextQuestion.prototype.dragEnd = function(pageX, pageY, drag) {\n var thisQ = this,\n root = this.getRoot(),\n placed = false;\n root.find('span.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, dropNode) {\n if (placed) {\n return false;\n }\n const dropZone = $(dropNode);\n if (!thisQ.isPointInDrop(pageX, pageY, dropZone)) {\n // Not this drop zone.\n return true;\n }\n let drop = null;\n if (dropZone.hasClass('placed')) {\n // This is an placed drag item in a drop.\n dropZone.removeClass('valid-drag-over-drop');\n // Get the correct drop.\n drop = thisQ.getDrop(drag, thisQ.getClassnameNumericSuffix(dropZone, 'inplace'));\n } else {\n // Empty drop.\n drop = dropZone;\n }\n // Now put this drag into the drop.\n drop.removeClass('valid-drag-over-drop');\n thisQ.sendDragToDrop(drag, drop);\n placed = true;\n return false; // Stop the each() here.\n });\n if (!placed) {\n this.sendDragHome(drag);\n }\n };\n\n /**\n * Animate a drag item into a given place (or back home).\n *\n * @param {jQuery|null} drag the item to place. If null, clear the place.\n * @param {jQuery} drop the place to put it.\n */\n DragDropToTextQuestion.prototype.sendDragToDrop = function(drag, drop) {\n // Send drag home if there is no place in drop.\n if (this.getPlace(drop) === null) {\n this.sendDragHome(drag);\n return;\n }\n\n // Is there already a drag in this drop? if so, evict it.\n var oldDrag = this.getCurrentDragInPlace(this.getPlace(drop));\n if (oldDrag.length !== 0) {\n var currentPlace = this.getClassnameNumericSuffix(oldDrag, 'inplace');\n // When infinite group and there is already a drag in a drop, reject the exact clone in the same drop.\n if (this.hasDropSameDrag(currentPlace, drop, oldDrag, drag)) {\n this.sendDragHome(drag);\n return;\n }\n var hiddenDrop = this.getDrop(oldDrag, currentPlace);\n hiddenDrop.addClass('active');\n oldDrag.addClass('beingdragged');\n oldDrag.offset(hiddenDrop.offset());\n this.sendDragHome(oldDrag);\n }\n\n if (drag.length === 0) {\n this.setInputValue(this.getPlace(drop), 0);\n if (drop.data('isfocus')) {\n drop.focus();\n }\n } else {\n // Prevent the drag item drop into two drop-zone.\n if (this.getClassnameNumericSuffix(drag, 'inplace')) {\n return;\n }\n\n this.setInputValue(this.getPlace(drop), this.getChoice(drag));\n drag.removeClass('unplaced')\n .addClass('placed inplace' + this.getPlace(drop));\n drag.attr('tabindex', 0);\n this.animateTo(drag, drop);\n }\n };\n\n /**\n * When infinite group and there is already a drag in a drop, reject the exact clone in the same drop.\n *\n * @param {int} currentPlace the position of the current drop.\n * @param {jQuery} drop the drop containing a drag.\n * @param {jQuery} oldDrag the drag already placed in drop.\n * @param {jQuery} drag the new drag which is exactly the same (clone) as oldDrag .\n * @returns {boolean}\n */\n DragDropToTextQuestion.prototype.hasDropSameDrag = function(currentPlace, drop, oldDrag, drag) {\n if (drag.hasClass('infinite')) {\n return drop.hasClass('place' + currentPlace) &&\n this.getGroup(drag) === this.getGroup(drop) &&\n this.getChoice(drag) === this.getChoice(oldDrag) &&\n this.getGroup(drag) === this.getGroup(oldDrag);\n }\n return false;\n };\n\n /**\n * Animate a drag back to its home.\n *\n * @param {jQuery} drag the item being moved.\n */\n DragDropToTextQuestion.prototype.sendDragHome = function(drag) {\n var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace');\n if (currentPlace !== null) {\n drag.removeClass('inplace' + currentPlace);\n }\n drag.data('unplaced', true);\n\n this.animateTo(drag, this.getDragHome(this.getGroup(drag), this.getChoice(drag)));\n };\n\n /**\n * Handles keyboard events on drops.\n *\n * Drops are focusable. Once focused, right/down/space switches to the next choice, and\n * left/up switches to the previous. Escape clear.\n *\n * @param {KeyboardEvent} e\n */\n DragDropToTextQuestion.prototype.handleKeyPress = function(e) {\n var drop = $(e.target).closest('.drop');\n if (drop.length === 0) {\n var placedDrag = $(e.target);\n var currentPlace = this.getClassnameNumericSuffix(placedDrag, 'inplace');\n if (currentPlace !== null) {\n drop = this.getDrop(placedDrag, currentPlace);\n }\n }\n var currentDrag = this.getCurrentDragInPlace(this.getPlace(drop)),\n nextDrag = $();\n\n switch (e.keyCode) {\n case keys.space:\n case keys.arrowRight:\n case keys.arrowDown:\n nextDrag = this.getNextDrag(this.getGroup(drop), currentDrag);\n break;\n\n case keys.arrowLeft:\n case keys.arrowUp:\n nextDrag = this.getPreviousDrag(this.getGroup(drop), currentDrag);\n break;\n\n case keys.escape:\n break;\n\n default:\n questionManager.isKeyboardNavigation = false;\n return; // To avoid the preventDefault below.\n }\n\n if (nextDrag.length) {\n nextDrag.data('isfocus', true);\n nextDrag.addClass('beingdragged');\n var hiddenDrag = this.getDragClone(nextDrag);\n if (hiddenDrag.length) {\n if (nextDrag.hasClass('infinite')) {\n var noOfDrags = this.noOfDropsInGroup(this.getGroup(nextDrag));\n var cloneDrags = this.getInfiniteDragClones(nextDrag, false);\n if (cloneDrags.length < noOfDrags) {\n var cloneDrag = nextDrag.clone();\n cloneDrag.removeClass('beingdragged');\n cloneDrag.removeAttr('tabindex');\n hiddenDrag.after(cloneDrag);\n questionManager.addEventHandlersToDrag(cloneDrag);\n nextDrag.offset(cloneDrag.offset());\n } else {\n hiddenDrag.addClass('active');\n nextDrag.offset(hiddenDrag.offset());\n }\n } else {\n hiddenDrag.addClass('active');\n nextDrag.offset(hiddenDrag.offset());\n }\n }\n } else {\n drop.data('isfocus', true);\n }\n\n e.preventDefault();\n this.sendDragToDrop(nextDrag, drop);\n };\n\n /**\n * Choose the next drag in a group.\n *\n * @param {int} group which group.\n * @param {jQuery} drag current choice (empty jQuery if there isn't one).\n * @return {jQuery} the next drag in that group, or null if there wasn't one.\n */\n DragDropToTextQuestion.prototype.getNextDrag = function(group, drag) {\n var choice,\n numChoices = this.noOfChoicesInGroup(group);\n\n if (drag.length === 0) {\n choice = 1; // Was empty, so we want to select the first choice.\n } else {\n choice = this.getChoice(drag) + 1;\n }\n\n var next = this.getUnplacedChoice(group, choice);\n while (next.length === 0 && choice < numChoices) {\n choice++;\n next = this.getUnplacedChoice(group, choice);\n }\n\n return next;\n };\n\n /**\n * Choose the previous drag in a group.\n *\n * @param {int} group which group.\n * @param {jQuery} drag current choice (empty jQuery if there isn't one).\n * @return {jQuery} the next drag in that group, or null if there wasn't one.\n */\n DragDropToTextQuestion.prototype.getPreviousDrag = function(group, drag) {\n var choice;\n\n if (drag.length === 0) {\n choice = this.noOfChoicesInGroup(group);\n } else {\n choice = this.getChoice(drag) - 1;\n }\n\n var previous = this.getUnplacedChoice(group, choice);\n while (previous.length === 0 && choice > 1) {\n choice--;\n previous = this.getUnplacedChoice(group, choice);\n }\n\n // Does this choice exist?\n return previous;\n };\n\n /**\n * Animate an object to the given destination.\n *\n * @param {jQuery} drag the element to be animated.\n * @param {jQuery} target element marking the place to move it to.\n */\n DragDropToTextQuestion.prototype.animateTo = function(drag, target) {\n var currentPos = drag.offset(),\n targetPos = target.offset(),\n thisQ = this;\n\n M.util.js_pending('qtype_ddwtos-animate-' + thisQ.containerId);\n // Animate works in terms of CSS position, whereas locating an object\n // on the page works best with jQuery offset() function. So, to get\n // the right target position, we work out the required change in\n // offset() and then add that to the current CSS position.\n drag.animate(\n {\n left: parseInt(drag.css('left')) + targetPos.left - currentPos.left,\n top: parseInt(drag.css('top')) + targetPos.top - currentPos.top\n },\n {\n duration: 'fast',\n done: function() {\n $('body').trigger('qtype_ddwtos-dragmoved', [drag, target, thisQ]);\n M.util.js_complete('qtype_ddwtos-animate-' + thisQ.containerId);\n }\n }\n );\n };\n\n /**\n * Detect if a point is inside a given DOM node.\n *\n * @param {Number} pageX the x position.\n * @param {Number} pageY the y position.\n * @param {jQuery} drop the node to check (typically a drop).\n * @return {boolean} whether the point is inside the node.\n */\n DragDropToTextQuestion.prototype.isPointInDrop = function(pageX, pageY, drop) {\n var position = drop.offset();\n return pageX >= position.left && pageX < position.left + drop.width()\n && pageY >= position.top && pageY < position.top + drop.height();\n };\n\n /**\n * Set the value of the hidden input for a place, to record what is currently there.\n *\n * @param {int} place which place to set the input value for.\n * @param {int} choice the value to set.\n */\n DragDropToTextQuestion.prototype.setInputValue = function(place, choice) {\n this.getRoot().find('input.placeinput.place' + place).val(choice);\n };\n\n /**\n * Get the outer div for this question.\n *\n * @returns {jQuery} containing that div.\n */\n DragDropToTextQuestion.prototype.getRoot = function() {\n return $(document.getElementById(this.containerId));\n };\n\n /**\n * Get drag home for a given choice.\n *\n * @param {int} group the group.\n * @param {int} choice the choice number.\n * @returns {jQuery} containing that div.\n */\n DragDropToTextQuestion.prototype.getDragHome = function(group, choice) {\n if (!this.getRoot().find('.draghome.dragplaceholder.group' + group + '.choice' + choice).is(':visible')) {\n return this.getRoot().find('.draggrouphomes' + group +\n ' span.draghome.infinite' +\n '.choice' + choice +\n '.group' + group);\n }\n return this.getRoot().find('.draghome.dragplaceholder.group' + group + '.choice' + choice);\n };\n\n /**\n * Get an unplaced choice for a particular group.\n *\n * @param {int} group the group.\n * @param {int} choice the choice number.\n * @returns {jQuery} jQuery wrapping the unplaced choice. If there isn't one, the jQuery will be empty.\n */\n DragDropToTextQuestion.prototype.getUnplacedChoice = function(group, choice) {\n return this.getRoot().find('.draghome.group' + group + '.choice' + choice + '.unplaced').slice(0, 1);\n };\n\n /**\n * Get the drag that is currently in a given place.\n *\n * @param {int} place the place number.\n * @return {jQuery} the current drag (or an empty jQuery if none).\n */\n DragDropToTextQuestion.prototype.getCurrentDragInPlace = function(place) {\n return this.getRoot().find('span.draghome.inplace' + place);\n };\n\n /**\n * Return the number of blanks in a given group.\n *\n * @param {int} group the group number.\n * @returns {int} the number of drops.\n */\n DragDropToTextQuestion.prototype.noOfDropsInGroup = function(group) {\n return this.getRoot().find('.drop.group' + group).length;\n };\n\n /**\n * Return the number of choices in a given group.\n *\n * @param {int} group the group number.\n * @returns {int} the number of choices.\n */\n DragDropToTextQuestion.prototype.noOfChoicesInGroup = function(group) {\n return this.getRoot().find('.draghome.group' + group).length;\n };\n\n /**\n * Return the number at the end of the CSS class name with the given prefix.\n *\n * @param {jQuery} node\n * @param {String} prefix name prefix\n * @returns {Number|null} the suffix if found, else null.\n */\n DragDropToTextQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) {\n var classes = node.attr('class');\n if (classes !== undefined && classes !== '') {\n var classesArr = classes.split(' ');\n for (var index = 0; index < classesArr.length; index++) {\n var patt1 = new RegExp('^' + prefix + '([0-9])+$');\n if (patt1.test(classesArr[index])) {\n var patt2 = new RegExp('([0-9])+$');\n var match = patt2.exec(classesArr[index]);\n return Number(match[0]);\n }\n }\n }\n return null;\n };\n\n /**\n * Get the choice number of a drag.\n *\n * @param {jQuery} drag the drag.\n * @returns {Number} the choice number.\n */\n DragDropToTextQuestion.prototype.getChoice = function(drag) {\n return this.getClassnameNumericSuffix(drag, 'choice');\n };\n\n /**\n * Given a DOM node that is significant to this question\n * (drag, drop, ...) get the group it belongs to.\n *\n * @param {jQuery} node a DOM node.\n * @returns {Number} the group it belongs to.\n */\n DragDropToTextQuestion.prototype.getGroup = function(node) {\n return this.getClassnameNumericSuffix(node, 'group');\n };\n\n /**\n * Get the place number of a drop, or its corresponding hidden input.\n *\n * @param {jQuery} node the DOM node.\n * @returns {Number} the place number.\n */\n DragDropToTextQuestion.prototype.getPlace = function(node) {\n return this.getClassnameNumericSuffix(node, 'place');\n };\n\n /**\n * Get drag clone for a given drag.\n *\n * @param {jQuery} drag the drag.\n * @returns {jQuery} the drag's clone.\n */\n DragDropToTextQuestion.prototype.getDragClone = function(drag) {\n return this.getRoot().find('.draggrouphomes' +\n this.getGroup(drag) +\n ' span.draghome' +\n '.choice' + this.getChoice(drag) +\n '.group' + this.getGroup(drag) +\n '.dragplaceholder');\n };\n\n /**\n * Get infinite drag clones for given drag.\n *\n * @param {jQuery} drag the drag.\n * @param {Boolean} inHome in the home area or not.\n * @returns {jQuery} the drag's clones.\n */\n DragDropToTextQuestion.prototype.getInfiniteDragClones = function(drag, inHome) {\n if (inHome) {\n return this.getRoot().find('.draggrouphomes' +\n this.getGroup(drag) +\n ' span.draghome' +\n '.choice' + this.getChoice(drag) +\n '.group' + this.getGroup(drag) +\n '.infinite').not('.dragplaceholder');\n }\n return this.getRoot().find('span.draghome' +\n '.choice' + this.getChoice(drag) +\n '.group' + this.getGroup(drag) +\n '.infinite').not('.dragplaceholder');\n };\n\n /**\n * Get drop for a given drag and place.\n *\n * @param {jQuery} drag the drag.\n * @param {Integer} currentPlace the current place of drag.\n * @returns {jQuery} the drop's clone.\n */\n DragDropToTextQuestion.prototype.getDrop = function(drag, currentPlace) {\n return this.getRoot().find('.drop.group' + this.getGroup(drag) + '.place' + currentPlace);\n };\n\n /**\n * Singleton that tracks all the DragDropToTextQuestions on this page, and deals\n * with event dispatching.\n *\n * @type {Object}\n */\n var questionManager = {\n /**\n * {boolean} used to ensure the event handlers are only initialised once per page.\n */\n eventHandlersInitialised: false,\n\n /**\n * {Object} ensures that the drag event handlers are only initialised once per question,\n * indexed by containerId (id on the .que div).\n */\n dragEventHandlersInitialised: {},\n\n /**\n * {boolean} is keyboard navigation or not.\n */\n isKeyboardNavigation: false,\n\n /**\n * {DragDropToTextQuestion[]} all the questions on this page, indexed by containerId (id on the .que div).\n */\n questions: {},\n\n /**\n * Initialise questions.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n */\n init: function(containerId, readOnly) {\n questionManager.questions[containerId] = new DragDropToTextQuestion(containerId, readOnly);\n if (!questionManager.eventHandlersInitialised) {\n questionManager.setupEventHandlers();\n questionManager.eventHandlersInitialised = true;\n }\n if (!questionManager.dragEventHandlersInitialised.hasOwnProperty(containerId)) {\n questionManager.dragEventHandlersInitialised[containerId] = true;\n // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.\n var questionContainer = document.getElementById(containerId);\n if (questionContainer.classList.contains('ddwtos') &&\n !questionContainer.classList.contains('qtype_ddwtos-readonly')) {\n // TODO: Convert all the jQuery selectors and events to native Javascript.\n questionManager.addEventHandlersToDrag($(questionContainer).find('span.draghome'));\n }\n }\n },\n\n /**\n * Set up the event handlers that make this question type work. (Done once per page.)\n */\n setupEventHandlers: function() {\n $('body')\n .on('keydown',\n '.que.ddwtos:not(.qtype_ddwtos-readonly) span.drop',\n questionManager.handleKeyPress)\n .on('keydown',\n '.que.ddwtos:not(.qtype_ddwtos-readonly) span.draghome.placed:not(.beingdragged)',\n questionManager.handleKeyPress)\n .on('qtype_ddwtos-dragmoved', questionManager.handleDragMoved);\n },\n\n /**\n * Binding the drag/touch event again for newly created element.\n *\n * @param {jQuery} element Element to bind the event\n */\n addEventHandlersToDrag: function(element) {\n // Unbind all the mousedown and touchstart events to prevent double binding.\n element.unbind('mousedown touchstart');\n element.on('mousedown touchstart', questionManager.handleDragStart);\n },\n\n /**\n * Handle mouse down / touch start on drags.\n * @param {Event} e the DOM event.\n */\n handleDragStart: function(e) {\n e.preventDefault();\n var question = questionManager.getQuestionForEvent(e);\n if (question) {\n question.handleDragStart(e);\n }\n },\n\n /**\n * Handle key down / press on drops.\n * @param {KeyboardEvent} e\n */\n handleKeyPress: function(e) {\n if (questionManager.isKeyboardNavigation) {\n return;\n }\n questionManager.isKeyboardNavigation = true;\n var question = questionManager.getQuestionForEvent(e);\n if (question) {\n question.handleKeyPress(e);\n }\n },\n\n /**\n * Given an event, work out which question it affects.\n *\n * @param {Event} e the event.\n * @returns {DragDropToTextQuestion|undefined} The question, or undefined.\n */\n getQuestionForEvent: function(e) {\n var containerId = $(e.currentTarget).closest('.que.ddwtos').attr('id');\n return questionManager.questions[containerId];\n },\n\n /**\n * Handle when drag moved.\n *\n * @param {Event} e the event.\n * @param {jQuery} drag the drag\n * @param {jQuery} target the target\n * @param {DragDropToTextQuestion} thisQ the question.\n */\n handleDragMoved: function(e, drag, target, thisQ) {\n drag.removeClass('beingdragged');\n drag.css('top', '').css('left', '');\n target.after(drag);\n target.removeClass('active');\n if (typeof drag.data('unplaced') !== 'undefined' && drag.data('unplaced') === true) {\n drag.removeClass('placed').addClass('unplaced');\n drag.removeAttr('tabindex');\n drag.removeData('unplaced');\n if (drag.hasClass('infinite') && thisQ.getInfiniteDragClones(drag, true).length > 1) {\n thisQ.getInfiniteDragClones(drag, true).first().remove();\n }\n }\n if (typeof drag.data('isfocus') !== 'undefined' && drag.data('isfocus') === true) {\n drag.focus();\n drag.removeData('isfocus');\n }\n if (typeof target.data('isfocus') !== 'undefined' && target.data('isfocus') === true) {\n target.removeData('isfocus');\n }\n if (questionManager.isKeyboardNavigation) {\n questionManager.isKeyboardNavigation = false;\n }\n if (thisQ.isQuestionInteracted()) {\n // The user has interacted with the draggable items. We need to mark the form as dirty.\n questionManager.handleFormDirty();\n // Save the new answered value.\n thisQ.questionAnswer = thisQ.getQuestionAnsweredValues();\n }\n },\n\n /**\n * Handle when the form is dirty.\n */\n handleFormDirty: function() {\n const responseForm = document.getElementById('responseform');\n FormChangeChecker.markFormAsDirty(responseForm);\n }\n };\n\n /**\n * @alias module:qtype_ddwtos/ddwtos\n */\n return {\n /**\n * Initialise one drag-drop into text question.\n *\n * @param {String} containerId id of the outer div for this question.\n * @param {boolean} readOnly whether the question is being displayed read-only.\n */\n init: questionManager.init\n };\n});\n"],"names":["define","$","dragDrop","keys","FormChangeChecker","filterEvent","DragDropToTextQuestion","containerId","readOnly","thisQ","this","questionAnswer","questionDragDropWidthHeight","getRoot","addClass","resizeAllDragsAndDrops","cloneDrags","positionDrags","document","addEventListener","eventTypes","filterContentRenderingComplete","elements","detail","nodes","forEach","element","changeAllDragsAndDropsToFilteredContent","prototype","find","each","i","node","resizeAllDragsAndDropsInGroup","getClassnameNumericSuffix","group","dragDropItems","maxWidth","maxHeight","drag","css","Math","max","ceil","offsetWidth","width","offsetHeight","setElementSize","filteredElement","currentFilteredItem","parentIsDD","parent","closest","hasClass","isDD","length","getGroup","choice","getChoice","listOfModifiedDragDrop","get","originalClass","attr","originalStyle","filteredDragDropClone","clone","before","push","remove","currentHeight","height","currentWidth","classList","add","index","draghome","placeHolder","removeClass","root","not","dragNode","currentPlace","removeAttr","inputNode","input","val","place","getPlace","drop","dropPosition","offset","data","top","left","unplacedDrag","getUnplacedChoice","hiddenDrag","getDragClone","noOfDrags","noOfDropsInGroup","getInfiniteDragClones","cloneDrag","after","questionManager","addEventHandlersToDrag","sendDragToDrop","getQuestionAnsweredValues","result","id","value","isQuestionInteracted","oldAnswer","newAnswer","isInteracted","JSON","stringify","Object","key","handleDragStart","e","target","prepare","start","setInputValue","hiddenDrop","getDrop","x","y","dragMove","dragEnd","pageX","pageY","dropNode","isPointInDrop","placed","dropZone","sendDragHome","oldDrag","getCurrentDragInPlace","hasDropSameDrag","focus","animateTo","getDragHome","handleKeyPress","placedDrag","currentDrag","nextDrag","keyCode","space","arrowRight","arrowDown","getNextDrag","arrowLeft","arrowUp","getPreviousDrag","escape","isKeyboardNavigation","preventDefault","numChoices","noOfChoicesInGroup","next","previous","currentPos","targetPos","M","util","js_pending","animate","parseInt","duration","done","trigger","js_complete","position","getElementById","is","slice","prefix","classes","undefined","classesArr","split","RegExp","test","match","exec","Number","inHome","eventHandlersInitialised","dragEventHandlersInitialised","questions","init","setupEventHandlers","hasOwnProperty","questionContainer","contains","on","handleDragMoved","unbind","question","getQuestionForEvent","currentTarget","removeData","first","handleFormDirty","responseForm","markFormAsDirty"],"mappings":"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"}
\ No newline at end of file
diff --git a/public/question/type/ddwtos/amd/src/ddwtos.js b/public/question/type/ddwtos/amd/src/ddwtos.js
index 941e992b9bd8d..3fd6bad9c2544 100644
--- a/public/question/type/ddwtos/amd/src/ddwtos.js
+++ b/public/question/type/ddwtos/amd/src/ddwtos.js
@@ -101,17 +101,27 @@ define([
maxWidth = 0,
maxHeight = 0;
- // Find the maximum size of any drag in this groups.
+ // Reset all items to natural sizing and find max width.
+ dragDropItems.each(function(i, drag) {
+ $(drag).css({'width': '', 'height': '', 'lineHeight': ''});
+ });
+
dragDropItems.each(function(i, drag) {
maxWidth = Math.max(maxWidth, Math.ceil(drag.offsetWidth));
- maxHeight = Math.max(maxHeight, Math.ceil(0 + drag.offsetHeight));
});
- // The size we will want to set is a bit bigger than this.
+ // The width we will want to set is a bit bigger than this.
maxWidth += 8;
+
+ // Set width, then measure wrapped heights.
+ dragDropItems.each(function(i, drag) {
+ $(drag).width(maxWidth);
+ maxHeight = Math.max(maxHeight, drag.offsetHeight);
+ });
maxHeight += 2;
+
thisQ.questionDragDropWidthHeight[group] = {maxWidth: maxWidth, maxHeight: maxHeight};
- // Set each drag home to that size.
+ // Set each drag and drop to the final size.
dragDropItems.each(function(i, drag) {
thisQ.setElementSize(drag, maxWidth, maxHeight);
});
@@ -199,7 +209,7 @@ define([
* @param {int} height
*/
DragDropToTextQuestion.prototype.setElementSize = function(element, width, height) {
- $(element).width(width).height(height).css('lineHeight', height + 'px');
+ $(element).width(width).height(height);
};
/**
diff --git a/public/question/type/ddwtos/styles.css b/public/question/type/ddwtos/styles.css
index 79ed926bb8a1a..8da83663aeffb 100644
--- a/public/question/type/ddwtos/styles.css
+++ b/public/question/type/ddwtos/styles.css
@@ -5,6 +5,16 @@
.que.ddwtos .draghome {
margin-bottom: 1em;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ background: transparent;
+ border: 1px solid #000;
+ cursor: move;
+ overflow-wrap: break-word;
+ white-space: normal;
+ line-height: normal;
}
.que.ddwtos .answertext {
@@ -12,10 +22,15 @@
}
.que.ddwtos .drop.active {
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
text-align: center;
border: 1px solid #000;
margin-bottom: 2px;
+ overflow-wrap: break-word;
+ white-space: normal;
+ line-height: normal;
}
.que.ddwtos .drop {
@@ -26,14 +41,6 @@
height: 0;
}
-.que.ddwtos .draghome {
- display: inline-block;
- text-align: center;
- background: transparent;
- border: 1px solid #000;
- cursor: move;
-}
-
.que.ddwtos.qtype_ddwtos-readonly .draghome {
cursor: default;
}
@@ -50,7 +57,7 @@
.que.ddwtos .draghome.dragplaceholder.active {
visibility: hidden;
- display: inline-block;
+ display: inline-flex;
}
.que.ddwtos .draghome.placed {
diff --git a/public/question/type/multianswer/classes/task/cleanup_duplicate_subquestions.php b/public/question/type/multianswer/classes/task/cleanup_duplicate_subquestions.php
index 3a8a54754f090..6c6f6d9927597 100644
--- a/public/question/type/multianswer/classes/task/cleanup_duplicate_subquestions.php
+++ b/public/question/type/multianswer/classes/task/cleanup_duplicate_subquestions.php
@@ -83,10 +83,24 @@ public function execute() {
$this->start_stored_progress();
$progress = $this->get_progress();
foreach ($duplicatedsubquestions as $subquestion) {
+ $where = "parent = :parent AND stamp = :stamp";
+ $params = ['parent' => $subquestion->parent, 'stamp' => $subquestion->stamp];
// Find instances of the subquestion that do not appear in the sequence of the parent.
- [$insql, $inparams] = $DB->get_in_or_equal(explode(',', $subquestion->sequence), equal: false);
- $params = array_merge([$subquestion->parent, $subquestion->stamp], $inparams);
- $duplicates = $DB->get_records_select('question', "parent = ? AND stamp = ? AND id {$insql}", $params);
+ // Make sure to filter out empty elements in the sequence.
+ // If the parent's sequence ends up empty for whatever reason, there are no "original" subquestions anymore and
+ // all that match the stamp and parent are fair game for deletion.
+ $sequence = array_filter(explode(',', $subquestion->sequence));
+ if (!empty($sequence)) {
+ [$insql, $inparams] = $DB->get_in_or_equal(
+ items: $sequence,
+ type: SQL_PARAMS_NAMED,
+ prefix: 'id',
+ equal: false,
+ );
+ $where .= " AND id $insql";
+ $params = array_merge($params, $inparams);
+ }
+ $duplicates = $DB->get_records_select('question', $where, $params);
$duplicatecount = count($duplicates);
// Delete each duplicate, with a progress bar.
mtrace("");
diff --git a/public/question/type/multianswer/db/upgrade.php b/public/question/type/multianswer/db/upgrade.php
index 9b0d0ad6d534c..eaf69e6087102 100644
--- a/public/question/type/multianswer/db/upgrade.php
+++ b/public/question/type/multianswer/db/upgrade.php
@@ -55,5 +55,16 @@ function xmldb_qtype_multianswer_upgrade($oldversion) {
// Automatically generated Moodle v5.1.0 release upgrade line.
// Put any upgrade step following this.
+ if ($oldversion < 2025100601) {
+ // The cleanup task may have failed before due to MDL-86281.
+ $task = new \qtype_multianswer\task\cleanup_duplicate_subquestions();
+ $queuedtask = \core\task\manager::get_queued_adhoc_task_record($task);
+ if ($queuedtask && $queuedtask->faildelay > 0 && $queuedtask->attemptsavailable === 0) {
+ mtrace('Subquestion cleanup task failed before. Re-queueing.');
+ \core\task\manager::queue_adhoc_task($task);
+ }
+ upgrade_plugin_savepoint(true, 2025100601, 'qtype', 'multianswer');
+ }
+
return true;
}
diff --git a/public/question/type/multianswer/edit_multianswer_form.php b/public/question/type/multianswer/edit_multianswer_form.php
index a192637c8d538..1580c24554d9c 100644
--- a/public/question/type/multianswer/edit_multianswer_form.php
+++ b/public/question/type/multianswer/edit_multianswer_form.php
@@ -390,7 +390,7 @@ public function set_data($question) {
foreach ($subquestion->answer as $key => $answer) {
if ($subquestion->qtype == 'numerical' && $key == 0) {
$defaultvalues[$prefix.'tolerance['.$key.']'] =
- $subquestion->tolerance[0];
+ $subquestion->tolerance[0] ?? 0;
}
if (is_array($answer)) {
$answer = $answer['text'];
diff --git a/public/question/type/multianswer/renderer.php b/public/question/type/multianswer/renderer.php
index fe21358574c60..ef9251149eb51 100644
--- a/public/question/type/multianswer/renderer.php
+++ b/public/question/type/multianswer/renderer.php
@@ -385,7 +385,7 @@ public function subquestion(question_attempt $qa, question_display_options $opti
$feedbackimg = '';
if ($options->correctness) {
- $inputattributes['class'] = $this->feedback_class($matchinganswer->fraction);
+ $inputattributes['class'] .= $this->feedback_class($matchinganswer->fraction);
$feedbackimg = $this->feedback_image($matchinganswer->fraction);
}
$select = html_writer::select($choices, $qa->get_qt_field_name($fieldname),
diff --git a/public/question/type/multianswer/tests/question_type_test.php b/public/question/type/multianswer/tests/question_type_test.php
index b184fc1e946d3..4fe795d7c09a3 100644
--- a/public/question/type/multianswer/tests/question_type_test.php
+++ b/public/question/type/multianswer/tests/question_type_test.php
@@ -493,4 +493,63 @@ public function test_save_question_options(): void {
$this->assertTrue($DB->record_exists('question', ['id' => $originalsubq2->id]));
$this->assertTrue($DB->record_exists('qtype_multichoice_options', ['questionid' => $originalsubq2->id]));
}
+
+ /**
+ * Test saving numerical cloze questions with various syntaxes do not throw errors.
+ */
+ public function test_numerical_cloze_questions_save_cleanly(): void {
+ $this->resetAfterTest(true);
+ $this->setAdminUser();
+
+ /** @var \core_question_generator $generator */
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $cat = $generator->create_question_category([]);
+
+ // Questions with cloze syntax mixed in.
+ $testcases = [
+ 'Valid numerical with wildcard feedback' =>
+ 'What is 2+2? {1:NUMERICAL:=4:0~*#Try again}',
+ 'Nonsensical but accepted syntax' =>
+ 'What is 2+2? {1:NUMERICAL:*#Nonsense~=4:0#Correct}',
+ ];
+
+ foreach ($testcases as $label => $questiontext) {
+ $fromform = (object)[
+ 'category' => $cat->id . ',' . $cat->contextid,
+ 'name' => 'Test cloze question',
+ 'questiontext' => [
+ 'text' => $questiontext,
+ 'format' => FORMAT_HTML,
+ ],
+ 'defaultmark' => 1,
+ ];
+ $question = new stdClass();
+ $question->qtype = 'multianswer';
+
+ // Capture errors during save (warnings and deprecations only).
+ $capturedwarnings = [];
+ set_error_handler(function($errno, $errstr) use (&$capturedwarnings) {
+ if ($errno === E_WARNING || $errno === E_DEPRECATED) {
+ $capturedwarnings[] = $errstr;
+ }
+ return true;
+ }, E_WARNING | E_DEPRECATED);
+
+ try {
+ $savedquestion = $this->qtype->save_question($question, $fromform);
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertEmpty(
+ $capturedwarnings,
+ "PHP error triggered for case '{$label}'"
+ );
+
+ $this->assertNotEmpty(
+ $savedquestion->id,
+ "Question not saved for case '{$label}'"
+ );
+ }
+ }
}
diff --git a/public/question/type/multianswer/tests/task/cleanup_duplicate_subquestions_test.php b/public/question/type/multianswer/tests/task/cleanup_duplicate_subquestions_test.php
index f04df39f4d9db..872f91586243e 100644
--- a/public/question/type/multianswer/tests/task/cleanup_duplicate_subquestions_test.php
+++ b/public/question/type/multianswer/tests/task/cleanup_duplicate_subquestions_test.php
@@ -16,6 +16,9 @@
namespace qtype_multianswer\task;
+use dml_exception;
+use PHPUnit\Framework\Attributes\DataProvider;
+
/**
* Unit tests for cleanup_duplicate_subquestions
*
@@ -31,7 +34,7 @@ final class cleanup_duplicate_subquestions_test extends \advanced_testcase {
* Create a multianswer question and duplicate its subquestions.
*
* @return array
- * @throws \dml_exception
+ * @throws dml_exception
*/
protected function generate_duplicated_subquestions(): array {
global $DB;
@@ -307,4 +310,83 @@ public function test_execute_duplicate_stamp(): void {
);
}
}
+
+ /**
+ * Handle the case where the `sequence` field is "corrupted" for whatever reason and contains no actual IDs.
+ *
+ * When that happens, no subquestions are referenced by the parent, so all instances ("originals" and duplicates) found by
+ * {@see cleanup_duplicate_subquestions::find_duplicated_subquestions} are considered obsolete and should be deleted.
+ *
+ * @link https://moodle.atlassian.net/browse/MDL-86281 MDL-86281
+ *
+ * @param string $sequence Corrupted `sequence` field value to set on the parent question.
+ * @throws dml_exception
+ */
+ #[DataProvider('provider_test_execute_with_empty_sequence_elements')]
+ public function test_execute_with_empty_sequence_elements(string $sequence): void {
+ global $DB;
+ $this->resetAfterTest();
+ $task = new cleanup_duplicate_subquestions();
+ // Create duplicated subquestions, then "corrupt" the parent's `sequence` to the provided string.
+ $subquestions = $this->generate_duplicated_subquestions();
+ $firstsubquestion = reset($subquestions);
+ $DB->set_field('question_multianswer', 'sequence', $sequence, ['question' => $firstsubquestion->parent]);
+ $this->expectOutputRegex('~Found 2 subquestions with duplicates~');
+ $task->execute();
+ // Ensure all those subquestions have been deleted.
+ foreach ($subquestions as $subq) {
+ $this->assertTrue($DB->record_exists('question', ['id' => $subq->parent]));
+ $this->assertFalse($DB->record_exists('question', ['id' => $subq->id]));
+ $this->assertFalse($DB->record_exists('question', ['id' => $subq->duplicate->question->id]));
+ $this->assertFalse($DB->record_exists('question_versions', ['id' => $subq->duplicate->version->id]));
+ $this->assertFalse($DB->record_exists('question_bank_entries', ['id' => $subq->duplicate->questionbankentry->id]));
+ }
+ }
+
+ /**
+ * Provides test data for the {@see test_execute_with_empty_sequence_elements} method.
+ *
+ * @return array[] Arguments for the test method.
+ */
+ public static function provider_test_execute_with_empty_sequence_elements(): array {
+ return [
+ 'Empty string' => ['sequence' => ''],
+ 'Single comma' => ['sequence' => ','],
+ 'Multiple consecutive commas' => ['sequence' => ',,,'],
+ ];
+ }
+
+ /**
+ * Handle the case where the `sequence` field contains valid IDs mixed with empty elements.
+ *
+ * When the sequence contains some valid IDs among empty elements, only the subquestions not referenced
+ * in the sequence should be deleted. The ones whose IDs appear in the sequence should be kept.
+ *
+ * @link https://moodle.atlassian.net/browse/MDL-86281 MDL-86281
+ *
+ * @throws dml_exception
+ */
+ public function test_execute_with_partially_empty_sequence(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $task = new cleanup_duplicate_subquestions();
+ // Create duplicated subquestions.
+ $subquestions = $this->generate_duplicated_subquestions();
+ $firstsubquestion = reset($subquestions);
+ $secondsubquestion = next($subquestions);
+ // Build a corrupted sequence that references only the original subquestion IDs, but with empty elements mixed in.
+ $sequence = ',' . $firstsubquestion->id . ',,,' . $secondsubquestion->id . ',';
+ $DB->set_field('question_multianswer', 'sequence', $sequence, ['question' => $firstsubquestion->parent]);
+ $this->expectOutputRegex('~Found 2 subquestions with duplicates~');
+ $task->execute();
+ // The original subquestions are referenced in the sequence and should be kept.
+ foreach ($subquestions as $subq) {
+ $this->assertTrue($DB->record_exists('question', ['id' => $subq->parent]));
+ $this->assertTrue($DB->record_exists('question', ['id' => $subq->id]));
+ // The duplicates are not in the sequence and should be deleted.
+ $this->assertFalse($DB->record_exists('question', ['id' => $subq->duplicate->question->id]));
+ $this->assertFalse($DB->record_exists('question_versions', ['id' => $subq->duplicate->version->id]));
+ $this->assertFalse($DB->record_exists('question_bank_entries', ['id' => $subq->duplicate->questionbankentry->id]));
+ }
+ }
}
diff --git a/public/question/type/multianswer/version.php b/public/question/type/multianswer/version.php
index 35a4527417838..a70ad16c13a77 100644
--- a/public/question/type/multianswer/version.php
+++ b/public/question/type/multianswer/version.php
@@ -26,7 +26,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'qtype_multianswer';
-$plugin->version = 2025100600;
+$plugin->version = 2025100601;
$plugin->requires = 2025092600;
$plugin->dependencies = [
diff --git a/public/question/type/multichoice/renderer.php b/public/question/type/multichoice/renderer.php
index cccaf774db871..ff76a1ed3e796 100644
--- a/public/question/type/multichoice/renderer.php
+++ b/public/question/type/multichoice/renderer.php
@@ -68,6 +68,7 @@ public function formulation_and_controls(question_attempt $qa,
$question = $qa->get_question();
$response = $question->get_response($qa);
+ $questiontextid = $qa->get_qt_field_name('qtext');
$inputname = $qa->get_qt_field_name('answer');
$inputattributes = array(
@@ -144,10 +145,12 @@ public function formulation_and_controls(question_attempt $qa,
}
$result = '';
- $result .= html_writer::tag('div', $question->format_questiontext($qa),
- array('class' => 'qtext'));
+ $result .= html_writer::tag('div', $question->format_questiontext($qa), ['class' => 'qtext', 'id' => $questiontextid]);
- $result .= html_writer::start_tag('fieldset', array('class' => 'ablock no-overflow visual-scroll-x'));
+ $result .= html_writer::start_tag('fieldset', [
+ 'class' => 'ablock no-overflow visual-scroll-x',
+ 'aria-describedby' => $questiontextid,
+ ]);
if ($question->showstandardinstruction == 1) {
$legendclass = '';
$questionnumber = $options->add_question_identifier_to_label($this->prompt(), true, true);
diff --git a/public/question/type/multichoice/tests/walkthrough_test.php b/public/question/type/multichoice/tests/walkthrough_test.php
index c09e39dd3e393..d1bf3a32b1d66 100644
--- a/public/question/type/multichoice/tests/walkthrough_test.php
+++ b/public/question/type/multichoice/tests/walkthrough_test.php
@@ -94,6 +94,33 @@ public function test_deferredfeedback_feedback_multichoice_single(): void {
get_string('deletedchoice', 'qtype_multichoice'), $this->currentoutput);
}
+ public function test_multichoice_single_question_text_describes_answer_group(): void {
+ // Render a single-choice question, which uses radio inputs.
+ $mc = \test_question_maker::make_a_multichoice_single_question();
+ $this->start_attempt_at_question($mc, 'deferredfeedback', 1);
+ $this->render();
+
+ // Parse the rendered question HTML.
+ $questiontextid = $this->get_question_attempt()->get_qt_field_name('qtext');
+ $dom = new \DOMDocument();
+ $previousinternalerrors = libxml_use_internal_errors(true);
+ $dom->loadHTML($this->currentoutput);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previousinternalerrors);
+
+ // Verify that the expected ID identifies the question text element.
+ $questiontext = $dom->getElementById($questiontextid);
+ $this->assertNotNull($questiontext);
+ $this->assertSame('div', $questiontext->nodeName);
+ $this->assertSame('qtext', $questiontext->getAttribute('class'));
+
+ // Verify that the radio button group is described by that question text.
+ $fieldsets = $dom->getElementsByTagName('fieldset');
+ $this->assertSame(1, $fieldsets->length);
+ $answergroup = $fieldsets->item(0);
+ $this->assertSame($questiontextid, $answergroup->getAttribute('aria-describedby'));
+ }
+
public function test_deferredfeedback_feedback_multichoice_single_showstandardunstruction_yes(): void {
// Create a multichoice, single question.
diff --git a/public/question/type/numerical/questiontype.php b/public/question/type/numerical/questiontype.php
index 387073d135b25..53323a7a897fa 100644
--- a/public/question/type/numerical/questiontype.php
+++ b/public/question/type/numerical/questiontype.php
@@ -229,7 +229,7 @@ public function save_question_options($question) {
}
$options->question = $question->id;
$options->answer = $answer->id;
- if (trim($question->tolerance[$key]) == '') {
+ if (!array_key_exists($key, $question->tolerance) || trim($question->tolerance[$key]) == '') {
$options->tolerance = '';
} else {
$options->tolerance = $this->apply_unit($question->tolerance[$key],
diff --git a/public/question/type/ordering/questiontype.php b/public/question/type/ordering/questiontype.php
index 36272ada1f733..c5b5e26e9dbbc 100644
--- a/public/question/type/ordering/questiontype.php
+++ b/public/question/type/ordering/questiontype.php
@@ -724,4 +724,22 @@ public function set_options_for_import(stdClass $question, string $layouttype, s
public function get_numberingstyle(stdClass $questiondata): string {
return $questiondata->options->numberingstyle;
}
+
+ #[\Override]
+ public function move_files($questionid, $oldcontextid, $newcontextid): void {
+ parent::move_files($questionid, $oldcontextid, $newcontextid);
+
+ $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid, true);
+ $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid);
+ $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
+ }
+
+ #[\Override]
+ public function delete_files($questionid, $contextid): void {
+ parent::delete_files($questionid, $contextid);
+
+ $this->delete_files_in_answers($questionid, $contextid, true);
+ $this->delete_files_in_combined_feedback($questionid, $contextid);
+ $this->delete_files_in_hints($questionid, $contextid);
+ }
}
diff --git a/public/question/type/ordering/tests/questiontype_test.php b/public/question/type/ordering/tests/questiontype_test.php
index 82cfce5f7328e..670b3a25fdbe9 100644
--- a/public/question/type/ordering/tests/questiontype_test.php
+++ b/public/question/type/ordering/tests/questiontype_test.php
@@ -321,4 +321,272 @@ public function test_gift_export(): void {
phpunit_util::normalise_line_endings($gift)
);
}
+
+ /**
+ * Test that move_files moves the files for answer, combined feedback and hints
+ */
+ public function test_move_files(): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $category = $generator->create_question_category();
+ $question = $generator->create_question('ordering', 'moodle', ['category' => $category->id]);
+
+ $oldcontextid = $category->contextid;
+
+ $fs = get_file_storage();
+
+ // Put files into various fileareas in the old context.
+ // Answer areas (both 'answer' and 'answerfeedback').
+ $answers = $DB->get_records('question_answers', ['question' => $question->id]);
+ foreach ($answers as $ans) {
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $oldcontextid,
+ 'component' => 'question',
+ 'filearea' => 'answer',
+ 'itemid' => $ans->id,
+ 'filepath' => '/',
+ 'filename' => 'ans.txt',
+ ],
+ 'answer area content'
+ );
+
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $oldcontextid,
+ 'component' => 'question',
+ 'filearea' => 'answerfeedback',
+ 'itemid' => $ans->id,
+ 'filepath' => '/',
+ 'filename' => 'ansfb.txt',
+ ],
+ 'answer feedback content'
+ );
+ }
+
+ // Combined feedback areas.
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $oldcontextid,
+ 'component' => 'question',
+ 'filearea' => 'correctfeedback',
+ 'itemid' => $question->id,
+ 'filepath' => '/',
+ 'filename' => 'correct.txt',
+ ],
+ 'correct'
+ );
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $oldcontextid,
+ 'component' => 'question',
+ 'filearea' => 'partiallycorrectfeedback',
+ 'itemid' => $question->id,
+ 'filepath' => '/',
+ 'filename' => 'partial.txt',
+ ],
+ 'partial'
+ );
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $oldcontextid,
+ 'component' => 'question',
+ 'filearea' => 'incorrectfeedback',
+ 'itemid' => $question->id,
+ 'filepath' => '/',
+ 'filename' => 'incorrect.txt',
+ ],
+ 'incorrect'
+ );
+
+ // Create a hint and add a file for it.
+ $hint = new \stdClass();
+ $hint->questionid = $question->id;
+ $hint->hint = 'a hint';
+ $hint->hintformat = FORMAT_MOODLE;
+ $hint->shownumcorrect = 0;
+ $hintid = $DB->insert_record('question_hints', $hint);
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $oldcontextid,
+ 'component' => 'question',
+ 'filearea' => 'hint',
+ 'itemid' => $hintid,
+ 'filepath' => '/',
+ 'filename' => 'hint.txt',
+ ],
+ 'hinttext'
+ );
+
+ // Destination context (different category).
+ $newcategory = $generator->create_question_category();
+ $newcontextid = $newcategory->contextid;
+
+ $qtype = new qtype_ordering();
+ $qtype->move_files($question->id, $oldcontextid, $newcontextid);
+
+ foreach ($answers as $ans) {
+ // Answer 'answer' files should been moved.
+ $oldfiles = $fs->get_area_files($oldcontextid, 'question', 'answer', $ans->id, 'id', false);
+ $this->assertEmpty($oldfiles);
+ $newfiles = $fs->get_area_files($newcontextid, 'question', 'answer', $ans->id, 'id', false);
+ $this->assertNotEmpty($newfiles);
+
+ // Answer feedback should have been moved.
+ $oldaf = $fs->get_area_files($oldcontextid, 'question', 'answerfeedback', $ans->id, 'id', false);
+ $this->assertEmpty($oldaf);
+ $newaf = $fs->get_area_files($newcontextid, 'question', 'answerfeedback', $ans->id, 'id', false);
+ $this->assertNotEmpty($newaf);
+ }
+
+ // Combined feedback and hint files should have been moved.
+ $this->assertEmpty($fs->get_area_files($oldcontextid, 'question', 'correctfeedback', $question->id, 'id', false));
+ $this->assertNotEmpty($fs->get_area_files($newcontextid, 'question', 'correctfeedback', $question->id, 'id', false));
+
+ $this->assertEmpty($fs->get_area_files($oldcontextid, 'question', 'partiallycorrectfeedback', $question->id, 'id', false));
+ $this->assertNotEmpty(
+ $fs->get_area_files(
+ $newcontextid,
+ 'question',
+ 'partiallycorrectfeedback',
+ $question->id,
+ 'id',
+ false
+ )
+ );
+
+ $this->assertEmpty(
+ $fs->get_area_files(
+ $oldcontextid,
+ 'question',
+ 'incorrectfeedback',
+ $question->id,
+ 'id',
+ false
+ )
+ );
+ $this->assertNotEmpty(
+ $fs->get_area_files(
+ $newcontextid,
+ 'question',
+ 'incorrectfeedback',
+ $question->id,
+ 'id',
+ false
+ )
+ );
+
+ $this->assertEmpty($fs->get_area_files($oldcontextid, 'question', 'hint', $hintid, 'id', false));
+ $this->assertNotEmpty($fs->get_area_files($newcontextid, 'question', 'hint', $hintid, 'id', false));
+ }
+
+ /**
+ * Test that delete_files deletes question's files in questiontext,
+ * generalfeedback, answers, combined feedback and hints.
+ */
+ public function test_delete_files(): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $category = $generator->create_question_category();
+ $question = $generator->create_question('ordering', 'moodle', ['category' => $category->id]);
+
+ $contextid = $category->contextid;
+ $fs = get_file_storage();
+
+ // Add files to questiontext and generalfeedback.
+ $fs->create_file_from_string([
+ 'contextid' => $contextid,
+ 'component' => 'question',
+ 'filearea' => 'questiontext',
+ 'itemid' => $question->id,
+ 'filepath' => '/',
+ 'filename' => 'qtext.txt',
+ ], 'qtext');
+ $fs->create_file_from_string([
+ 'contextid' => $contextid,
+ 'component' => 'question',
+ 'filearea' => 'generalfeedback',
+ 'itemid' => $question->id,
+ 'filepath' => '/',
+ 'filename' => 'gfb.txt',
+ ], 'gfb');
+
+ // Answer files.
+ $answers = $DB->get_records('question_answers', ['question' => $question->id]);
+ foreach ($answers as $ans) {
+ $fs->create_file_from_string([
+ 'contextid' => $contextid,
+ 'component' => 'question',
+ 'filearea' => 'answer',
+ 'itemid' => $ans->id,
+ 'filepath' => '/',
+ 'filename' => 'ans.txt',
+ ], 'answer area content');
+ $fs->create_file_from_string([
+ 'contextid' => $contextid,
+ 'component' => 'question',
+ 'filearea' => 'answerfeedback',
+ 'itemid' => $ans->id,
+ 'filepath' => '/',
+ 'filename' => 'ansfb.txt',
+ ], 'answer feedback content');
+ }
+
+ // Combined feedback.
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $contextid,
+ 'component' => 'question',
+ 'filearea' => 'correctfeedback',
+ 'itemid' => $question->id,
+ 'filepath' => '/',
+ 'filename' => 'correct.txt',
+ ],
+ 'correct'
+ );
+
+ // Hint.
+ $hint = new \stdClass();
+ $hint->questionid = $question->id;
+ $hint->hint = 'a hint';
+ $hint->hintformat = FORMAT_MOODLE;
+ $hint->shownumcorrect = 0;
+ $hintid = $DB->insert_record('question_hints', $hint);
+ $fs->create_file_from_string(
+ [
+ 'contextid' => $contextid,
+ 'component' => 'question',
+ 'filearea' => 'hint',
+ 'itemid' => $hintid,
+ 'filepath' => '/',
+ 'filename' => 'hint.txt',
+ ],
+ 'hinttext'
+ );
+
+ $qtype = new qtype_ordering();
+ $qtype->delete_files($question->id, $contextid);
+
+ // Questiontext and generalfeedback removed.
+ $this->assertEmpty($fs->get_area_files($contextid, 'question', 'questiontext', $question->id, 'id', false));
+ $this->assertEmpty($fs->get_area_files($contextid, 'question', 'generalfeedback', $question->id, 'id', false));
+
+ // Answer and answerfeedback removed.
+ foreach ($answers as $ans) {
+ $this->assertEmpty($fs->get_area_files($contextid, 'question', 'answer', $ans->id, 'id', false));
+ $this->assertEmpty($fs->get_area_files($contextid, 'question', 'answerfeedback', $ans->id, 'id', false));
+ }
+
+ // Combined feedback and hint files removed.
+ $this->assertEmpty($fs->get_area_files($contextid, 'question', 'correctfeedback', $question->id, 'id', false));
+ $this->assertEmpty($fs->get_area_files($contextid, 'question', 'hint', $hintid, 'id', false));
+ }
}
diff --git a/public/rating/lib.php b/public/rating/lib.php
index 9d0a779a8b941..2da1494669d7a 100644
--- a/public/rating/lib.php
+++ b/public/rating/lib.php
@@ -834,25 +834,33 @@ public function get_user_grades($options) {
$aggregationfield = "1.0 * {$aggregationfield}";
}
- // If userid is not 0 we only want the grade for a single user.
- $singleuserwhere = '';
+ // If userid is not 0, fetch the grade for a single user.
+ // Use LEFT JOIN to include the user even if they have no ratings,
+ // ensuring a result is returned when ratings are deleted.
if ($options->userid != 0) {
- $params['userid1'] = intval($options->userid);
- $singleuserwhere = "AND i.{$itemtableusercolumn} = :userid1";
+ $params['userid'] = intval($options->userid);
+ $sql = "SELECT u.id, u.id AS userid, {$aggregationstring}({$aggregationfield}) AS rawgrade
+ FROM {user} u
+ LEFT JOIN {{$itemtable}} i ON u.id = i.{$itemtableusercolumn}
+ LEFT JOIN {rating} r ON r.itemid = i.id
+ AND r.contextid = :contextid
+ AND r.component = :component
+ AND r.ratingarea = :ratingarea
+ WHERE u.id = :userid
+ GROUP BY u.id";
+ } else {
+ // MDL-24648 The where line used to be "WHERE (r.contextid is null or r.contextid=:contextid)".
+ // r.contextid will be null for users who haven't been rated yet.
+ // No longer including users who haven't been rated to reduce memory requirements.
+ $sql = "SELECT u.id, u.id AS userid, {$aggregationstring}({$aggregationfield}) AS rawgrade
+ FROM {user} u
+ JOIN {{$itemtable}} i ON u.id = i.{$itemtableusercolumn}
+ JOIN {rating} r ON r.itemid = i.id
+ WHERE r.contextid = :contextid
+ AND r.component = :component
+ AND r.ratingarea = :ratingarea
+ GROUP BY u.id";
}
-
- // MDL-24648 The where line used to be "WHERE (r.contextid is null or r.contextid=:contextid)".
- // r.contextid will be null for users who haven't been rated yet.
- // No longer including users who haven't been rated to reduce memory requirements.
- $sql = "SELECT u.id as id, u.id AS userid, {$aggregationstring}({$aggregationfield}) AS rawgrade
- FROM {user} u
- LEFT JOIN {{$itemtable}} i ON u.id=i.{$itemtableusercolumn}
- LEFT JOIN {rating} r ON r.itemid=i.id
- WHERE r.contextid = :contextid AND
- r.component = :component AND
- r.ratingarea = :ratingarea
- $singleuserwhere
- GROUP BY u.id";
$results = $DB->get_records_sql($sql, $params);
if ($results) {
diff --git a/public/report/log/tests/behat/filter_log.feature b/public/report/log/tests/behat/filter_log.feature
index 41ab52762e714..815a382bfae1a 100644
--- a/public/report/log/tests/behat/filter_log.feature
+++ b/public/report/log/tests/behat/filter_log.feature
@@ -22,8 +22,7 @@ Feature: In a report, admin can filter log data
And I log in as "admin"
Scenario: Filter log report for standard log reader
- Given I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page
And I follow "Ann, Jill, Grainne, Beauchamp"
And I click on "Log in as" "link"
And I press "Continue"
diff --git a/public/report/log/tests/behat/user_log.feature b/public/report/log/tests/behat/user_log.feature
index 653fd7d7f6118..54e356e06fa4b 100644
--- a/public/report/log/tests/behat/user_log.feature
+++ b/public/report/log/tests/behat/user_log.feature
@@ -38,9 +38,7 @@ Feature: User can view activity log.
And I log out
Scenario: View Todays' and all log report for user
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I follow "Ann, Jill, Grainne, Beauchamp"
When I follow "Today's logs"
And I should see "Assignment: Test assignment name"
@@ -52,15 +50,11 @@ Feature: User can view activity log.
Given I log in as "admin"
And I navigate to "Plugins > Logging > Manage log stores" in site administration
And I click on "Disable" "link" in the "Standard log" "table_row"
- And I log out
- And I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I follow "Ann, Jill, Grainne, Beauchamp"
When I follow "Today's logs"
And I should see "No log reader enabled"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I follow "Ann, Jill, Grainne, Beauchamp"
And I follow "All logs"
Then I should see "No log reader enabled"
diff --git a/public/reportbuilder/classes/form/audience.php b/public/reportbuilder/classes/form/audience.php
index 2ea186524387a..4ec72dc9b127a 100644
--- a/public/reportbuilder/classes/form/audience.php
+++ b/public/reportbuilder/classes/form/audience.php
@@ -19,6 +19,7 @@
namespace core_reportbuilder\form;
use context;
+use core\exception\invalid_parameter_exception;
use core_form\dynamic_form;
use core_reportbuilder\local\audiences\base;
use core_reportbuilder\output\audience_heading_editable;
@@ -39,6 +40,7 @@ class audience extends dynamic_form {
* Audience we work with
*
* @return base
+ * @throws invalid_parameter_exception
*/
protected function get_audience(): base {
$id = $this->optional_param('id', 0, PARAM_INT);
@@ -49,7 +51,11 @@ protected function get_audience(): base {
$record->reportid = $this->optional_param('reportid', null, PARAM_INT);
$record->classname = $this->optional_param('classname', null, PARAM_RAW_TRIMMED);
}
- return base::instance($id, $record);
+ $instance = base::instance($id, $record);
+ if ($instance === null) {
+ throw new invalid_parameter_exception($record->classname);
+ }
+ return $instance;
}
/**
diff --git a/public/reportbuilder/classes/local/audiences/base.php b/public/reportbuilder/classes/local/audiences/base.php
index 41f9a5460021d..4b3b65d86935c 100644
--- a/public/reportbuilder/classes/local/audiences/base.php
+++ b/public/reportbuilder/classes/local/audiences/base.php
@@ -66,7 +66,7 @@ final public static function instance(int $id = 0, ?stdClass $record = null): ?s
}
// Check if audience type class still exists in the system.
- if (!class_exists($classname)) {
+ if (!class_exists($classname) || !is_subclass_of($classname, self::class)) {
return null;
}
diff --git a/public/reportbuilder/classes/local/helpers/user_profile_fields.php b/public/reportbuilder/classes/local/helpers/user_profile_fields.php
index 6e3c92c471845..11495c381086c 100644
--- a/public/reportbuilder/classes/local/helpers/user_profile_fields.php
+++ b/public/reportbuilder/classes/local/helpers/user_profile_fields.php
@@ -18,6 +18,7 @@
namespace core_reportbuilder\local\helpers;
+use core\context\system;
use core\lang_string;
use core_text;
use core_reportbuilder\local\filters\{boolean_select, date, select, text};
@@ -56,7 +57,8 @@ public function __construct(
/** @var string The entity name used when adding columns and filters */
private readonly string $entityname,
) {
- $this->userprofilefields = profile_get_user_fields_with_data(0);
+ // Specify a "non-empty" userid here, that won't match a real user account.
+ $this->userprofilefields = profile_get_user_fields_with_data(\core\user::SUPPORT_USER);
}
/**
@@ -146,7 +148,7 @@ public function get_columns(): array {
return $field->display_data();
}, $profilefield)
- ->set_is_available($profilefield->is_visible());
+ ->set_is_available($profilefield->is_visible(system::instance()));
}
return array_values($columns);
@@ -209,7 +211,7 @@ public function get_filters(): array {
))
->add_joins($this->get_joins())
->add_join($this->get_table_join($profilefield))
- ->set_is_available($profilefield->is_visible());
+ ->set_is_available($profilefield->is_visible(system::instance()));
// If using a select filter, then populate the options.
if ($filter->get_filter_class() === select::class) {
diff --git a/public/reportbuilder/classes/reportbuilder/schedule/message.php b/public/reportbuilder/classes/reportbuilder/schedule/message.php
index d3c0799cc8380..6a664e0f8e0a8 100644
--- a/public/reportbuilder/classes/reportbuilder/schedule/message.php
+++ b/public/reportbuilder/classes/reportbuilder/schedule/message.php
@@ -89,7 +89,7 @@ public function execute(array $users, progress_trace $trace): void {
$schedule = $this->get_persistent();
$scheduleuserviewas = $schedule->get('userviewas');
- $schedulereportempty = $this->get_configdata()['reportempty'] ?? static::REPORT_EMPTY_SEND_EMPTY;
+ $schedulereportempty = (int) ($this->get_configdata()['reportempty'] ?? static::REPORT_EMPTY_SEND_EMPTY);
// Handle schedule configuration as to who the report should be viewed as.
if ($scheduleuserviewas === schedule::REPORT_VIEWAS_CREATOR) {
diff --git a/public/reportbuilder/lib.php b/public/reportbuilder/lib.php
index 08c888b019570..fb4938dc8ae49 100644
--- a/public/reportbuilder/lib.php
+++ b/public/reportbuilder/lib.php
@@ -24,11 +24,15 @@
declare(strict_types=1);
+use core\exception\invalid_parameter_exception;
use core\output\inplace_editable;
use core_reportbuilder\form\audience;
use core_reportbuilder\form\filter;
+use core_reportbuilder\local\audiences\base as audience_base;
use core_reportbuilder\local\helpers\audience as audience_helper;
use core_reportbuilder\local\models\report;
+use core_reportbuilder\local\report\base as report_base;
+use core_reportbuilder\{manager, permission};
use core_tag\output\{tagfeed, tagindex};
/**
@@ -38,11 +42,20 @@
* @return string
*/
function core_reportbuilder_output_fragment_filters_form(array $params): string {
+ $report = new report($params['reportid']);
+
+ // Verify current user can access the report data.
+ if ($report->get('type') === report_base::TYPE_CUSTOM_REPORT) {
+ permission::require_can_view_report($report);
+ } else {
+ $reportinstance = manager::get_report_from_persistent($report, (array) json_decode($params['parameters']));
+ $reportinstance->require_can_view();
+ }
+
$filtersform = new filter(null, null, 'post', '', [], true, [
'reportid' => $params['reportid'],
'parameters' => $params['parameters'],
]);
-
$filtersform->set_data_for_dynamic_submission();
return $filtersform->render();
@@ -53,10 +66,21 @@ function core_reportbuilder_output_fragment_filters_form(array $params): string
*
* @param array $params
* @return string
+ * @throws invalid_parameter_exception
*/
function core_reportbuilder_output_fragment_audience_form(array $params): string {
global $PAGE;
+ $report = new report($params['reportid']);
+ permission::require_can_edit_report($report);
+
+ // Verify current user can add the requested audience type.
+ $instance = audience_base::instance(0, (object) $params);
+ if ($instance === null) {
+ throw new invalid_parameter_exception($params['classname']);
+ }
+ $instance->require_user_can_add();
+
$audienceform = new audience(null, null, 'post', '', [], true, [
'reportid' => $params['reportid'],
'classname' => $params['classname'],
diff --git a/public/reportbuilder/tests/reportbuilder/schedule/message_test.php b/public/reportbuilder/tests/reportbuilder/schedule/message_test.php
index b71772bc24fd9..a5ab2ac3e9826 100644
--- a/public/reportbuilder/tests/reportbuilder/schedule/message_test.php
+++ b/public/reportbuilder/tests/reportbuilder/schedule/message_test.php
@@ -164,7 +164,8 @@ public function test_execute_report_empty(): void {
'reportid' => $report->get('id'),
'name' => 'My schedule',
'audiences' => json_encode([$audience->get_persistent()->get('id')]),
- 'configdata' => json_encode(['reportempty' => message::REPORT_EMPTY_DONT_SEND]),
+ // Cast to string to simulate how Moodle forms stores data.
+ 'configdata' => json_encode(['reportempty' => (string) message::REPORT_EMPTY_DONT_SEND]),
]);
$this->expectOutputString("Sending schedule: My schedule (Schedule an email)\n" .
diff --git a/public/repository/contentbank/classes/browser/contentbank_browser.php b/public/repository/contentbank/classes/browser/contentbank_browser.php
index c8bb979328db0..4441acc729b89 100644
--- a/public/repository/contentbank/classes/browser/contentbank_browser.php
+++ b/public/repository/contentbank/classes/browser/contentbank_browser.php
@@ -136,7 +136,7 @@ private function get_context_folders(): array {
return array_reduce($children, function ($list, $child) {
$browser = \repository_contentbank\helper::get_contentbank_browser($child);
if ($browser->can_access_content()) {
- $name = $child->get_context_name(false);
+ $name = $child->get_context_name(false, false, false);
$path = base64_encode(json_encode(['contextid' => $child->id]));
$list[] = \repository_contentbank\helper::create_context_folder_node($name, $path);
}
diff --git a/public/repository/contentbank/classes/helper.php b/public/repository/contentbank/classes/helper.php
index bb0309146d034..79b548e43600a 100644
--- a/public/repository/contentbank/classes/helper.php
+++ b/public/repository/contentbank/classes/helper.php
@@ -129,7 +129,7 @@ public static function create_contentbank_content_node(\core_contentbank\content
public static function create_navigation_node(\context $context): array {
return [
'path' => base64_encode(json_encode(['contextid' => $context->id])),
- 'name' => $context->get_context_name(false)
+ 'name' => $context->get_context_name(false, false, false),
];
}
}
diff --git a/public/repository/contentbank/tests/behat/file_update.feature b/public/repository/contentbank/tests/behat/file_update.feature
index 13e7f6f47b4a5..ffab8575ec8d6 100644
--- a/public/repository/contentbank/tests/behat/file_update.feature
+++ b/public/repository/contentbank/tests/behat/file_update.feature
@@ -25,8 +25,8 @@ Feature: Updating a file in the content bank after using in a course
And I click on "package.h5p" "file" in repository content area
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "Press here to reveal answer"
And I switch to the main frame
# Now edit the content in the content bank.
@@ -39,20 +39,20 @@ Feature: Updating a file in the content bank after using in a course
And I click on "package.h5p" "link"
And I click on "Edit" "link"
And I wait until the page is ready
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Title" to "Required title"
And I set the field "Descriptive solution label" to "This is a new text"
And I switch to the main frame
And I click on "Save" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "This is a new text"
And I switch to the main frame
# Check the course page is updated.
When I am on "Course1" course homepage with editing mode on
And I click on "guessFile" "link" in the "page-content" "region"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "This is a new text"
And I switch to the main frame
@@ -67,8 +67,8 @@ Feature: Updating a file in the content bank after using in a course
And I click on "Make a copy of the file" "radio"
And I click on "Select this file" "button"
And I click on "Save and display" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "Press here to reveal answer"
And I switch to the main frame
# Now edit the content in the content bank.
@@ -81,19 +81,19 @@ Feature: Updating a file in the content bank after using in a course
And I click on "package.h5p" "link"
And I click on "Edit" "link"
And I wait until the page is ready
- And I switch to "h5p-editor-iframe" class iframe
+ And I wait until "h5p-editor-iframe" iframe is interactable and switch to it
And I set the field "Title" to "Required title"
And I set the field "Descriptive solution label" to "This is a new text"
And I switch to the main frame
And I click on "Save" "button"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "This is a new text"
And I switch to the main frame
# Check the course page is not updated.
When I am on "Course1" course homepage with editing mode on
And I click on "guessFile" "link" in the "page-content" "region"
- And I switch to "h5p-player" class iframe
- And I switch to "h5p-iframe" class iframe
+ And I wait until "h5p-player" iframe is interactable and switch to it
+ And I wait until "h5p-iframe" iframe is interactable and switch to it
Then I should see "Press here to reveal answer"
And I switch to the main frame
diff --git a/public/repository/local/lib.php b/public/repository/local/lib.php
index 6a3ec3c62ea00..b7b4fa16b93ca 100644
--- a/public/repository/local/lib.php
+++ b/public/repository/local/lib.php
@@ -210,7 +210,7 @@ private function get_node(file_info $fileinfo) {
global $OUTPUT;
$encodedpath = base64_encode(json_encode($fileinfo->get_params()));
$node = array(
- 'title' => $fileinfo->get_visible_name(),
+ 'title' => html_entity_decode($fileinfo->get_visible_name()),
'datemodified' => $fileinfo->get_timemodified(),
'datecreated' => $fileinfo->get_timecreated()
);
@@ -251,7 +251,7 @@ private function get_node_path(file_info $fileinfo) {
$encodedpath = base64_encode(json_encode($fileinfo->get_params()));
return array(
'path' => $encodedpath,
- 'name' => $fileinfo->get_visible_name()
+ 'name' => html_entity_decode($fileinfo->get_visible_name()),
);
}
diff --git a/public/search/engine/simpledb/classes/engine.php b/public/search/engine/simpledb/classes/engine.php
index ff39920ad6313..e2ab53558c2fa 100644
--- a/public/search/engine/simpledb/classes/engine.php
+++ b/public/search/engine/simpledb/classes/engine.php
@@ -347,8 +347,8 @@ protected function get_simple_query($q) {
$DB->sql_like('description2', '?', false, false) .
')';
- // Remove quotes from the query.
- $q = str_replace('"', '', $q);
+ // Remove single and double quotes from the query.
+ $q = str_replace(['"', "'"], '', $q);
$params = [
'%' . $q . '%',
'%' . $q . '%',
diff --git a/public/tag/classes/tag.php b/public/tag/classes/tag.php
index ae2d92bf4391f..2026306f0faf5 100644
--- a/public/tag/classes/tag.php
+++ b/public/tag/classes/tag.php
@@ -74,6 +74,14 @@ class core_tag_tag {
/** @var int option to hide standard tags when editing item tags */
const HIDE_STANDARD = 2;
+ /**
+ * @var int batch size for chunking id lists passed to IN () clauses.
+ *
+ * Kept well under the database parameter limit (e.g. 65535 on PostgreSQL) so bulk tag
+ * deletions never exceed it. See MDL-87395.
+ */
+ const DELETE_CHUNK_SIZE = 1000;
+
/** @var int|null tag context ID. */
public $taginstancecontextid;
@@ -1523,68 +1531,74 @@ public static function delete_tags($tagids) {
return;
}
- // Use the tagids to create a select statement to be used later.
- list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids);
-
- // Store the tags and tag instances we are going to delete.
- $tags = $DB->get_records_select('tag', 'id ' . $tagsql, $tagparams);
- $taginstances = $DB->get_records_select('tag_instance', 'tagid ' . $tagsql, $tagparams);
-
- // Delete all the tag instances.
- $select = 'WHERE tagid ' . $tagsql;
- $sql = "DELETE FROM {tag_instance} $select";
- $DB->execute($sql, $tagparams);
-
- // Delete all the tag correlations.
- $sql = "DELETE FROM {tag_correlation} $select";
- $DB->execute($sql, $tagparams);
-
- // Delete all the tags.
- $select = 'WHERE id ' . $tagsql;
- $sql = "DELETE FROM {tag} $select";
- $DB->execute($sql, $tagparams);
-
- // Fire an event that these items were untagged.
- if ($taginstances) {
- // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
- $syscontextid = context_system::instance()->id;
- // Loop through the tag instances and fire a 'tag_removed'' event.
- foreach ($taginstances as $taginstance) {
- // We can not fire an event with 'null' as the contextid.
- if (is_null($taginstance->contextid)) {
- $taginstance->contextid = $syscontextid;
- }
+ // Chunk the tagids so we never exceed the database parameter limit (e.g. 65535 on PostgreSQL).
+ foreach (array_chunk($tagids, self::DELETE_CHUNK_SIZE) as $tagchunk) {
+ // Use the tagids to create a select statement to be used later.
+ [$tagsql, $tagparams] = $DB->get_in_or_equal($tagchunk);
+
+ // Store the tags and tag instances we are going to delete.
+ $tags = $DB->get_records_select('tag', 'id ' . $tagsql, $tagparams);
+ $taginstances = $DB->get_records_select('tag_instance', 'tagid ' . $tagsql, $tagparams);
+
+ // Delete all the tag instances.
+ $select = 'WHERE tagid ' . $tagsql;
+ $sql = "DELETE FROM {tag_instance} $select";
+ $DB->execute($sql, $tagparams);
+
+ // Delete all the tag correlations.
+ $sql = "DELETE FROM {tag_correlation} $select";
+ $DB->execute($sql, $tagparams);
+
+ // Delete all the tags.
+ $select = 'WHERE id ' . $tagsql;
+ $sql = "DELETE FROM {tag} $select";
+ $DB->execute($sql, $tagparams);
+
+ // Fire an event that these items were untagged.
+ if ($taginstances) {
+ // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
+ $syscontextid = context_system::instance()->id;
+ // Loop through the tag instances and fire a 'tag_removed'' event.
+ foreach ($taginstances as $taginstance) {
+ // We can not fire an event with 'null' as the contextid.
+ if (is_null($taginstance->contextid)) {
+ $taginstance->contextid = $syscontextid;
+ }
- // Trigger tag removed event.
- \core\event\tag_removed::create_from_tag_instance($taginstance,
- $tags[$taginstance->tagid]->name, $tags[$taginstance->tagid]->rawname,
- true)->trigger();
+ // Trigger tag removed event.
+ \core\event\tag_removed::create_from_tag_instance(
+ $taginstance,
+ $tags[$taginstance->tagid]->name,
+ $tags[$taginstance->tagid]->rawname,
+ true
+ )->trigger();
+ }
}
- }
- // Fire an event that these tags were deleted.
- if ($tags) {
- $context = context_system::instance();
- foreach ($tags as $tag) {
- // Delete all files associated with this tag.
- $fs = get_file_storage();
- $files = $fs->get_area_files($context->id, 'tag', 'description', $tag->id);
- foreach ($files as $file) {
- $file->delete();
- }
+ // Fire an event that these tags were deleted.
+ if ($tags) {
+ $context = context_system::instance();
+ foreach ($tags as $tag) {
+ // Delete all files associated with this tag.
+ $fs = get_file_storage();
+ $files = $fs->get_area_files($context->id, 'tag', 'description', $tag->id);
+ foreach ($files as $file) {
+ $file->delete();
+ }
- // Trigger an event for deleting this tag.
- $event = \core\event\tag_deleted::create(array(
- 'objectid' => $tag->id,
- 'relateduserid' => $tag->userid,
- 'context' => $context,
- 'other' => array(
- 'name' => $tag->name,
- 'rawname' => $tag->rawname
- )
- ));
- $event->add_record_snapshot('tag', $tag);
- $event->trigger();
+ // Trigger an event for deleting this tag.
+ $event = \core\event\tag_deleted::create([
+ 'objectid' => $tag->id,
+ 'relateduserid' => $tag->userid,
+ 'context' => $context,
+ 'other' => [
+ 'name' => $tag->name,
+ 'rawname' => $tag->rawname,
+ ],
+ ]);
+ $event->add_record_snapshot('tag', $tag);
+ $event->trigger();
+ }
}
}
diff --git a/public/tag/tests/reportbuilder/datasource/tags_test.php b/public/tag/tests/reportbuilder/datasource/tags_test.php
index cd796b4755616..32b8fad4f1811 100644
--- a/public/tag/tests/reportbuilder/datasource/tags_test.php
+++ b/public/tag/tests/reportbuilder/datasource/tags_test.php
@@ -46,7 +46,10 @@ public function test_datasource_default(): void {
$generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder');
$report = $generator->create_report(['name' => 'Tags', 'source' => tags::class, 'default' => 1]);
- $content = $this->get_custom_report_content($report->get('id'));
+ $content = $this->filter_custom_report_content(
+ $this->get_custom_report_content($report->get('id')),
+ fn(array $row): bool => $row['c0_name'] === 'Default collection',
+ );
$this->assertCount(2, $content);
// Default columns are collection, tag (with link), standard, context. Sorted by collection and tag.
@@ -101,7 +104,10 @@ public function test_datasource_non_default_columns(): void {
$generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'instance:timecreated']);
$generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'instance:timemodified']);
- $content = $this->get_custom_report_content($report->get('id'));
+ $content = $this->filter_custom_report_content(
+ $this->get_custom_report_content($report->get('id')),
+ fn(array $row): bool => $row['c0_isdefault'] === 'Yes',
+ );
$this->assertCount(1, $content);
[
@@ -278,11 +284,13 @@ public function test_datasource_filters(
$generator->create_filter(['reportid' => $report->get('id'), 'uniqueidentifier' => $filtername]);
$content = $this->get_custom_report_content($report->get('id'), 0, $filtervalues);
+ // Merge report tag names into easily traversable array.
+ $tagnames = array_merge(...array_map('array_values', $content));
+
if ($expectmatch) {
- $this->assertCount(1, $content);
- $this->assertEquals('Horses', reset($content[0]));
+ $this->assertContains('Horses', $tagnames);
} else {
- $this->assertEmpty($content);
+ $this->assertNotContains('Horses', $tagnames);
}
}
@@ -304,4 +312,16 @@ public function test_stress_datasource(): void {
$this->datasource_stress_test_columns_aggregation(tags::class);
$this->datasource_stress_test_conditions(tags::class, 'tag:name');
}
+
+ /**
+ * Ensuring report content only includes tags from the default collection
+ *
+ * @param array $content
+ * @param callable $callback
+ * @return array
+ */
+ protected function filter_custom_report_content(array $content, callable $callback): array {
+ $content = array_filter($content, $callback);
+ return array_values($content);
+ }
}
diff --git a/public/tag/tests/taglib_test.php b/public/tag/tests/taglib_test.php
index 08238dc055f8d..d6c3ae075257b 100644
--- a/public/tag/tests/taglib_test.php
+++ b/public/tag/tests/taglib_test.php
@@ -235,6 +235,99 @@ public function test_tag_bulk_delete_instances(): void {
$this->assertEquals(0, $instancecount);
}
+ /**
+ * Test that cleanup() handles more orphaned instances than the chunk size.
+ *
+ * Regression test for MDL-87395: the id list must be chunked so it never exceeds the
+ * DB parameter limit (e.g. 65535 on PostgreSQL).
+ *
+ * Note: inserting 65535+ rows to reproduce the raw parameter-limit error is impractical here,
+ * so this exercises the multi-chunk path (more rows than core_tag_tag::DELETE_CHUNK_SIZE) to prove
+ * every batch is processed. It guards against a broken or incomplete chunk loop rather than the parameter limit itself.
+ */
+ public function test_cleanup_chunks_large_instance_list(): void {
+ global $DB;
+ $task = new \core\task\tag_cron_task();
+
+ // Insert more orphaned tag instances (pointing to a non-existent tag) than the chunk
+ // size so cleanup() is forced through more than one batch (including a partial last one).
+ $count = core_tag_tag::DELETE_CHUNK_SIZE + 500;
+ $syscontextid = \context_system::instance()->id;
+ $bogustagid = 999999;
+ $records = [];
+ for ($i = 0; $i < $count; $i++) {
+ $records[] = (object) [
+ 'tagid' => $bogustagid,
+ 'component' => 'core',
+ 'itemtype' => 'user',
+ 'itemid' => $i + 1,
+ 'contextid' => $syscontextid,
+ 'tiuserid' => 0,
+ 'ordering' => 0,
+ 'timecreated' => time(),
+ 'timemodified' => time(),
+ ];
+ }
+ $DB->insert_records('tag_instance', $records);
+ $this->assertEquals($count, $DB->count_records('tag_instance', ['tagid' => $bogustagid]));
+
+ // Cleanup should remove them all without hitting the parameter limit.
+ $task->cleanup();
+
+ $this->assertEquals(0, $DB->count_records('tag_instance', ['tagid' => $bogustagid]));
+ }
+
+ /**
+ * Test that delete_tags() handles more tag ids than the chunk size.
+ *
+ * Regression test for MDL-87395: the id list must be chunked so it never exceeds the
+ * DB parameter limit (e.g. 65535 on PostgreSQL).
+ *
+ * Note: inserting 65535+ rows to reproduce the raw parameter-limit error is impractical
+ * here, so this exercises the multi-chunk path (more tags than core_tag_tag::DELETE_CHUNK_SIZE) to prove
+ * every batch is processed. It guards against a broken or incomplete chunk loop rather than the parameter limit itself.
+ */
+ public function test_delete_tags_chunks_large_tag_list(): void {
+ global $DB;
+
+ // Insert more tags than the chunk size so delete_tags() is forced through more than
+ // one batch (including a partial last one).
+ $count = core_tag_tag::DELETE_CHUNK_SIZE + 500;
+ $tagcollid = core_tag_collection::get_default();
+ $records = [];
+ for ($i = 0; $i < $count; $i++) {
+ $records[] = (object) [
+ 'userid' => 0,
+ 'tagcollid' => $tagcollid,
+ 'name' => 'chunktag' . $i,
+ 'rawname' => 'chunktag' . $i,
+ 'isstandard' => 0,
+ 'descriptionformat' => 0,
+ 'timemodified' => time(),
+ ];
+ }
+ $DB->insert_records('tag', $records);
+ $tagids = $DB->get_fieldset_select(
+ 'tag',
+ 'id',
+ $DB->sql_like('name', ':name'),
+ ['name' => 'chunktag%']
+ );
+ $this->assertCount($count, $tagids);
+
+ // Deleting them all must not hit the parameter limit.
+ core_tag_tag::delete_tags($tagids);
+
+ $this->assertEquals(
+ 0,
+ $DB->count_records_select(
+ 'tag',
+ $DB->sql_like('name', ':name'),
+ ['name' => 'chunktag%']
+ )
+ );
+ }
+
/**
* Test that setting a list of tags for "tag" item type throws exception if userid specified
*/
diff --git a/public/theme/boost/amd/build/loader.min.js b/public/theme/boost/amd/build/loader.min.js
index 23df96fb06d73..43c0ba6e97a61 100644
--- a/public/theme/boost/amd/build/loader.min.js
+++ b/public/theme/boost/amd/build/loader.min.js
@@ -1,4 +1,4 @@
-define("theme_boost/loader",["exports","./aria","./index","core/pending","core_filters/events","./bootstrap/util/sanitizer","./pending","./bootstrap/dom/event-handler"],(function(_exports,Aria,Bootstrap,_pending,_events,_sanitizer,_pending2,_eventHandler){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}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}
+define("theme_boost/loader",["exports","./aria","./index","core/pending","core_filters/events","./bootstrap/util/sanitizer","./pending","./bootstrap/dom/event-handler","./bootstrap/dom/selector-engine"],(function(_exports,Aria,Bootstrap,_pending,_events,_sanitizer,_pending2,_eventHandler,_selectorEngine){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}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}
/**
* Template renderer for Moodle. Load and render Moodle templates with Mustache.
*
@@ -6,6 +6,6 @@ define("theme_boost/loader",["exports","./aria","./index","core/pending","core_f
* @copyright 2015 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 2.9
- */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.Bootstrap=void 0,Aria=_interopRequireWildcard(Aria),Bootstrap=_interopRequireWildcard(Bootstrap),_exports.Bootstrap=Bootstrap,_pending=_interopRequireDefault(_pending),_pending2=_interopRequireDefault(_pending2),_eventHandler=_interopRequireDefault(_eventHandler);const enableTooltips=function(){let rootElement=arguments.length>0&&void 0!==arguments[0]?arguments[0]:document;const tooltipTriggerList=rootElement.querySelectorAll('[data-bs-toggle="tooltip"]'),tooltipList=[...tooltipTriggerList].map((tooltipTriggerEl=>new Bootstrap.Tooltip(tooltipTriggerEl)));document.addEventListener("keydown",(e=>{"Escape"===e.key&&tooltipList.forEach((tooltip=>{tooltip.hide()}))}))},pendingPromise=new _pending.default("theme_boost/loader:init");(0,_pending2.default)(),Aria.init(),(()=>{[...document.querySelectorAll('a[data-bs-toggle="tab"]')].map((tabTriggerEl=>tabTriggerEl.addEventListener("shown.bs.tab",(e=>{var hash=e.target.getAttribute("href");history.replaceState?history.replaceState(null,null,hash):location.hash=hash}))));const hash=window.location.hash;if(hash){const tab=document.querySelector('[role="tablist"] [href="'+hash+'"]');tab&&tab.click()}})(),(()=>{const popoverTriggerList=document.querySelectorAll('[data-bs-toggle="popover"]'),popoverConfig={container:"body",trigger:"focus",allowList:Object.assign(_sanitizer.DefaultAllowlist,{table:[],thead:[],tbody:[],tr:[],th:[],td:[]})};[...popoverTriggerList].map((popoverTriggerEl=>new Bootstrap.Popover(popoverTriggerEl,popoverConfig))),document.addEventListener("core/modal:bodyRendered",(e=>{[...e.target.querySelectorAll('[data-bs-toggle="popover"]')].map((popoverTriggerEl=>new Bootstrap.Popover(popoverTriggerEl,popoverConfig)))})),document.addEventListener("keydown",(e=>{const popoverTrigger=e.target.closest('[data-bs-toggle="popover"]');"Escape"===e.key&&popoverTrigger&&Bootstrap.Popover.getOrCreateInstance(popoverTrigger).hide(),"Enter"===e.key&&popoverTrigger&&Bootstrap.Popover.getOrCreateInstance(popoverTrigger).show()})),document.addEventListener("click",(e=>{const popoverTrigger=e.target.closest('[data-bs-toggle="popover"]');if(!popoverTrigger)return;const popover=Bootstrap.Popover.getOrCreateInstance(popoverTrigger);popover._isShown()||popover.show()}))})(),enableTooltips(),document.addEventListener(_events.eventTypes.filterContentUpdated,(e=>{e.detail.nodes.forEach((node=>{node instanceof HTMLElement&&enableTooltips(node)}))})),_eventHandler.default.off(document,"keydown.bs.dropdown.data-api",".dropdown-menu",Bootstrap.Dropdown.dataApiKeydownHandler),_eventHandler.default.on(document.body,"keydown.bs.dropdown.data-api",".dropdown-menu",Bootstrap.Dropdown.dataApiKeydownHandler),pendingPromise.resolve()}));
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.Bootstrap=void 0,Aria=_interopRequireWildcard(Aria),Bootstrap=_interopRequireWildcard(Bootstrap),_exports.Bootstrap=Bootstrap,_pending=_interopRequireDefault(_pending),_pending2=_interopRequireDefault(_pending2),_eventHandler=_interopRequireDefault(_eventHandler),_selectorEngine=_interopRequireDefault(_selectorEngine);const enableTooltips=function(){let rootElement=arguments.length>0&&void 0!==arguments[0]?arguments[0]:document;const tooltipTriggerList=rootElement.querySelectorAll('[data-bs-toggle="tooltip"]'),tooltipList=[...tooltipTriggerList].map((tooltipTriggerEl=>new Bootstrap.Tooltip(tooltipTriggerEl)));document.addEventListener("keydown",(e=>{"Escape"===e.key&&tooltipList.forEach((tooltip=>{tooltip.hide()}))}))},pendingPromise=new _pending.default("theme_boost/loader:init");(0,_pending2.default)(),Aria.init(),(()=>{[...document.querySelectorAll('a[data-bs-toggle="tab"]')].map((tabTriggerEl=>tabTriggerEl.addEventListener("shown.bs.tab",(e=>{var hash=e.target.getAttribute("href");history.replaceState?history.replaceState(null,null,hash):location.hash=hash}))));const hash=window.location.hash;if(hash){const tab=document.querySelector('[role="tablist"] [href="'+hash+'"]');tab&&tab.click()}})(),(()=>{const popoverTriggerList=document.querySelectorAll('[data-bs-toggle="popover"]'),getTabbableElements=(container,excludeSelector)=>{const elements=_selectorEngine.default.focusableChildren(container).filter((element=>element.tabIndex>=0)).filter((element=>!excludeSelector||!element.matches(excludeSelector))),tabbableRadios=new Set;return elements.filter((element=>element.matches('input[type="radio"][name]'))).forEach((radio=>{var _group$find;const group=elements.filter((element=>element.matches('input[type="radio"]')&&element.name===radio.name&&element.form===radio.form));tabbableRadios.add(null!==(_group$find=group.find((element=>element.checked)))&&void 0!==_group$find?_group$find:group[0])})),elements.filter((element=>!element.matches('input[type="radio"][name]')||tabbableRadios.has(element))).sort(((elementA,elementB)=>elementA.tabIndex===elementB.tabIndex?0:0===elementA.tabIndex?1:0===elementB.tabIndex?-1:elementA.tabIndex-elementB.tabIndex))},popoverConfig={container:"body",trigger:"focus",allowList:Object.assign(_sanitizer.DefaultAllowlist,{table:[],thead:[],tbody:[],tr:[],th:[],td:[]})},helpPopoverTriggers=new WeakMap,initialisePopover=popoverTriggerEl=>{const isHelpPopover=popoverTriggerEl.classList.contains("help-icon"),config=isHelpPopover?{...popoverConfig,trigger:"manual",template:Bootstrap.Popover.Default.template.replace('role="tooltip"','role="dialog"')}:popoverConfig,popover=new Bootstrap.Popover(popoverTriggerEl,config);return isHelpPopover&&popoverTriggerEl.setAttribute("aria-haspopup","dialog"),popover};[...popoverTriggerList].map(initialisePopover),document.addEventListener("core/modal:bodyRendered",(e=>{[...e.target.querySelectorAll('[data-bs-toggle="popover"]')].map(initialisePopover)})),document.addEventListener("keydown",(e=>{const popoverTrigger=e.target.closest('[data-bs-toggle="popover"]'),helpPopover=e.target.closest(".help-popover"),helpPopoverTrigger=helpPopover?helpPopoverTriggers.get(helpPopover):null;if("Escape"===e.key&&popoverTrigger&&Bootstrap.Popover.getOrCreateInstance(popoverTrigger).hide(),"Escape"===e.key&&helpPopoverTrigger&&(helpPopoverTrigger.focus(),Bootstrap.Popover.getOrCreateInstance(helpPopoverTrigger).hide()),"Enter"===e.key&&popoverTrigger){const popover=Bootstrap.Popover.getOrCreateInstance(popoverTrigger);popover._isShown()||popover.show()}if("Tab"===e.key&&!e.shiftKey&&null!=popoverTrigger&&popoverTrigger.classList.contains("help-icon")){const popover=Bootstrap.Popover.getOrCreateInstance(popoverTrigger);if(popover._isShown()&&popover.tip){const firstFocusableElement=getTabbableElements(popover.tip)[0];firstFocusableElement&&(e.preventDefault(),firstFocusableElement.focus())}}if("Tab"===e.key&&helpPopoverTrigger){const popoverFocusableElements=getTabbableElements(helpPopover),focusedElementIndex=popoverFocusableElements.indexOf(e.target);if(e.shiftKey&&0===focusedElementIndex)return e.preventDefault(),void helpPopoverTrigger.focus();if(e.shiftKey||focusedElementIndex!==popoverFocusableElements.length-1)return;const focusableElements=getTabbableElements(document.body,".help-popover, .help-popover *"),triggerIndex=focusableElements.indexOf(helpPopoverTrigger),nextFocusableElement=-1===triggerIndex?null:focusableElements[triggerIndex+1];nextFocusableElement&&(e.preventDefault(),nextFocusableElement.focus())}})),document.addEventListener("click",(e=>{const popoverTrigger=e.target.closest('[data-bs-toggle="popover"]');if(document.querySelectorAll(".help-icon[aria-describedby]").forEach((trigger=>{var _triggerPopover$tip;const triggerPopover=Bootstrap.Popover.getOrCreateInstance(trigger);trigger===popoverTrigger||null!==(_triggerPopover$tip=triggerPopover.tip)&&void 0!==_triggerPopover$tip&&_triggerPopover$tip.contains(e.target)||triggerPopover.hide()})),!popoverTrigger)return;const popover=Bootstrap.Popover.getOrCreateInstance(popoverTrigger);popover._isShown()||popover.show()})),document.addEventListener("focusin",(e=>{const popoverTrigger=e.target.closest('.help-icon[data-bs-toggle="popover"]');if(popoverTrigger){const popover=Bootstrap.Popover.getOrCreateInstance(popoverTrigger);popover._isShown()||popover.show()}})),document.addEventListener("focusout",(e=>{const popoverTrigger=e.target.closest('.help-icon[data-bs-toggle="popover"]'),helpPopover=e.target.closest(".help-popover"),trigger=null!=popoverTrigger?popoverTrigger:helpPopover?helpPopoverTriggers.get(helpPopover):null;if(!trigger)return;const popover=Bootstrap.Popover.getOrCreateInstance(trigger),popoverElement=null!=helpPopover?helpPopover:popover.tip;trigger.contains(e.relatedTarget)||null!=popoverElement&&popoverElement.contains(e.relatedTarget)||popover.hide()})),document.addEventListener("inserted.bs.popover",(e=>{if(e.target.classList.contains("help-icon")){const tip=Bootstrap.Popover.getOrCreateInstance(e.target).tip;helpPopoverTriggers.set(tip,e.target),tip.setAttribute("aria-label",e.target.getAttribute("aria-label"));const content=tip.querySelector(".popover-body");content&&(content.id=content.id||"".concat(tip.id,"-content"),e.target.setAttribute("aria-describedby",content.id))}}))})(),enableTooltips(),document.addEventListener(_events.eventTypes.filterContentUpdated,(e=>{e.detail.nodes.forEach((node=>{node instanceof HTMLElement&&enableTooltips(node)}))})),_eventHandler.default.off(document,"keydown.bs.dropdown.data-api",".dropdown-menu",Bootstrap.Dropdown.dataApiKeydownHandler),_eventHandler.default.on(document.body,"keydown.bs.dropdown.data-api",".dropdown-menu",Bootstrap.Dropdown.dataApiKeydownHandler),pendingPromise.resolve()}));
//# sourceMappingURL=loader.min.js.map
\ No newline at end of file
diff --git a/public/theme/boost/amd/build/loader.min.js.map b/public/theme/boost/amd/build/loader.min.js.map
index 508a690c2db18..496de32e8e995 100644
--- a/public/theme/boost/amd/build/loader.min.js.map
+++ b/public/theme/boost/amd/build/loader.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"loader.min.js","sources":["../src/loader.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 * Template renderer for Moodle. Load and render Moodle templates with Mustache.\n *\n * @module theme_boost/loader\n * @copyright 2015 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 2.9\n */\n\nimport * as Aria from './aria';\nimport * as Bootstrap from './index';\nimport Pending from 'core/pending';\nimport {eventTypes} from 'core_filters/events';\nimport {DefaultAllowlist} from './bootstrap/util/sanitizer';\nimport setupBootstrapPendingChecks from './pending';\nimport EventHandler from './bootstrap/dom/event-handler';\n\n/**\n * Rember the last visited tabs.\n */\nconst rememberTabs = () => {\n const tabTriggerList = document.querySelectorAll('a[data-bs-toggle=\"tab\"]');\n [...tabTriggerList].map(tabTriggerEl => tabTriggerEl.addEventListener('shown.bs.tab', (e) => {\n var hash = e.target.getAttribute('href');\n if (history.replaceState) {\n history.replaceState(null, null, hash);\n } else {\n location.hash = hash;\n }\n }));\n const hash = window.location.hash;\n if (hash) {\n const tab = document.querySelector('[role=\"tablist\"] [href=\"' + hash + '\"]');\n if (tab) {\n tab.click();\n }\n }\n};\n\n/**\n * Enable all popovers\n *\n */\nconst enablePopovers = () => {\n const popoverTriggerList = document.querySelectorAll('[data-bs-toggle=\"popover\"]');\n const popoverConfig = {\n container: 'body',\n trigger: 'focus',\n allowList: Object.assign(DefaultAllowlist, {table: [], thead: [], tbody: [], tr: [], th: [], td: []}),\n };\n [...popoverTriggerList].map(popoverTriggerEl => new Bootstrap.Popover(popoverTriggerEl, popoverConfig));\n\n // Enable dynamically created popovers inside modals.\n document.addEventListener('core/modal:bodyRendered', (e) => {\n const modal = e.target;\n const popoverTriggerList = modal.querySelectorAll('[data-bs-toggle=\"popover\"]');\n [...popoverTriggerList].map(popoverTriggerEl => new Bootstrap.Popover(popoverTriggerEl, popoverConfig));\n });\n\n document.addEventListener('keydown', e => {\n const popoverTrigger = e.target.closest('[data-bs-toggle=\"popover\"]');\n if (e.key === 'Escape' && popoverTrigger) {\n Bootstrap.Popover.getOrCreateInstance(popoverTrigger).hide();\n }\n if (e.key === 'Enter' && popoverTrigger) {\n Bootstrap.Popover.getOrCreateInstance(popoverTrigger).show();\n }\n });\n document.addEventListener('click', e => {\n const popoverTrigger = e.target.closest('[data-bs-toggle=\"popover\"]');\n if (!popoverTrigger) {\n return;\n }\n const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);\n if (!popover._isShown()) {\n popover.show();\n }\n });\n};\n\n/**\n * Enable tooltips\n *\n * @param {Element} rootElement\n */\nconst enableTooltips = (rootElement = document) => {\n const tooltipTriggerList = rootElement.querySelectorAll('[data-bs-toggle=\"tooltip\"]');\n const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Bootstrap.Tooltip(tooltipTriggerEl));\n\n document.addEventListener('keydown', e => {\n if (e.key === 'Escape') {\n tooltipList.forEach(tooltip => {\n tooltip.hide();\n });\n }\n });\n};\n\n/**\n * Enable tooltips for dynamic content updates\n */\nconst enableTooltipsOnContentUpdated = () => {\n document.addEventListener(eventTypes.filterContentUpdated, e => {\n e.detail.nodes.forEach(node => {\n if (node instanceof HTMLElement) {\n enableTooltips(node);\n }\n });\n });\n};\n\n/**\n * Realocate Bootstrap events to the body element.\n *\n * Bootstrap 5 has a unique event handling mechanism that attaches all event handlers at the document level\n * during the capture phase, rather than the usual bubbling phase. As a result, original Bootstrap events\n * cannot be stopped or prevented, since the document is the first node executed in the capture phase.\n * For certain advanced UI elements, such as form autocomplete, it is important to capture key-down events before\n * Bootstrap's handlers to prevent unintended closures of elements. Therefore, we need to change the Bootstrap handler\n * so that it operates one level lower, specifically at the body level.\n */\nconst realocateBootstrapEvents = () => {\n EventHandler.off(document, 'keydown.bs.dropdown.data-api', '.dropdown-menu', Bootstrap.Dropdown.dataApiKeydownHandler);\n EventHandler.on(document.body, 'keydown.bs.dropdown.data-api', '.dropdown-menu', Bootstrap.Dropdown.dataApiKeydownHandler);\n};\n\nconst pendingPromise = new Pending('theme_boost/loader:init');\n\n// Add pending promise event listeners to relevant Bootstrap custom events.\nsetupBootstrapPendingChecks();\n\n// Setup Aria helpers for Bootstrap features.\nAria.init();\n\n// Remember the last visited tabs.\nrememberTabs();\n\n// Enable all popovers.\nenablePopovers();\n\n// Enable all tooltips.\nenableTooltips();\nenableTooltipsOnContentUpdated();\n\n// Realocate Bootstrap events to the body element.\nrealocateBootstrapEvents();\n\npendingPromise.resolve();\n\nexport {\n Bootstrap,\n};\n"],"names":["enableTooltips","rootElement","document","tooltipTriggerList","querySelectorAll","tooltipList","map","tooltipTriggerEl","Bootstrap","Tooltip","addEventListener","e","key","forEach","tooltip","hide","pendingPromise","Pending","Aria","init","tabTriggerEl","hash","target","getAttribute","history","replaceState","location","window","tab","querySelector","click","rememberTabs","popoverTriggerList","popoverConfig","container","trigger","allowList","Object","assign","DefaultAllowlist","table","thead","tbody","tr","th","td","popoverTriggerEl","Popover","popoverTrigger","closest","getOrCreateInstance","show","popover","_isShown","enablePopovers","eventTypes","filterContentUpdated","detail","nodes","node","HTMLElement","off","Dropdown","dataApiKeydownHandler","on","body","resolve"],"mappings":";;;;;;;;oVAoGMA,eAAiB,eAACC,mEAAcC,eAC5BC,mBAAqBF,YAAYG,iBAAiB,8BAClDC,YAAc,IAAIF,oBAAoBG,KAAIC,kBAAoB,IAAIC,UAAUC,QAAQF,oBAE1FL,SAASQ,iBAAiB,WAAWC,IACnB,WAAVA,EAAEC,KACFP,YAAYQ,SAAQC,UAChBA,QAAQC,cAkClBC,eAAiB,IAAIC,iBAAQ,mDAMnCC,KAAKC,OAhHgB,UACMjB,SAASE,iBAAiB,4BAC7BE,KAAIc,cAAgBA,aAAaV,iBAAiB,gBAAiBC,QAC/EU,KAAOV,EAAEW,OAAOC,aAAa,QAC7BC,QAAQC,aACRD,QAAQC,aAAa,KAAM,KAAMJ,MAEjCK,SAASL,KAAOA,gBAGlBA,KAAOM,OAAOD,SAASL,QACzBA,KAAM,OACAO,IAAM1B,SAAS2B,cAAc,2BAA6BR,KAAO,MACnEO,KACAA,IAAIE,UAqGhBC,GA5FuB,YACbC,mBAAqB9B,SAASE,iBAAiB,8BAC/C6B,cAAgB,CAClBC,UAAW,OACXC,QAAS,QACTC,UAAWC,OAAOC,OAAOC,4BAAkB,CAACC,MAAO,GAAIC,MAAO,GAAIC,MAAO,GAAIC,GAAI,GAAIC,GAAI,GAAIC,GAAI,UAEjGb,oBAAoB1B,KAAIwC,kBAAoB,IAAItC,UAAUuC,QAAQD,iBAAkBb,iBAGxF/B,SAASQ,iBAAiB,2BAA4BC,QACpCA,EAAEW,OACiBlB,iBAAiB,+BAC1BE,KAAIwC,kBAAoB,IAAItC,UAAUuC,QAAQD,iBAAkBb,oBAG5F/B,SAASQ,iBAAiB,WAAWC,UAC3BqC,eAAiBrC,EAAEW,OAAO2B,QAAQ,8BAC1B,WAAVtC,EAAEC,KAAoBoC,gBACtBxC,UAAUuC,QAAQG,oBAAoBF,gBAAgBjC,OAE5C,UAAVJ,EAAEC,KAAmBoC,gBACrBxC,UAAUuC,QAAQG,oBAAoBF,gBAAgBG,UAG9DjD,SAASQ,iBAAiB,SAASC,UACzBqC,eAAiBrC,EAAEW,OAAO2B,QAAQ,kCACnCD,4BAGCI,QAAU5C,UAAUuC,QAAQG,oBAAoBF,gBACjDI,QAAQC,YACTD,QAAQD,WA+DpBG,GAGAtD,iBAvCIE,SAASQ,iBAAiB6C,mBAAWC,sBAAsB7C,IACvDA,EAAE8C,OAAOC,MAAM7C,SAAQ8C,OACfA,gBAAgBC,aAChB5D,eAAe2D,kCAiBdE,IAAI3D,SAAU,+BAAgC,iBAAkBM,UAAUsD,SAASC,6CACnFC,GAAG9D,SAAS+D,KAAM,+BAAgC,iBAAkBzD,UAAUsD,SAASC,uBAwBxG/C,eAAekD"}
\ No newline at end of file
+{"version":3,"file":"loader.min.js","sources":["../src/loader.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 * Template renderer for Moodle. Load and render Moodle templates with Mustache.\n *\n * @module theme_boost/loader\n * @copyright 2015 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 2.9\n */\n\nimport * as Aria from './aria';\nimport * as Bootstrap from './index';\nimport Pending from 'core/pending';\nimport {eventTypes} from 'core_filters/events';\nimport {DefaultAllowlist} from './bootstrap/util/sanitizer';\nimport setupBootstrapPendingChecks from './pending';\nimport EventHandler from './bootstrap/dom/event-handler';\nimport SelectorEngine from './bootstrap/dom/selector-engine';\n\n/**\n * Rember the last visited tabs.\n */\nconst rememberTabs = () => {\n const tabTriggerList = document.querySelectorAll('a[data-bs-toggle=\"tab\"]');\n [...tabTriggerList].map(tabTriggerEl => tabTriggerEl.addEventListener('shown.bs.tab', (e) => {\n var hash = e.target.getAttribute('href');\n if (history.replaceState) {\n history.replaceState(null, null, hash);\n } else {\n location.hash = hash;\n }\n }));\n const hash = window.location.hash;\n if (hash) {\n const tab = document.querySelector('[role=\"tablist\"] [href=\"' + hash + '\"]');\n if (tab) {\n tab.click();\n }\n }\n};\n\n/**\n * Enable all popovers\n *\n */\nconst enablePopovers = () => {\n const popoverTriggerList = document.querySelectorAll('[data-bs-toggle=\"popover\"]');\n // ExcludeSelector lets a call site trim out elements (e.g. other open help popovers) that\n // Bootstrap's own SelectorEngine.focusableChildren() would otherwise include.\n const getTabbableElements = (container, excludeSelector) => {\n const elements = SelectorEngine.focusableChildren(container)\n .filter(element => element.tabIndex >= 0)\n .filter(element => !excludeSelector || !element.matches(excludeSelector));\n const tabbableRadios = new Set();\n elements.filter(element => element.matches('input[type=\"radio\"][name]')).forEach(radio => {\n const group = elements.filter(element => element.matches('input[type=\"radio\"]')\n && element.name === radio.name && element.form === radio.form);\n tabbableRadios.add(group.find(element => element.checked) ?? group[0]);\n });\n return elements.filter(element => !element.matches('input[type=\"radio\"][name]') || tabbableRadios.has(element))\n .sort((elementA, elementB) => {\n if (elementA.tabIndex === elementB.tabIndex) {\n return 0;\n }\n if (elementA.tabIndex === 0) {\n return 1;\n }\n if (elementB.tabIndex === 0) {\n return -1;\n }\n return elementA.tabIndex - elementB.tabIndex;\n });\n };\n const popoverConfig = {\n container: 'body',\n trigger: 'focus',\n allowList: Object.assign(DefaultAllowlist, {table: [], thead: [], tbody: [], tr: [], th: [], td: []}),\n };\n // Maps a help popover's tip element back to its trigger. Looking this up via the trigger's\n // aria-describedby attribute isn't reliable since that attribute is repointed to the tip's\n // content element (see the 'inserted.bs.popover' listener below).\n const helpPopoverTriggers = new WeakMap();\n const initialisePopover = popoverTriggerEl => {\n const isHelpPopover = popoverTriggerEl.classList.contains('help-icon');\n const config = isHelpPopover\n ? {\n ...popoverConfig,\n trigger: 'manual',\n template: Bootstrap.Popover.Default.template.replace('role=\"tooltip\"', 'role=\"dialog\"'),\n }\n : popoverConfig;\n const popover = new Bootstrap.Popover(popoverTriggerEl, config);\n if (isHelpPopover) {\n popoverTriggerEl.setAttribute('aria-haspopup', 'dialog');\n }\n return popover;\n };\n [...popoverTriggerList].map(initialisePopover);\n\n // Enable dynamically created popovers inside modals.\n document.addEventListener('core/modal:bodyRendered', (e) => {\n const modal = e.target;\n const popoverTriggerList = modal.querySelectorAll('[data-bs-toggle=\"popover\"]');\n [...popoverTriggerList].map(initialisePopover);\n });\n\n document.addEventListener('keydown', e => {\n const popoverTrigger = e.target.closest('[data-bs-toggle=\"popover\"]');\n const helpPopover = e.target.closest('.help-popover');\n const helpPopoverTrigger = helpPopover ? helpPopoverTriggers.get(helpPopover) : null;\n if (e.key === 'Escape' && popoverTrigger) {\n Bootstrap.Popover.getOrCreateInstance(popoverTrigger).hide();\n }\n if (e.key === 'Escape' && helpPopoverTrigger) {\n // Focus the trigger before hiding so the focusin handler's \"already shown\" guard\n // is still true, otherwise it re-shows a new tip that the pending hide() then\n // destroys once its (animated, therefore deferred) cleanup callback runs.\n helpPopoverTrigger.focus();\n Bootstrap.Popover.getOrCreateInstance(helpPopoverTrigger).hide();\n }\n if (e.key === 'Enter' && popoverTrigger) {\n const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);\n if (!popover._isShown()) {\n popover.show();\n }\n }\n if (e.key === 'Tab' && !e.shiftKey && popoverTrigger?.classList.contains('help-icon')) {\n const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);\n if (popover._isShown() && popover.tip) {\n const firstFocusableElement = getTabbableElements(popover.tip)[0];\n if (firstFocusableElement) {\n e.preventDefault();\n firstFocusableElement.focus();\n }\n }\n }\n if (e.key === 'Tab' && helpPopoverTrigger) {\n const popoverFocusableElements = getTabbableElements(helpPopover);\n const focusedElementIndex = popoverFocusableElements.indexOf(e.target);\n if (e.shiftKey && focusedElementIndex === 0) {\n e.preventDefault();\n helpPopoverTrigger.focus();\n return;\n }\n if (e.shiftKey || focusedElementIndex !== popoverFocusableElements.length - 1) {\n return;\n }\n const focusableElements = getTabbableElements(document.body, '.help-popover, .help-popover *');\n const triggerIndex = focusableElements.indexOf(helpPopoverTrigger);\n const nextFocusableElement = triggerIndex === -1 ? null : focusableElements[triggerIndex + 1];\n if (nextFocusableElement) {\n e.preventDefault();\n nextFocusableElement.focus();\n }\n }\n });\n document.addEventListener('click', e => {\n const popoverTrigger = e.target.closest('[data-bs-toggle=\"popover\"]');\n document.querySelectorAll('.help-icon[aria-describedby]').forEach(trigger => {\n const triggerPopover = Bootstrap.Popover.getOrCreateInstance(trigger);\n if (trigger !== popoverTrigger && !triggerPopover.tip?.contains(e.target)) {\n triggerPopover.hide();\n }\n });\n if (!popoverTrigger) {\n return;\n }\n const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);\n if (!popover._isShown()) {\n popover.show();\n }\n });\n document.addEventListener('focusin', e => {\n const popoverTrigger = e.target.closest('.help-icon[data-bs-toggle=\"popover\"]');\n if (popoverTrigger) {\n const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);\n if (!popover._isShown()) {\n popover.show();\n }\n }\n });\n document.addEventListener('focusout', e => {\n const popoverTrigger = e.target.closest('.help-icon[data-bs-toggle=\"popover\"]');\n const helpPopover = e.target.closest('.help-popover');\n const trigger = popoverTrigger ?? (helpPopover ? helpPopoverTriggers.get(helpPopover) : null);\n if (!trigger) {\n return;\n }\n const popover = Bootstrap.Popover.getOrCreateInstance(trigger);\n const popoverElement = helpPopover ?? popover.tip;\n if (!trigger.contains(e.relatedTarget) && !popoverElement?.contains(e.relatedTarget)) {\n popover.hide();\n }\n });\n document.addEventListener('inserted.bs.popover', e => {\n if (e.target.classList.contains('help-icon')) {\n const tip = Bootstrap.Popover.getOrCreateInstance(e.target).tip;\n helpPopoverTriggers.set(tip, e.target);\n tip.setAttribute('aria-label', e.target.getAttribute('aria-label'));\n // The trigger's aria-describedby points at the tip above. Per the accessible name/\n // description computation, a referenced element's own aria-label takes precedence\n // over its content, so pointing aria-describedby at the tip itself (which now has an\n // aria-label) would make the description collapse to \"Help\" instead of the actual\n // help text. Point it at the content element instead, which has no aria-label of\n // its own.\n const content = tip.querySelector('.popover-body');\n if (content) {\n content.id = content.id || `${tip.id}-content`;\n e.target.setAttribute('aria-describedby', content.id);\n }\n }\n });\n};\n\n/**\n * Enable tooltips\n *\n * @param {Element} rootElement\n */\nconst enableTooltips = (rootElement = document) => {\n const tooltipTriggerList = rootElement.querySelectorAll('[data-bs-toggle=\"tooltip\"]');\n const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Bootstrap.Tooltip(tooltipTriggerEl));\n\n document.addEventListener('keydown', e => {\n if (e.key === 'Escape') {\n tooltipList.forEach(tooltip => {\n tooltip.hide();\n });\n }\n });\n};\n\n/**\n * Enable tooltips for dynamic content updates\n */\nconst enableTooltipsOnContentUpdated = () => {\n document.addEventListener(eventTypes.filterContentUpdated, e => {\n e.detail.nodes.forEach(node => {\n if (node instanceof HTMLElement) {\n enableTooltips(node);\n }\n });\n });\n};\n\n/**\n * Realocate Bootstrap events to the body element.\n *\n * Bootstrap 5 has a unique event handling mechanism that attaches all event handlers at the document level\n * during the capture phase, rather than the usual bubbling phase. As a result, original Bootstrap events\n * cannot be stopped or prevented, since the document is the first node executed in the capture phase.\n * For certain advanced UI elements, such as form autocomplete, it is important to capture key-down events before\n * Bootstrap's handlers to prevent unintended closures of elements. Therefore, we need to change the Bootstrap handler\n * so that it operates one level lower, specifically at the body level.\n */\nconst realocateBootstrapEvents = () => {\n EventHandler.off(document, 'keydown.bs.dropdown.data-api', '.dropdown-menu', Bootstrap.Dropdown.dataApiKeydownHandler);\n EventHandler.on(document.body, 'keydown.bs.dropdown.data-api', '.dropdown-menu', Bootstrap.Dropdown.dataApiKeydownHandler);\n};\n\nconst pendingPromise = new Pending('theme_boost/loader:init');\n\n// Add pending promise event listeners to relevant Bootstrap custom events.\nsetupBootstrapPendingChecks();\n\n// Setup Aria helpers for Bootstrap features.\nAria.init();\n\n// Remember the last visited tabs.\nrememberTabs();\n\n// Enable all popovers.\nenablePopovers();\n\n// Enable all tooltips.\nenableTooltips();\nenableTooltipsOnContentUpdated();\n\n// Realocate Bootstrap events to the body element.\nrealocateBootstrapEvents();\n\npendingPromise.resolve();\n\nexport {\n Bootstrap,\n};\n"],"names":["enableTooltips","rootElement","document","tooltipTriggerList","querySelectorAll","tooltipList","map","tooltipTriggerEl","Bootstrap","Tooltip","addEventListener","e","key","forEach","tooltip","hide","pendingPromise","Pending","Aria","init","tabTriggerEl","hash","target","getAttribute","history","replaceState","location","window","tab","querySelector","click","rememberTabs","popoverTriggerList","getTabbableElements","container","excludeSelector","elements","SelectorEngine","focusableChildren","filter","element","tabIndex","matches","tabbableRadios","Set","radio","group","name","form","add","find","checked","has","sort","elementA","elementB","popoverConfig","trigger","allowList","Object","assign","DefaultAllowlist","table","thead","tbody","tr","th","td","helpPopoverTriggers","WeakMap","initialisePopover","popoverTriggerEl","isHelpPopover","classList","contains","config","template","Popover","Default","replace","popover","setAttribute","popoverTrigger","closest","helpPopover","helpPopoverTrigger","get","getOrCreateInstance","focus","_isShown","show","shiftKey","tip","firstFocusableElement","preventDefault","popoverFocusableElements","focusedElementIndex","indexOf","length","focusableElements","body","triggerIndex","nextFocusableElement","triggerPopover","_triggerPopover$tip","popoverElement","relatedTarget","set","content","id","enablePopovers","eventTypes","filterContentUpdated","detail","nodes","node","HTMLElement","off","Dropdown","dataApiKeydownHandler","on","resolve"],"mappings":"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"}
\ No newline at end of file
diff --git a/public/theme/boost/amd/src/loader.js b/public/theme/boost/amd/src/loader.js
index 1b5c49bf55773..53f32775a53d9 100644
--- a/public/theme/boost/amd/src/loader.js
+++ b/public/theme/boost/amd/src/loader.js
@@ -29,6 +29,7 @@ import {eventTypes} from 'core_filters/events';
import {DefaultAllowlist} from './bootstrap/util/sanitizer';
import setupBootstrapPendingChecks from './pending';
import EventHandler from './bootstrap/dom/event-handler';
+import SelectorEngine from './bootstrap/dom/selector-engine';
/**
* Rember the last visited tabs.
@@ -58,31 +59,123 @@ const rememberTabs = () => {
*/
const enablePopovers = () => {
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
+ // ExcludeSelector lets a call site trim out elements (e.g. other open help popovers) that
+ // Bootstrap's own SelectorEngine.focusableChildren() would otherwise include.
+ const getTabbableElements = (container, excludeSelector) => {
+ const elements = SelectorEngine.focusableChildren(container)
+ .filter(element => element.tabIndex >= 0)
+ .filter(element => !excludeSelector || !element.matches(excludeSelector));
+ const tabbableRadios = new Set();
+ elements.filter(element => element.matches('input[type="radio"][name]')).forEach(radio => {
+ const group = elements.filter(element => element.matches('input[type="radio"]')
+ && element.name === radio.name && element.form === radio.form);
+ tabbableRadios.add(group.find(element => element.checked) ?? group[0]);
+ });
+ return elements.filter(element => !element.matches('input[type="radio"][name]') || tabbableRadios.has(element))
+ .sort((elementA, elementB) => {
+ if (elementA.tabIndex === elementB.tabIndex) {
+ return 0;
+ }
+ if (elementA.tabIndex === 0) {
+ return 1;
+ }
+ if (elementB.tabIndex === 0) {
+ return -1;
+ }
+ return elementA.tabIndex - elementB.tabIndex;
+ });
+ };
const popoverConfig = {
container: 'body',
trigger: 'focus',
allowList: Object.assign(DefaultAllowlist, {table: [], thead: [], tbody: [], tr: [], th: [], td: []}),
};
- [...popoverTriggerList].map(popoverTriggerEl => new Bootstrap.Popover(popoverTriggerEl, popoverConfig));
+ // Maps a help popover's tip element back to its trigger. Looking this up via the trigger's
+ // aria-describedby attribute isn't reliable since that attribute is repointed to the tip's
+ // content element (see the 'inserted.bs.popover' listener below).
+ const helpPopoverTriggers = new WeakMap();
+ const initialisePopover = popoverTriggerEl => {
+ const isHelpPopover = popoverTriggerEl.classList.contains('help-icon');
+ const config = isHelpPopover
+ ? {
+ ...popoverConfig,
+ trigger: 'manual',
+ template: Bootstrap.Popover.Default.template.replace('role="tooltip"', 'role="dialog"'),
+ }
+ : popoverConfig;
+ const popover = new Bootstrap.Popover(popoverTriggerEl, config);
+ if (isHelpPopover) {
+ popoverTriggerEl.setAttribute('aria-haspopup', 'dialog');
+ }
+ return popover;
+ };
+ [...popoverTriggerList].map(initialisePopover);
// Enable dynamically created popovers inside modals.
document.addEventListener('core/modal:bodyRendered', (e) => {
const modal = e.target;
const popoverTriggerList = modal.querySelectorAll('[data-bs-toggle="popover"]');
- [...popoverTriggerList].map(popoverTriggerEl => new Bootstrap.Popover(popoverTriggerEl, popoverConfig));
+ [...popoverTriggerList].map(initialisePopover);
});
document.addEventListener('keydown', e => {
const popoverTrigger = e.target.closest('[data-bs-toggle="popover"]');
+ const helpPopover = e.target.closest('.help-popover');
+ const helpPopoverTrigger = helpPopover ? helpPopoverTriggers.get(helpPopover) : null;
if (e.key === 'Escape' && popoverTrigger) {
Bootstrap.Popover.getOrCreateInstance(popoverTrigger).hide();
}
+ if (e.key === 'Escape' && helpPopoverTrigger) {
+ // Focus the trigger before hiding so the focusin handler's "already shown" guard
+ // is still true, otherwise it re-shows a new tip that the pending hide() then
+ // destroys once its (animated, therefore deferred) cleanup callback runs.
+ helpPopoverTrigger.focus();
+ Bootstrap.Popover.getOrCreateInstance(helpPopoverTrigger).hide();
+ }
if (e.key === 'Enter' && popoverTrigger) {
- Bootstrap.Popover.getOrCreateInstance(popoverTrigger).show();
+ const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);
+ if (!popover._isShown()) {
+ popover.show();
+ }
+ }
+ if (e.key === 'Tab' && !e.shiftKey && popoverTrigger?.classList.contains('help-icon')) {
+ const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);
+ if (popover._isShown() && popover.tip) {
+ const firstFocusableElement = getTabbableElements(popover.tip)[0];
+ if (firstFocusableElement) {
+ e.preventDefault();
+ firstFocusableElement.focus();
+ }
+ }
+ }
+ if (e.key === 'Tab' && helpPopoverTrigger) {
+ const popoverFocusableElements = getTabbableElements(helpPopover);
+ const focusedElementIndex = popoverFocusableElements.indexOf(e.target);
+ if (e.shiftKey && focusedElementIndex === 0) {
+ e.preventDefault();
+ helpPopoverTrigger.focus();
+ return;
+ }
+ if (e.shiftKey || focusedElementIndex !== popoverFocusableElements.length - 1) {
+ return;
+ }
+ const focusableElements = getTabbableElements(document.body, '.help-popover, .help-popover *');
+ const triggerIndex = focusableElements.indexOf(helpPopoverTrigger);
+ const nextFocusableElement = triggerIndex === -1 ? null : focusableElements[triggerIndex + 1];
+ if (nextFocusableElement) {
+ e.preventDefault();
+ nextFocusableElement.focus();
+ }
}
});
document.addEventListener('click', e => {
const popoverTrigger = e.target.closest('[data-bs-toggle="popover"]');
+ document.querySelectorAll('.help-icon[aria-describedby]').forEach(trigger => {
+ const triggerPopover = Bootstrap.Popover.getOrCreateInstance(trigger);
+ if (trigger !== popoverTrigger && !triggerPopover.tip?.contains(e.target)) {
+ triggerPopover.hide();
+ }
+ });
if (!popoverTrigger) {
return;
}
@@ -91,6 +184,46 @@ const enablePopovers = () => {
popover.show();
}
});
+ document.addEventListener('focusin', e => {
+ const popoverTrigger = e.target.closest('.help-icon[data-bs-toggle="popover"]');
+ if (popoverTrigger) {
+ const popover = Bootstrap.Popover.getOrCreateInstance(popoverTrigger);
+ if (!popover._isShown()) {
+ popover.show();
+ }
+ }
+ });
+ document.addEventListener('focusout', e => {
+ const popoverTrigger = e.target.closest('.help-icon[data-bs-toggle="popover"]');
+ const helpPopover = e.target.closest('.help-popover');
+ const trigger = popoverTrigger ?? (helpPopover ? helpPopoverTriggers.get(helpPopover) : null);
+ if (!trigger) {
+ return;
+ }
+ const popover = Bootstrap.Popover.getOrCreateInstance(trigger);
+ const popoverElement = helpPopover ?? popover.tip;
+ if (!trigger.contains(e.relatedTarget) && !popoverElement?.contains(e.relatedTarget)) {
+ popover.hide();
+ }
+ });
+ document.addEventListener('inserted.bs.popover', e => {
+ if (e.target.classList.contains('help-icon')) {
+ const tip = Bootstrap.Popover.getOrCreateInstance(e.target).tip;
+ helpPopoverTriggers.set(tip, e.target);
+ tip.setAttribute('aria-label', e.target.getAttribute('aria-label'));
+ // The trigger's aria-describedby points at the tip above. Per the accessible name/
+ // description computation, a referenced element's own aria-label takes precedence
+ // over its content, so pointing aria-describedby at the tip itself (which now has an
+ // aria-label) would make the description collapse to "Help" instead of the actual
+ // help text. Point it at the content element instead, which has no aria-label of
+ // its own.
+ const content = tip.querySelector('.popover-body');
+ if (content) {
+ content.id = content.id || `${tip.id}-content`;
+ e.target.setAttribute('aria-describedby', content.id);
+ }
+ }
+ });
};
/**
diff --git a/public/theme/boost/scss/moodle/blocks.scss b/public/theme/boost/scss/moodle/blocks.scss
index 4d63471e92102..562141c235c75 100644
--- a/public/theme/boost/scss/moodle/blocks.scss
+++ b/public/theme/boost/scss/moodle/blocks.scss
@@ -204,6 +204,10 @@ $blocks-plus-gutter: calc(#{$blocks-column-width} + (#{$grid-gutter-width} * 0.5
margin-top: 0;
}
}
+ // Reset the negative margin in editing mode so the pagination controls do not obscure the block's move and action controls.
+ .editing & .paging-bar-container {
+ margin-top: 0;
+ }
}
#block-region-side-pre {
.block_recentlyaccessedcourses {
diff --git a/public/theme/boost/scss/moodle/core.scss b/public/theme/boost/scss/moodle/core.scss
index bca51eabea661..89c8362f5ac0e 100644
--- a/public/theme/boost/scss/moodle/core.scss
+++ b/public/theme/boost/scss/moodle/core.scss
@@ -1759,6 +1759,7 @@ nav.navbar .logo img {
ul.dragdrop-keyboard-drag li {
list-style-type: none;
+ margin-bottom: 0.1em;
a,
a:hover {
color: inherit;
@@ -1793,6 +1794,11 @@ body.lockscroll {
display: inline-block;
}
+.yui3-calendar-weekday {
+ max-width: 40px;
+ @include text-truncate();
+}
+
dd:before,
dd:after {
display: block;
@@ -2808,6 +2814,12 @@ body.dragging {
margin-left: 0;
margin-right: 4px;
width: 9px;
+ display: inline-block;
+ vertical-align: 0.255em;
+}
+
+.dropleft .dropdown-toggle::after {
+ content: none;
}
.dir-rtl .dropleft .dropdown-toggle::before {
@@ -2817,6 +2829,7 @@ body.dragging {
.dropright .dropdown-toggle::after {
border: 0;
font: var(--fa-font-solid);
+ font-size: 9px;
content: fa-content($fa-var-chevron-right);
}
@@ -2828,6 +2841,7 @@ body.dragging {
.dropup .dropdown-toggle::after {
border: 0;
font: var(--fa-font-solid);
+ font-size: 9px;
content: fa-content($fa-var-chevron-up);
}
diff --git a/public/theme/boost/scss/moodle/message.scss b/public/theme/boost/scss/moodle/message.scss
index f555a0ff928c2..26ad0f0fe1464 100644
--- a/public/theme/boost/scss/moodle/message.scss
+++ b/public/theme/boost/scss/moodle/message.scss
@@ -525,6 +525,9 @@ $message-day-color: color-contrast($message-app-bg) !default;
&:hover {
color: $white;
background-color: $primary;
+ .message-icon-forward {
+ color: inherit !important; // stylelint-disable-line declaration-no-important
+ }
}
&:first-child {
border-top: 0;
diff --git a/public/theme/boost/scss/moodle/modchooser.scss b/public/theme/boost/scss/moodle/modchooser.scss
index 4ac9f588dd9a5..493ac70cd6a90 100644
--- a/public/theme/boost/scss/moodle/modchooser.scss
+++ b/public/theme/boost/scss/moodle/modchooser.scss
@@ -75,6 +75,51 @@
}
}
}
+
+ // Reflow support for narrow or short viewports (e.g. 320x256 at 400% zoom on a
+ // 1280x1024 display, or 480x270 at 400% zoom on a 1920x1080 display), to satisfy
+ // WCAG 2.2 SC 1.4.10 (Reflow). The width and height conditions are combined with an
+ // "or" (comma-separated media queries) rather than "and", so the fix engages when
+ // either dimension is constrained, not only when both are constrained together.
+ // The search header and category tabs are no longer sticky/fixed and scroll away with
+ // the rest of the content, and the footer remains sticky but with reduced padding.
+ @media (max-width: 400px), (max-height: 300px) {
+ .modal-header {
+ padding-top: map-get($spacers, 1);
+ padding-bottom: map-get($spacers, 1);
+ }
+
+ .modal-footer {
+ height: auto;
+ min-height: 2.5rem;
+ padding-top: map-get($spacers, 0);
+ padding-bottom: map-get($spacers, 0);
+
+ .activitychooserfooter {
+ flex-wrap: wrap;
+ padding-left: 0 !important; /* stylelint-disable-line declaration-no-important */
+ padding-right: 0 !important; /* stylelint-disable-line declaration-no-important */
+ }
+ }
+
+ .modal-body,
+ .modal-body .carousel,
+ .modal-body .carousel-inner,
+ .modal-body .carousel-item {
+ height: auto;
+ overflow: visible;
+ }
+
+ .modal-body {
+ overflow-y: auto;
+ }
+
+ .modal-body .carousel-item {
+ float: none;
+ margin-right: 0;
+ padding: map-get($spacers, 1) !important; /* stylelint-disable-line declaration-no-important */
+ }
+ }
}
/* Main container layout styles. */
@@ -277,3 +322,45 @@
overflow-y: auto;
}
}
+
+/* Reflow support for narrow or short viewports (e.g. 320x256 at 400% zoom on a
+ 1280x1024 display, or 480x270 at 400% zoom on a 1920x1080 display), to satisfy
+ WCAG 2.2 SC 1.4.10 (Reflow). The width and height conditions are combined with an
+ "or" (comma-separated media queries) rather than "and", so the fix engages when
+ either dimension is constrained, not only when both are constrained together.
+ Category tabs stack vertically and activities stack in a single column instead of
+ the fixed-height grid layout used at larger sizes. */
+@media (max-width: 400px), (max-height: 300px) {
+ .modchoosercontainer {
+ display: block;
+ height: auto;
+ overflow: visible;
+
+ .modchooserfilters .searchcontainer {
+ margin-bottom: map-get($spacers, 1) !important; /* stylelint-disable-line declaration-no-important */
+ }
+
+ .modchoosernav {
+ // Bootstrap's flex-row/flex-md-column utility classes on this element are
+ // generated with !important, so the override below must match.
+ flex-direction: column !important; /* stylelint-disable-line declaration-no-important */
+ height: auto;
+ overflow: visible;
+ white-space: normal;
+
+ .nav-link {
+ width: 100%;
+ max-width: none;
+ }
+ }
+
+ .modchoosercontent {
+ height: auto;
+ overflow: visible;
+ }
+ }
+
+ .modchoosercontent .optionscontainer {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/public/theme/boost/scss/moodle/modules.scss b/public/theme/boost/scss/moodle/modules.scss
index 785b1f3387d69..358b9893e8d64 100644
--- a/public/theme/boost/scss/moodle/modules.scss
+++ b/public/theme/boost/scss/moodle/modules.scss
@@ -1033,6 +1033,10 @@ div#dock {
.fitem .felement .form-select {
align-self: flex-start;
}
+ .mediaplugin,
+ .mediaplugin video {
+ min-width: 200px;
+ }
}
.path-mod-lesson .table td {
vertical-align: middle;
@@ -1537,6 +1541,16 @@ $popout-header-height: 4rem;
z-index: 1;
}
+// At the small viewport produced by high browser zoom the primary navbar is no
+// longer fixed, so a sticky grading-table header would consume a large part of
+// the available height. Make the header non-sticky at this breakpoint instead
+// so submission rows stay readable.
+@media (max-width: map-get($grid-breakpoints, "sm")) and (max-height: 320px) {
+ .path-mod-assign .gradingtable thead tr {
+ position: static;
+ }
+}
+
/**
* Mod LTI.
*/
diff --git a/public/theme/boost/scss/moodle/primarynavigation.scss b/public/theme/boost/scss/moodle/primarynavigation.scss
index 60b685f32f39e..b4fb26fddd471 100644
--- a/public/theme/boost/scss/moodle/primarynavigation.scss
+++ b/public/theme/boost/scss/moodle/primarynavigation.scss
@@ -30,3 +30,19 @@
}
}
}
+
+.drawer-primary .drawerheader {
+ [data-region="site-home-link"] {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ }
+
+ .logo {
+ max-width: 100%;
+ max-height: $navbar-height;
+ width: auto;
+ height: $navbar-height;
+ object-fit: contain;
+ }
+}
diff --git a/public/theme/boost/scss/preset/default.scss b/public/theme/boost/scss/preset/default.scss
index 576ad180bf376..c3c669797ea74 100644
--- a/public/theme/boost/scss/preset/default.scss
+++ b/public/theme/boost/scss/preset/default.scss
@@ -80,6 +80,7 @@ $card-group-margin: .25rem;
$focus-ring-opacity: .75 !default;
$input-border-color: var(--#{$prefix}gray-500) !default;
$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}gray-500) !default;
+$form-switch-color: rgba($black, .45) !default;
// Dropdowns
$dropdown-link-hover-color: $white;
diff --git a/public/theme/boost/style/moodle.css b/public/theme/boost/style/moodle.css
index 0c9ffac016f22..3467871b654ac 100644
--- a/public/theme/boost/style/moodle.css
+++ b/public/theme/boost/style/moodle.css
@@ -15734,7 +15734,7 @@ textarea.form-control-lg {
padding-left: 2.5em;
}
.form-switch .form-check-input {
- --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");
+ --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.45%29'/%3e%3c/svg%3e");
width: 2em;
margin-left: -2.5em;
background-image: var(--bs-form-switch-bg);
@@ -27354,6 +27354,7 @@ nav.navbar .logo img {
ul.dragdrop-keyboard-drag li {
list-style-type: none;
+ margin-bottom: 0.1em;
}
ul.dragdrop-keyboard-drag li a,
ul.dragdrop-keyboard-drag li a:hover {
@@ -27388,6 +27389,13 @@ body.lockscroll {
display: inline-block;
}
+.yui3-calendar-weekday {
+ max-width: 40px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
dd:before,
dd:after {
display: block;
@@ -28297,6 +28305,12 @@ body.dragging .dragging {
margin-left: 0;
margin-right: 4px;
width: 9px;
+ display: inline-block;
+ vertical-align: 0.255em;
+}
+
+.dropleft .dropdown-toggle::after {
+ content: none;
}
.dir-rtl .dropleft .dropdown-toggle::before {
@@ -28306,6 +28320,7 @@ body.dragging .dragging {
.dropright .dropdown-toggle::after {
border: 0;
font: var(--fa-font-solid);
+ font-size: 9px;
content: "\f054";
}
@@ -28316,6 +28331,7 @@ body.dragging .dragging {
.dropup .dropdown-toggle::after {
border: 0;
font: var(--fa-font-solid);
+ font-size: 9px;
content: "\f077";
}
@@ -29616,6 +29632,9 @@ img.icon {
margin-top: 0;
}
}
+.editing .block_recentlyaccessedcourses .paging-bar-container {
+ margin-top: 0;
+}
#block-region-side-pre .block_recentlyaccessedcourses .paging-bar-container {
margin-top: 0;
@@ -31768,6 +31787,38 @@ table.calendartable caption {
max-width: 6rem;
margin-bottom: 0.6rem;
}
+@media (max-width: 400px), (max-height: 300px) {
+ .modchooser.modal-dialog .modal-header {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ }
+ .modchooser.modal-dialog .modal-footer {
+ height: auto;
+ min-height: 2.5rem;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ .modchooser.modal-dialog .modal-footer .activitychooserfooter {
+ flex-wrap: wrap;
+ padding-left: 0 !important; /* stylelint-disable-line declaration-no-important */
+ padding-right: 0 !important; /* stylelint-disable-line declaration-no-important */
+ }
+ .modchooser.modal-dialog .modal-body,
+ .modchooser.modal-dialog .modal-body .carousel,
+ .modchooser.modal-dialog .modal-body .carousel-inner,
+ .modchooser.modal-dialog .modal-body .carousel-item {
+ height: auto;
+ overflow: visible;
+ }
+ .modchooser.modal-dialog .modal-body {
+ overflow-y: auto;
+ }
+ .modchooser.modal-dialog .modal-body .carousel-item {
+ float: none;
+ margin-right: 0;
+ padding: 0.25rem !important; /* stylelint-disable-line declaration-no-important */
+ }
+}
/* Main container layout styles. */
.modchoosercontainer {
@@ -31926,6 +31977,40 @@ table.calendartable caption {
overflow-y: auto;
}
}
+/* Reflow support for narrow or short viewports (e.g. 320x256 at 400% zoom on a
+ 1280x1024 display, or 480x270 at 400% zoom on a 1920x1080 display), to satisfy
+ WCAG 2.2 SC 1.4.10 (Reflow). The width and height conditions are combined with an
+ "or" (comma-separated media queries) rather than "and", so the fix engages when
+ either dimension is constrained, not only when both are constrained together.
+ Category tabs stack vertically and activities stack in a single column instead of
+ the fixed-height grid layout used at larger sizes. */
+@media (max-width: 400px), (max-height: 300px) {
+ .modchoosercontainer {
+ display: block;
+ height: auto;
+ overflow: visible;
+ }
+ .modchoosercontainer .modchooserfilters .searchcontainer {
+ margin-bottom: 0.25rem !important; /* stylelint-disable-line declaration-no-important */
+ }
+ .modchoosercontainer .modchoosernav {
+ flex-direction: column !important; /* stylelint-disable-line declaration-no-important */
+ height: auto;
+ overflow: visible;
+ white-space: normal;
+ }
+ .modchoosercontainer .modchoosernav .nav-link {
+ width: 100%;
+ max-width: none;
+ }
+ .modchoosercontainer .modchoosercontent {
+ height: auto;
+ overflow: visible;
+ }
+ .modchoosercontent .optionscontainer {
+ grid-template-columns: 1fr;
+ }
+}
/* Anchor link offset fix. This makes hash links scroll 60px down to account for the fixed header. */
:target,
:focus {
@@ -33742,6 +33827,9 @@ a.ygtvspacer:hover {
color: #fff;
background-color: #0f6cbf;
}
+.message-app .list-group .list-group-item:hover .message-icon-forward {
+ color: inherit !important;
+}
.message-app .list-group .list-group-item:first-child {
border-top: 0;
}
@@ -36299,6 +36387,10 @@ div#dock {
#page-mod-lesson-view .fitem .felement .form-select {
align-self: flex-start;
}
+#page-mod-lesson-view .mediaplugin,
+#page-mod-lesson-view .mediaplugin video {
+ min-width: 200px;
+}
.path-mod-lesson .table td {
vertical-align: middle;
@@ -36841,6 +36933,11 @@ img.userpicture {
z-index: 1;
}
+@media (max-width: 576px) and (max-height: 320px) {
+ .path-mod-assign .gradingtable thead tr {
+ position: static;
+ }
+}
/**
* Mod LTI.
*/
@@ -40826,6 +40923,19 @@ div.editor_atto_toolbar button .icon {
border-right: 0;
}
+.drawer-primary .drawerheader [data-region=site-home-link] {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+}
+.drawer-primary .drawerheader .logo {
+ max-width: 100%;
+ max-height: 60px;
+ width: auto;
+ height: 60px;
+ object-fit: contain;
+}
+
.secondary-navigation {
padding-bottom: 15px;
}
diff --git a/public/theme/boost/templates/drawer.mustache b/public/theme/boost/templates/drawer.mustache
index 496ee73cd4dce..2c84b62baac8d 100644
--- a/public/theme/boost/templates/drawer.mustache
+++ b/public/theme/boost/templates/drawer.mustache
@@ -44,6 +44,7 @@
data-bs-toggle="tooltip"
data-bs-placement="{{$tooltipplacement}}right{{/tooltipplacement}}"
title="{{$closebuttontext}}{{#str}}closedrawer, core{{/str}}{{/closebuttontext}}"
+ aria-label="{{$closebuttontext}}{{#str}}closedrawer, core{{/str}}{{/closebuttontext}}"
>
{{#pix}} e/cancel, core {{/pix}}
diff --git a/public/theme/boost/templates/primary-drawer-mobile.mustache b/public/theme/boost/templates/primary-drawer-mobile.mustache
index fe7d27f6b51f1..45655e10aa9ab 100644
--- a/public/theme/boost/templates/primary-drawer-mobile.mustache
+++ b/public/theme/boost/templates/primary-drawer-mobile.mustache
@@ -55,10 +55,10 @@
href="{{{ config.homeurl }}}"
title="{{{ sitename }}}"
data-region="site-home-link"
- class="aabtn text-reset d-flex align-items-center py-1 h-100"
+ class="aabtn text-reset d-flex align-items-center h-100"
>
{{# output.should_display_navbar_logo }}
-
+
{{/ output.should_display_navbar_logo }}
{{^ output.should_display_navbar_logo }}
{{{ sitename }}}
diff --git a/public/theme/boost/tests/behat/help_popover.feature b/public/theme/boost/tests/behat/help_popover.feature
index 5c1ca601bfe53..4f2cc9c9a8509 100644
--- a/public/theme/boost/tests/behat/help_popover.feature
+++ b/public/theme/boost/tests/behat/help_popover.feature
@@ -3,17 +3,88 @@ Feature: Using the help popover
As a user who wants to use the help popover
The help popover must be accessible
- Background:
+ @javascript @accessibility
+ Scenario: Checking the policies link in the footer popover
Given the following config values are set as admin:
| sitepolicyhandler | tool_policy |
And the following policies exist:
- | Name | Revision | Content | Summary | Status |
- | This site policy | | full text2 | short text2 | active |
-
- @javascript @accessibility
- Scenario: Checking the policies link in the footer popover
- Given I am on site homepage
+ | Name | Revision | Content | Summary | Status |
+ | This site policy | | full text2 | short text2 | active |
+ And I am on site homepage
And I click on "Continue" "link"
When I click on "Show footer" "button" in the "page-footer" "region"
Then I should see "Policies" in the "page-footer" "region"
And the page should meet accessibility standards with "best-practice" extra tests
+
+ @javascript
+ Scenario: Navigate to a link in a form help popover using the keyboard
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "course" exists:
+ | fullname | Course 1 |
+ | shortname | C1 |
+ | category | 0 |
+ | enablecompletion | 1 |
+ And the following "activity" exists:
+ | activity | quiz |
+ | course | C1 |
+ | name | Test quiz |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I am on the "Test quiz" "quiz activity" page logged in as teacher1
+ And I navigate to "Settings" in current page administration
+ And I click on "Timing" "link"
+ When I click on "#fitem_id_timelimit .help-icon" "css_element"
+ Then ".help-popover a[href*='/mod/quiz/timing'][target='_blank']" "css_element" should be visible
+ And I press the tab key
+ Then the focused element is "More help" "link"
+ And "More help" "link" should be visible
+ When I press the shift tab key
+ Then the focused element is "#fitem_id_timelimit .help-icon" "css_element"
+ When I press the enter key
+ Then ".help-popover" "css_element" should be visible
+ When I press the tab key
+ Then the focused element is "More help" "link"
+ When I press the tab key
+ Then the focused element is "#id_timelimit_enabled" "css_element"
+ When I press the tab key
+ Then the focused element is "#fitem_id_overduehandling .help-icon" "css_element"
+ When I click on "#fitem_id_timelimit .help-icon" "css_element"
+ And I press the tab key
+ And I press the enter key
+ And I switch to a second window
+ And I close all opened windows
+ And I switch to the main window
+ And I click on "#fitem_id_timelimit .help-icon" "css_element"
+ And I press the escape key
+ Then ".help-popover" "css_element" should not be visible
+ And the focused element is "#fitem_id_timelimit .help-icon" "css_element"
+
+ @javascript
+ Scenario: Use a form help popover without a More help link
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "course" exists:
+ | fullname | Course 1 |
+ | shortname | C1 |
+ | category | 0 |
+ And the following "activity" exists:
+ | activity | quiz |
+ | course | C1 |
+ | name | Test quiz |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I am on the "Test quiz" "quiz activity" page logged in as teacher1
+ And I navigate to "Settings" in current page administration
+ And I click on "Timing" "link"
+ And I set the field "When time expires" to "There is a grace period when open attempts can be submitted, but no more questions answered"
+ When I click on "#fitem_id_graceperiod .help-icon" "css_element"
+ Then ".help-popover" "css_element" should be visible
+ And "More help" "link" should not exist in the ".help-popover" "css_element"
+ When I press the tab key
+ Then the focused element is not "#fitem_id_graceperiod .help-icon" "css_element"
+ And ".help-popover" "css_element" should not be visible
diff --git a/public/theme/classic/scss/preset/default.scss b/public/theme/classic/scss/preset/default.scss
index c47575989e8d5..54d74f4779824 100644
--- a/public/theme/classic/scss/preset/default.scss
+++ b/public/theme/classic/scss/preset/default.scss
@@ -79,6 +79,7 @@ $card-group-margin: .25rem;
$focus-ring-opacity: .75 !default;
$input-border-color: $gray-500 !default;
$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}gray-500) !default;
+$form-switch-color: rgba($black, .45) !default;
// Dropdowns
$dropdown-link-hover-color: $white;
diff --git a/public/theme/classic/style/moodle.css b/public/theme/classic/style/moodle.css
index 53e9ccf07feda..9a8e4182829e8 100644
--- a/public/theme/classic/style/moodle.css
+++ b/public/theme/classic/style/moodle.css
@@ -15734,7 +15734,7 @@ textarea.form-control-lg {
padding-left: 2.5em;
}
.form-switch .form-check-input {
- --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");
+ --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.45%29'/%3e%3c/svg%3e");
width: 2em;
margin-left: -2.5em;
background-image: var(--bs-form-switch-bg);
@@ -27354,6 +27354,7 @@ nav.navbar .logo img {
ul.dragdrop-keyboard-drag li {
list-style-type: none;
+ margin-bottom: 0.1em;
}
ul.dragdrop-keyboard-drag li a,
ul.dragdrop-keyboard-drag li a:hover {
@@ -27388,6 +27389,13 @@ body.lockscroll {
display: inline-block;
}
+.yui3-calendar-weekday {
+ max-width: 40px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
dd:before,
dd:after {
display: block;
@@ -28297,6 +28305,12 @@ body.dragging .dragging {
margin-left: 0;
margin-right: 4px;
width: 9px;
+ display: inline-block;
+ vertical-align: 0.255em;
+}
+
+.dropleft .dropdown-toggle::after {
+ content: none;
}
.dir-rtl .dropleft .dropdown-toggle::before {
@@ -28306,6 +28320,7 @@ body.dragging .dragging {
.dropright .dropdown-toggle::after {
border: 0;
font: var(--fa-font-solid);
+ font-size: 9px;
content: "\f054";
}
@@ -28316,6 +28331,7 @@ body.dragging .dragging {
.dropup .dropdown-toggle::after {
border: 0;
font: var(--fa-font-solid);
+ font-size: 9px;
content: "\f077";
}
@@ -29616,6 +29632,9 @@ img.icon {
margin-top: 0;
}
}
+.editing .block_recentlyaccessedcourses .paging-bar-container {
+ margin-top: 0;
+}
#block-region-side-pre .block_recentlyaccessedcourses .paging-bar-container {
margin-top: 0;
@@ -31768,6 +31787,38 @@ table.calendartable caption {
max-width: 6rem;
margin-bottom: 0.6rem;
}
+@media (max-width: 400px), (max-height: 300px) {
+ .modchooser.modal-dialog .modal-header {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ }
+ .modchooser.modal-dialog .modal-footer {
+ height: auto;
+ min-height: 2.5rem;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ .modchooser.modal-dialog .modal-footer .activitychooserfooter {
+ flex-wrap: wrap;
+ padding-left: 0 !important; /* stylelint-disable-line declaration-no-important */
+ padding-right: 0 !important; /* stylelint-disable-line declaration-no-important */
+ }
+ .modchooser.modal-dialog .modal-body,
+ .modchooser.modal-dialog .modal-body .carousel,
+ .modchooser.modal-dialog .modal-body .carousel-inner,
+ .modchooser.modal-dialog .modal-body .carousel-item {
+ height: auto;
+ overflow: visible;
+ }
+ .modchooser.modal-dialog .modal-body {
+ overflow-y: auto;
+ }
+ .modchooser.modal-dialog .modal-body .carousel-item {
+ float: none;
+ margin-right: 0;
+ padding: 0.25rem !important; /* stylelint-disable-line declaration-no-important */
+ }
+}
/* Main container layout styles. */
.modchoosercontainer {
@@ -31926,6 +31977,40 @@ table.calendartable caption {
overflow-y: auto;
}
}
+/* Reflow support for narrow or short viewports (e.g. 320x256 at 400% zoom on a
+ 1280x1024 display, or 480x270 at 400% zoom on a 1920x1080 display), to satisfy
+ WCAG 2.2 SC 1.4.10 (Reflow). The width and height conditions are combined with an
+ "or" (comma-separated media queries) rather than "and", so the fix engages when
+ either dimension is constrained, not only when both are constrained together.
+ Category tabs stack vertically and activities stack in a single column instead of
+ the fixed-height grid layout used at larger sizes. */
+@media (max-width: 400px), (max-height: 300px) {
+ .modchoosercontainer {
+ display: block;
+ height: auto;
+ overflow: visible;
+ }
+ .modchoosercontainer .modchooserfilters .searchcontainer {
+ margin-bottom: 0.25rem !important; /* stylelint-disable-line declaration-no-important */
+ }
+ .modchoosercontainer .modchoosernav {
+ flex-direction: column !important; /* stylelint-disable-line declaration-no-important */
+ height: auto;
+ overflow: visible;
+ white-space: normal;
+ }
+ .modchoosercontainer .modchoosernav .nav-link {
+ width: 100%;
+ max-width: none;
+ }
+ .modchoosercontainer .modchoosercontent {
+ height: auto;
+ overflow: visible;
+ }
+ .modchoosercontent .optionscontainer {
+ grid-template-columns: 1fr;
+ }
+}
/* Anchor link offset fix. This makes hash links scroll 60px down to account for the fixed header. */
:target,
:focus {
@@ -33742,6 +33827,9 @@ a.ygtvspacer:hover {
color: #fff;
background-color: #0f6cbf;
}
+.message-app .list-group .list-group-item:hover .message-icon-forward {
+ color: inherit !important;
+}
.message-app .list-group .list-group-item:first-child {
border-top: 0;
}
@@ -36299,6 +36387,10 @@ div#dock {
#page-mod-lesson-view .fitem .felement .form-select {
align-self: flex-start;
}
+#page-mod-lesson-view .mediaplugin,
+#page-mod-lesson-view .mediaplugin video {
+ min-width: 200px;
+}
.path-mod-lesson .table td {
vertical-align: middle;
@@ -36841,6 +36933,11 @@ img.userpicture {
z-index: 1;
}
+@media (max-width: 576px) and (max-height: 320px) {
+ .path-mod-assign .gradingtable thead tr {
+ position: static;
+ }
+}
/**
* Mod LTI.
*/
@@ -40760,6 +40857,19 @@ div.editor_atto_toolbar button .icon {
border-right: 0;
}
+.drawer-primary .drawerheader [data-region=site-home-link] {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+}
+.drawer-primary .drawerheader .logo {
+ max-width: 100%;
+ max-height: 50px;
+ width: auto;
+ height: 50px;
+ object-fit: contain;
+}
+
.secondary-navigation {
padding-bottom: 15px;
}
diff --git a/public/theme/classic/tests/behat/help_popover.feature b/public/theme/classic/tests/behat/help_popover.feature
new file mode 100644
index 0000000000000..684944b4513d1
--- /dev/null
+++ b/public/theme/classic/tests/behat/help_popover.feature
@@ -0,0 +1,74 @@
+@theme_classic @javascript
+Feature: Navigate form help popover links in Classic
+ As a keyboard user
+ I need links in form help popovers to be accessible
+
+ Scenario: Navigate to a link in a form help popover using the keyboard
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "course" exists:
+ | fullname | Course 1 |
+ | shortname | C1 |
+ | category | 0 |
+ And the following "activity" exists:
+ | activity | quiz |
+ | course | C1 |
+ | name | Test quiz |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I am on the "Test quiz" "quiz activity" page logged in as teacher1
+ And I navigate to "Settings" in current page administration
+ And I click on "Timing" "link"
+ When I click on "#fitem_id_timelimit .help-icon" "css_element"
+ Then ".help-popover a[href*='/mod/quiz/timing'][target='_blank']" "css_element" should be visible
+ And I press the tab key
+ Then the focused element is "More help" "link"
+ And "More help" "link" should be visible
+ When I press the shift tab key
+ Then the focused element is "#fitem_id_timelimit .help-icon" "css_element"
+ When I press the enter key
+ Then ".help-popover" "css_element" should be visible
+ When I press the tab key
+ Then the focused element is "More help" "link"
+ When I press the tab key
+ Then the focused element is "#id_timelimit_enabled" "css_element"
+ When I press the tab key
+ Then the focused element is "#fitem_id_overduehandling .help-icon" "css_element"
+ When I click on "#fitem_id_timelimit .help-icon" "css_element"
+ And I press the tab key
+ And I press the enter key
+ And I switch to a second window
+ And I close all opened windows
+ And I switch to the main window
+ And I click on "#fitem_id_timelimit .help-icon" "css_element"
+ And I press the escape key
+ Then ".help-popover" "css_element" should not be visible
+ And the focused element is "#fitem_id_timelimit .help-icon" "css_element"
+
+ Scenario: Use a form help popover without a More help link
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "course" exists:
+ | fullname | Course 1 |
+ | shortname | C1 |
+ | category | 0 |
+ And the following "activity" exists:
+ | activity | quiz |
+ | course | C1 |
+ | name | Test quiz |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I am on the "Test quiz" "quiz activity" page logged in as teacher1
+ And I navigate to "Settings" in current page administration
+ And I click on "Timing" "link"
+ And I set the field "When time expires" to "There is a grace period when open attempts can be submitted, but no more questions answered"
+ When I click on "#fitem_id_graceperiod .help-icon" "css_element"
+ Then ".help-popover" "css_element" should be visible
+ And "More help" "link" should not exist in the ".help-popover" "css_element"
+ When I press the tab key
+ Then the focused element is not "#fitem_id_graceperiod .help-icon" "css_element"
+ And ".help-popover" "css_element" should not be visible
diff --git a/public/theme/classic/tests/behat/pageadministrationmenu.feature b/public/theme/classic/tests/behat/pageadministrationmenu.feature
index 8d1843819d877..e077804b4aeb6 100644
--- a/public/theme/classic/tests/behat/pageadministrationmenu.feature
+++ b/public/theme/classic/tests/behat/pageadministrationmenu.feature
@@ -26,7 +26,7 @@ Feature: Page administration menu
And "Settings" "link" should exist in current page administration
And I navigate to "Settings" in current page administration
And I should see "Edit settings"
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I should see the page administration menu
And I am on the "Course 1" "Enrolment methods" page
And I should see "Enrolment methods"
diff --git a/public/theme/font.php b/public/theme/font.php
index be8a3af130e07..9e0fd98208c2f 100644
--- a/public/theme/font.php
+++ b/public/theme/font.php
@@ -202,7 +202,9 @@ function send_cached_font($fontpath, $etag, $font, $mimetype) {
// No need to gzip already compressed fonts.
- readfile($fontpath);
+ if (readfile($fontpath) === false) {
+ font_not_found();
+ }
die;
}
@@ -215,11 +217,14 @@ function send_uncached_font($fontpath, $font, $mimetype) {
header('Content-Type: '.$mimetype);
header('Content-Length: '.filesize($fontpath));
- readfile($fontpath);
+ if (readfile($fontpath) === false) {
+ font_not_found();
+ }
die;
}
function font_not_found() {
+ header_remove();
header('HTTP/1.0 404 not found');
die('font was not found, sorry.');
}
diff --git a/public/theme/image.php b/public/theme/image.php
index caa81ca9d3127..ca99c6f66b707 100644
--- a/public/theme/image.php
+++ b/public/theme/image.php
@@ -255,7 +255,9 @@ function send_cached_image($imagepath, $etag) {
header('Content-Length: '.filesize($imagepath));
}
- readfile($imagepath);
+ if (readfile($imagepath) === false) {
+ image_not_found();
+ }
die;
}
@@ -273,11 +275,14 @@ function send_uncached_image($imagepath) {
header('Content-Type: '.$mimetype);
header('Content-Length: '.filesize($imagepath));
- readfile($imagepath);
+ if (readfile($imagepath) === false) {
+ image_not_found();
+ }
die;
}
function image_not_found() {
+ header_remove();
header('HTTP/1.0 404 not found');
die('Image was not found, sorry.');
}
diff --git a/public/theme/jquery.php b/public/theme/jquery.php
index 89fffdd007a73..6568ae6320a4d 100644
--- a/public/theme/jquery.php
+++ b/public/theme/jquery.php
@@ -143,13 +143,16 @@
header('Content-Length: '.filesize($file));
}
-readfile($file);
+if (readfile($file) === false) {
+ jquery_file_not_found();
+}
die;
function jquery_file_not_found() {
// Note: we can not disclose the exact file path here, sorry.
+ header_remove();
header('HTTP/1.0 404 not found');
die('File was not found, sorry.');
}
diff --git a/public/theme/yui_image.php b/public/theme/yui_image.php
index c432691ffd4de..4daf2b7242184 100644
--- a/public/theme/yui_image.php
+++ b/public/theme/yui_image.php
@@ -135,11 +135,14 @@ function yui_image_cached($imagepath, $imagename, $mimetype, $etag) {
// no need to gzip already compressed images ;-)
- readfile($imagepath);
+ if (readfile($imagepath) === false) {
+ yui_image_not_found();
+ }
die;
}
function yui_image_not_found() {
+ header_remove();
header('HTTP/1.0 404 not found');
die('Image was not found, sorry.');
}
diff --git a/public/user/calendar.php b/public/user/calendar.php
index 5f7ec2bef13d5..b7d3f9e2bab7f 100644
--- a/public/user/calendar.php
+++ b/public/user/calendar.php
@@ -45,11 +45,12 @@
// Create form.
$calendarform = new core_user\form\calendar_form(null, array('userid' => $user->id));
-$user->timeformat = get_user_preferences('calendar_timeformat', '');
-$user->startwday = calendar_get_starting_weekday();
-$user->maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents);
-$user->lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead);
-$user->persistflt = get_user_preferences('calendar_persistflt', 0);
+$user->timeformat = get_user_preferences('calendar_timeformat', '', $user);
+$user->startwday = get_user_preferences('calendar_startwday', calendar_get_starting_weekday(), $user);
+$user->maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents, $user);
+$user->lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead, $user);
+$user->persistflt = get_user_preferences('calendar_persistflt', 0, $user);
+
$calendarform->set_data($user);
$redirect = new moodle_url("/user/preferences.php", array('userid' => $user->id));
@@ -58,14 +59,14 @@
} else if ($calendarform->is_submitted() && $calendarform->is_validated() && confirm_sesskey()) {
$data = $calendarform->get_data();
- $usernew = ['id' => $USER->id,
+ useredit_update_user_preference([
+ 'id' => $user->id,
'preference_calendar_timeformat' => $data->timeformat,
'preference_calendar_startwday' => $data->startwday,
'preference_calendar_maxevents' => $data->maxevents,
'preference_calendar_lookahead' => $data->lookahead,
- 'preference_calendar_persistflt' => $data->persistflt
- ];
- useredit_update_user_preference($usernew);
+ 'preference_calendar_persistflt' => $data->persistflt,
+ ]);
// Calendar type.
$calendartype = $data->calendartype;
diff --git a/public/user/edit_form.php b/public/user/edit_form.php
index 1e227354af128..4fc8bd9b6f2ac 100644
--- a/public/user/edit_form.php
+++ b/public/user/edit_form.php
@@ -235,6 +235,8 @@ public function validation($usernew, $files) {
}
}
+ $errors += useredit_validate_description_length((array)$usernew);
+
// Next the customisable profile fields.
$errors += profile_validation($usernew, $files);
diff --git a/public/user/editadvanced_form.php b/public/user/editadvanced_form.php
index a796111167d77..db146671681a7 100644
--- a/public/user/editadvanced_form.php
+++ b/public/user/editadvanced_form.php
@@ -324,6 +324,8 @@ public function validation($usernew, $files) {
}
}
+ $err += useredit_validate_description_length((array)$usernew);
+
// Next the customisable profile fields.
$err += profile_validation($usernew, $files);
diff --git a/public/user/editlib.php b/public/user/editlib.php
index 2f47e30700a14..0a5f5bb3432a3 100644
--- a/public/user/editlib.php
+++ b/public/user/editlib.php
@@ -489,3 +489,30 @@ function useredit_get_disabled_name_fields($enabledadditionalusernames = null) {
}
return $result;
}
+
+/**
+ * Validate the length of the user profile description field.
+ *
+ * Shared by user_edit_form and user_editadvanced_form to ensure consistent
+ * validation of the description_editor field length.
+ *
+ * The limit can be overridden in config.php by setting USER_DESCRIPTION_MAX_LENGTH constant.
+ *
+ * @param array $data Form data submitted by the user.
+ * @return array Validation errors, keyed by field name. Empty if valid.
+ */
+function useredit_validate_description_length(array $data): array {
+ if (defined('USER_DESCRIPTION_MAX_LENGTH')) {
+ $maxlength = USER_DESCRIPTION_MAX_LENGTH;
+ } else {
+ // Default maximum character length for a user profile description.
+ $maxlength = 50000;
+ }
+ $errors = [];
+ if (!empty($data['description_editor']['text'])) {
+ if (core_text::strlen($data['description_editor']['text']) > $maxlength) {
+ $errors['description_editor'] = get_string('maximumchars', '', $maxlength);
+ }
+ }
+ return $errors;
+}
diff --git a/public/user/profile.php b/public/user/profile.php
index c7c55f4e990ef..10bc557d3fced 100644
--- a/public/user/profile.php
+++ b/public/user/profile.php
@@ -147,6 +147,7 @@
// Toggle the editing state and switches.
if ($PAGE->user_allowed_editing()) {
if ($reset !== null) {
+ require_sesskey();
if (!is_null($userid)) {
if (!$currentpage = my_reset_page($userid, MY_PAGE_PUBLIC, 'user-profile')) {
throw new \moodle_exception('reseterror', 'my');
diff --git a/public/user/tests/behat/bulk_editenrolment.feature b/public/user/tests/behat/bulk_editenrolment.feature
index bc414b3ca9da2..5fc48cb4f296b 100644
--- a/public/user/tests/behat/bulk_editenrolment.feature
+++ b/public/user/tests/behat/bulk_editenrolment.feature
@@ -24,9 +24,7 @@ Feature: Bulk enrolments
@javascript
Scenario: Bulk edit enrolments
- When I log in as "admin"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "admin"
And I click on "Select all" "checkbox"
And I set the field "With selected users..." to "Edit selected user enrolments"
And I set the field "Alter status" to "Suspended"
@@ -37,9 +35,7 @@ Feature: Bulk enrolments
@javascript
Scenario: Bulk delete enrolments
- When I log in as "admin"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "admin"
And I click on "Select all" "checkbox"
And I set the field "With selected users..." to "Delete selected user enrolments"
And I press "Unenrol users"
@@ -50,9 +46,7 @@ Feature: Bulk enrolments
@javascript
Scenario: Bulk delete enrolments when user is themselves enrolled
- When I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "teacher1"
# Select all three users (the teacher themselves and both students).
And I click on "Select all" "checkbox"
And I set the field "With selected users..." to "Delete selected user enrolments"
diff --git a/public/user/tests/behat/bulk_message.feature b/public/user/tests/behat/bulk_message.feature
index 067dea7a9fb67..ee7d1b22f3343 100644
--- a/public/user/tests/behat/bulk_message.feature
+++ b/public/user/tests/behat/bulk_message.feature
@@ -20,9 +20,7 @@ Feature: Bulk message
| student2 | C1 | student |
Scenario: Send a message to students from participants list
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I click on "Select all" "checkbox"
And I set the field "With selected users..." to "Send a message"
And "Send message to 3 people" "dialogue" should exist
diff --git a/public/user/tests/behat/custom_profile_fields.feature b/public/user/tests/behat/custom_profile_fields.feature
index 1a1856683e716..370ded96b1dc7 100644
--- a/public/user/tests/behat/custom_profile_fields.feature
+++ b/public/user/tests/behat/custom_profile_fields.feature
@@ -69,9 +69,7 @@ Feature: Custom profile fields should be visible and editable by those with the
And the following "course enrolments" exist:
| user | course | role |
| user_updateusers | C1 | editingteacher |
- And I log in as "user_updateusers"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "user_updateusers"
And I follow "userwithinformation 1"
Then I should see "everyonevisible_field"
@@ -106,9 +104,7 @@ Feature: Custom profile fields should be visible and editable by those with the
And the following "course enrolments" exist:
| user | course | role |
| user_viewalldetails | C1 | editingteacher |
- And I log in as "user_viewalldetails"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "user_viewalldetails"
And I follow "userwithinformation 1"
Then I should see "everyonevisible_field"
@@ -139,9 +135,7 @@ Feature: Custom profile fields should be visible and editable by those with the
And the following "course enrolments" exist:
| user | course | role |
| user_viewalldetailsandupdateusers | C1 | editingteacher |
- And I log in as "user_viewalldetailsandupdateusers"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "user_viewalldetailsandupdateusers"
And I follow "userwithinformation 1"
Then I should see "everyonevisible_field"
diff --git a/public/user/tests/behat/edit_user_enrolment.feature b/public/user/tests/behat/edit_user_enrolment.feature
index 748a121e06544..6ec7ed258d663 100644
--- a/public/user/tests/behat/edit_user_enrolment.feature
+++ b/public/user/tests/behat/edit_user_enrolment.feature
@@ -21,9 +21,7 @@ Feature: Edit user enrolment
@javascript
Scenario: Edit a user's enrolment
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I click on "Edit enrolment" "icon" in the "student1" "table_row"
And I should see "Edit Student 1's enrolment"
And I set the field "Status" to "Suspended"
@@ -41,18 +39,14 @@ Feature: Edit user enrolment
@javascript
Scenario: Unenrol a student
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I click on "Unenrol" "icon" in the "student1" "table_row"
And I click on "Unenrol" "button" in the "Unenrol" "dialogue"
Then I should not see "Student 1" in the "participants" "table"
@javascript
Scenario: View a student's enrolment details
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I click on "Manual enrolments" "icon" in the "student1" "table_row"
Then I should see "Enrolment details"
And I should see "Student 1" in the "Full name" "table_row"
@@ -84,10 +78,7 @@ Feature: Edit user enrolment
And I click on "Enable" "link" in the "Course meta link" "table_row"
And I add "Course meta link" enrolment method in "Course 1" with:
| Link course | C2 |
- And I log out
- And I log in as "teacher1"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "teacher1"
Then I should see "Student 3" in the "participants" "table"
And "Edit enrolment" "icon" should not exist in the "student3" "table_row"
And "Unenrol" "icon" should not exist in the "student3" "table_row"
@@ -100,9 +91,7 @@ Feature: Edit user enrolment
@javascript
Scenario: Edit a student's enrolment details from the status dialogue
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I click on "Manual enrolments" "icon" in the "student2" "table_row"
And I click on "Edit enrolment" "icon" in the "Enrolment method" "table_row"
And I should see "Edit Student 2's enrolment"
@@ -112,9 +101,7 @@ Feature: Edit user enrolment
# Without JS, the user should be redirected to the original edit enrolment form.
Scenario: Edit a user's enrolment without JavaScript
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I click on "Edit enrolment" "link" in the "student1" "table_row"
And I should see "Student 1"
And I set the field "Status" to "Suspended"
@@ -132,9 +119,7 @@ Feature: Edit user enrolment
# Without JS, the user should be redirected to the original unenrol confirmation page.
Scenario: Unenrol a student
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I click on "Unenrol" "link" in the "student1" "table_row"
And I click on "Continue" "button"
Then I should not see "Student 1" in the "participants" "table"
diff --git a/public/user/tests/behat/edit_user_roles.feature b/public/user/tests/behat/edit_user_roles.feature
index 90fdfea4a6d24..afb65f1d1258b 100644
--- a/public/user/tests/behat/edit_user_roles.feature
+++ b/public/user/tests/behat/edit_user_roles.feature
@@ -21,9 +21,7 @@ Feature: Edit user roles
@javascript
Scenario: Assign roles on participants page
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I click on "Student 1's role assignments" "link"
And I type "Non-editing teacher"
And I press the enter key
@@ -32,9 +30,7 @@ Feature: Edit user roles
@javascript
Scenario: Remove roles on participants page
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I click on "Student 1's role assignments" "link"
And I click on "Student" "autocomplete_selection"
When I click on "Save changes" "link"
diff --git a/public/user/tests/behat/enrol_cohort_list.feature b/public/user/tests/behat/enrol_cohort_list.feature
index 3f09a38415e79..8e27142e58867 100644
--- a/public/user/tests/behat/enrol_cohort_list.feature
+++ b/public/user/tests/behat/enrol_cohort_list.feature
@@ -15,7 +15,7 @@ Feature: Viewing the list of cohorts to enrol in a course
| user | course | role |
| teacher1 | C1 | editingteacher |
- @javascript @skip_chrome_zerosize
+ @javascript
Scenario: Check the teacher does not see the cohorts field without the proper capabilities
Given the following "cohort" exists:
| name | Test cohort name |
@@ -25,9 +25,7 @@ Feature: Viewing the list of cohorts to enrol in a course
| role | editingteacher |
| moodle/cohort:manage | prohibit |
| moodle/cohort:view | prohibit |
- And I log out
- And I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as teacher1
When I press "Enrol users"
Then I should not see "Select cohorts"
And I should not see "Enrol selected users and cohorts"
@@ -38,16 +36,14 @@ Feature: Viewing the list of cohorts to enrol in a course
| name | Test cohort name |
| idnumber | 1337 |
| description | Test cohort description |
- And I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as teacher1
When I press "Enrol users"
Then I should see "Select cohorts"
And I should see "Enrol selected users and cohorts"
@javascript
Scenario: Check we do not show the cohorts field if there are none present
- Given I am on the "Course 1" course page logged in as teacher1
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as teacher1
When I press "Enrol users"
Then I should not see "Select cohorts"
And I should not see "Enrol selected users and cohorts"
diff --git a/public/user/tests/behat/filter_participants.feature b/public/user/tests/behat/filter_participants.feature
index 9376e4266abd9..6a81115b67861 100644
--- a/public/user/tests/behat/filter_participants.feature
+++ b/public/user/tests/behat/filter_participants.feature
@@ -61,8 +61,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: No filters applied
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
Then I should see "Student 1" in the "participants" "table"
And I should see "Student 2" in the "participants" "table"
And I should see "Student 3" in the "participants" "table"
@@ -70,8 +69,7 @@ Feature: Course participants can be filtered
@javascript
Scenario Outline: Filter users for a course with a single value
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
And I set the field "Match" in the "Filter 1" "fieldset" to ""
And I set the field "type" in the "Filter 1" "fieldset" to ""
And I set the field "Type or select..." in the "Filter 1" "fieldset" to ""
@@ -100,8 +98,7 @@ Feature: Course participants can be filtered
@javascript
Scenario Outline: Filter users for a course with multiple values for a single filter
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
And I set the field "Match" in the "Filter 1" "fieldset" to ""
And I set the field "type" in the "Filter 1" "fieldset" to ""
And I set the field "Type or select..." in the "Filter 1" "fieldset" to ","
@@ -121,8 +118,7 @@ Feature: Course participants can be filtered
@javascript
Scenario Outline: Filter users which are group members in several courses
- Given I am on the "C3" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C3" "enrolled users" page logged in as "patricia"
And I set the field "type" in the "Filter 1" "fieldset" to ""
And I set the field "Type or select..." in the "Filter 1" "fieldset" to ""
When I click on "Apply filters" "button"
@@ -140,8 +136,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: In separate groups mode, a student in a single group can only view and filter by users in their own group
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
# Unsuspend student 2 for to improve coverage of this test.
And I click on "Edit enrolment" "icon" in the "Student 2" "table_row"
@@ -153,9 +148,7 @@ Feature: Course participants can be filtered
# Match:
# Groups Any ["Group 2"].
- When I log in as "student3"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "student3"
Then I should see "Student 2" in the "participants" "table"
And I should see "Student 3" in the "participants" "table"
@@ -199,8 +192,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: In separate groups mode, a student in multiple groups can only view and filter by users in their own groups
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
# Unsuspend student 2 for to improve coverage of this test.
And I click on "Edit enrolment" "icon" in the "Student 2" "table_row"
@@ -208,9 +200,7 @@ Feature: Course participants can be filtered
And I click on "Save changes" "button"
And I log out
- When I log in as "student2"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "student2"
# Default view should have groups filter pre-set.
# Match:
@@ -262,8 +252,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Filter users who have no role in a course
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
# Remove the user role.
And I click on "Student 1's role assignments" "link"
@@ -308,8 +297,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Multiple filters applied (All filterset match type)
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
# Match Any:
# Roles All ["Student"] and
@@ -410,9 +398,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Multiple filters applied (Any filterset match type)
- Given I log in as "patricia"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "patricia"
# Match Any:
# Roles All ["Teacher"] and
@@ -475,9 +461,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Multiple filters applied (None filterset match type)
- Given I log in as "patricia"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "patricia"
# Match None:
# Roles All ["Teacher"] and
@@ -550,8 +534,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Filter match by one or more keywords and modified match types
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
# Match:
# Keyword Any ["1@example"].
@@ -624,8 +607,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Reorder users without losing filter
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
When I set the field "type" in the "Filter 1" "fieldset" to "Roles"
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Student"
@@ -646,8 +628,7 @@ Feature: Course participants can be filtered
@javascript
Scenario: Only possible to add filter rows for the number of filters available
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
When I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
And I click on "Add condition" "button"
And I set the field "type" in the "Filter 2" "fieldset" to "Status"
@@ -664,16 +645,14 @@ Feature: Course participants can be filtered
@javascript
Scenario: Rendering filter options for teachers in a course that don't support groups
- Given I am on the "C2" "Course" page logged in as "patricia"
- When I navigate to course participants
+ Given I am on the "C2" "enrolled users" page logged in as "patricia"
Then I should see "Roles" in the "type" "field"
And I should see "Enrolment methods" in the "type" "field"
But I should not see "Groups" in the "type" "field"
@javascript
Scenario: Rendering filter options for students who have limited privileges
- Given I am on the "C2" "Course" page logged in as "student1"
- When I navigate to course participants
+ Given I am on the "C2" "enrolled users" page logged in as "student1"
Then I should see "Roles" in the "type" "field"
But I should not see "Status" in the "type" "field"
And I should not see "Enrolment methods" in the "type" "field"
@@ -682,8 +661,7 @@ Feature: Course participants can be filtered
Scenario: Filter by user identity fields
Given the following config values are set as admin:
| showuseridentity | idnumber,email,city,country |
- And I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ And I am on the "C1" "enrolled users" page logged in as "patricia"
# Search by email (only) - should only see visible email + own.
# Match:
@@ -750,7 +728,7 @@ Feature: Course participants can be filtered
Then I should see "Nothing to display"
- @javascript @skip_chrome_zerosize
+ @javascript
Scenario: Filter by user identity fields when cannot see the field data
Given the following "role capability" exists:
| role | editingteacher |
@@ -758,10 +736,8 @@ Feature: Course participants can be filtered
And I log in as "admin"
And the following config values are set as admin:
| showuseridentity | idnumber,email,city,country |
- And I log out
- And I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ And I am on the "C1" "enrolled users" page logged in as "patricia"
# Match:
# Keyword Any ["@example.com"].
@@ -829,8 +805,7 @@ Feature: Course participants can be filtered
# Keyword Any ["@example.com"].
# Set the Roles to "All" ["Student"].
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
And I set the field "Match" in the "Filter 1" "fieldset" to "All"
And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Student"
@@ -867,8 +842,7 @@ Feature: Course participants can be filtered
# Keyword Any ["@example.com"].
# Set the Roles to "All" ["Student"].
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
When I set the field "Match" in the "Filter 1" "fieldset" to "All"
And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Student"
@@ -902,8 +876,7 @@ Feature: Course participants can be filtered
# Match None:
# Keyword Any ["@example.com"]; and
# Roles All ["Teacher"].
- Given I am on the "C1" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C1" "enrolled users" page logged in as "patricia"
# Set the Keyword to "Any" ["@example.com"]
When I set the field "Match" in the "Filter 1" "fieldset" to "Any"
@@ -958,8 +931,7 @@ Feature: Course participants can be filtered
# Match:
# No filters; and
# First initial "T".
- Given I am on the "C2" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C2" "enrolled users" page logged in as "patricia"
And I should see "Student 1" in the "participants" "table"
And I should see "Student 2" in the "participants" "table"
And I should see "Student 3" in the "participants" "table"
@@ -977,8 +949,7 @@ Feature: Course participants can be filtered
# Match:
# No filters; and
# Last initial "L".
- Given I am on the "C2" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C2" "enrolled users" page logged in as "patricia"
And I should see "Student 1" in the "participants" "table"
And I should see "Student 2" in the "participants" "table"
And I should see "Student 3" in the "participants" "table"
@@ -997,8 +968,7 @@ Feature: Course participants can be filtered
# No filters; and
# First initial "T"; and
# Last initial "L".
- Given I am on the "C2" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C2" "enrolled users" page logged in as "patricia"
And I should see "Student 1" in the "participants" "table"
And I should see "Student 2" in the "participants" "table"
And I should see "Student 3" in the "participants" "table"
@@ -1017,8 +987,7 @@ Feature: Course participants can be filtered
# Match:
# Roles All ["Teacher"]; and
# First initial "T".
- Given I am on the "C2" "Course" page logged in as "patricia"
- And I navigate to course participants
+ Given I am on the "C2" "enrolled users" page logged in as "patricia"
And I should see "Student 1" in the "participants" "table"
And I should see "Student 2" in the "participants" "table"
And I should see "Student 3" in the "participants" "table"
@@ -1043,8 +1012,7 @@ Feature: Course participants can be filtered
Scenario: Filtering works correctly with custom profile fields
Given the following config values are set as admin:
| showuseridentity | email,profile_field_frog |
- And I am on the "C2" "Course" page logged in as "patricia"
- And I navigate to course participants
+ And I am on the "C2" "enrolled users" page logged in as "patricia"
And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
And I set the field "Type..." to "Kermit"
And I press enter
diff --git a/public/user/tests/behat/filter_participants_showall.feature b/public/user/tests/behat/filter_participants_showall.feature
index 1ebbcb30e5ecc..5883057a36e44 100644
--- a/public/user/tests/behat/filter_participants_showall.feature
+++ b/public/user/tests/behat/filter_participants_showall.feature
@@ -71,9 +71,7 @@ Feature: Course participants can be filtered to display all the users
@javascript
Scenario: Show all users in a course that match a single filter value
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I set the field "Match" in the "Filter 1" "fieldset" to "All"
And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Student"
@@ -88,9 +86,7 @@ Feature: Course participants can be filtered to display all the users
@javascript
Scenario: Show all users as a student
- Given I log in as "student1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "student1"
And I set the field "Match" in the "Filter 1" "fieldset" to "All"
And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Student"
@@ -104,9 +100,7 @@ Feature: Course participants can be filtered to display all the users
@javascript
Scenario: Apply one value for more than one filter and show all matching users
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I click on "Add condition" "button"
And I set the field "Match" to "All"
And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
diff --git a/public/user/tests/behat/full_name_display.feature b/public/user/tests/behat/full_name_display.feature
index fa3b7abf52105..5b36713759488 100644
--- a/public/user/tests/behat/full_name_display.feature
+++ b/public/user/tests/behat/full_name_display.feature
@@ -24,25 +24,19 @@ Feature: Users' names are displayed across the site according to the user policy
| alternativefullnameformat | middlename, alternatename, firstname, lastname |
Scenario: As a student, 'fullnamedisplay' should be used in the participants list and when viewing my own course profile
- Given I log in as "user1"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "user1"
And I click on "Gronya,Beecham" "link" in the "Gronya,Beecham" "table_row"
Then I should see "Gronya,Beecham" in the "region-main" "region"
And I log out
Scenario: As a student, 'fullnamedisplay' should be used in the participants list and when viewing another user's course profile
- Given I log in as "user2"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "user2"
And I click on "Gronya,Beecham" "link" in the "Gronya,Beecham" "table_row"
Then I should see "Gronya,Beecham" in the "region-main" "region"
And I log out
Scenario: As a teacher, 'alternativefullnameformat' should be used in the participants list but 'fullnamedisplay' used on the course profile
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
Then I should see "Ann, Jill, Grainne, Beauchamp" in the "Ann, Jill, Grainne, Beauchamp" "table_row"
And I click on "Ann, Jill, Grainne, Beauchamp" "link" in the "Ann, Jill, Grainne, Beauchamp" "table_row"
And I should see "Gronya,Beecham" in the "region-main" "region"
@@ -74,9 +68,7 @@ Feature: Users' names are displayed across the site according to the user policy
@javascript
Scenario: As a teacher, the 'alternativefullnameformat' should be used when searching for and enrolling a user
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I press "Enrol users"
And I click on "Select users" "field"
And I type "three@example.com"
diff --git a/public/user/tests/behat/hidden_user_fields.feature b/public/user/tests/behat/hidden_user_fields.feature
index 2ec8cbc26ae7c..54a160452522e 100644
--- a/public/user/tests/behat/hidden_user_fields.feature
+++ b/public/user/tests/behat/hidden_user_fields.feature
@@ -22,9 +22,7 @@ Feature: Hidden user fields behavior
| hiddenuserfields | description,email |
Scenario Outline: Hidden user fields on course context profile based on role permission
- Given I log in as ""
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as ""
And I should see "Profile User"
When I click on "Profile User" "link"
Then I "This is me"
@@ -38,9 +36,7 @@ Feature: Hidden user fields behavior
| admin | should see |
Scenario Outline: Hidden user fields on system context profile based on role permission
- Given I log in as ""
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as ""
And I should see "Profile User"
When I click on "Profile User" "link"
And I click on "Full profile" "link"
diff --git a/public/user/tests/behat/participants_in_group_modes.feature b/public/user/tests/behat/participants_in_group_modes.feature
index 7478aee1b909f..961cf2a637e1b 100644
--- a/public/user/tests/behat/participants_in_group_modes.feature
+++ b/public/user/tests/behat/participants_in_group_modes.feature
@@ -50,9 +50,7 @@ Feature: Viewing participants page in different group modes
| teacher2 | C3G1 |
Scenario: Viewing participants page as an editing teacher in a course without group mode
- When I log in as "teacher1"
- And I am on "C1 nogroups" course homepage
- And I navigate to course participants
+ When I am on the "C1 nogroups" "enrolled users" page logged in as "teacher1"
Then "Student 1" row "Groups" column of "participants" table should contain "No groups"
And "Student 2" row "Groups" column of "participants" table should contain "No groups"
And "Student 3" row "Groups" column of "participants" table should contain "No groups"
@@ -60,9 +58,7 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "No groups"
Scenario: Viewing participants page as an editing teacher in a course in visible groups mode
- When I log in as "teacher1"
- And I am on "C2 visgroups" course homepage
- And I navigate to course participants
+ When I am on the "C2 visgroups" "enrolled users" page logged in as "teacher1"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Student 2" row "Groups" column of "participants" table should contain "G2"
And "Student 3" row "Groups" column of "participants" table should contain "No groups"
@@ -70,9 +66,7 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
Scenario: Viewing participants page as an editing teacher in a course in separate groups mode
- When I log in as "teacher1"
- And I am on "C3 sepgroups" course homepage
- And I navigate to course participants
+ When I am on the "C3 sepgroups" "enrolled users" page logged in as "teacher1"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Student 2" row "Groups" column of "participants" table should contain "G2"
And "Student 3" row "Groups" column of "participants" table should contain "No groups"
@@ -80,9 +74,7 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
Scenario: Viewing participants page as a non-editing teacher in a course without group mode
- When I log in as "teacher2"
- And I am on "C1 nogroups" course homepage
- And I navigate to course participants
+ When I am on the "C1 nogroups" "enrolled users" page logged in as "teacher2"
Then "Student 1" row "Groups" column of "participants" table should contain "No groups"
And "Student 2" row "Groups" column of "participants" table should contain "No groups"
And "Student 3" row "Groups" column of "participants" table should contain "No groups"
@@ -90,9 +82,7 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "No groups"
Scenario: Viewing participants page as a non-editing teacher in a course in visible groups mode
- When I log in as "teacher2"
- And I am on "C2 visgroups" course homepage
- And I navigate to course participants
+ When I am on the "C2 visgroups" "enrolled users" page logged in as "teacher2"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
And I should not see "Teacher 1"
@@ -107,9 +97,7 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
Scenario: Viewing participants page as a non-editing teacher in a course in separate groups mode
- When I log in as "teacher2"
- And I am on "C3 sepgroups" course homepage
- And I navigate to course participants
+ When I am on the "C3 sepgroups" "enrolled users" page logged in as "teacher2"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
And I should not see "Teacher 1"
@@ -124,17 +112,13 @@ Feature: Viewing participants page in different group modes
And I should not see "Student 3"
Scenario: Viewing participants page as a student in a course without group mode
- When I log in as "student1"
- And I am on "C1 nogroups" course homepage
- And I navigate to course participants
+ When I am on the "C1 nogroups" "enrolled users" page logged in as "student1"
Then "Student 1" row "Groups" column of "participants" table should contain "No groups"
And "Student 2" row "Groups" column of "participants" table should contain "No groups"
And "Student 3" row "Groups" column of "participants" table should contain "No groups"
Scenario: Viewing participants page as a student in a group in a course in visible groups mode
- When I log in as "student1"
- And I am on "C2 visgroups" course homepage
- And I navigate to course participants
+ When I am on the "C2 visgroups" "enrolled users" page logged in as "student1"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
And I should not see "Student 2"
@@ -148,9 +132,7 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
Scenario: Viewing participants page as a student in a group in a course in separate groups mode
- When I log in as "student1"
- And I am on "C3 sepgroups" course homepage
- And I navigate to course participants
+ When I am on the "C3 sepgroups" "enrolled users" page logged in as "student1"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
And I should not see "Student 2"
@@ -164,9 +146,7 @@ Feature: Viewing participants page in different group modes
And I should not see "Teacher 1"
Scenario: Viewing participants page as a student not in a group in a course in visible groups mode
- When I log in as "student3"
- And I am on "C2 visgroups" course homepage
- And I navigate to course participants
+ When I am on the "C2 visgroups" "enrolled users" page logged in as "student3"
Then "Student 1" row "Groups" column of "participants" table should contain "G1"
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
And I should not see "Student 2"
@@ -180,7 +160,5 @@ Feature: Viewing participants page in different group modes
And "Teacher 2" row "Groups" column of "participants" table should contain "G1"
Scenario: Viewing participants page as a student not in a group in a course in separate groups mode
- When I log in as "student3"
- And I am on "C3 sepgroups" course homepage
- And I navigate to course participants
+ When I am on the "C3 sepgroups" "enrolled users" page logged in as "student3"
Then I should see "Sorry, but you need to be part of a group to see this page."
diff --git a/public/user/tests/behat/set_email_display.feature b/public/user/tests/behat/set_email_display.feature
index 27b6e0141e5c5..10fb86b1cf025 100644
--- a/public/user/tests/behat/set_email_display.feature
+++ b/public/user/tests/behat/set_email_display.feature
@@ -32,23 +32,19 @@ Feature: Set email display preference
@javascript
Scenario: Student peer on the same course viewing profiles
- Given I log in as "studentp"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "studentp"
When I follow "Student NONE"
Then I should not see "studentN@example.com"
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
When I follow "Student EVERYONE"
Then I should see "studentE@example.com"
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
When I follow "Student MEMBERS"
Then I should see "studentM@example.com"
@javascript
Scenario: Student viewing teacher email (whose maildisplay = MEMBERS)
- Given I log in as "studentp"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "studentp"
When I follow "Teacher 1"
Then I should see "teacher1@example.com"
@@ -56,12 +52,10 @@ Feature: Set email display preference
Scenario: Teacher viewing student email, whilst site:showuseridentity = “email”
Given the following config values are set as admin:
| showuseridentity | email |
- Given I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "teacher1"
When I follow "Student NONE"
Then I should see "studentN@example.com"
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
When I follow "Student MEMBERS"
Then I should see "studentM@example.com"
@@ -70,11 +64,10 @@ Feature: Set email display preference
Given I log in as "teacher1"
And the following config values are set as admin:
| showuseridentity | |
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
When I follow "Student NONE"
Then I should not see "studentN@example.com"
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
When I follow "Student MEMBERS"
Then I should see "studentM@example.com"
diff --git a/public/user/tests/behat/table_column_visibility.feature b/public/user/tests/behat/table_column_visibility.feature
index f7dd23b663d52..17ad44adf9114 100644
--- a/public/user/tests/behat/table_column_visibility.feature
+++ b/public/user/tests/behat/table_column_visibility.feature
@@ -21,9 +21,7 @@ Feature: The visibility of table columns can be toggled
@javascript
Scenario: The visibility of columns can be individually toggled within the participants table
- Given I log in as "t1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "t1"
And I should see "Email address" in the "participants" "table"
And I should see "matilda@example.com" in the "participants" "table"
And I should see "Roles" in the "participants" "table"
diff --git a/public/user/tests/behat/view_full_profile.feature b/public/user/tests/behat/view_full_profile.feature
index 909cd7d3db44f..0bfe69c8a952a 100644
--- a/public/user/tests/behat/view_full_profile.feature
+++ b/public/user/tests/behat/view_full_profile.feature
@@ -27,21 +27,17 @@ Feature: Access to full profiles of users
| messaging | 1 |
Scenario: Viewing full profiles with default settings
- When I log in as "student1"
+ When I am on the "Course 1" "enrolled users" page logged in as "student1"
# Another student's full profile is visible
- And I am on "Course 1" course homepage
- And I navigate to course participants
And I follow "Student 2"
Then I should see "Full profile"
# Teacher's full profile is visible
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I follow "Teacher 1"
And I follow "Full profile"
And I should see "First access to site"
# Own full profile is visible
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I click on "Student 1" "link" in the "#participants" "css_element"
And I follow "Full profile"
And I should see "First access to site"
@@ -49,9 +45,7 @@ Feature: Access to full profiles of users
Scenario: Viewing full profiles with forceloginforprofiles off
Given the following config values are set as admin:
| forceloginforprofiles | 0 |
- When I log in as "student1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "student1"
And I follow "Student 2"
And I follow "Full profile"
Then I should see "First access to site"
@@ -60,17 +54,13 @@ Feature: Access to full profiles of users
Given the following "role capability" exists:
| role | user |
| moodle/user:viewdetails | allow |
- When I log in as "student1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "student1"
And I follow "Student 2"
And I follow "Full profile"
Then I should see "First access to site"
Scenario: Viewing full profiles of students as a teacher
- When I log in as "teacher1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I follow "Student 1"
And I follow "Full profile"
Then I should see "First access to site"
@@ -91,9 +81,7 @@ Feature: Access to full profiles of users
| student3 | G2 |
| teacher1 | G1 |
| teacher1 | G2 |
- When I log in as "student3"
- And I am on "Course 2" course homepage
- And I navigate to course participants
+ When I am on the "Course 2" "enrolled users" page logged in as "student3"
And I follow "Teacher 1"
Then I should see "Group 2"
And I should not see "Group 1"
@@ -109,9 +97,7 @@ Feature: Access to full profiles of users
| student2 | G2 |
| teacher1 | G1 |
| teacher1 | G2 |
- When I log in as "student1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "student1"
And I follow "Teacher 1"
Then I should see "Group 1"
And I should see "Group 2"
@@ -173,9 +159,7 @@ Feature: Access to full profiles of users
@javascript
Scenario: Accessibility, users can not click on profile image when on user's profile page.
- Given I log in as "admin"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "admin"
Then "//img[contains(@class, 'userpicture')]" "xpath_element" should exist
And "//a/child::img[contains(@class, 'userpicture')]" "xpath_element" should exist
When I follow "Teacher 1"
diff --git a/public/user/tests/behat/view_participants.feature b/public/user/tests/behat/view_participants.feature
index 4604bf2514286..f3503d47d72fb 100644
--- a/public/user/tests/behat/view_participants.feature
+++ b/public/user/tests/behat/view_participants.feature
@@ -56,9 +56,7 @@ Feature: View course participants
@javascript
Scenario: Use select and deselect all buttons
- Given I log in as "teacher1x"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1x"
When I click on "Select all" "checkbox"
Then the field "Select 'Teacher 1x'" matches value "1"
And the field "Select 'Student 0x'" matches value "1"
@@ -111,8 +109,7 @@ Feature: View course participants
And the following "course enrolments" exist:
| user | course | role |
| student19x | C1 | student |
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page
And I follow "Email address"
When I click on "2" "link" in the "//nav[@aria-label='Page']" "xpath_element"
Then I should not see "student0x@example.com"
@@ -130,9 +127,7 @@ Feature: View course participants
Given the following "course enrolments" exist:
| user | course | role |
| student19x | C1 | student |
- When I log in as "teacher1x"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ When I am on the "Course 1" "enrolled users" page logged in as "teacher1x"
And I click on "Select all" "checkbox"
Then I should not see "Student 9x"
And the field "Select 'Teacher 1x'" matches value "1"
@@ -232,9 +227,7 @@ Feature: View course participants
And the field "Select 'Student 19x'" matches value "0"
Scenario: View the participants page as a teacher
- Given I log in as "teacher1x"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "teacher1x"
Then I should see "Active" in the "student0x" "table_row"
Then I should see "Active" in the "student1x" "table_row"
And I should see "Active" in the "student2x" "table_row"
@@ -256,9 +249,7 @@ Feature: View course participants
And I should see "Active" in the "student18x" "table_row"
Scenario: View the participants page as a student
- Given I log in as "student1x"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "student1x"
# Student should not see the status column.
Then I should not see "Status" in the "participants" "table"
# Student should be able to see the other actively-enrolled students.
@@ -278,5 +269,5 @@ Feature: View course participants
Given I log in as "admin"
And I am on the "Course 1" "enrolment methods" page
And I click on "Disable" "link" in the "Manual enrolments" "table_row"
- Then I navigate to course participants
+ Then I am on the "Course 1" "enrolled users" page
And I should see "Not current" in the "student0x" "table_row"
diff --git a/public/user/tests/behat/view_participants_groups.feature b/public/user/tests/behat/view_participants_groups.feature
index efb7fe0259550..2fd5522f44496 100644
--- a/public/user/tests/behat/view_participants_groups.feature
+++ b/public/user/tests/behat/view_participants_groups.feature
@@ -34,9 +34,7 @@ Feature: View course participants groups
| student4x | G2 |
Scenario: User should not be able to see other groups in separated group mode
- Given I log in as "student1x"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "student1x"
Then I should see "Group A"
And I should see "Student 1x"
And I should see "Student 2x"
@@ -52,10 +50,7 @@ Feature: View course participants groups
And I expand all fieldsets
And I set the field "Group mode" to "Visible groups"
And I press "Save and display"
- And I log out
- And I log in as "student1x"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "student1x"
Then I should see "Group A"
And I should see "Student 1x"
And I should see "Student 2x"
@@ -72,10 +67,7 @@ Feature: View course participants groups
And I expand all fieldsets
And I set the field "Group mode" to "No groups"
And I press "Save and display"
- And I log out
- And I log in as "student1x"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "student1x"
Then I should see "Group A"
And I should see "Student 1x"
And I should see "Student 2x"
diff --git a/public/user/tests/behat/view_preferences_page.feature b/public/user/tests/behat/view_preferences_page.feature
index 14e7ccf51443f..fd6cae2f8d60a 100644
--- a/public/user/tests/behat/view_preferences_page.feature
+++ b/public/user/tests/behat/view_preferences_page.feature
@@ -26,28 +26,18 @@ Feature: Access to preferences page
| manager1 | Acceptance test site | manager |
Scenario: A student and teacher with normal permissions can not view another user's permissions page.
- Given I log in as "student1"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "student1"
And I follow "Student 2"
And I should not see "Preferences" in the "region-main" "region"
- And I log out
- And I log in as "teacher1"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "teacher1"
And I follow "Student 2"
Then I should not see "Preferences" in the "region-main" "region"
Scenario: Administrators and Managers can view another user's permissions page.
- Given I log in as "admin"
- And I am on "Course 1" course homepage
- And I navigate to course participants
+ Given I am on the "Course 1" "enrolled users" page logged in as "admin"
And I follow "Student 2"
And I should see "Preferences" in the "region-main" "region"
- And I log out
- And I log in as "manager1"
- And I am on "Course 1" course homepage
- When I navigate to course participants
+ And I am on the "Course 1" "enrolled users" page logged in as "manager1"
And I follow "Student 2"
Then I should see "Preferences" in the "region-main" "region"
diff --git a/public/user/tests/editlib_validate_description_test.php b/public/user/tests/editlib_validate_description_test.php
new file mode 100644
index 0000000000000..7ce9056706201
--- /dev/null
+++ b/public/user/tests/editlib_validate_description_test.php
@@ -0,0 +1,76 @@
+.
+
+namespace core_user;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/user/editlib.php');
+
+/**
+ * Unit tests for useredit_validate_description_length().
+ *
+ * @package core_user
+ * @category test
+ * @copyright 2026 Andi Permana
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers ::useredit_validate_description_length
+ */
+final class editlib_validate_description_test extends \advanced_testcase {
+
+ /**
+ * Data provider for {@see test_useredit_validate_description_length}.
+ *
+ * @return array[]
+ */
+ public static function useredit_validate_description_length_provider(): array {
+ define('USER_DESCRIPTION_MAX_LENGTH', 100);
+ return [
+ 'empty description passes' => [
+ [],
+ [],
+ ],
+ 'short description passes' => [
+ ['description_editor' => ['text' => 'Hello world']],
+ [],
+ ],
+ 'exactly at limit passes' => [
+ ['description_editor' => ['text' => str_repeat('a', USER_DESCRIPTION_MAX_LENGTH)]],
+ [],
+ ],
+ 'one char over limit fails' => [
+ ['description_editor' => ['text' => str_repeat('a', USER_DESCRIPTION_MAX_LENGTH + 1)]],
+ ['description_editor' => get_string('maximumchars', '', USER_DESCRIPTION_MAX_LENGTH)],
+ ],
+ 'well over limit fails' => [
+ ['description_editor' => ['text' => str_repeat('a', USER_DESCRIPTION_MAX_LENGTH * 10)]],
+ ['description_editor' => get_string('maximumchars', '', USER_DESCRIPTION_MAX_LENGTH)],
+ ],
+ ];
+ }
+
+ /**
+ * Test that useredit_validate_description_length returns correct errors.
+ *
+ * @dataProvider useredit_validate_description_length_provider
+ * @param array $data Form data to validate.
+ * @param array $expected Expected errors array.
+ */
+ public function test_useredit_validate_description_length(array $data, array $expected): void {
+ $this->assertSame($expected, useredit_validate_description_length($data));
+ }
+}
diff --git a/public/version.php b/public/version.php
index c1d14cbd6bbcf..3555291f62449 100644
--- a/public/version.php
+++ b/public/version.php
@@ -29,9 +29,9 @@
defined('MOODLE_INTERNAL') || die();
-$version = 2025100604.00; // 20251006 = branching date YYYYMMDD - do not modify!
+$version = 2025100606.00; // 20251006 = branching date YYYYMMDD - do not modify!
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
-$release = '5.1.4 (Build: 20260420)'; // Human-friendly version name
+$release = '5.1.6 (Build: 20260810)'; // Human-friendly version name
$branch = '501'; // This version's branch.
$maturity = MATURITY_STABLE; // This version's maturity level.
diff --git a/public/webservice/classes/reportbuilder/local/systemreports/tokens.php b/public/webservice/classes/reportbuilder/local/systemreports/tokens.php
index ff251136cf2da..1167611e7be7f 100644
--- a/public/webservice/classes/reportbuilder/local/systemreports/tokens.php
+++ b/public/webservice/classes/reportbuilder/local/systemreports/tokens.php
@@ -73,7 +73,7 @@ protected function initialise(): void {
// Only show tokens created by the current user for non-manager users.
if (!has_capability('moodle/webservice:managealltokens', context_system::instance())) {
- $this->add_base_condition_simple("{$entitycreatoralias}.userid", $USER->id);
+ $this->add_base_condition_simple("{$entitytokenalias}.creatorid", $USER->id);
}
$this->add_columns($entityuseralias, $entityservicealias);
diff --git a/public/webservice/tests/behat/manage_tokens.feature b/public/webservice/tests/behat/manage_tokens.feature
index 5f09e4af3d447..13265561e0fd2 100644
--- a/public/webservice/tests/behat/manage_tokens.feature
+++ b/public/webservice/tests/behat/manage_tokens.feature
@@ -43,7 +43,7 @@ Feature: Manage external services tokens
And I press "Delete"
And "Webservice1" "table_row" should not exist
- @javascript @skip_chrome_zerosize
+ @javascript
Scenario: Tokens can be filtered by name (case-insensitive), by user and by service
Given the following "core_webservice > Service" exists:
| name | Site information |