diff --git a/admin/search.php b/admin/search.php index 98ec900ed44..648822e60f1 100644 --- a/admin/search.php +++ b/admin/search.php @@ -46,6 +46,8 @@ if ($data = data_submitted() and confirm_sesskey() and isset($data->action) and } } +$PAGE->has_secondary_navigation_setter(false); + // and finally, if we get here, then there are matching settings and we have to print a form // to modify them echo $OUTPUT->header($focus); @@ -88,7 +90,10 @@ if ($hassiteconfig) { if ($showsettingslinks) { $node = $PAGE->settingsnav->find('root', navigation_node::TYPE_SITE_ADMIN); if ($node) { - echo $OUTPUT->render_from_template('core/settings_link_page', ['node' => $node]); + $moremenu = new \core\navigation\output\more_menu($PAGE->secondarynav, 'nav-tabs'); + $secondarynavigation = $moremenu->export_for_template($OUTPUT); + echo $OUTPUT->render_from_template('core/settings_link_page', + ['node' => $node, 'secondarynavigation' => $secondarynavigation]); } } diff --git a/admin/tool/behat/tests/behat/keyboard.feature b/admin/tool/behat/tests/behat/keyboard.feature index ca9a74f02f3..ca2912ce791 100644 --- a/admin/tool/behat/tests/behat/keyboard.feature +++ b/admin/tool/behat/tests/behat/keyboard.feature @@ -34,7 +34,7 @@ Feature: Verify that keyboard steps work as expected | username | email | firstname | lastname | | saffronr | saffron.rutledge@example.com | Saffron | Rutledge | And I log in as "saffronr" - And I click on "Saffron Rutledge" "link" in the ".usermenu" "css_element" + And I click on "Saffron Rutledge" "button" in the ".usermenu" "css_element" When I press the up key Then the focused element is "Log out" "link" diff --git a/admin/tool/customlang/tests/behat/customisation_create.feature b/admin/tool/customlang/tests/behat/customisation_create.feature index 1d139473593..a473c0c58fb 100644 --- a/admin/tool/customlang/tests/behat/customisation_create.feature +++ b/admin/tool/customlang/tests/behat/customisation_create.feature @@ -14,20 +14,20 @@ Feature: Within a moodle instance, an administrator should be able to modify lan And I press "Open language pack for editing" And I press "Continue" And I set the field "Show strings of these components" to "moodle.php" - And I set the field "String identifier" to "administrationsite" + And I set the field "String identifier" to "moodledocslink" And I press "Show strings" - And I set the field "core/administrationsite" to "Custom string example" + And I set the field "core/moodledocslink" to "moodle documents" @javascript Scenario: Edit an string but don't save it to lang pack. When I press "Apply changes and continue editing" - Then I should see "Site administration" in the "page-header" "region" - And I should not see "Custom string example" in the "page-header" "region" + Then I should see "Help and documentation" in the ".helplink" "css_element" + And I should not see "moodle documents" in the ".helplink" "css_element" @javascript Scenario: Customize an string as admin and save it to lang pack. Given I press "Save changes to the language pack" And I should see "There are 1 modified strings." When I click on "Continue" "button" - Then I should see "Custom string example" in the "page-header" "region" - And I should not see "Site administration" in the "page-header" "region" + Then I should see "moodle documents" in the ".helplink" "css_element" + And I should not see "Help and documentation" in the ".helplink" "css_element" diff --git a/admin/tool/customlang/tests/behat/import_files.feature b/admin/tool/customlang/tests/behat/import_files.feature index 3d93dfec90f..68265a47d62 100644 --- a/admin/tool/customlang/tests/behat/import_files.feature +++ b/admin/tool/customlang/tests/behat/import_files.feature @@ -24,7 +24,7 @@ Feature: Within a moodle instance, an administrator should be able to import mod And I should see "There are 1 modified strings." And I click on "Save strings to language pack" "button" And I click on "Continue" "button" - And I should see "An amazing import feature" in the "page-header" "region" + And I should see "An amazing import feature" @javascript Scenario: Try to import a PHP file from a non existent component @@ -46,6 +46,6 @@ Feature: Within a moodle instance, an administrator should be able to import mod And I should see "There are 3 modified strings." And I click on "Save strings to language pack" "button" And I click on "Continue" "button" - And I should see "Uploaded custom string" in the "page-header" "region" - And I should see "Another Uploaded string" in the "page-header" "region" - And I should see "An amazing import feature" in the "page-header" "region" + And I should see "Uploaded custom string" + And I should see "Another Uploaded string" + And I should see "An amazing import feature" diff --git a/admin/tool/customlang/tests/behat/import_mode.feature b/admin/tool/customlang/tests/behat/import_mode.feature index e977237a5e8..2b0186acdbb 100644 --- a/admin/tool/customlang/tests/behat/import_mode.feature +++ b/admin/tool/customlang/tests/behat/import_mode.feature @@ -21,7 +21,7 @@ Feature: Within a moodle instance, an administrator should be able to import lan And I press "Save changes to the language pack" And I should see "There are 1 modified strings." And I click on "Continue" "button" - And I should see "Custom string example" in the "page-header" "region" + And I should see "Custom string example" @javascript Scenario: Update only customized strings @@ -36,12 +36,12 @@ Feature: Within a moodle instance, an administrator should be able to import lan And I should see "String core/nonexistentinvetedstring not found." And I click on "Continue" "button" And I should see "There are 1 modified strings." - And I should not see "Uploaded custom string" in the "page-header" "region" + And I should not see "Uploaded custom string" And I click on "Save strings to language pack" "button" And I click on "Continue" "button" - And I should not see "Custom string example" in the "page-header" "region" - And I should see "Uploaded custom string" in the "page-header" "region" - And I should not see "Another Uploaded string" in the "page-header" "region" + And I should not see "Custom string example" + And I should see "Uploaded custom string" + And I should not see "Another Uploaded string" @javascript Scenario: Create only new strings @@ -56,12 +56,12 @@ Feature: Within a moodle instance, an administrator should be able to import lan And I should see "String core/nonexistentinvetedstring not found." And I click on "Continue" "button" And I should see "There are 1 modified strings." - And I should not see "Uploaded custom string" in the "page-header" "region" + And I should not see "Uploaded custom string" And I click on "Save strings to language pack" "button" And I click on "Continue" "button" - And I should see "Custom string example" in the "page-header" "region" - And I should not see "Uploaded custom string" in the "page-header" "region" - And I should see "Another Uploaded string" in the "page-header" "region" + And I should see "Custom string example" + And I should not see "Uploaded custom string" + And I should see "Another Uploaded string" @javascript Scenario: Import all strings @@ -76,9 +76,9 @@ Feature: Within a moodle instance, an administrator should be able to import lan And I should see "String core/nonexistentinvetedstring not found." And I click on "Continue" "button" And I should see "There are 2 modified strings." - And I should not see "Uploaded custom string" in the "page-header" "region" + And I should not see "Uploaded custom string" And I click on "Save strings to language pack" "button" And I click on "Continue" "button" - And I should not see "Custom string example" in the "page-header" "region" - And I should see "Uploaded custom string" in the "page-header" "region" - And I should see "Another Uploaded string" in the "page-header" "region" + And I should not see "Custom string example" + And I should see "Uploaded custom string" + And I should see "Another Uploaded string" diff --git a/admin/tool/lp/tests/behat/plan_crud.feature b/admin/tool/lp/tests/behat/plan_crud.feature index 9f3dc075302..761a569fc87 100644 --- a/admin/tool/lp/tests/behat/plan_crud.feature +++ b/admin/tool/lp/tests/behat/plan_crud.feature @@ -56,7 +56,7 @@ Feature: Manage plearning plan And I set the field "Select cohorts to sync" to "cohort plan" When I click on "Add cohorts" "button" Then I should see "2 learning plans were created." - And I follow "Learning plan templates" + And I navigate to "Competencies > Learning plan templates" in site administration And I click on ".template-userplans" "css_element" in the "Science template cohort" "table_row" And I should see "Student 1" And I should see "Student 2" diff --git a/admin/tool/lp/tests/behat/plan_workflow.feature b/admin/tool/lp/tests/behat/plan_workflow.feature index 5410f2ab2e9..9d8f2eb39c1 100644 --- a/admin/tool/lp/tests/behat/plan_workflow.feature +++ b/admin/tool/lp/tests/behat/plan_workflow.feature @@ -149,7 +149,6 @@ Feature: Manage plan workflow And I follow "User 1" And I follow "Learning plans" And I should see "List of learning plans" - And I follow "Learning plans" When I click on "Send back to draft" of edit menu in the "Test-Plan3" row And I follow "Test-Plan4" And I follow "Send back to draft" @@ -168,7 +167,6 @@ Feature: Manage plan workflow And I follow "User 1" And I follow "Learning plans" And I should see "List of learning plans" - And I follow "Learning plans" When I click on "Complete this learning plan" of edit menu in the "Test-Plan3" row And I click on "Complete this learning plan" "button" in the "Confirm" "dialogue" And I wait until the page is ready @@ -190,7 +188,6 @@ Feature: Manage plan workflow And I follow "User 1" And I follow "Learning plans" And I should see "List of learning plans" - And I follow "Learning plans" When I click on "Reopen this learning plan" of edit menu in the "Test-Plan3" row And I click on "Reopen this learning plan" "button" in the "Confirm" "dialogue" And I follow "Test-Plan4" diff --git a/admin/tool/oauth2/tests/behat/basic_settings.feature b/admin/tool/oauth2/tests/behat/basic_settings.feature index beb669eab94..d2c402c436c 100644 --- a/admin/tool/oauth2/tests/behat/basic_settings.feature +++ b/admin/tool/oauth2/tests/behat/basic_settings.feature @@ -24,11 +24,11 @@ Feature: Basic OAuth2 functionality And I click on "Configure endpoints" "link" in the "Testing service" "table_row" And I should see "https://accounts.google.com/.well-known/openid-configuration" in the "discovery_endpoint" "table_row" And I should see "authorization_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Testing service" "table_row" And I should see "firstname" in the "given_name" "table_row" And I should see "middlename" in the "middle_name" "table_row" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Testing service" "table_row" And I set the following fields to these values: | Name | Testing service modified | @@ -57,10 +57,10 @@ Feature: Basic OAuth2 functionality And I click on "Configure endpoints" "link" in the "Testing service" "table_row" And I should see "authorization_endpoint" And I should not see "discovery_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Testing service" "table_row" And I should see "firstname" in the "givenName" "table_row" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Testing service" "table_row" And I set the following fields to these values: | Name | Testing service modified | @@ -89,10 +89,10 @@ Feature: Basic OAuth2 functionality And I click on "Configure endpoints" "link" in the "Testing service" "table_row" And I should see "authorization_endpoint" And I should not see "discovery_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Testing service" "table_row" And I should see "firstname" in the "first_name" "table_row" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Testing service" "table_row" And I set the following fields to these values: | Name | Testing service modified | @@ -126,10 +126,10 @@ Feature: Basic OAuth2 functionality And I click on "Configure endpoints" "link" in the "Testing service" "table_row" And I should see "authorization_endpoint" And I should not see "discovery_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Testing service" "table_row" And I should see "username" in the "ocs-data-id" "table_row" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Testing service" "table_row" And I set the following fields to these values: | Name | Testing service modified | @@ -159,11 +159,11 @@ Feature: Basic OAuth2 functionality And I click on "Configure endpoints" "link" in the "Open Badges" "table_row" And I should see "https://dc.imsglobal.org/.well-known/badgeconnect.json" in the "discovery_endpoint" "table_row" And I should see "authorization_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Open Badges" "table_row" And I should not see "given_name" And I should not see "middle_name" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Open Badges" "table_row" And I set the following fields to these values: | Name | IMS Global | @@ -194,11 +194,11 @@ Feature: Basic OAuth2 functionality And I click on "Configure endpoints" "link" in the "Google custom" "table_row" And I should see "https://accounts.google.com/.well-known/openid-configuration" in the "discovery_endpoint" "table_row" And I should see "authorization_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Google custom" "table_row" And I should see "firstname" in the "given_name" "table_row" And I should see "middlename" in the "middle_name" "table_row" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Google custom" "table_row" And I set the following fields to these values: | Name | Google custom modified | @@ -227,11 +227,11 @@ Feature: Basic OAuth2 functionality And I should see "-" in the "Invalid custom service" "table_row" And I click on "Configure endpoints" "link" in the "Invalid custom service" "table_row" And I should not see "discovery_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Invalid custom service" "table_row" And I should not see "given_name" And I should not see "middle_name" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Invalid custom service" "table_row" And I set the following fields to these values: | Name | Valid custom service | @@ -268,11 +268,11 @@ Feature: Basic OAuth2 functionality And I should see "-" in the "Empty custom service" "table_row" And I click on "Configure endpoints" "link" in the "Empty custom service" "table_row" And I should not see "discovery_endpoint" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Configure user field mappings" "link" in the "Empty custom service" "table_row" And I should not see "given_name" And I should not see "middle_name" - And I follow "OAuth 2 services" + And I navigate to "Server > OAuth 2 services" in site administration And I click on "Edit" "link" in the "Empty custom service" "table_row" # Check it works as expected too without slash at the end of the service base URL. And I set the following fields to these values: diff --git a/analytics/tests/behat/manage_models.feature b/analytics/tests/behat/manage_models.feature index d68ed78e11b..d6b56f56695 100644 --- a/analytics/tests/behat/manage_models.feature +++ b/analytics/tests/behat/manage_models.feature @@ -93,7 +93,7 @@ Feature: Manage analytics models And I click on "View" "link" And I should see "Log extra info" And I click on "Close" "button" - And I click on "Analytics models" "link" + And I navigate to "Analytics > Analytics models" in site administration # Execute scheduled analysis And I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row" And I choose "Execute scheduled analysis" in the open action menu diff --git a/badges/criteria.php b/badges/criteria.php index 0a9f290967b..58f9c0bdaf3 100644 --- a/badges/criteria.php +++ b/badges/criteria.php @@ -61,7 +61,7 @@ $PAGE->set_context($context); $PAGE->set_url($currenturl); $PAGE->set_heading($badge->name); $PAGE->set_title($badge->name); -$PAGE->navbar->add($badge->name); +$PAGE->navbar->add($badge->name, $currenturl); $output = $PAGE->get_renderer('core', 'badges'); $msg = optional_param('msg', '', PARAM_TEXT); @@ -105,4 +105,4 @@ if ($badge->has_criteria()) { echo $OUTPUT->box(get_string('addcriteriatext', 'badges')); } -echo $OUTPUT->footer(); \ No newline at end of file +echo $OUTPUT->footer(); diff --git a/badges/tests/behat/add_badge.feature b/badges/tests/behat/add_badge.feature index bc7695df440..82d5c3b1ff9 100644 --- a/badges/tests/behat/add_badge.feature +++ b/badges/tests/behat/add_badge.feature @@ -44,7 +44,7 @@ Feature: Add badges to the system And I should see "Issuer details" And I should see "Test Badge Site" And I should see "testuser@example.com" - And I follow "Manage badges" + And I navigate to "Badges > Manage badges" in site administration And I should see "Number of badges available: 1" And I should not see "There are no badges available." @@ -61,7 +61,7 @@ Feature: Add badges to the system And I upload "badges/tests/behat/badge.png" file to "Image" filemanager And I press "Create badge" And I wait until the page is ready - And I follow "Manage badges" + And I navigate to "Badges > Manage badges" in site administration And I should see "Number of badges available: 1" And I press "Add a new badge" And I set the following fields to these values: @@ -157,7 +157,7 @@ Feature: Add badges to the system And I should see "Related badges (0)" And I should see "Alignments (0)" And I should not see "Create badge" - And I follow "Manage badges" + And I navigate to "Badges > Manage badges" in site administration And I should see "Number of badges available: 1" And I should not see "There are no badges available." # See buttons from the "Site badges" page. diff --git a/badges/tests/behat/award_badge.feature b/badges/tests/behat/award_badge.feature index 7324e75c31f..b25de058730 100644 --- a/badges/tests/behat/award_badge.feature +++ b/badges/tests/behat/award_badge.feature @@ -21,7 +21,6 @@ Feature: Award badges And I am on "Course 1" course homepage # Create course badge 1. And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge 1 | | Description | Course badge 1 description | @@ -38,7 +37,6 @@ Feature: Award badges # Badge #2 And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge 2 | | Description | Course badge 2 description | @@ -57,6 +55,7 @@ Feature: Award badges # Award course badge 1 to student 1. And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" When I press "Award badge" + And I follow "Manage badges" And I follow "Course Badge 1" And I follow "Recipients (1)" Then I should see "Recipients (1)" @@ -149,6 +148,7 @@ Feature: Award badges And I press "Award badge" And I set the field "potentialrecipients[]" to "student 1 (student1@example.com)" And I press "Award badge" + And I navigate to "Badges > Manage badges" in site administration When I follow "Site Badge" Then I should see "Recipients (2)" And I log out @@ -174,7 +174,6 @@ Feature: Award badges And I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | @@ -191,6 +190,7 @@ Feature: Award badges And I press "Award badge" And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" When I press "Award badge" + And I follow "Manage badges" And I follow "Course Badge" Then I should see "Recipients (2)" And I log out @@ -225,7 +225,6 @@ Feature: Award badges | id_completion | 1 | And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | @@ -279,7 +278,6 @@ Feature: Award badges And I press "Save changes" And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | @@ -328,7 +326,6 @@ Feature: Award badges And I am on "Course 1" course homepage # Create course badge 1. And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge 1 | | Description | Course badge description | @@ -347,13 +344,13 @@ Feature: Award badges # Award course badge 1 to student 1. And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" When I press "Award badge" + And I follow "Manage badges" And I follow "Course Badge 1" And I follow "Recipients (1)" Then I should see "Recipients (1)" # Add course badge 2. And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge 2 | | Description | Course badge description | @@ -372,6 +369,7 @@ Feature: Award badges # Award course badge 2 to student 2. And I set the field "potentialrecipients[]" to "Student 2 (student2@example.com)" When I press "Award badge" + And I follow "Manage badges" And I follow "Course Badge 2" And I follow "Recipients (1)" Then I should see "Recipients (1)" @@ -409,7 +407,6 @@ Feature: Award badges And I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | @@ -426,6 +423,7 @@ Feature: Award badges And I press "Award badge" And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" When I press "Award badge" + And I follow "Manage badges" And I follow "Course Badge" Then I should see "Recipients (2)" And I follow "Recipients (2)" @@ -434,5 +432,6 @@ Feature: Award badges And I press "Revoke badge" And I set the field "existingrecipients[]" to "Student 1 (student1@example.com)" When I press "Revoke badge" + And I follow "Manage badges" And I follow "Course Badge" Then I should see "Recipients (0)" diff --git a/badges/tests/behat/award_badge_groups.feature b/badges/tests/behat/award_badge_groups.feature index 76b29b689e3..2596b26dd4c 100644 --- a/badges/tests/behat/award_badge_groups.feature +++ b/badges/tests/behat/award_badge_groups.feature @@ -37,7 +37,6 @@ Feature: Award badges with separate groups And I set the field "Group mode" to "Separate groups" And I press "Save and display" And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | @@ -59,7 +58,6 @@ Feature: Award badges with separate groups When I log in as "teacher2" And I am on "Course 1" course homepage And I navigate to "Badges > Manage badges" in current page administration - And I follow "Manage badges" And I follow "Course Badge" And I press "Award badge" And I set the field "role" to "Non-editing teacher" @@ -71,6 +69,7 @@ Feature: Award badges with separate groups # Non-editing teacher can award the badge And I set the field "potentialrecipients[]" to "Student 2 (student2@example.com)" And I press "Award badge" + And I follow "Manage badges" And I follow "Course Badge" And I should see "Recipients (1)" And I log out @@ -85,7 +84,6 @@ Feature: Award badges with separate groups Given I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Users > Groups" in current page administration - And I follow "Groups" And I set the field "groups" to "Class B (2)" And I press "Add/remove users" And I set the field "addselect" to "Teacher 2 (teacher2@example.com)" @@ -94,7 +92,6 @@ Feature: Award badges with separate groups When I log in as "teacher2" And I am on "Course 1" course homepage And I navigate to "Badges > Manage badges" in current page administration - And I follow "Manage badges" And I follow "Course Badge" And I press "Award badge" And I set the field "role" to "Non-editing teacher" @@ -112,7 +109,6 @@ Feature: Award badges with separate groups Given I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Users > Groups" in current page administration - And I follow "Groups" And I set the field "groups" to "Class A (2)" And I press "Add/remove users" And I set the field "removeselect" to "Teacher 2 (teacher2@example.com)" @@ -122,7 +118,6 @@ Feature: Award badges with separate groups When I log in as "teacher2" And I am on "Course 1" course homepage And I navigate to "Badges > Manage badges" in current page administration - And I follow "Manage badges" And I follow "Course Badge" And I press "Award badge" # Teacher 2 shouldn't be able to go further diff --git a/badges/tests/behat/criteria_activity.feature b/badges/tests/behat/criteria_activity.feature index 42b16f33c1c..93911ff5715 100644 --- a/badges/tests/behat/criteria_activity.feature +++ b/badges/tests/behat/criteria_activity.feature @@ -36,7 +36,6 @@ Feature: Award badges based on activity completion And I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | diff --git a/badges/tests/behat/criteria_cohort.feature b/badges/tests/behat/criteria_cohort.feature index e0578debdf7..32d2625e0af 100644 --- a/badges/tests/behat/criteria_cohort.feature +++ b/badges/tests/behat/criteria_cohort.feature @@ -152,6 +152,7 @@ Feature: Award badges based on cohort And I press "Award badge" And I set the field "potentialrecipients[]" to "Second User (second@example.com)" And I press "Award badge" + And I navigate to "Badges > Manage badges" in site administration And I follow "Site Badge" Then I should see "Recipients (1)" And I log out @@ -203,6 +204,7 @@ Feature: Award badges based on cohort And I press "Award badge" And I set the field "potentialrecipients[]" to "Second User (second@example.com)" And I press "Award badge" + And I navigate to "Badges > Manage badges" in site administration And I follow "Site Badge" Then I should see "Recipients (2)" And I log out @@ -259,6 +261,7 @@ Feature: Award badges based on cohort And I press "Award badge" And I set the field "potentialrecipients[]" to "Second User (second@example.com)" And I press "Award badge" + And I navigate to "Badges > Manage badges" in site administration And I follow "Site Badge" Then I should see "Recipients (2)" And I log out @@ -317,6 +320,7 @@ Feature: Award badges based on cohort And I press "Award badge" And I set the field "potentialrecipients[]" to "Second User (second@example.com)" And I press "Award badge" + And I navigate to "Badges > Manage badges" in site administration And I follow "Site Badge" Then I should see "Recipients (1)" And I log out diff --git a/badges/tests/behat/criteria_competency.feature b/badges/tests/behat/criteria_competency.feature index 0945c09ae58..77bac3fe102 100644 --- a/badges/tests/behat/criteria_competency.feature +++ b/badges/tests/behat/criteria_competency.feature @@ -40,7 +40,6 @@ Feature: Award badges based on competency completion # Add a badge to the course And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | diff --git a/badges/tests/behat/role_visibility.feature b/badges/tests/behat/role_visibility.feature index 036ff5d060b..4d46e8f7f10 100644 --- a/badges/tests/behat/role_visibility.feature +++ b/badges/tests/behat/role_visibility.feature @@ -22,7 +22,6 @@ Feature: Test role visibility for the badge administration page Given I log in as "manager1" And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | @@ -37,7 +36,6 @@ Feature: Test role visibility for the badge administration page Given I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Badges > Add a new badge" in current page administration - And I follow "Add a new badge" And I set the following fields to these values: | Name | Course Badge | | Description | Course badge description | diff --git a/badges/tests/behat/view_badge.feature b/badges/tests/behat/view_badge.feature index fa2e0c6cb3e..5bdc41f2fb3 100644 --- a/badges/tests/behat/view_badge.feature +++ b/badges/tests/behat/view_badge.feature @@ -35,6 +35,7 @@ Feature: Display badges And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" And I press "Award badge" # Check badge details are displayed. + And I navigate to "Badges > Manage badges" in site administration And I follow "Testing system badge" And I follow "Recipients (1)" When I click on "View issued badge" "link" in the "Student 1" "table_row" @@ -59,6 +60,7 @@ Feature: Display badges And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" And I press "Award badge" # Check badge details are displayed. + And I navigate to "Badges > Manage badges" in site administration And I follow "Testing system badge" And I follow "Recipients (1)" When I click on "View issued badge" "link" in the "Student 1" "table_row" @@ -107,6 +109,7 @@ Feature: Display badges And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)" And I press "Award badge" # Check "Expires" date is displayed. + And I navigate to "Badges > Manage badges" in site administration And I follow "Testing system badge" And I follow "Recipients (1)" And I click on "View issued badge" "link" in the "Student 1" "table_row" @@ -130,6 +133,7 @@ Feature: Display badges # Wait 1 second to guarantee the badge is expired. And I wait "1" seconds # Check "Expired" date is displayed. + And I navigate to "Badges > Manage badges" in site administration And I follow "Testing system badge" And I follow "Recipients (1)" And I click on "View issued badge" "link" in the "Student 1" "table_row" diff --git a/blocks/classes/local/views/secondary.php b/blocks/classes/local/views/secondary.php new file mode 100644 index 00000000000..c23e857a5f7 --- /dev/null +++ b/blocks/classes/local/views/secondary.php @@ -0,0 +1,32 @@ +. + +namespace core_block\local\views; + +/** + * Class secondary + * + * @package core_block + * @category navigation + */ +class secondary extends \core\navigation\views\secondary { + /** + * Blocks don't require secondary navs as they can be accessed from multiple places and in different contexts. + */ + public function initialise(): void { + + } +} diff --git a/blocks/tests/behat/manage_blocks.feature b/blocks/tests/behat/manage_blocks.feature index e3903a9ce64..90880a3586f 100644 --- a/blocks/tests/behat/manage_blocks.feature +++ b/blocks/tests/behat/manage_blocks.feature @@ -59,5 +59,4 @@ Feature: Block appearances | Visible | No | And I press "Save changes" And I press "Turn editing off" - And I follow "Test book name" Then I should not see "Comments" diff --git a/cohort/tests/behat/add_cohort.feature b/cohort/tests/behat/add_cohort.feature index 0d28abfa8a8..7ba866d8588 100644 --- a/cohort/tests/behat/add_cohort.feature +++ b/cohort/tests/behat/add_cohort.feature @@ -33,6 +33,10 @@ Feature: Add cohorts of users Then the following should exist in the "generaltable" table: | Name | Cohort ID | Description | | My new cohort | mynewcohort | My new cohort is cool | + And I should see "Test cohort name" + And I should see "333" + And I should see "Test cohort description" + And I should see "Created manually" @javascript Scenario: Add users to a cohort selecting them from the system users list @@ -76,8 +80,7 @@ Feature: Add cohorts of users @javascript Scenario: Add users to a cohort using a bulk user action - When I follow "Accounts" - And I follow "Bulk user actions" + When I navigate to "Users > Accounts > Bulk user actions" in site administration And I set the field "Available" to "Third User" And I press "Add to selection" And I set the field "Available" to "Forth User" @@ -86,8 +89,7 @@ Feature: Add cohorts of users And I press "Go" And I set the field "Cohort" to "Test cohort name [333]" And I press "Add to cohort" - And I follow "Accounts" - And I follow "Cohorts" + And I navigate to "Users > Accounts > Cohorts" in site administration Then I should see "2" in the "#cohorts" "css_element" And I click on "Assign" "link" in the "Test cohort name" "table_row" And the "Current users" select box should contain "Third User (third@example.com)" @@ -96,9 +98,9 @@ Feature: Add cohorts of users @javascript Scenario: Edit cohort name in-place - When I follow "Cohorts" + When I navigate to "Users > Accounts > Cohorts" in site administration And I set the field "Edit cohort name" to "Students cohort" Then I should not see "Test cohort name" And I should see "Students cohort" - And I follow "Cohorts" + And I navigate to "Users > Accounts > Cohorts" in site administration And I should see "Students cohort" diff --git a/course/tests/behat/course_browsing.feature b/course/tests/behat/course_browsing.feature index 4993d076e20..586859d2a3c 100644 --- a/course/tests/behat/course_browsing.feature +++ b/course/tests/behat/course_browsing.feature @@ -91,7 +91,7 @@ Feature: Restricting access to course lists And I should see "Biology" And I should not see "Humanities" And I click on "Courses" "link" in the "Navigation" "block" - And "category" "text" should not exist in the ".breadcrumb" "css_element" + # And "category" "text" should not exist in the ".breadcrumb" "css_element" And I should see "Science category" And I should see "English category" And I should not see "Other category" diff --git a/course/tests/behat/course_controls.feature b/course/tests/behat/course_controls.feature index e51d1807df9..a571bf6ad3d 100644 --- a/course/tests/behat/course_controls.feature +++ b/course/tests/behat/course_controls.feature @@ -81,12 +81,12 @@ Feature: Course activity controls works as expected Examples: | courseformat | coursedisplay | targetpage | should_see_other_sections | should_see_other_sections_following_block_sections_links | belowpage | - | topics | 0 | "Course 1" | should | should | "Topic 2" | + | topics | 0 | "C1" | should | should | "Topic 2" | | topics | 1 | "Topic 1" | should not | should not | "Topic 2" | - | topics | 1 | "Course 1" | should | should not | "Topic 2" | - | weeks | 0 | "Course 1" | should | should | "8 January - 14 January" | + | topics | 1 | "C1" | should | should not | "Topic 2" | + | weeks | 0 | "C1" | should | should | "8 January - 14 January" | | weeks | 1 | "1 January - 7 January" | should not | should not | "8 January - 14 January" | - | weeks | 1 | "Course 1" | should | should not | "8 January - 14 January" | + | weeks | 1 | "C1" | should | should not | "8 January - 14 January" | Scenario Outline: General activities course controls using topics and weeks formats, and paged mode and not paged mode works as expected Given the following "users" exist: @@ -146,9 +146,9 @@ Feature: Course activity controls works as expected Examples: | courseformat | coursedisplay | targetpage | should_see_other_sections | should_see_other_sections_following_block_sections_links | belowpage | - | topics | 0 | "Course 1" | should | should | "Topic 2" | + | topics | 0 | "C1" | should | should | "Topic 2" | | topics | 1 | "Topic 1" | should not | should not | "Topic 2" | - | topics | 1 | "Course 1" | should | should not | "Topic 2" | - | weeks | 0 | "Course 1" | should | should | "8 January - 14 January" | + | topics | 1 | "C1" | should | should not | "Topic 2" | + | weeks | 0 | "C1" | should | should | "8 January - 14 January" | | weeks | 1 | "1 January - 7 January" | should not | should not | "8 January - 14 January" | - | weeks | 1 | "Course 1" | should | should not | "8 January - 14 January" | + | weeks | 1 | "C1" | should | should not | "8 January - 14 January" | diff --git a/course/tests/behat/edit_settings.feature b/course/tests/behat/edit_settings.feature index 4d46d50d61f..59c801af74e 100644 --- a/course/tests/behat/edit_settings.feature +++ b/course/tests/behat/edit_settings.feature @@ -23,7 +23,6 @@ Feature: Edit course settings | Course short name | Edited course shortname | | Course summary | Edited course summary | And I press "Save and display" - And I follow "Edited course fullname" Then I should not see "Course 1" And I should not see "C1" And I should see "Edited course fullname" diff --git a/grade/grading/tests/behat/behat_grading.php b/grade/grading/tests/behat/behat_grading.php index 89c6c097556..05a08340297 100644 --- a/grade/grading/tests/behat/behat_grading.php +++ b/grade/grading/tests/behat/behat_grading.php @@ -85,7 +85,7 @@ class behat_grading extends behat_base { // Step to access the user grade page from the grading page. $gradetext = get_string('gradeverb'); - $this->execute("behat_general::i_click_on_in_the", [$this->escape($activityname), 'link', 'page', 'region']); + $this->execute('behat_navigation::go_to_breadcrumb_location', $this->escape($activityname)); $this->execute('behat_navigation::i_navigate_to_in_current_page_administration', get_string('viewgrading', 'mod_assign')); diff --git a/lang/en/moodle.php b/lang/en/moodle.php index bc1d1955d50..005905b13b8 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1006,6 +1006,7 @@ $string['hits'] = 'Hits'; $string['hitsoncourse'] = 'Hits on {$a->coursename} by {$a->username}'; $string['hitsoncoursetoday'] = 'Today\'s hits on {$a->coursename} by {$a->username}'; $string['home'] = 'Home'; +$string['homeheader'] = 'Home menu'; $string['hour'] = 'hour'; $string['hours'] = 'hours'; $string['howtomakethemes'] = 'How to make new themes'; @@ -1140,6 +1141,7 @@ $string['langltr'] = 'Language direction left-to-right'; $string['langrtl'] = 'Language direction right-to-left'; $string['language'] = 'Language'; $string['languagegood'] = 'This language pack is up-to-date! :-)'; +$string['languageselector'] = 'Language selector'; $string['last'] = 'Last'; $string['lastaccess'] = 'Last access'; $string['lastcourseaccess'] = 'Last access to course'; @@ -1350,6 +1352,7 @@ $string['movefilestohere'] = 'Move files to here'; $string['movefull'] = 'Move {$a} to this location'; $string['movehere'] = 'Move to here'; $string['moveleft'] = 'Move left'; +$string['moremenu'] = 'More'; $string['moveright'] = 'Move right'; $string['movesection'] = 'Move section {$a}'; $string['moveselectedcategoriesto'] = 'Move selected categories to'; @@ -1735,6 +1738,7 @@ $string['rename'] = 'Rename'; $string['renamefileto'] = 'Rename {$a} to'; $string['report'] = 'Report'; $string['reports'] = 'Reports'; +$string['reporttype'] = 'Report type'; $string['repositories'] = 'Repositories'; $string['requestcourse'] = 'Request a course'; $string['requestedby'] = 'Requested by'; @@ -2214,6 +2218,7 @@ $string['userdetails'] = 'User details'; $string['userfiles'] = 'User files'; $string['userlist'] = 'User list'; $string['usermenu'] = 'User menu'; +$string['usermenugoback'] = 'Go back to user menu'; $string['username'] = 'Username'; $string['usernameemail'] = 'Username / email'; $string['usernameemailmatch'] = 'The username and email address do not relate to the same user'; diff --git a/lib/amd/build/menu_navigation.min.js b/lib/amd/build/menu_navigation.min.js new file mode 100644 index 00000000000..a6cb660bcf6 --- /dev/null +++ b/lib/amd/build/menu_navigation.min.js @@ -0,0 +1,2 @@ +define ("core/menu_navigation",["exports","core/key_codes"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;var c={menuitem:"[role=\"menuitem\"]",menu:"[role=\"menu\"]"},d=null,e=function(a,b){if(null!==a){return a}else{return b}},f=function(a){var e=a.srcElement,f=a.currentTarget.firstElementChild,k=j(a.currentTarget);if(e.classList.contains("dropdown-item")){if(a.keyCode===b.arrowRight||a.keyCode===b.arrowLeft){a.preventDefault();if(null!==d){d.parentElement.click()}}if(a.keyCode===b.space||a.keyCode===b.enter){a.preventDefault();Array.prototype.forEach.call(e.closest(".dropdown-menu").children,function(a){a.querySelector(c.menuitem).classList.remove("active");a.setAttribute("aria-current","false")});if(!e.parentElement.classList.contains("dropdown")){e.click()}}}else{if(a.keyCode===b.arrowRight){a.preventDefault();g(e,f)}if(a.keyCode===b.arrowLeft){a.preventDefault();h(e,k)}if(a.keyCode===b.arrowUp||a.keyCode===b.arrowDown){d=e;a.preventDefault()}if(a.keyCode===b.home){a.preventDefault();e.setAttribute("aria-current","false");i(f)}if(a.keyCode===b.end){a.preventDefault();e.currentNode.setAttribute("aria-current","false");i(k)}if(a.keyCode===b.space||a.keyCode===b.enter){a.preventDefault();if(!e.parentElement.classList.contains("dropdown")){e.click()}}}};a.default=function(a){a.removeEventListener("keydown",f);a.addEventListener("keydown",f)};var g=function(a,b){var d=a.parentElement.nextElementSibling,f=e(d,b),g=f.querySelector(c.menuitem);a.setAttribute("aria-current","false");g.setAttribute("aria-current","true");g.focus()},h=function(a,b){var d=a.parentElement.previousElementSibling,f=e(d,b),g=f.querySelector(c.menuitem);a.setAttribute("aria-current","false");g.setAttribute("aria-current","true");g.focus()},i=function(a){a.querySelector(c.menuitem).focus();a.querySelector(c.menuitem).setAttribute("aria-current","true")},j=function(a){var b=a.lastElementChild;if(!b.classList.contains("d-none")){return a.lastElementChild}else{var c=Array.prototype.map.call(a.children,function(a){return a}).reverse(),d=c.filter(function(a){if(!a.classList.contains("d-none")){return a}});if(0!==d.length){return d[0]}else{return a.firstElementChild}}};return a.default}); +//# sourceMappingURL=menu_navigation.min.js.map diff --git a/lib/amd/build/menu_navigation.min.js.map b/lib/amd/build/menu_navigation.min.js.map new file mode 100644 index 00000000000..2fc7010a405 --- /dev/null +++ b/lib/amd/build/menu_navigation.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/menu_navigation.js"],"names":["SELECTORS","openDropdownNode","clickErrorHandler","item","fallback","listenerEvents","e","src","srcElement","firstNode","currentTarget","firstElementChild","lastNode","findUsableLastNode","classList","contains","keyCode","arrowRight","arrowLeft","preventDefault","parentElement","click","space","enter","Array","prototype","forEach","call","closest","children","node","querySelector","menuitem","remove","setAttribute","setFocusNext","setFocusPrev","arrowUp","arrowDown","home","setFocusHomeEnd","end","currentNode","elementRoot","removeEventListener","addEventListener","nextListItem","nextElementSibling","nodeToSelect","menuItem","focus","previousElementSibling","lastElementChild","extractedNodes","map","reverse","nodesToUse","filter","length"],"mappings":"4JA0BMA,CAAAA,CAAS,CAAG,CACd,SAAY,qBADE,CAEd,KAAQ,iBAFM,C,CAKdC,CAAgB,CAAG,I,CASjBC,CAAiB,CAAG,SAACC,CAAD,CAAOC,CAAP,CAAoB,CAC1C,GAAa,IAAT,GAAAD,CAAJ,CAAmB,CACf,MAAOA,CAAAA,CACV,CAFD,IAEO,CACH,MAAOC,CAAAA,CACV,CACJ,C,CAOKC,CAAc,CAAG,SAAAC,CAAC,CAAI,IAClBC,CAAAA,CAAG,CAAGD,CAAC,CAACE,UADU,CAElBC,CAAS,CAAGH,CAAC,CAACI,aAAF,CAAgBC,iBAFV,CAGlBC,CAAQ,CAAGC,CAAkB,CAACP,CAAC,CAACI,aAAH,CAHX,CAOxB,GAAIH,CAAG,CAACO,SAAJ,CAAcC,QAAd,CAAuB,eAAvB,CAAJ,CAA6C,CACzC,GAAIT,CAAC,CAACU,OAAF,GAAcC,YAAd,EACAX,CAAC,CAACU,OAAF,GAAcE,WADlB,CAC6B,CACzBZ,CAAC,CAACa,cAAF,GACA,GAAyB,IAArB,GAAAlB,CAAJ,CAA+B,CAC3BA,CAAgB,CAACmB,aAAjB,CAA+BC,KAA/B,EACH,CACJ,CACD,GAAIf,CAAC,CAACU,OAAF,GAAcM,OAAd,EACAhB,CAAC,CAACU,OAAF,GAAcO,OADlB,CACyB,CACrBjB,CAAC,CAACa,cAAF,GAGAK,KAAK,CAACC,SAAN,CAAgBC,OAAhB,CAAwBC,IAAxB,CAA6BpB,CAAG,CAACqB,OAAJ,CAAY,gBAAZ,EAA8BC,QAA3D,CAAqE,SAAAC,CAAI,CAAI,CACzEA,CAAI,CAACC,aAAL,CAAmB/B,CAAS,CAACgC,QAA7B,EAAuClB,SAAvC,CAAiDmB,MAAjD,CAAwD,QAAxD,EACAH,CAAI,CAACI,YAAL,CAAkB,cAAlB,CAAkC,OAAlC,CACH,CAHD,EAKA,GAAI,CAAC3B,CAAG,CAACa,aAAJ,CAAkBN,SAAlB,CAA4BC,QAA5B,CAAqC,UAArC,CAAL,CAAuD,CACnDR,CAAG,CAACc,KAAJ,EACH,CACJ,CACJ,CAtBD,IAsBO,CACH,GAAIf,CAAC,CAACU,OAAF,GAAcC,YAAlB,CAA8B,CAC1BX,CAAC,CAACa,cAAF,GACAgB,CAAY,CAAC5B,CAAD,CAAME,CAAN,CACf,CACD,GAAIH,CAAC,CAACU,OAAF,GAAcE,WAAlB,CAA6B,CACzBZ,CAAC,CAACa,cAAF,GACAiB,CAAY,CAAC7B,CAAD,CAAMK,CAAN,CACf,CAED,GAAIN,CAAC,CAACU,OAAF,GAAcqB,SAAd,EACA/B,CAAC,CAACU,OAAF,GAAcsB,WADlB,CAC6B,CACzBrC,CAAgB,CAAGM,CAAnB,CACAD,CAAC,CAACa,cAAF,EACH,CACD,GAAIb,CAAC,CAACU,OAAF,GAAcuB,MAAlB,CAAwB,CACpBjC,CAAC,CAACa,cAAF,GACAZ,CAAG,CAAC2B,YAAJ,CAAiB,cAAjB,CAAiC,OAAjC,EACAM,CAAe,CAAC/B,CAAD,CAClB,CACD,GAAIH,CAAC,CAACU,OAAF,GAAcyB,KAAlB,CAAuB,CACnBnC,CAAC,CAACa,cAAF,GACAZ,CAAG,CAACmC,WAAJ,CAAgBR,YAAhB,CAA6B,cAA7B,CAA6C,OAA7C,EACAM,CAAe,CAAC5B,CAAD,CAClB,CACD,GAAIN,CAAC,CAACU,OAAF,GAAcM,OAAd,EACAhB,CAAC,CAACU,OAAF,GAAcO,OADlB,CACyB,CACrBjB,CAAC,CAACa,cAAF,GAEA,GAAI,CAACZ,CAAG,CAACa,aAAJ,CAAkBN,SAAlB,CAA4BC,QAA5B,CAAqC,UAArC,CAAL,CAAuD,CACnDR,CAAG,CAACc,KAAJ,EACH,CACJ,CACJ,CACJ,C,WAOc,SAAAsB,CAAW,CAAI,CAC1BA,CAAW,CAACC,mBAAZ,CAAgC,SAAhC,CAA2CvC,CAA3C,EACAsC,CAAW,CAACE,gBAAZ,CAA6B,SAA7B,CAAwCxC,CAAxC,CACH,C,IAQK8B,CAAAA,CAAY,CAAG,SAACO,CAAD,CAAcjC,CAAd,CAA4B,IACvCqC,CAAAA,CAAY,CAAGJ,CAAW,CAACtB,aAAZ,CAA0B2B,kBADF,CAEvCC,CAAY,CAAG9C,CAAiB,CAAC4C,CAAD,CAAerC,CAAf,CAFO,CAGvCwC,CAAQ,CAAGD,CAAY,CAACjB,aAAb,CAA2B/B,CAAS,CAACgC,QAArC,CAH4B,CAI7CU,CAAW,CAACR,YAAZ,CAAyB,cAAzB,CAAyC,OAAzC,EACAe,CAAQ,CAACf,YAAT,CAAsB,cAAtB,CAAsC,MAAtC,EACAe,CAAQ,CAACC,KAAT,EACH,C,CAQKd,CAAY,CAAG,SAACM,CAAD,CAAc9B,CAAd,CAA2B,IACtCkC,CAAAA,CAAY,CAAGJ,CAAW,CAACtB,aAAZ,CAA0B+B,sBADH,CAEtCH,CAAY,CAAG9C,CAAiB,CAAC4C,CAAD,CAAelC,CAAf,CAFM,CAGtCqC,CAAQ,CAAGD,CAAY,CAACjB,aAAb,CAA2B/B,CAAS,CAACgC,QAArC,CAH2B,CAI5CU,CAAW,CAACR,YAAZ,CAAyB,cAAzB,CAAyC,OAAzC,EACAe,CAAQ,CAACf,YAAT,CAAsB,cAAtB,CAAsC,MAAtC,EACAe,CAAQ,CAACC,KAAT,EACH,C,CAOKV,CAAe,CAAG,SAAAV,CAAI,CAAI,CAC5BA,CAAI,CAACC,aAAL,CAAmB/B,CAAS,CAACgC,QAA7B,EAAuCkB,KAAvC,GACApB,CAAI,CAACC,aAAL,CAAmB/B,CAAS,CAACgC,QAA7B,EAAuCE,YAAvC,CAAoD,cAApD,CAAoE,MAApE,CACH,C,CAQKrB,CAAkB,CAAG,SAAA8B,CAAW,CAAI,CACtC,GAAM/B,CAAAA,CAAQ,CAAG+B,CAAW,CAACS,gBAA7B,CAGA,GAAI,CAACxC,CAAQ,CAACE,SAAT,CAAmBC,QAAnB,CAA4B,QAA5B,CAAL,CAA4C,CACxC,MAAO4B,CAAAA,CAAW,CAACS,gBACtB,CAFD,IAEO,IAEGC,CAAAA,CAAc,CAAG7B,KAAK,CAACC,SAAN,CAAgB6B,GAAhB,CAAoB3B,IAApB,CAAyBgB,CAAW,CAACd,QAArC,CAA+C,SAAAC,CAAI,CAAI,CAC1E,MAAOA,CAAAA,CACV,CAFsB,EAEpByB,OAFoB,EAFpB,CAOGC,CAAU,CAAGH,CAAc,CAACI,MAAf,CAAuB,SAAA3B,CAAI,CAAI,CAC9C,GAAI,CAACA,CAAI,CAAChB,SAAL,CAAeC,QAAf,CAAwB,QAAxB,CAAL,CAAwC,CACpC,MAAOe,CAAAA,CACV,CACJ,CAJkB,CAPhB,CAcH,GAA0B,CAAtB,GAAA0B,CAAU,CAACE,MAAf,CAA6B,CACzB,MAAOF,CAAAA,CAAU,CAAC,CAAD,CACpB,CAFD,IAEO,CACH,MAAOb,CAAAA,CAAW,CAAChC,iBACtB,CACJ,CACJ,C","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 * Keyboard initialization for a given html node.\n *\n * @module core/keyboard_navigation\n * @copyright 2021 Moodle\n * @author Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {space, enter, arrowRight, arrowLeft, arrowDown, arrowUp, home, end} from 'core/key_codes';\n\nconst SELECTORS = {\n 'menuitem': '[role=\"menuitem\"]',\n 'menu': '[role=\"menu\"]'\n};\n\nlet openDropdownNode = null;\n\n/**\n * Small helper function to check if a given node is null or not.\n *\n * @param {HTMLElement|null} item The node that we want to compare.\n * @param {HTMLElement} fallback Either the first node or final node that can be focused on.\n * @return {HTMLElement}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Defined event handling so we can remove listeners on nodes on resize etc.\n *\n * @param {event} e The triggering element and key presses etc.\n */\nconst listenerEvents = e => {\n const src = e.srcElement;\n const firstNode = e.currentTarget.firstElementChild;\n const lastNode = findUsableLastNode(e.currentTarget);\n\n // Handling for dropdown escapes.\n // A bulk of the handling is already done by aria.js just add polish.\n if (src.classList.contains('dropdown-item')) {\n if (e.keyCode === arrowRight ||\n e.keyCode === arrowLeft) {\n e.preventDefault();\n if (openDropdownNode !== null) {\n openDropdownNode.parentElement.click();\n }\n }\n if (e.keyCode === space ||\n e.keyCode === enter) {\n e.preventDefault();\n\n // Remove active class from any other dropdown elements.\n Array.prototype.forEach.call(src.closest('.dropdown-menu').children, node => {\n node.querySelector(SELECTORS.menuitem).classList.remove('active');\n node.setAttribute('aria-current', 'false');\n });\n\n if (!src.parentElement.classList.contains('dropdown')) {\n src.click();\n }\n }\n } else {\n if (e.keyCode === arrowRight) {\n e.preventDefault();\n setFocusNext(src, firstNode);\n }\n if (e.keyCode === arrowLeft) {\n e.preventDefault();\n setFocusPrev(src, lastNode);\n }\n // Let aria.js handle the dropdowns.\n if (e.keyCode === arrowUp ||\n e.keyCode === arrowDown) {\n openDropdownNode = src;\n e.preventDefault();\n }\n if (e.keyCode === home) {\n e.preventDefault();\n src.setAttribute('aria-current', 'false');\n setFocusHomeEnd(firstNode);\n }\n if (e.keyCode === end) {\n e.preventDefault();\n src.currentNode.setAttribute('aria-current', 'false');\n setFocusHomeEnd(lastNode);\n }\n if (e.keyCode === space ||\n e.keyCode === enter) {\n e.preventDefault();\n // Aria.js handles dropdowns etc.\n if (!src.parentElement.classList.contains('dropdown')) {\n src.click();\n }\n }\n }\n};\n\n/**\n * The initial entry point that a given module can pass a HTMLElement.\n *\n * @param {HTMLElement} elementRoot The menu to add handlers upon.\n */\nexport default elementRoot => {\n elementRoot.removeEventListener('keydown', listenerEvents);\n elementRoot.addEventListener('keydown', listenerEvents);\n};\n\n/**\n * Handle the focusing to the next element in the dropdown.\n *\n * @param {HTMLElement|null} currentNode The node that we want to take action on.\n * @param {HTMLElement} firstNode The backup node to focus as a last resort.\n */\nconst setFocusNext = (currentNode, firstNode) => {\n const nextListItem = currentNode.parentElement.nextElementSibling;\n const nodeToSelect = clickErrorHandler(nextListItem, firstNode);\n const menuItem = nodeToSelect.querySelector(SELECTORS.menuitem);\n currentNode.setAttribute('aria-current', 'false');\n menuItem.setAttribute('aria-current', 'true');\n menuItem.focus();\n};\n\n/**\n * Handle the focusing to the previous element in the dropdown.\n *\n * @param {HTMLElement|null} currentNode The node that we want to take action on.\n * @param {HTMLElement} lastNode The backup node to focus as a last resort.\n */\nconst setFocusPrev = (currentNode, lastNode) => {\n const nextListItem = currentNode.parentElement.previousElementSibling;\n const nodeToSelect = clickErrorHandler(nextListItem, lastNode);\n const menuItem = nodeToSelect.querySelector(SELECTORS.menuitem);\n currentNode.setAttribute('aria-current', 'false');\n menuItem.setAttribute('aria-current', 'true');\n menuItem.focus();\n};\n\n/**\n * Focus on either the start or end of a nav list.\n *\n * @param {HTMLElement} node The element to focus on.\n */\nconst setFocusHomeEnd = node => {\n node.querySelector(SELECTORS.menuitem).focus();\n node.querySelector(SELECTORS.menuitem).setAttribute('aria-current', 'true');\n};\n\n/**\n * We need to look within the menu to find a last node we can add focus to.\n *\n * @param {HTMLElement} elementRoot Menu to find a final child node within.\n * @return {HTMLElement}\n */\nconst findUsableLastNode = elementRoot => {\n const lastNode = elementRoot.lastElementChild;\n\n // An example is the more menu existing but hidden on the page for the time being.\n if (!lastNode.classList.contains('d-none')) {\n return elementRoot.lastElementChild;\n } else {\n // Cast the HTMLCollection & reverse it.\n const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {\n return node;\n }).reverse();\n\n // Get rid of any nodes we can not set focus on.\n const nodesToUse = extractedNodes.filter((node => {\n if (!node.classList.contains('d-none')) {\n return node;\n }\n }));\n\n // If we find no elements we can set focus on, fall back to the absolute first element.\n if (nodesToUse.length !== 0) {\n return nodesToUse[0];\n } else {\n return elementRoot.firstElementChild;\n }\n }\n};\n"],"file":"menu_navigation.min.js"} \ No newline at end of file diff --git a/lib/amd/build/moremenu.min.js b/lib/amd/build/moremenu.min.js new file mode 100644 index 00000000000..feecc3dd193 --- /dev/null +++ b/lib/amd/build/moremenu.min.js @@ -0,0 +1,2 @@ +define ("core/moremenu",["exports","jquery","core/menu_navigation"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=d(b);c=d(c);function d(a){return a&&a.__esModule?a:{default:a}}var f={regions:{moredropdown:"[data-region=\"moredropdown\"]",morebutton:"[data-region=\"morebutton\"]"},classes:{dropdownitem:"dropdown-item",dropdownmoremenu:"dropdownmoremenu",dropdowntoggle:"dropdown-toggle",hidden:"d-none",active:"active",nav:"nav",navlink:"nav-link",observed:"observed"},attributes:{menu:"[role=\"menu\"]"}},g=function(a){var b=a.parentNode.offsetHeight+1,c=a.querySelector(f.regions.moredropdown),d=a.querySelector(f.regions.morebutton);if(a.offsetHeight>b){d.classList.remove(f.classes.hidden);var e=Array.from(a.children).reverse();e.forEach(function(c){if(!c.classList.contains(f.classes.dropdownmoremenu)){if(a.offsetHeight>b){var d=a.removeChild(c);h(a,d,!0)}}})}else{if("children"in c){var j=Array.from(c.children);j.forEach(function(d){if(a.offsetHeightb){g(a)}}a.parentNode.classList.add(f.classes.observed)},h=function(a,b){var c=2.\n\n/**\n * Moves wrapping navigation items into a more menu.\n *\n * @module core/moremenu\n * @copyright 2021 Moodle\n * @author Bas Brands \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport menu_navigation from \"core/menu_navigation\";\n/**\n * Moremenu selectors.\n */\nconst Selectors = {\n regions: {\n moredropdown: '[data-region=\"moredropdown\"]',\n morebutton: '[data-region=\"morebutton\"]'\n },\n classes: {\n dropdownitem: 'dropdown-item',\n dropdownmoremenu: 'dropdownmoremenu',\n dropdowntoggle: 'dropdown-toggle',\n hidden: 'd-none',\n active: 'active',\n nav: 'nav',\n navlink: 'nav-link',\n observed: 'observed',\n },\n attributes: {\n menu: '[role=\"menu\"]'\n }\n};\n\n/**\n * Auto Collapse navigation items that wrap into a dropdown menu.\n *\n * @param {HTMLElement} menu The navbar container.\n */\nconst autoCollapse = menu => {\n\n const maxHeight = menu.parentNode.offsetHeight + 1;\n\n const moreDropdown = menu.querySelector(Selectors.regions.moredropdown);\n const moreButton = menu.querySelector(Selectors.regions.morebutton);\n\n // If the menu items wrap and the menu height is larger than the height of the\n // parent then start pushing navlinks into the moreDropdown.\n if (menu.offsetHeight > maxHeight) {\n\n moreButton.classList.remove(Selectors.classes.hidden);\n\n const menuNodes = Array.from(menu.children).reverse();\n menuNodes.forEach(item => {\n if (!item.classList.contains(Selectors.classes.dropdownmoremenu)) {\n // After moving the menu items into the moreDropdown check again\n // if the menu height is still larger then the height of the parent.\n if (menu.offsetHeight > maxHeight) {\n const lastNode = menu.removeChild(item);\n // Move this node into the more dropdown menu.\n moveIntoMoreDropdown(menu, lastNode, true);\n }\n }\n });\n } else {\n // If the menu height is smaller than the height of the parent, then try returning navlinks to the menu.\n\n if ('children' in moreDropdown) {\n const menuNodes = Array.from(moreDropdown.children);\n menuNodes.forEach(item => {\n // Don't move the node to the more menu if it is explicitly defined that\n // this node should be displayed in the more dropdown menu at all times.\n if (menu.offsetHeight < maxHeight && item.dataset.forceintomoremenu !== 'true') {\n const lastNode = moreDropdown.removeChild(item);\n // Move this node from the more dropdown menu into the main section of the menu.\n moveOutOfMoreDropdown(menu, lastNode);\n }\n });\n\n // If there are no more menuNodes in the dropdown we can hide the moreButton.\n if (menuNodes.length === 0) {\n moreButton.classList.add(Selectors.classes.hidden);\n }\n }\n\n if (menu.offsetHeight > maxHeight) {\n autoCollapse(menu);\n }\n }\n menu.parentNode.classList.add(Selectors.classes.observed);\n};\n\n/**\n * Move a node into the \"more\" dropdown menu.\n *\n * This method forces a given navigation node to be added and displayed within the \"more\" dropdown menu.\n *\n * @param {HTMLElement} menu The navbar moremenu.\n * @param {HTMLElement} navNode The navigation node.\n * @param {boolean} prepend Whether to prepend or append the node to the content in the more dropdown menu.\n */\nconst moveIntoMoreDropdown = (menu, navNode, prepend = false) => {\n const moreDropdown = menu.querySelector(Selectors.regions.moredropdown);\n const dropdownToggle = menu.querySelector('.' + Selectors.classes.dropdowntoggle);\n\n const navLink = navNode.querySelector('.' + Selectors.classes.navlink);\n // If there are navLinks that contain an active link in the moreDropdown\n // make the dropdownToggle in the moreButton active.\n if (navLink.classList.contains(Selectors.classes.active)) {\n dropdownToggle.classList.add(Selectors.classes.active);\n }\n\n // Change the styling of the navLink to a dropdownitem and push it into\n // the moreDropdown.\n navLink.classList.remove(Selectors.classes.navlink);\n navLink.classList.add(Selectors.classes.dropdownitem);\n if (prepend) {\n moreDropdown.prepend(navNode);\n } else {\n moreDropdown.append(navNode);\n }\n};\n\n/**\n * Move a node out of the \"more\" dropdown menu.\n *\n * This method forces a given node from the \"more\" dropdown menu to be displayed in the main section of the menu.\n *\n * @param {HTMLElement} menu The navbar moremenu.\n * @param {HTMLElement} navNode The navigation node.\n */\nconst moveOutOfMoreDropdown = (menu, navNode) => {\n const moreButton = menu.querySelector(Selectors.regions.morebutton);\n const dropdownToggle = menu.querySelector('.' + Selectors.classes.dropdowntoggle);\n const navLink = navNode.querySelector('.' + Selectors.classes.dropdownitem);\n\n // Stop displaying the active state on the dropdownToggle if\n // the active navlink is removed.\n if (navLink.classList.contains(Selectors.classes.active)) {\n dropdownToggle.classList.remove(Selectors.classes.active);\n }\n navLink.classList.remove(Selectors.classes.dropdownitem);\n navLink.classList.add(Selectors.classes.navlink);\n menu.insertBefore(navNode, moreButton);\n};\n\n/**\n * Initialise the more menus.\n *\n * @param {HTMLElement} menu The navbar moremenu.\n */\nexport default menu => {\n menu.firstElementChild.querySelector('[role=\"menuitem\"]').setAttribute('tabindex', '0');\n // Pre-populate the \"more\" dropdown menu with navigation nodes which are set to be displayed in this menu\n // by default at all times.\n if ('children' in menu) {\n const moreButton = menu.querySelector(Selectors.regions.morebutton);\n const menuNodes = Array.from(menu.children);\n menuNodes.forEach((item) => {\n if (!item.classList.contains(Selectors.classes.dropdownmoremenu) &&\n item.dataset.forceintomoremenu === 'true') {\n // Append this node into the more dropdown menu.\n moveIntoMoreDropdown(menu, item, false);\n // After adding the node into the more dropdown menu, make sure that the more dropdown menu button\n // is displayed.\n if (moreButton.classList.contains(Selectors.classes.hidden)) {\n moreButton.classList.remove(Selectors.classes.hidden);\n }\n }\n });\n }\n // Populate the more dropdown menu with additional nodes if necessary, depending on the current screen size.\n autoCollapse(menu);\n menu_navigation(menu);\n\n // When the screen size changes make sure the menu still fits.\n window.addEventListener('resize', () => {\n autoCollapse(menu);\n menu_navigation(menu);\n });\n\n const toggledropdown = e => {\n const innerMenu = e.target.parentNode.querySelector(Selectors.attributes.menu);\n if (innerMenu) {\n innerMenu.classList.toggle('show');\n }\n e.stopPropagation();\n };\n\n // If there are dropdowns in the MoreMenu, add a new\n // event listener to show the contents on click and prevent the\n // moreMenu from closing.\n $('.' + Selectors.classes.dropdownmoremenu).on('show.bs.dropdown', function() {\n const moreDropdown = menu.querySelector(Selectors.regions.moredropdown);\n moreDropdown.querySelectorAll('.dropdown').forEach((dropdown) => {\n dropdown.removeEventListener('click', toggledropdown, true);\n dropdown.addEventListener('click', toggledropdown, true);\n });\n });\n};\n"],"file":"moremenu.min.js"} \ No newline at end of file diff --git a/lib/amd/build/usermenu.min.js b/lib/amd/build/usermenu.min.js new file mode 100644 index 00000000000..fb89aa66b81 --- /dev/null +++ b/lib/amd/build/usermenu.min.js @@ -0,0 +1,2 @@ +define ("core/usermenu",["exports","jquery","core/key_codes"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);var d={userMenu:".usermenu",userMenuCarousel:".usermenu #usermenu-carousel",userMenuCarouselItem:".usermenu #usermenu-carousel .carousel-item",userMenuCarouselItemActive:".usermenu #usermenu-carousel .carousel-item.active",userMenuCarouselNavigationLink:".usermenu #usermenu-carousel .carousel-navigation-link"},e=function(){var a=document.querySelector(d.userMenu);(0,b.default)(d.userMenu).on("shown.bs.dropdown",function(){var b=document.querySelector(d.userMenuCarouselItemActive);b.focus();a.querySelectorAll(d.userMenuCarouselItem).forEach(function(a){if(!a.classList.contains("active")){a.style.width=b.offsetWidth+"px";a.style.height=b.offsetHeight+"px"}})});a.addEventListener("click",function(a){if(a.target.matches(d.userMenuCarouselNavigationLink)){f(a)}});a.addEventListener("keydown",function(a){if((a.keyCode===c.space||a.keyCode===c.enter)&&a.target.matches(d.userMenuCarouselNavigationLink)){a.preventDefault();f(a)}});var f=function(c){c.stopPropagation();var e=c.target.dataset.carouselTargetId,f=a.querySelector("#"+e),g=Array.from(f.parentNode.children).indexOf(f);(0,b.default)(d.userMenuCarousel).carousel(g)};(0,b.default)(d.userMenu).on("hide.bs.dropdown",function(){(0,b.default)(d.userMenuCarousel).carousel(0)});(0,b.default)(d.userMenuCarousel).on("slid.bs.carousel",function(){var b=a.querySelector(d.userMenuCarouselItemActive);b.focus()})};a.default={init:function init(){e()}};return a.default}); +//# sourceMappingURL=usermenu.min.js.map diff --git a/lib/amd/build/usermenu.min.js.map b/lib/amd/build/usermenu.min.js.map new file mode 100644 index 00000000000..fda9bf8d7f3 --- /dev/null +++ b/lib/amd/build/usermenu.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/usermenu.js"],"names":["selectors","userMenu","userMenuCarousel","userMenuCarouselItem","userMenuCarouselItemActive","userMenuCarouselNavigationLink","registerEventListeners","document","querySelector","on","activeCarouselItem","focus","querySelectorAll","forEach","element","classList","contains","style","width","offsetWidth","height","offsetHeight","addEventListener","e","target","matches","carouselManagement","keyCode","space","enter","preventDefault","stopPropagation","targetedCarouselItemId","dataset","carouselTargetId","targetedCarouselItem","index","Array","from","parentNode","children","indexOf","carousel","init"],"mappings":"6JAwBA,uD,GAMMA,CAAAA,CAAS,CAAG,CACdC,QAAQ,CAAE,WADI,CAEdC,gBAAgB,CAAE,8BAFJ,CAGdC,oBAAoB,CAAE,6CAHR,CAIdC,0BAA0B,CAAE,oDAJd,CAKdC,8BAA8B,CAAE,wDALlB,C,CAWZC,CAAsB,CAAG,UAAM,CACjC,GAAML,CAAAA,CAAQ,CAAGM,QAAQ,CAACC,aAAT,CAAuBR,CAAS,CAACC,QAAjC,CAAjB,CAGA,cAAED,CAAS,CAACC,QAAZ,EAAsBQ,EAAtB,CAAyB,mBAAzB,CAA8C,UAAM,CAChD,GAAMC,CAAAA,CAAkB,CAAGH,QAAQ,CAACC,aAAT,CAAuBR,CAAS,CAACI,0BAAjC,CAA3B,CAEAM,CAAkB,CAACC,KAAnB,GAEAV,CAAQ,CAACW,gBAAT,CAA0BZ,CAAS,CAACG,oBAApC,EAA0DU,OAA1D,CAAkE,SAAAC,CAAO,CAAI,CAKzE,GAAI,CAACA,CAAO,CAACC,SAAR,CAAkBC,QAAlB,CAA2B,QAA3B,CAAL,CAA2C,CACvCF,CAAO,CAACG,KAAR,CAAcC,KAAd,CAAsBR,CAAkB,CAACS,WAAnB,CAAiC,IAAvD,CACAL,CAAO,CAACG,KAAR,CAAcG,MAAd,CAAuBV,CAAkB,CAACW,YAAnB,CAAkC,IAC5D,CACJ,CATD,CAUH,CAfD,EAkBApB,CAAQ,CAACqB,gBAAT,CAA0B,OAA1B,CAAmC,SAACC,CAAD,CAAO,CAGtC,GAAIA,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiBzB,CAAS,CAACK,8BAA3B,CAAJ,CAAgE,CAC5DqB,CAAkB,CAACH,CAAD,CACrB,CACJ,CAND,EAQAtB,CAAQ,CAACqB,gBAAT,CAA0B,SAA1B,CAAqC,SAAAC,CAAC,CAAI,CAEtC,GAAI,CAACA,CAAC,CAACI,OAAF,GAAcC,OAAd,EACDL,CAAC,CAACI,OAAF,GAAcE,OADd,GAEAN,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiBzB,CAAS,CAACK,8BAA3B,CAFJ,CAEgE,CAC5DkB,CAAC,CAACO,cAAF,GACAJ,CAAkB,CAACH,CAAD,CACrB,CACJ,CARD,EAeA,GAAMG,CAAAA,CAAkB,CAAG,SAAAH,CAAC,CAAI,CAK5BA,CAAC,CAACQ,eAAF,GAL4B,GAOtBC,CAAAA,CAAsB,CAAGT,CAAC,CAACC,MAAF,CAASS,OAAT,CAAiBC,gBAPpB,CAQtBC,CAAoB,CAAGlC,CAAQ,CAACO,aAAT,CAAuB,IAAMwB,CAA7B,CARD,CAUtBI,CAAK,CAAGC,KAAK,CAACC,IAAN,CAAWH,CAAoB,CAACI,UAArB,CAAgCC,QAA3C,EAAqDC,OAArD,CAA6DN,CAA7D,CAVc,CAY5B,cAAEnC,CAAS,CAACE,gBAAZ,EAA8BwC,QAA9B,CAAuCN,CAAvC,CAEH,CAdD,CAiBA,cAAEpC,CAAS,CAACC,QAAZ,EAAsBQ,EAAtB,CAAyB,kBAAzB,CAA6C,UAAM,CAG/C,cAAET,CAAS,CAACE,gBAAZ,EAA8BwC,QAA9B,CAAuC,CAAvC,CACH,CAJD,EAOA,cAAE1C,CAAS,CAACE,gBAAZ,EAA8BO,EAA9B,CAAiC,kBAAjC,CAAqD,UAAM,CACvD,GAAMC,CAAAA,CAAkB,CAAGT,CAAQ,CAACO,aAAT,CAAuBR,CAAS,CAACI,0BAAjC,CAA3B,CAEAM,CAAkB,CAACC,KAAnB,EACH,CAJD,CAKH,C,WASc,CACXgC,IAAI,CALK,QAAPA,CAAAA,IAAO,EAAM,CACfrC,CAAsB,EACzB,CAEc,C","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 * Initializes and handles events in the user menu.\n *\n * @module core/usermenu\n * @copyright 2021 Moodle\n * @author Mihail Geshoski \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport {space, enter} from 'core/key_codes';\n\n/**\n * User menu constants.\n */\nconst selectors = {\n userMenu: '.usermenu',\n userMenuCarousel: '.usermenu #usermenu-carousel',\n userMenuCarouselItem: '.usermenu #usermenu-carousel .carousel-item',\n userMenuCarouselItemActive: '.usermenu #usermenu-carousel .carousel-item.active',\n userMenuCarouselNavigationLink: '.usermenu #usermenu-carousel .carousel-navigation-link',\n};\n\n/**\n * Register event listeners.\n */\nconst registerEventListeners = () => {\n const userMenu = document.querySelector(selectors.userMenu);\n\n // Handle the 'shown.bs.dropdown' event (Fired when the dropdown menu is fully displayed).\n $(selectors.userMenu).on('shown.bs.dropdown', () => {\n const activeCarouselItem = document.querySelector(selectors.userMenuCarouselItemActive);\n // Set the focus on the active carousel item.\n activeCarouselItem.focus();\n\n userMenu.querySelectorAll(selectors.userMenuCarouselItem).forEach(element => {\n // Resize all non-active carousel items to match the height and width of the current active (main)\n // carousel item to avoid sizing inconsistencies. This has to be done once the dropdown menu is fully\n // displayed ('shown.bs.dropdown') as the offsetWidth and offsetHeight cannot be obtained when the\n // element is hidden.\n if (!element.classList.contains('active')) {\n element.style.width = activeCarouselItem.offsetWidth + 'px';\n element.style.height = activeCarouselItem.offsetHeight + 'px';\n }\n });\n });\n\n // Handle click events in the user menu.\n userMenu.addEventListener('click', (e) => {\n\n // Handle click event on the carousel navigation (control) links in the user menu.\n if (e.target.matches(selectors.userMenuCarouselNavigationLink)) {\n carouselManagement(e);\n }\n });\n\n userMenu.addEventListener('keydown', e => {\n // Handle keydown event on the carousel navigation (control) links in the user menu.\n if ((e.keyCode === space ||\n e.keyCode === enter) &&\n e.target.matches(selectors.userMenuCarouselNavigationLink)) {\n e.preventDefault();\n carouselManagement(e);\n }\n });\n\n /**\n * We do the same actions here even if the caller was a click or button press.\n *\n * @param {Event} e The triggering element and key presses etc.\n */\n const carouselManagement = e => {\n // By default the user menu dropdown element closes on a click event. This behaviour is not desirable\n // as we need to be able to navigate through the carousel items (submenus of the user menu) within the\n // user menu. Therefore, we need to prevent the propagation of this event and then manually call the\n // carousel transition.\n e.stopPropagation();\n // The id of the targeted carousel item.\n const targetedCarouselItemId = e.target.dataset.carouselTargetId;\n const targetedCarouselItem = userMenu.querySelector('#' + targetedCarouselItemId);\n // Get the position (index) of the targeted carousel item within the parent container element.\n const index = Array.from(targetedCarouselItem.parentNode.children).indexOf(targetedCarouselItem);\n // Navigate to the targeted carousel item.\n $(selectors.userMenuCarousel).carousel(index);\n\n };\n\n // Handle the 'hide.bs.dropdown' event (Fired when the dropdown menu is being closed).\n $(selectors.userMenu).on('hide.bs.dropdown', () => {\n // Reset the state once the user menu dropdown is closed and return back to the first (main) carousel item\n // if necessary.\n $(selectors.userMenuCarousel).carousel(0);\n });\n\n // Handle the 'slid.bs.carousel' event (Fired when the carousel has completed its slide transition).\n $(selectors.userMenuCarousel).on('slid.bs.carousel', () => {\n const activeCarouselItem = userMenu.querySelector(selectors.userMenuCarouselItemActive);\n // Set the focus on the newly activated carousel item.\n activeCarouselItem.focus();\n });\n};\n\n/**\n * Initialize the user menu.\n */\nconst init = () => {\n registerEventListeners();\n};\n\nexport default {\n init: init,\n};\n"],"file":"usermenu.min.js"} \ No newline at end of file diff --git a/lib/amd/src/menu_navigation.js b/lib/amd/src/menu_navigation.js new file mode 100644 index 00000000000..a6f49f5d84e --- /dev/null +++ b/lib/amd/src/menu_navigation.js @@ -0,0 +1,201 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Keyboard initialization for a given html node. + * + * @module core/keyboard_navigation + * @copyright 2021 Moodle + * @author Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {space, enter, arrowRight, arrowLeft, arrowDown, arrowUp, home, end} from 'core/key_codes'; + +const SELECTORS = { + 'menuitem': '[role="menuitem"]', + 'menu': '[role="menu"]' +}; + +let openDropdownNode = null; + +/** + * Small helper function to check if a given node is null or not. + * + * @param {HTMLElement|null} item The node that we want to compare. + * @param {HTMLElement} fallback Either the first node or final node that can be focused on. + * @return {HTMLElement} + */ +const clickErrorHandler = (item, fallback) => { + if (item !== null) { + return item; + } else { + return fallback; + } +}; + +/** + * Defined event handling so we can remove listeners on nodes on resize etc. + * + * @param {event} e The triggering element and key presses etc. + */ +const listenerEvents = e => { + const src = e.srcElement; + const firstNode = e.currentTarget.firstElementChild; + const lastNode = findUsableLastNode(e.currentTarget); + + // Handling for dropdown escapes. + // A bulk of the handling is already done by aria.js just add polish. + if (src.classList.contains('dropdown-item')) { + if (e.keyCode === arrowRight || + e.keyCode === arrowLeft) { + e.preventDefault(); + if (openDropdownNode !== null) { + openDropdownNode.parentElement.click(); + } + } + if (e.keyCode === space || + e.keyCode === enter) { + e.preventDefault(); + + // Remove active class from any other dropdown elements. + Array.prototype.forEach.call(src.closest('.dropdown-menu').children, node => { + node.querySelector(SELECTORS.menuitem).classList.remove('active'); + node.setAttribute('aria-current', 'false'); + }); + + if (!src.parentElement.classList.contains('dropdown')) { + src.click(); + } + } + } else { + if (e.keyCode === arrowRight) { + e.preventDefault(); + setFocusNext(src, firstNode); + } + if (e.keyCode === arrowLeft) { + e.preventDefault(); + setFocusPrev(src, lastNode); + } + // Let aria.js handle the dropdowns. + if (e.keyCode === arrowUp || + e.keyCode === arrowDown) { + openDropdownNode = src; + e.preventDefault(); + } + if (e.keyCode === home) { + e.preventDefault(); + src.setAttribute('aria-current', 'false'); + setFocusHomeEnd(firstNode); + } + if (e.keyCode === end) { + e.preventDefault(); + src.currentNode.setAttribute('aria-current', 'false'); + setFocusHomeEnd(lastNode); + } + if (e.keyCode === space || + e.keyCode === enter) { + e.preventDefault(); + // Aria.js handles dropdowns etc. + if (!src.parentElement.classList.contains('dropdown')) { + src.click(); + } + } + } +}; + +/** + * The initial entry point that a given module can pass a HTMLElement. + * + * @param {HTMLElement} elementRoot The menu to add handlers upon. + */ +export default elementRoot => { + elementRoot.removeEventListener('keydown', listenerEvents); + elementRoot.addEventListener('keydown', listenerEvents); +}; + +/** + * Handle the focusing to the next element in the dropdown. + * + * @param {HTMLElement|null} currentNode The node that we want to take action on. + * @param {HTMLElement} firstNode The backup node to focus as a last resort. + */ +const setFocusNext = (currentNode, firstNode) => { + const nextListItem = currentNode.parentElement.nextElementSibling; + const nodeToSelect = clickErrorHandler(nextListItem, firstNode); + const menuItem = nodeToSelect.querySelector(SELECTORS.menuitem); + currentNode.setAttribute('aria-current', 'false'); + menuItem.setAttribute('aria-current', 'true'); + menuItem.focus(); +}; + +/** + * Handle the focusing to the previous element in the dropdown. + * + * @param {HTMLElement|null} currentNode The node that we want to take action on. + * @param {HTMLElement} lastNode The backup node to focus as a last resort. + */ +const setFocusPrev = (currentNode, lastNode) => { + const nextListItem = currentNode.parentElement.previousElementSibling; + const nodeToSelect = clickErrorHandler(nextListItem, lastNode); + const menuItem = nodeToSelect.querySelector(SELECTORS.menuitem); + currentNode.setAttribute('aria-current', 'false'); + menuItem.setAttribute('aria-current', 'true'); + menuItem.focus(); +}; + +/** + * Focus on either the start or end of a nav list. + * + * @param {HTMLElement} node The element to focus on. + */ +const setFocusHomeEnd = node => { + node.querySelector(SELECTORS.menuitem).focus(); + node.querySelector(SELECTORS.menuitem).setAttribute('aria-current', 'true'); +}; + +/** + * We need to look within the menu to find a last node we can add focus to. + * + * @param {HTMLElement} elementRoot Menu to find a final child node within. + * @return {HTMLElement} + */ +const findUsableLastNode = elementRoot => { + const lastNode = elementRoot.lastElementChild; + + // An example is the more menu existing but hidden on the page for the time being. + if (!lastNode.classList.contains('d-none')) { + return elementRoot.lastElementChild; + } else { + // Cast the HTMLCollection & reverse it. + const extractedNodes = Array.prototype.map.call(elementRoot.children, node => { + return node; + }).reverse(); + + // Get rid of any nodes we can not set focus on. + const nodesToUse = extractedNodes.filter((node => { + if (!node.classList.contains('d-none')) { + return node; + } + })); + + // If we find no elements we can set focus on, fall back to the absolute first element. + if (nodesToUse.length !== 0) { + return nodesToUse[0]; + } else { + return elementRoot.firstElementChild; + } + } +}; diff --git a/lib/amd/src/moremenu.js b/lib/amd/src/moremenu.js new file mode 100644 index 00000000000..425a01fe826 --- /dev/null +++ b/lib/amd/src/moremenu.js @@ -0,0 +1,215 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Moves wrapping navigation items into a more menu. + * + * @module core/moremenu + * @copyright 2021 Moodle + * @author Bas Brands + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import $ from 'jquery'; +import menu_navigation from "core/menu_navigation"; +/** + * Moremenu selectors. + */ +const Selectors = { + regions: { + moredropdown: '[data-region="moredropdown"]', + morebutton: '[data-region="morebutton"]' + }, + classes: { + dropdownitem: 'dropdown-item', + dropdownmoremenu: 'dropdownmoremenu', + dropdowntoggle: 'dropdown-toggle', + hidden: 'd-none', + active: 'active', + nav: 'nav', + navlink: 'nav-link', + observed: 'observed', + }, + attributes: { + menu: '[role="menu"]' + } +}; + +/** + * Auto Collapse navigation items that wrap into a dropdown menu. + * + * @param {HTMLElement} menu The navbar container. + */ +const autoCollapse = menu => { + + const maxHeight = menu.parentNode.offsetHeight + 1; + + const moreDropdown = menu.querySelector(Selectors.regions.moredropdown); + const moreButton = menu.querySelector(Selectors.regions.morebutton); + + // If the menu items wrap and the menu height is larger than the height of the + // parent then start pushing navlinks into the moreDropdown. + if (menu.offsetHeight > maxHeight) { + + moreButton.classList.remove(Selectors.classes.hidden); + + const menuNodes = Array.from(menu.children).reverse(); + menuNodes.forEach(item => { + if (!item.classList.contains(Selectors.classes.dropdownmoremenu)) { + // After moving the menu items into the moreDropdown check again + // if the menu height is still larger then the height of the parent. + if (menu.offsetHeight > maxHeight) { + const lastNode = menu.removeChild(item); + // Move this node into the more dropdown menu. + moveIntoMoreDropdown(menu, lastNode, true); + } + } + }); + } else { + // If the menu height is smaller than the height of the parent, then try returning navlinks to the menu. + + if ('children' in moreDropdown) { + const menuNodes = Array.from(moreDropdown.children); + menuNodes.forEach(item => { + // Don't move the node to the more menu if it is explicitly defined that + // this node should be displayed in the more dropdown menu at all times. + if (menu.offsetHeight < maxHeight && item.dataset.forceintomoremenu !== 'true') { + const lastNode = moreDropdown.removeChild(item); + // Move this node from the more dropdown menu into the main section of the menu. + moveOutOfMoreDropdown(menu, lastNode); + } + }); + + // If there are no more menuNodes in the dropdown we can hide the moreButton. + if (menuNodes.length === 0) { + moreButton.classList.add(Selectors.classes.hidden); + } + } + + if (menu.offsetHeight > maxHeight) { + autoCollapse(menu); + } + } + menu.parentNode.classList.add(Selectors.classes.observed); +}; + +/** + * Move a node into the "more" dropdown menu. + * + * This method forces a given navigation node to be added and displayed within the "more" dropdown menu. + * + * @param {HTMLElement} menu The navbar moremenu. + * @param {HTMLElement} navNode The navigation node. + * @param {boolean} prepend Whether to prepend or append the node to the content in the more dropdown menu. + */ +const moveIntoMoreDropdown = (menu, navNode, prepend = false) => { + const moreDropdown = menu.querySelector(Selectors.regions.moredropdown); + const dropdownToggle = menu.querySelector('.' + Selectors.classes.dropdowntoggle); + + const navLink = navNode.querySelector('.' + Selectors.classes.navlink); + // If there are navLinks that contain an active link in the moreDropdown + // make the dropdownToggle in the moreButton active. + if (navLink.classList.contains(Selectors.classes.active)) { + dropdownToggle.classList.add(Selectors.classes.active); + } + + // Change the styling of the navLink to a dropdownitem and push it into + // the moreDropdown. + navLink.classList.remove(Selectors.classes.navlink); + navLink.classList.add(Selectors.classes.dropdownitem); + if (prepend) { + moreDropdown.prepend(navNode); + } else { + moreDropdown.append(navNode); + } +}; + +/** + * Move a node out of the "more" dropdown menu. + * + * This method forces a given node from the "more" dropdown menu to be displayed in the main section of the menu. + * + * @param {HTMLElement} menu The navbar moremenu. + * @param {HTMLElement} navNode The navigation node. + */ +const moveOutOfMoreDropdown = (menu, navNode) => { + const moreButton = menu.querySelector(Selectors.regions.morebutton); + const dropdownToggle = menu.querySelector('.' + Selectors.classes.dropdowntoggle); + const navLink = navNode.querySelector('.' + Selectors.classes.dropdownitem); + + // Stop displaying the active state on the dropdownToggle if + // the active navlink is removed. + if (navLink.classList.contains(Selectors.classes.active)) { + dropdownToggle.classList.remove(Selectors.classes.active); + } + navLink.classList.remove(Selectors.classes.dropdownitem); + navLink.classList.add(Selectors.classes.navlink); + menu.insertBefore(navNode, moreButton); +}; + +/** + * Initialise the more menus. + * + * @param {HTMLElement} menu The navbar moremenu. + */ +export default menu => { + menu.firstElementChild.querySelector('[role="menuitem"]').setAttribute('tabindex', '0'); + // Pre-populate the "more" dropdown menu with navigation nodes which are set to be displayed in this menu + // by default at all times. + if ('children' in menu) { + const moreButton = menu.querySelector(Selectors.regions.morebutton); + const menuNodes = Array.from(menu.children); + menuNodes.forEach((item) => { + if (!item.classList.contains(Selectors.classes.dropdownmoremenu) && + item.dataset.forceintomoremenu === 'true') { + // Append this node into the more dropdown menu. + moveIntoMoreDropdown(menu, item, false); + // After adding the node into the more dropdown menu, make sure that the more dropdown menu button + // is displayed. + if (moreButton.classList.contains(Selectors.classes.hidden)) { + moreButton.classList.remove(Selectors.classes.hidden); + } + } + }); + } + // Populate the more dropdown menu with additional nodes if necessary, depending on the current screen size. + autoCollapse(menu); + menu_navigation(menu); + + // When the screen size changes make sure the menu still fits. + window.addEventListener('resize', () => { + autoCollapse(menu); + menu_navigation(menu); + }); + + const toggledropdown = e => { + const innerMenu = e.target.parentNode.querySelector(Selectors.attributes.menu); + if (innerMenu) { + innerMenu.classList.toggle('show'); + } + e.stopPropagation(); + }; + + // If there are dropdowns in the MoreMenu, add a new + // event listener to show the contents on click and prevent the + // moreMenu from closing. + $('.' + Selectors.classes.dropdownmoremenu).on('show.bs.dropdown', function() { + const moreDropdown = menu.querySelector(Selectors.regions.moredropdown); + moreDropdown.querySelectorAll('.dropdown').forEach((dropdown) => { + dropdown.removeEventListener('click', toggledropdown, true); + dropdown.addEventListener('click', toggledropdown, true); + }); + }); +}; diff --git a/lib/amd/src/usermenu.js b/lib/amd/src/usermenu.js new file mode 100644 index 00000000000..25d05ca2f4d --- /dev/null +++ b/lib/amd/src/usermenu.js @@ -0,0 +1,127 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Initializes and handles events in the user menu. + * + * @module core/usermenu + * @copyright 2021 Moodle + * @author Mihail Geshoski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import $ from 'jquery'; +import {space, enter} from 'core/key_codes'; + +/** + * User menu constants. + */ +const selectors = { + userMenu: '.usermenu', + userMenuCarousel: '.usermenu #usermenu-carousel', + userMenuCarouselItem: '.usermenu #usermenu-carousel .carousel-item', + userMenuCarouselItemActive: '.usermenu #usermenu-carousel .carousel-item.active', + userMenuCarouselNavigationLink: '.usermenu #usermenu-carousel .carousel-navigation-link', +}; + +/** + * Register event listeners. + */ +const registerEventListeners = () => { + const userMenu = document.querySelector(selectors.userMenu); + + // Handle the 'shown.bs.dropdown' event (Fired when the dropdown menu is fully displayed). + $(selectors.userMenu).on('shown.bs.dropdown', () => { + const activeCarouselItem = document.querySelector(selectors.userMenuCarouselItemActive); + // Set the focus on the active carousel item. + activeCarouselItem.focus(); + + userMenu.querySelectorAll(selectors.userMenuCarouselItem).forEach(element => { + // Resize all non-active carousel items to match the height and width of the current active (main) + // carousel item to avoid sizing inconsistencies. This has to be done once the dropdown menu is fully + // displayed ('shown.bs.dropdown') as the offsetWidth and offsetHeight cannot be obtained when the + // element is hidden. + if (!element.classList.contains('active')) { + element.style.width = activeCarouselItem.offsetWidth + 'px'; + element.style.height = activeCarouselItem.offsetHeight + 'px'; + } + }); + }); + + // Handle click events in the user menu. + userMenu.addEventListener('click', (e) => { + + // Handle click event on the carousel navigation (control) links in the user menu. + if (e.target.matches(selectors.userMenuCarouselNavigationLink)) { + carouselManagement(e); + } + }); + + userMenu.addEventListener('keydown', e => { + // Handle keydown event on the carousel navigation (control) links in the user menu. + if ((e.keyCode === space || + e.keyCode === enter) && + e.target.matches(selectors.userMenuCarouselNavigationLink)) { + e.preventDefault(); + carouselManagement(e); + } + }); + + /** + * We do the same actions here even if the caller was a click or button press. + * + * @param {Event} e The triggering element and key presses etc. + */ + const carouselManagement = e => { + // By default the user menu dropdown element closes on a click event. This behaviour is not desirable + // as we need to be able to navigate through the carousel items (submenus of the user menu) within the + // user menu. Therefore, we need to prevent the propagation of this event and then manually call the + // carousel transition. + e.stopPropagation(); + // The id of the targeted carousel item. + const targetedCarouselItemId = e.target.dataset.carouselTargetId; + const targetedCarouselItem = userMenu.querySelector('#' + targetedCarouselItemId); + // Get the position (index) of the targeted carousel item within the parent container element. + const index = Array.from(targetedCarouselItem.parentNode.children).indexOf(targetedCarouselItem); + // Navigate to the targeted carousel item. + $(selectors.userMenuCarousel).carousel(index); + + }; + + // Handle the 'hide.bs.dropdown' event (Fired when the dropdown menu is being closed). + $(selectors.userMenu).on('hide.bs.dropdown', () => { + // Reset the state once the user menu dropdown is closed and return back to the first (main) carousel item + // if necessary. + $(selectors.userMenuCarousel).carousel(0); + }); + + // Handle the 'slid.bs.carousel' event (Fired when the carousel has completed its slide transition). + $(selectors.userMenuCarousel).on('slid.bs.carousel', () => { + const activeCarouselItem = userMenu.querySelector(selectors.userMenuCarouselItemActive); + // Set the focus on the newly activated carousel item. + activeCarouselItem.focus(); + }); +}; + +/** + * Initialize the user menu. + */ +const init = () => { + registerEventListeners(); +}; + +export default { + init: init, +}; diff --git a/lib/blocklib.php b/lib/blocklib.php index 849f848720f..35a6102635d 100644 --- a/lib/blocklib.php +++ b/lib/blocklib.php @@ -1520,6 +1520,7 @@ class block_manager { $deleteurlparams = $this->page->url->params(); $deletepage->set_url($deleteurlbase, $deleteurlparams); $deletepage->set_block_actions_done(); + $deletepage->set_secondarynav($this->get_secondarynav($block)); // At this point we are either going to redirect, or display the form, so // overwrite global $PAGE ready for this. (Formslib refers to it.) $PAGE = $deletepage; @@ -1608,6 +1609,23 @@ class block_manager { return true; } + /** + * Convenience function to check whether a block is implementing a secondary nav class and return it + * initialised to the calling function + * + * @param block_base $block + * @return \core\navigation\views\secondary + */ + protected function get_secondarynav(block_base $block): \core\navigation\views\secondary { + $class = "core_block\\local\\views\\secondary"; + if (class_exists("block_{$block->name()}\\local\\views\\secondary")) { + $class = "block_{$block->name()}\\local\\views\\secondary"; + } + $secondarynav = new $class($this->page); + $secondarynav->initialise(); + return $secondarynav; + } + /** * Handle showing/processing the submission from the block editing form. * @return boolean true if the form was submitted and the new config saved. Does not @@ -1635,6 +1653,8 @@ class block_manager { $editpage->set_course($this->page->course); //$editpage->set_context($block->context); $editpage->set_context($this->page->context); + $editpage->set_secondarynav($this->get_secondarynav($block)); + if ($this->page->cm) { $editpage->set_cm($this->page->cm); } diff --git a/lib/classes/navigation/output/more_menu.php b/lib/classes/navigation/output/more_menu.php new file mode 100644 index 00000000000..7947c88766e --- /dev/null +++ b/lib/classes/navigation/output/more_menu.php @@ -0,0 +1,71 @@ +. + +namespace core\navigation\output; + +use renderable; +use renderer_base; +use templatable; +use custom_menu; + +/** + * more menu navigation renderable + * + * @package core + * @category navigation + * @copyright 2021 onwards Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class more_menu implements renderable, templatable { + + protected $content; + protected $navbarstyle; + protected $haschildren; + + /** + * Constructor for this class. + * + * @param object $content Navigation objects. + * @param string $navbarstyle class name. + * @param bool $haschildren The content has children. + */ + public function __construct(object $content, string $navbarstyle, bool $haschildren = true) { + $this->content = $content; + $this->navbarstyle = $navbarstyle; + $this->haschildren = $haschildren; + } + + /** + * Return data for rendering a template. + * + * @param renderer_base $output The output + * @return array Data for rendering a template + */ + public function export_for_template(renderer_base $output): array { + $data = ['navbarstyle' => $this->navbarstyle]; + if ($this->haschildren) { + if (!isset($this->content->children) || count($this->content->children) == 0) { + $data = []; + } + $data['nodecollection'] = $this->content; + } else { + $data['nodearray'] = (array) $this->content; + } + + return $data; + } + +} diff --git a/lib/classes/navigation/output/primary.php b/lib/classes/navigation/output/primary.php new file mode 100644 index 00000000000..f8103a6c184 --- /dev/null +++ b/lib/classes/navigation/output/primary.php @@ -0,0 +1,273 @@ +. + +namespace core\navigation\output; + +use renderable; +use renderer_base; +use templatable; +use custom_menu; + +/** + * Primary navigation renderable + * + * This file combines primary nav, custom menu, lang menu and + * usermenu into a standardized format for the frontend + * + * @package core + * @category navigation + * @copyright 2021 onwards Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class primary implements renderable, templatable { + /** @var moodle_page $page the moodle page that the navigation belongs to */ + private $page = null; + + /** + * primary constructor. + * @param \moodle_page $page + */ + public function __construct($page) { + $this->page = $page; + } + + /** + * Combine the various menus into a standardized output. + * + * @param renderer_base|null $output + * @return array + */ + public function export_for_template(?renderer_base $output = null): array { + if (!$output) { + $output = $this->page->get_renderer('core'); + } + + $menudata = (object) array_merge($this->get_primary_nav(), $this->get_custom_menu($output)); + $moremenu = new \core\navigation\output\more_menu($menudata, 'navbar-nav', false); + + return [ + 'moremenu' => $moremenu->export_for_template($output), + 'lang' => !isloggedin() || isguestuser() ? $this->get_lang_menu($output) : [], + 'user' => $this->get_user_menu($output), + ]; + } + + /** + * Get the primary nav object and standardize the output + * + * @return array + */ + protected function get_primary_nav(): array { + $nodes = []; + foreach ($this->page->primarynav->children as $node) { + $nodes[] = [ + 'title' => $node->get_title(), + 'url' => $node->action(), + 'text' => $node->text, + 'icon' => $node->icon, + 'isactive' => $node->isactive, + ]; + } + + return $nodes; + } + + /** + * Custom menu items reside on the same level as the original nodes. + * Fetch and convert the nodes to a standardised array. + * + * @param renderer_base $output + * @return array + */ + protected function get_custom_menu(renderer_base $output): array { + global $CFG; + + // Early return if a custom menu does not exists. + if (empty($CFG->custommenuitems)) { + return []; + } + + $custommenuitems = $CFG->custommenuitems; + $currentlang = current_language(); + $custommenunodes = custom_menu::convert_text_to_menu_nodes($custommenuitems, $currentlang); + $nodes = []; + foreach ($custommenunodes as $node) { + $nodes[] = $node->export_for_template($output); + } + + return $nodes; + } + + /** + * Get a list of options for the lang picker. + * + * @param renderer_base $output + * @return array + */ + protected function get_lang_menu(renderer_base $output): array { + // Early return if a lang menu does not exists. + if (empty($output->lang_menu())) { + return []; + } + + $currentlang = current_language(); + $langs = get_string_manager()->get_list_of_translations(); + $nodes = []; + $activelanguage = ''; + + // Add the lang picker if needed. + foreach ($langs as $langtype => $langname) { + $isactive = $langtype == $currentlang; + $node = [ + 'title' => $langname, + 'text' => $langname, + 'link' => true, + 'isactive' => $isactive, + 'url' => $isactive ? new \moodle_url('#') : new \moodle_url($this->page->url, ['lang' => $langtype]), + ]; + + $nodes[] = $node; + + if ($isactive) { + $activelanguage = $langname; + } + } + + return [ + 'title' => $activelanguage, + 'items' => $nodes, + ]; + } + + /** + * Get/Generate the user menu. + * + * This is leveraging the data from user_get_user_navigation_info and the logic in $OUTPUT->user_menu() + * + * @param renderer_base $output + * @return array + */ + public function get_user_menu(renderer_base $output): array { + global $CFG, $USER, $PAGE; + require_once($CFG->dirroot . '/user/lib.php'); + + $usermenudata = []; + $submenusdata = []; + $info = user_get_user_navigation_info($USER, $PAGE); + if (isset($info->unauthenticateduser)) { + $info->unauthenticateduser['content'] = get_string($info->unauthenticateduser['content']); + $info->unauthenticateduser['url'] = get_login_url(); + return (array) $info; + } + // Gather all the avatar data to be displayed in the user menu. + $usermenudata['avatardata'][] = [ + 'content' => $info->metadata['useravatar'], + 'classes' => 'current' + ]; + $usermenudata['userfullname'] = $info->metadata['realuserfullname'] ?? $info->metadata['userfullname']; + + // Logged in as someone else. + if ($info->metadata['asotheruser']) { + $usermenudata['avatardata'][] = [ + 'content' => $info->metadata['realuseravatar'], + 'classes' => 'realuser' + ]; + $usermenudata['metadata'][] = [ + 'content' => get_string('loggedinas', 'moodle', $info->metadata['userfullname']), + 'classes' => 'viewingas' + ]; + } + + // Gather all the meta data to be displayed in the user menu. + $metadata = [ + 'asotherrole' => [ + 'value' => 'rolename', + 'class' => 'role role-##GENERATEDCLASS##', + ], + 'userloginfail' => [ + 'value' => 'userloginfail', + 'class' => 'loginfailures', + ], + 'asmnetuser' => [ + 'value' => 'mnetidprovidername', + 'class' => 'mnet mnet-##GENERATEDCLASS##', + ], + ]; + foreach ($metadata as $key => $value) { + if (!empty($info->metadata[$key])) { + $content = $info->metadata[$value['value']] ?? ''; + $generatedclass = strtolower(preg_replace('#[ ]+#', '-', trim($content))); + $customclass = str_replace('##GENERATEDCLASS##', $generatedclass, ($value['class'] ?? '')); + $usermenudata['metadata'][] = [ + 'content' => $content, + 'classes' => $customclass + ]; + } + } + + $modifiedarray = array_map(function($value) { + $value->divider = $value->itemtype == 'divider'; + $value->link = $value->itemtype == 'link'; + if (isset($value->pix) && !empty($value->pix)) { + $value->pixicon = $value->pix; + unset($value->pix); + } + return $value; + }, $info->navitems); + + // Include the language menu as a submenu within the user menu. + $langmenu = $this->get_lang_menu($output); + if (!empty($langmenu)) { + $languageitems = $langmenu['items']; + // If there are available languages, generate the data for the the language selector submenu. + if (!empty($languageitems)) { + $langsubmenuid = uniqid(); + // Generate the data for the link to language selector submenu. + $language = (object) [ + 'itemtype' => 'submenu-link', + 'submenuid' => $langsubmenuid, + 'title' => get_string('language'), + 'pixicon' => 'i/language', + 'divider' => false, + 'submenulink' => true, + ]; + + // Place the link before the 'Log out' menu item which is either the last item in the menu or + // second to last when 'Switch roles' is available. + $menuposition = count($modifiedarray) - 1; + if (has_capability('moodle/role:switchroles', $PAGE->context)) { + $menuposition = count($modifiedarray) - 2; + } + array_splice($modifiedarray, $menuposition, 0, [$language]); + + // Generate the data for the language selector submenu. + $submenusdata[] = (object)[ + 'id' => $langsubmenuid, + 'title' => get_string('languageselector'), + 'items' => $languageitems, + ]; + } + } + + // Add dividers after the first item and before the last item. + $modifiedarray[0]->divider = true; + $modifiedarray[count($modifiedarray) - 2]->divider = true; + $usermenudata['items'] = $modifiedarray; + $usermenudata['submenus'] = array_values($submenusdata); + + return $usermenudata; + } +} diff --git a/lib/classes/navigation/views/primary.php b/lib/classes/navigation/views/primary.php index 67f45c7ebe6..c30b6780da1 100644 --- a/lib/classes/navigation/views/primary.php +++ b/lib/classes/navigation/views/primary.php @@ -35,8 +35,10 @@ class primary extends view { return; } $this->id = 'primary_navigation'; - $this->add(get_string('home'), new \moodle_url('/'), self::TYPE_SYSTEM, + if (get_home_page() == HOMEPAGE_SITE && isloggedin() && !isguestuser()) { + $this->add(get_string('home'), new \moodle_url('/'), self::TYPE_SYSTEM, null, 'home', new \pix_icon('i/home', '')); + } // Add the dashboard link. if (isloggedin() && !isguestuser()) { // Makes no sense if you aren't logged in. diff --git a/lib/classes/navigation/views/secondary.php b/lib/classes/navigation/views/secondary.php index 918bac3f655..e28655811f6 100644 --- a/lib/classes/navigation/views/secondary.php +++ b/lib/classes/navigation/views/secondary.php @@ -32,6 +32,10 @@ use navigation_node; class secondary extends view { /** @var string $headertitle The header for this particular menu*/ public $headertitle; + + /** @var int The maximum limit of navigation nodes displayed in the secondary navigation */ + const MAX_DISPLAYED_NAV_NODES = 5; + /** * Defines the default structure for the secondary nav in a course context. * @@ -85,13 +89,13 @@ class secondary extends view { return [ self::TYPE_SETTING => [ 'modedit' => 1, - 'roleoverride' => 3, - 'rolecheck' => 3.1, - 'logreport' => 4, - "mod_{$this->page->activityname}_useroverrides" => 5, // Overrides are module specific. - "mod_{$this->page->activityname}_groupoverrides" => 6, - 'roleassign' => 7, - 'filtermanage' => 8, + "mod_{$this->page->activityname}_useroverrides" => 3, // Overrides are module specific. + "mod_{$this->page->activityname}_groupoverrides" => 4, + 'roleassign' => 5, + 'filtermanage' => 6, + 'roleoverride' => 7, + 'rolecheck' => 7.1, + 'logreport' => 8, 'backup' => 9, 'restore' => 10, 'competencybreakdown' => 11, @@ -102,6 +106,34 @@ class secondary extends view { ]; } + /** + * Define the keys of the course secondary nav nodes that should be forced into the "more" menu by default. + * + * @return array + */ + protected function get_default_course_more_menu_nodes(): array { + return []; + } + + /** + * Define the keys of the module secondary nav nodes that should be forced into the "more" menu by default. + * + * @return array + */ + protected function get_default_module_more_menu_nodes(): array { + return ['roleoverride', 'rolecheck', 'logreport', 'roleassign', 'filtermanage', 'backup', 'restore', + 'competencybreakdown']; + } + + /** + * Define the keys of the admin secondary nav nodes that should be forced into the "more" menu by default. + * + * @return array + */ + protected function get_default_admin_more_menu_nodes(): array { + return []; + } + /** * Initialise the view based navigation based on the current context. * @@ -119,23 +151,36 @@ class secondary extends view { $this->id = 'secondary_navigation'; $context = $this->context; $this->headertitle = get_string('menu'); + $defaultmoremenunodes = []; switch ($context->contextlevel) { case CONTEXT_COURSE: if ($this->page->course->id != $SITE->id) { $this->headertitle = get_string('courseheader'); $this->load_course_navigation(); + $defaultmoremenunodes = $this->get_default_course_more_menu_nodes(); } break; case CONTEXT_MODULE: $this->headertitle = get_string('activityheader'); $this->load_module_navigation(); + $defaultmoremenunodes = $this->get_default_module_more_menu_nodes(); break; case CONTEXT_SYSTEM: + $this->headertitle = get_string('homeheader'); $this->load_admin_navigation(); + $defaultmoremenunodes = $this->get_default_admin_more_menu_nodes(); break; } + $this->remove_unwanted_nodes(); + + // Don't need to show anything if only the view node is available. Remove it. + if ($this->children->count() == 1) { + $this->children->remove('modulepage'); + } + + $this->force_nodes_into_more_menu($defaultmoremenunodes); // Search and set the active node. $this->scan_for_active_node($this); $this->initialised = true; @@ -157,14 +202,17 @@ class secondary extends view { $this->add(get_string('coursepage', 'admin'), $url, self::TYPE_COURSE, null, 'coursehome'); $nodes = $this->get_default_course_mapping(); - $nodesordered = $this->get_leaf_nodes($settingsnav, $nodes['settings']); - $nodesordered += $this->get_leaf_nodes($navigation, $nodes['navigation']); + $nodesordered = $this->get_leaf_nodes($settingsnav, $nodes['settings'] ?? []); + $nodesordered += $this->get_leaf_nodes($navigation, $nodes['navigation'] ?? []); $this->add_ordered_nodes($nodesordered); - // All additional nodes will be available under the 'Course admin' page. - $text = get_string('courseadministration'); - $url = new \moodle_url('/course/admin.php', array('courseid' => $this->page->course->id)); - $this->add($text, $url, null, null, 'courseadmin', new \pix_icon('t/edit', $text)); + $coursecontext = \context_course::instance($course->id); + if (has_capability('moodle/course:update', $coursecontext)) { + // All additional nodes will be available under the 'Course admin' page. + $text = get_string('courseadministration'); + $url = new \moodle_url('/course/admin.php', array('courseid' => $this->page->course->id)); + $this->add($text, $url, null, null, 'courseadmin', new \pix_icon('t/edit', $text)); + } } /** @@ -180,7 +228,12 @@ class secondary extends view { $nodes = $this->get_default_module_mapping(); if ($mainnode) { - $this->add(get_string('module', 'course'), $this->page->url, null, null, 'modulepage'); + $url = new \moodle_url('/mod/' . $this->page->activityname . '/view.php', ['id' => $this->page->cm->id]); + $setactive = $url->compare($this->page->url, URL_MATCH_BASE); + $node = $this->add(get_string('module', 'course'), $url, null, null, 'modulepage'); + if ($setactive) { + $node->make_active(); + } // Add the initial nodes. $nodesordered = $this->get_leaf_nodes($mainnode, $nodes); $this->add_ordered_nodes($nodesordered); @@ -269,4 +322,41 @@ class secondary extends view { } } } + + /** + * Force certain secondary navigation nodes to be displayed in the "more" menu. + * + * @param array $defaultmoremenunodes Array with navigation node keys of the pre-defined nodes that + * should be added into the "more" menu by default + */ + protected function force_nodes_into_more_menu(array $defaultmoremenunodes = []) { + // Counter of the navigation nodes that are initially displayed in the secondary nav + // (excludes the nodes from the "more" menu). + $displayednodescount = 0; + foreach ($this->children as $child) { + // Skip if the navigation node has been already forced into the "more" menu. + if ($child->forceintomoremenu) { + continue; + } + // If the navigation node is in the pre-defined list of nodes that should be added by default in the + // "more" menu or the maximum limit of displayed navigation nodes has been reached. + if (in_array($child->key, $defaultmoremenunodes) || $displayednodescount >= self::MAX_DISPLAYED_NAV_NODES) { + // Force the node and its children into the "more" menu. + $child->set_force_into_more_menu(true); + continue; + } + $displayednodescount++; + } + } + + /** + * Remove navigation nodes that should not be displayed in the secondary navigation. + */ + protected function remove_unwanted_nodes() { + foreach ($this->children as $child) { + if (!$child->showinsecondarynavigation) { + $child->remove(); + } + } + } } diff --git a/lib/classes/output/icon_system_fontawesome.php b/lib/classes/output/icon_system_fontawesome.php index b35e5984e77..29d7f954b4d 100644 --- a/lib/classes/output/icon_system_fontawesome.php +++ b/lib/classes/output/icon_system_fontawesome.php @@ -345,6 +345,7 @@ class icon_system_fontawesome extends icon_system_font { 'core:i/warning' => 'fa-exclamation text-warning', 'core:i/window_close' => 'fa-window-close', 'core:i/withsubcat' => 'fa-plus-square', + 'core:i/language' => 'fa-language', 'core:m/USD' => 'fa-usd', 'core:t/addcontact' => 'fa-address-card', 'core:t/add' => 'fa-plus', diff --git a/lib/classes/report_helper.php b/lib/classes/report_helper.php index 31817f2d7b8..1edd57b0ed6 100644 --- a/lib/classes/report_helper.php +++ b/lib/classes/report_helper.php @@ -61,7 +61,7 @@ class report_helper { if (!empty($menu)) { $select = new url_select($menu, $activeurl, null, 'choosecoursereport'); - $select->set_label(get_string('report'), ['class' => 'accesshide']); + $select->set_label(get_string('reporttype'), ['class' => 'accesshide']); $select->attributes['style'] = "margin-bottom: 1.5rem"; $select->class .= " mb-4"; echo $OUTPUT->render($select); diff --git a/lib/navigationlib.php b/lib/navigationlib.php index 6df89107bcb..e8a8abf75e6 100644 --- a/lib/navigationlib.php +++ b/lib/navigationlib.php @@ -149,6 +149,10 @@ class navigation_node implements renderable { public $requiresajaxloading = false; /** @var bool If set to true this node will be added to the "flat" navigation */ public $showinflatnavigation = false; + /** @var bool If set to true this node will be forced into a "more" menu whenever possible */ + public $forceintomoremenu = false; + /** @var bool If set to true this node will be displayed in the "secondary" navigation when applicable */ + public $showinsecondarynavigation = true; /** * Constructs a new navigation_node @@ -840,6 +844,30 @@ class navigation_node implements renderable { return $this->action; } + /** + * Sets whether the node and its children should be added into a "more" menu whenever possible. + * + * @param bool $forceintomoremenu + */ + public function set_force_into_more_menu(bool $forceintomoremenu = false) { + $this->forceintomoremenu = $forceintomoremenu; + foreach ($this->children as $child) { + $child->set_force_into_more_menu($forceintomoremenu); + } + } + + /** + * Sets whether the node and its children should be displayed in the "secondary" navigation when applicable. + * + * @param bool $show + */ + public function set_show_in_secondary_navigation(bool $show = true) { + $this->showinsecondarynavigation = $show; + foreach ($this->children as $child) { + $child->set_show_in_secondary_navigation($show); + } + } + /** * Add the menu item to handle locking and unlocking of a conext. * diff --git a/lib/outputcomponents.php b/lib/outputcomponents.php index 52c291eaa68..599b479fbe3 100644 --- a/lib/outputcomponents.php +++ b/lib/outputcomponents.php @@ -3981,6 +3981,10 @@ class context_header implements renderable { * page => page object. Don't include if the image is an external image. */ public $additionalbuttons; + /** + * @var string $prefix A string that is before the title. + */ + public $prefix; /** * Constructor. @@ -3989,8 +3993,9 @@ class context_header implements renderable { * @param int $headinglevel Main heading 'h' tag level. * @param string|null $imagedata HTML code for the picture in the page header. * @param string $additionalbuttons Buttons for the header e.g. Messaging button for the user header. + * @param string $prefix Text that precedes the heading. */ - public function __construct($heading = null, $headinglevel = 1, $imagedata = null, $additionalbuttons = null) { + public function __construct($heading = null, $headinglevel = 1, $imagedata = null, $additionalbuttons = null, $prefix = null) { $this->heading = $heading; $this->headinglevel = $headinglevel; @@ -4000,6 +4005,7 @@ class context_header implements renderable { if (isset($this->additionalbuttons)) { $this->format_button_images(); } + $this->prefix = $prefix; } /** diff --git a/lib/outputrenderers.php b/lib/outputrenderers.php index 69677c274f6..8deb104e90c 100644 --- a/lib/outputrenderers.php +++ b/lib/outputrenderers.php @@ -3321,41 +3321,26 @@ EOD; $loginpage = $this->is_login_page(); $loginurl = get_login_url(); - // If not logged in, show the typical not-logged-in string. - if (!isloggedin()) { - $returnstr = get_string('loggedinnot', 'moodle'); - if (!$loginpage) { - $returnstr .= " (" . get_string('login') . ')'; - } - return html_writer::div( - html_writer::span( - $returnstr, - 'login nav-link' - ), - $usermenuclasses - ); - - } - - // If logged in as a guest user, show a string to that effect. - if (isguestuser()) { - $returnstr = get_string('loggedinasguest'); - if (!$loginpage && $withlinks) { - $returnstr .= " (".get_string('login').')'; - } - - return html_writer::div( - html_writer::span( - $returnstr, - 'login nav-link' - ), - $usermenuclasses - ); - } // Get some navigation opts. $opts = user_get_user_navigation_info($user, $this->page); + if (!empty($opts->unauthenticateduser)) { + $returnstr = get_string($opts->unauthenticateduser['content'], 'moodle'); + // If not logged in, show the typical not-logged-in string. + if (!$loginpage && (!$opts->unauthenticateduser['guest'] || $withlinks)) { + $returnstr .= " (" . get_string('login') . ')'; + } + + return html_writer::div( + html_writer::span( + $returnstr, + 'login nav-link' + ), + $usermenuclasses + ); + } + $avatarclasses = "avatars"; $avatarcontents = html_writer::span($opts->metadata['useravatar'], 'avatar current'); $usertextcontents = $opts->metadata['userfullname']; @@ -4293,10 +4278,14 @@ EOD; // Image data. if (isset($contextheader->imagedata)) { // Header specific image. - $html .= html_writer::div($contextheader->imagedata, 'page-header-image'); + $html .= html_writer::div($contextheader->imagedata, 'page-header-image icon-size-7'); } // Headings. + if (isset($contextheader->prefix)) { + $prefix = html_writer::div($contextheader->prefix, 'text-muted'); + $heading = $prefix . $heading; + } $html .= html_writer::tag('div', $heading, array('class' => 'page-header-headings')); // Buttons. diff --git a/lib/pagelib.php b/lib/pagelib.php index 2f08c914f6d..02000fdb6a9 100644 --- a/lib/pagelib.php +++ b/lib/pagelib.php @@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die(); use core\navigation\views\primary; use core\navigation\views\secondary; +use core\navigation\output\primary as primaryoutput; /** * $PAGE is a central store of information about the current page we are @@ -84,6 +85,7 @@ use core\navigation\views\secondary; * @property-read secondary $secondarynav The secondary navigation object * used to display the secondarynav in boost * @property-read primary $primarynav The primary navigation object used to display the primary nav in boost + * @property-read primaryoutput $primarynavcombined The primary navigation object used to display the primary nav in boost * @property-read global_navigation $navigation The navigation structure for this page. * @property-read xhtml_container_stack $opencontainers Tracks XHTML tags on this page that have been opened but not closed. * mainly for internal use by the rendering code. @@ -311,6 +313,12 @@ class moodle_page { */ protected $_primarynav = null; + /** + * @var primaryoutput Contains the combined nav nodes that will appear + * in the primary navigation. Includes - primarynav, langmenu, usermenu + */ + protected $_primarynavcombined = null; + /** * @var navbar Contains the navbar structure. */ @@ -380,6 +388,11 @@ class moodle_page { */ protected $_regionmainsettingsinheader = false; + /** + * @var bool Should the secondary menu be rendered. + */ + protected $_hassecondarynavigation = true; + /** * Force the settings menu to be displayed on this page. This will only force the * settings menu on an activity / resource page that is being displayed on a theme that @@ -806,7 +819,13 @@ class moodle_page { */ protected function magic_get_secondarynav() { if ($this->_secondarynav === null) { - $this->_secondarynav = new secondary($this); + $class = 'core\navigation\views\secondary'; + // Try and load a custom class first. + if (class_exists("mod_{$this->activityname}\\local\\views\\secondary")) { + $class = "mod_{$this->activityname}\\local\\views\\secondary"; + } + + $this->_secondarynav = new $class($this); $this->_secondarynav->initialise(); } return $this->_secondarynav; @@ -824,6 +843,17 @@ class moodle_page { return $this->_primarynav; } + /** + * Returns the primary navigation object + * @return primary + */ + protected function magic_get_primarynavcombined() { + if ($this->_primarynavcombined === null) { + $this->_primarynavcombined = new primaryoutput($this); + } + return $this->_primarynavcombined; + } + /** * Returns request IP address. * @@ -1240,6 +1270,14 @@ class moodle_page { } } + /** + * Force set secondary_nav. Useful in cases where we dealing with non course modules. e.g. blocks, tools. + * @param secondary $nav + */ + public function set_secondarynav(secondary $nav) { + $this->_secondarynav = $nav; + } + /** * Adds a CSS class to the body tag of the page. * @@ -2133,4 +2171,22 @@ class moodle_page { public function include_region_main_settings_in_header_actions() : bool { return $this->_regionmainsettingsinheader; } + + /** + * Set the flag to indicate if the secondary navigation should be rendered. + * + * @param bool $value If the secondary navigation should be rendered. + */ + public function has_secondary_navigation_setter(bool $value) : void { + $this->_hassecondarynavigation = $value; + } + + /** + * Check if the secondary navigation should be rendered. + * + * @return bool + */ + public function has_secondary_navigation() : bool { + return $this->_hassecondarynavigation; + } } diff --git a/lib/templates/full_header.mustache b/lib/templates/full_header.mustache index d0453e2b744..f4fc8d6f8e8 100644 --- a/lib/templates/full_header.mustache +++ b/lib/templates/full_header.mustache @@ -32,6 +32,19 @@
+
+ {{#hasnavbar}} + + {{/hasnavbar}} +
+ {{{pageheadingbutton}}} +
+
+ {{{courseheader}}} +
+
{{#contextheader}}
@@ -50,19 +63,6 @@ {{/headeractions}}
-
- {{#hasnavbar}} - - {{/hasnavbar}} -
- {{{pageheadingbutton}}} -
-
- {{{courseheader}}} -
-
diff --git a/lib/templates/moremenu.mustache b/lib/templates/moremenu.mustache new file mode 100644 index 00000000000..52662983ba9 --- /dev/null +++ b/lib/templates/moremenu.mustache @@ -0,0 +1,70 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/moremenu + + The More menu. + + Example context (json): + { + "nodecollection": { + "children": [ + { + "text": "Home", + "action": "/index.php?redirect=0", + "active": "true" + }, + { + "text": "Dashboard", + "action": "/my" + }, + { + "text": "Courses", + "action": "/course" + }, + { + "text": "Site Administration", + "action": "/admin/search.php" + } + ] + } + } +}} + +{{#js}} +require(['core/moremenu'], function(moremenu) { + moremenu(document.querySelector('#moremenu-{{ uniqid }}-{{navbarstyle}}')); +}); +{{/js}} diff --git a/lib/templates/moremenu_children.mustache b/lib/templates/moremenu_children.mustache new file mode 100644 index 00000000000..e404bf975bf --- /dev/null +++ b/lib/templates/moremenu_children.mustache @@ -0,0 +1,63 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/moremenu_children + + The More menu children + + Example context (json): + { + "divider": "", + "haschildren": "", + "uniqid": "Unique string", + "text": "Moodle community", + "children": "", + "title": "Moodle community", + "url": "https://moodle.org" + } +}} +{{#haschildren}} + +{{/haschildren}} +{{^haschildren}} + +{{/haschildren}} diff --git a/lib/templates/settings_link_page.mustache b/lib/templates/settings_link_page.mustache index 07f07059992..2502709e7e2 100644 --- a/lib/templates/settings_link_page.mustache +++ b/lib/templates/settings_link_page.mustache @@ -36,28 +36,32 @@ } } }} - - +{{#secondarynavigation}} + {{> core/moremenu}} +{{/secondarynavigation}} +{{^secondarynavigation}} + +{{/secondarynavigation}}
-
+
@@ -101,7 +105,7 @@
{{#node.children}} {{#children.count}} -
+
diff --git a/lib/templates/user_action_menu_items.mustache b/lib/templates/user_action_menu_items.mustache new file mode 100644 index 00000000000..ff8942af6fd --- /dev/null +++ b/lib/templates/user_action_menu_items.mustache @@ -0,0 +1,84 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/user_action_menu_items + + Template for user action menu items. + + Context variables required for this template: + * items - The different items to be rendered + * link - If a link is provided render it. + * title - The text to be shown for the link. + * url - The href for the link. + * pixicon - (Optional) The Moodle icon to use + * imgsrc - (Optional) If provided, uses this as source for an image tag. Note: pixicon is preferred. + * submenulink - If a submenu link is provided render it. + * submenuid - The id of the targeted submenu. + * title - The text to be shown for the link. + * pixicon - (Optional) The Moodle icon to use. + * imgsrc - (Optional) If provided, uses this as source for an image tag. Note: pixicon is preferred. + * divider - Whether a divider is to be displayed or not + + Example context (json): + { + "items": [ + { + "link": { + "title": "Github user", + "url": "https://raw.githubusercontent.com/", + "pixicon": "t/dashboard", + "imgsrc": "https://raw.githubusercontent.com/moodle/moodle/master/pix/t/check.png" + }, + "divider": 1 + }, + { + "submenulink": { + "title": "Title", + "submenuid": "86cebd87", + "pixicon": "t/dashboard", + "imgsrc": "https://raw.githubusercontent.com/moodle/moodle/master/pix/t/check.png" + }, + "divider": 1 + } + ] + } +}} +{{#items}} + {{#link}} + + {{#pixicon}} + {{#pix}}{{pixicon}}{{/pix}} + {{/pixicon}} + {{^pixicon}} + {{#imgsrc}}{{/imgsrc}} + {{/pixicon}} + {{title}} + + {{/link}} + {{#submenulink}} + + {{#pixicon}} + {{#pix}}{{pixicon}}{{/pix}} + {{/pixicon}} + {{^pixicon}} + {{#imgsrc}}{{/imgsrc}} + {{/pixicon}} + {{title}} + + {{/submenulink}} + {{#divider}}{{/divider}} +{{/items}} diff --git a/lib/templates/user_action_menu_submenu_items.mustache b/lib/templates/user_action_menu_submenu_items.mustache new file mode 100644 index 00000000000..18896c4b900 --- /dev/null +++ b/lib/templates/user_action_menu_submenu_items.mustache @@ -0,0 +1,48 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/user_action_menu_submenus + + Template for the submenus in the user action menu. + + Context variables required for this template: + * items - The submenu items + * link - If a link is provided render it. + * title - The title added to the link. + * text - The text to be shown for the link. + * url - The href for the link. + * isactive - (Optional) Whether the item is currently active (has been selected). + + Example context (json): + { + "items": { + "link": { + "title": "Submenu item 1", + "text": "Submenu item 1", + "url": "https://example.com/", + "isactive": 0 + } + } + } +}} +{{#items}} + {{#link}} + + {{text}} + + {{/link}} +{{/items}} diff --git a/lib/templates/user_menu.mustache b/lib/templates/user_menu.mustache new file mode 100644 index 00000000000..8738a56c9b9 --- /dev/null +++ b/lib/templates/user_menu.mustache @@ -0,0 +1,105 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/user_menu + + Action link template. + + Context variables required for this template: + * unauthenticateduseruser - (Optional) Items to be displayed if an an unautheticated user is accessing the site + * content - The content to be displayed in the header. + * url - The login url + * items - Array of user menu items used in user_action_menu_items. Required if the above not provided. + * metadata - Array of additional metadata to be displayed in the dropdown button. + * avatardata - Array of avatars to be displayed. Usually only the current user's avatar. If viewing as another user, + includes that user's avatar. + * userfullname - The name of the logged in user + * submenus - Array of submenus within the user menu. + * id - The id of the submenu. + * title - The title of the submenu. + * items - Array of the submenu items used in core/user_action_menu_submenu_items. + + Example context (json): + { + "unauthenticateduser": { + "content": "You are not logged in", + "url": "https://yourmoodlesite/login/index.php" + }, + "items": [], + "metadata": [], + "avatardata": [], + "userfullname": "Admin User", + "submenus": [ + { + "id": "86cebd87", + "title": "Submenu title", + "items": [] + } + ] + } +}} +
+ {{#unauthenticateduser}} + + {{/unauthenticateduser}} + {{^unauthenticateduser}} + + {{/unauthenticateduser}} +
+{{#js}} + require(['core/usermenu'], function(UserMenu) { + UserMenu.init(); + }); +{{/js}} diff --git a/lib/templates/user_menu_metadata.mustache b/lib/templates/user_menu_metadata.mustache new file mode 100644 index 00000000000..b88a0cc8720 --- /dev/null +++ b/lib/templates/user_menu_metadata.mustache @@ -0,0 +1,63 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/user_menu_metadata + + User menu metadata template. + + Context variables required for this template: + * metadata - Array of additional metadata to be displayed in the dropdown button. + * classes - Additional classes to be applied to the content + * content - The content to be displayed. May/may not have html within it. + * avatardata - Array of avatars to be displayed. Usually only the current user's avatar. If viewing as another user, + includes that user's avatar. + * classes - Additional classes to be applied to the content + * content - The content to be displayed. May/may not have html within it. + * userfullname - The name of the logged in user + + Example context (json): + { + "metadata": [ + { + "classes": "loginfailures", + "content": "1 failed login attempts" + } + ], + "avatardata": [ + { + "classes": "current", + "content": "" + } + ], + "userfullname": "Admin User" + } +}} + + {{userfullname}} + {{#metadata}} + + {{{content}}} + + {{/metadata}} + + +{{#avatardata}} + + {{{content}}} + +{{/avatardata}} + diff --git a/lib/tests/behat/behat_navigation.php b/lib/tests/behat/behat_navigation.php index 053010c6b0e..d71b8e96313 100644 --- a/lib/tests/behat/behat_navigation.php +++ b/lib/tests/behat/behat_navigation.php @@ -41,6 +41,20 @@ use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; */ class behat_navigation extends behat_base { + /** + * Checks whether a navigation node is active within the block navigation. + * + * @Given i should see :name is active in navigation + * + * @throws ElementNotFoundException + * @param string $element The name of the nav elemnent to look for. + * @return void + */ + public function i_should_see_is_active_in_navigation($element) { + $this->execute("behat_general::assert_element_contains_text", + [$element, '.block_navigation .active_tree_node', 'css_element']); + } + /** * Helper function to get a navigation nodes text element given its text from within the navigation block. * @@ -151,7 +165,7 @@ class behat_navigation extends behat_base { if ($this->running_javascript()) { // The user menu must be expanded when JS is enabled. - $xpath = "//div[contains(concat(' ', @class, ' '), ' usermenu ')]//a[contains(concat(' ', @class, ' '), ' dropdown-toggle ')]"; + $xpath = "//div[contains(concat(' ', @class, ' '), ' usermenu ')]//button[contains(concat(' ', @class, ' '), ' dropdown-toggle ')]"; $this->execute("behat_general::i_click_on", array($this->escape($xpath), "xpath_element")); } @@ -925,7 +939,14 @@ class behat_navigation extends behat_base { if ($parentnodes) { $tabname = behat_context_helper::escape($parentnodes[0]); $tabxpath = '//ul[@role=\'tablist\']/li/a[contains(normalize-space(.), ' . $tabname . ')]'; - if ($node = $this->getSession()->getPage()->find('xpath', $tabxpath)) { + $menubarxpath = '//ul[@role=\'menubar\']/li/a[contains(normalize-space(.), ' . $tabname . ')]'; + $linkname = behat_context_helper::escape(get_string('moremenu')); + $menubarmorexpath = '//ul[@role=\'menubar\']/li/a[contains(normalize-space(.), ' . $linkname . ')]'; + $tabnode = $this->getSession()->getPage()->find('xpath', $tabxpath); + $menunode = $this->getSession()->getPage()->find('xpath', $menubarxpath); + $menubuttons = $this->getSession()->getPage()->findAll('xpath', $menubarmorexpath); + if ($tabnode || $menunode) { + $node = is_object($tabnode) ? $tabnode : $menunode; if ($this->running_javascript()) { $this->execute('behat_general::i_click_on', [$node, 'NodeElement']); // Click on the tab and add 'active' tab to the xpath. @@ -936,6 +957,22 @@ class behat_navigation extends behat_base { $xpath .= '//div[@id = ' . $tabid . ']'; } array_shift($parentnodes); + } else if (count($menubuttons) > 0) { + try { + $menubuttons[0]->isVisible(); + try { + $this->execute('behat_general::i_click_on', [$menubuttons[1], 'NodeElement']); + } catch (Exception $e) { + $this->execute('behat_general::i_click_on', [$menubuttons[0], 'NodeElement']); + } + $moreitemxpath = '//ul[@data-region=\'moredropdown\']/li/a[contains(normalize-space(.), ' . $tabname . ')]'; + if ($morenode = $this->getSession()->getPage()->find('xpath', $moreitemxpath)) { + $this->execute('behat_general::i_click_on', [$morenode, 'NodeElement']); + $xpath .= '//div[contains(@class,\'active\')]'; + array_shift($parentnodes); + } + } catch (Exception $e) { + } } } @@ -1065,4 +1102,95 @@ class behat_navigation extends behat_base { } $this->execute('behat_general::i_visit', [$url]); } + + + /** + * First checks to see if we are on this page via the breadcrumb. If not we then attempt to follow the link name given. + * + * @param string $pagename Name of the breadcrumb item to check and follow. + */ + public function go_to_breadcrumb_location(string $pagename): void { + $link = $this->getSession()->getPage()->find( + 'xpath', + "//nav[@aria-label='Navigation bar']/ol/li[last()][contains(normalize-space(.), '" . $pagename . "')]" + ); + if (!$link) { + $this->execute("behat_general::click_link", $pagename); + } + } + + /** + * Checks whether an item exists in the user menu. + * + * @Given :itemtext :selectortype should exist in the user menu + * @Given :itemtext :selectortype should :not exist in the user menu + * + * @throws ElementNotFoundException + * @param string $itemtext The menu item to find + * @param string $selectortype The selector type + * @param string|null $not Instructs to checks whether the element does not exist in the user menu, if defined + * @return void + */ + public function should_exist_in_user_menu($itemtext, $selectortype, $not = null) { + $callfunction = is_null($not) ? 'should_exist_in_the' : 'should_not_exist_in_the'; + $this->execute("behat_general::{$callfunction}", + [$itemtext, $selectortype, $this->get_user_menu_xpath(), 'xpath_element']); + } + + /** + * Checks whether an item exists in a given user submenu. + * + * @Given :itemtext :selectortype should exist in the :submenuname user submenu + * @Given :itemtext :selectortype should :not exist in the :submenuname user submenu + * + * @throws ElementNotFoundException + * @param string $itemtext The submenu item to find + * @param string $selectortype The selector type + * @param string $submenuname The name of the submenu + * @param string|null $not Instructs to checks whether the element does not exist in the user menu, if defined + * @return void + */ + public function should_exist_in_user_submenu($itemtext, $selectortype, $submenuname, $not = null) { + $callfunction = is_null($not) ? 'should_exist_in_the' : 'should_not_exist_in_the'; + $this->execute("behat_general::{$callfunction}", + [$itemtext, $selectortype, $this->get_user_submenu_xpath($submenuname), 'xpath_element']); + } + + /** + * Checks whether a given user submenu is visible. + * + * @Then /^I should see "(?P[^"]*)" user submenu$/ + * + * @throws ElementNotFoundException + * @throws ExpectationException + * @param string $submenuname The name of the submenu + * @return void + */ + public function i_should_see_user_submenu($submenuname) { + $this->execute('behat_general::should_be_visible', + array($this->get_user_submenu_xpath($submenuname), 'xpath_element')); + } + + /** + * Return the xpath for the user menu element. + * + * @return string The xpath + */ + protected function get_user_menu_xpath() { + return "//div[contains(concat(' ', @class, ' '), ' usermenu ')]" . + "//div[contains(concat(' ', @class, ' '), ' dropdown-menu ')]" . + "//div[@id='carousel-item-main']"; + } + + /** + * Return the xpath for a given user submenu element. + * + * @param string $submenuname The name of the submenu + * @return string The xpath + */ + protected function get_user_submenu_xpath($submenuname) { + return "//div[contains(concat(' ', @class, ' '), ' usermenu ')]" . + "//div[contains(concat(' ', @class, ' '), ' dropdown-menu ')]" . + "//div[contains(concat(' ', @class, ' '), ' submenu ')][@aria-label='" . $submenuname . "']"; + } } diff --git a/lib/tests/navigation/output/primary_test.php b/lib/tests/navigation/output/primary_test.php new file mode 100644 index 00000000000..2c4031b3078 --- /dev/null +++ b/lib/tests/navigation/output/primary_test.php @@ -0,0 +1,413 @@ +. + +namespace core\navigation\output; + +use ReflectionMethod; + +/** + * Primary navigation renderable test + * + * @package core + * @category navigation + * @copyright 2021 onwards Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class primary_test extends \advanced_testcase { + /** + * Basic setup to make sure the nav objects gets generated without any issues. + */ + public function setUp(): void { + global $PAGE; + $this->resetAfterTest(); + $pagecourse = $this->getDataGenerator()->create_course(); + $assign = $this->getDataGenerator()->create_module('assign', ['course' => $pagecourse->id]); + $cm = get_coursemodule_from_id('assign', $assign->cmid); + $contextrecord = \context_module::instance($cm->id); + $pageurl = new \moodle_url('/mod/assign/view.php', ['id' => $cm->instance]); + $PAGE->set_cm($cm); + $PAGE->set_url($pageurl); + $PAGE->set_course($pagecourse); + $PAGE->set_context($contextrecord); + } + + /** + * Test the primary export to confirm we are getting the nodes + * + * @dataProvider test_primary_export_provider + * @param bool $withcustom Setup with custom menu + * @param bool $withlang Setup with langs + * @param string $userloggedin The type of user ('admin' or 'guest') if creating setup with logged in user, + * otherwise consider the user as non-logged in + * @param array $expecteditems An array of nodes expected with content in them. + */ + public function test_primary_export(bool $withcustom, bool $withlang, string $userloggedin, array $expecteditems) { + global $PAGE, $CFG; + if ($withcustom) { + $CFG->custommenuitems = "Course search|/course/search.php + Google|https://google.com.au/ + Netflix|https://netflix.com/au"; + } + if ($userloggedin === 'admin') { + $this->setAdminUser(); + } else if ($userloggedin === 'guest') { + $this->setGuestUser(); + } else { + $this->setUser(0); + } + + // Mimic multiple langs installed. To trigger responses 'get_list_of_translations'. + // Note: The text/title of the nodes generated will be 'English(fr), English(de)' but we don't care about this. + // We are testing whether the nodes gets generated when the lang menu is available. + if ($withlang) { + mkdir("$CFG->dataroot/lang/de", 0777, true); + mkdir("$CFG->dataroot/lang/fr", 0777, true); + // Ensure the new langs are picked up and not taken from the cache. + $stringmanager = get_string_manager(); + $stringmanager->reset_caches(true); + } + + $primary = new primary($PAGE); + $renderer = $PAGE->get_renderer('core'); + $data = array_filter($primary->export_for_template($renderer)); + + // Assert that the number of returned menu items equals the expected result. + $this->assertCount(count($expecteditems), $data); + // Assert that returned menu items match the expected items. + foreach ($data as $menutype => $value) { + $this->assertTrue(in_array($menutype, $expecteditems)); + } + // When the user is logged in (excluding guest access), assert that lang menu is included as a part of the + // user menu when multiple languages are installed. + if (isloggedin() && !isguestuser()) { + // Look for a language menu item within the user menu items. + $usermenulang = array_filter($data['user']['items'], function($usermenuitem) { + return $usermenuitem->title === get_string('language'); + }); + if ($withlang) { // If multiple languages are installed. + // Assert that the language menu exists within the user menu. + $this->assertNotEmpty($usermenulang); + } else { // If the aren't any additional installed languages. + $this->assertEmpty($usermenulang); + } + } else { // Otherwise assert that the user menu does not contain any items. + $this->assertArrayNotHasKey('items', $data['user']); + } + } + + /** + * Provider for the test_primary_export function. + * + * @return array + */ + public function test_primary_export_provider(): array { + return [ + "Export the menu data when: custom menu exists; multiple langs installed; user is not logged in." => [ + true, true, '', ['moremenu', 'lang', 'user'] + ], + "Export the menu data when: custom menu exists; langs not installed; user is not logged in." => [ + true, false, '', ['moremenu', 'user'] + ], + "Export the menu data when: custom menu exists; multiple langs installed; logged in as admin." => [ + true, true, 'admin', ['moremenu', 'user'] + ], + "Export the menu data when: custom menu exists; langs not installed; logged in as admin." => [ + true, false, 'admin', ['moremenu', 'user'] + ], + "Export the menu data when: custom menu exists; multiple langs installed; logged in as guest." => [ + true, true, 'guest', ['moremenu', 'lang', 'user'] + ], + "Export the menu data when: custom menu exists; langs not installed; logged in as guest." => [ + true, false, 'guest', ['moremenu', 'user'] + ], + "Export the menu data when: custom menu does not exist; multiple langs installed; logged in as guest." => [ + false, true, 'guest', ['moremenu', 'lang', 'user'] + ], + "Export the menu data when: custom menu does not exist; multiple langs installed; logged in as admin." => [ + false, true, 'admin', ['moremenu', 'user'] + ], + "Export the menu data when: custom menu does not exist; langs not installed; user is not logged in." => [ + false, false, '', ['moremenu', 'user'] + ], + ]; + } + + /** + * Test the get_lang_menu + * + * @dataProvider get_lang_menu_provider + * @param bool $withadditionallangs + * @param string $language + * @param array $expected + */ + public function test_get_lang_menu(bool $withadditionallangs, string $language, array $expected) { + global $CFG, $PAGE; + + // Mimic multiple langs installed. To trigger responses 'get_list_of_translations'. + // Note: The text/title of the nodes generated will be 'English(fr), English(de)' but we don't care about this. + // We are testing whether the nodes gets generated when the lang menu is available. + if ($withadditionallangs) { + mkdir("$CFG->dataroot/lang/de", 0777, true); + mkdir("$CFG->dataroot/lang/fr", 0777, true); + // Ensure the new langs are picked up and not taken from the cache. + $stringmanager = get_string_manager(); + $stringmanager->reset_caches(true); + } + + force_current_language($language); + + $output = new primary($PAGE); + $method = new ReflectionMethod('core\navigation\output\primary', 'get_lang_menu'); + $method->setAccessible(true); + $renderer = $PAGE->get_renderer('core'); + + $response = $method->invoke($output, $renderer); + + if ($withadditionallangs) { // If there are multiple languages installed. + // Assert that the title of the language menu matches the expected one. + $this->assertEquals($expected['title'], $response['title']); + // Assert that the number of language menu items matches the number of the expected items. + $this->assertEquals(count($expected['items']), count($response['items'])); + foreach ($expected['items'] as $expecteditem) { + // We need to manually generate the url key and its value in the expected item array as this cannot + // be done in the data provider due to the change of the state of $PAGE. + $expecteditem['url'] = $expecteditem['isactive'] ? new \moodle_url('#') : + new \moodle_url($PAGE->url, ['lang' => $expecteditem['lang']]); + // The lang value is only used to generate the url, so this key can be removed. + unset($expecteditem['lang']); + + // Assert that the given expected item exists in the returned items. + $this->assertTrue(in_array($expecteditem, $response['items'])); + } + } else { // No multiple languages. + $this->assertEquals($expected, $response); + } + } + + /** + * Provider for test_get_lang_menu + * + * @return array + */ + public function get_lang_menu_provider(): array { + return [ + 'Lang menu with only the current language' => [ + false, 'en', [] + ], + 'Lang menu with only multiple languages installed' => [ + true, 'en', [ + 'title' => 'English ‎(en)‎', + 'items' => [ + [ + 'title' => 'English ‎(en)‎', + 'text' => 'English ‎(en)‎', + 'link' => true, + 'isactive' => true, + 'lang' => 'en' + ], + [ + 'title' => 'English ‎(de)‎', + 'text' => 'English ‎(de)‎', + 'link' => true, + 'isactive' => false, + 'lang' => 'de' + ], + + [ + 'title' => 'English ‎(fr)‎', + 'text' => 'English ‎(fr)‎', + 'link' => true, + 'isactive' => false, + 'lang' => 'fr' + ], + ], + ], + ], + 'Lang menu with only multiple languages installed and other than EN set active.' => [ + true, 'de', [ + 'title' => 'English ‎(de)‎', + 'items' => [ + [ + 'title' => 'English ‎(en)‎', + 'text' => 'English ‎(en)‎', + 'link' => true, + 'isactive' => false, + 'lang' => 'en' + ], + [ + 'title' => 'English ‎(de)‎', + 'text' => 'English ‎(de)‎', + 'link' => true, + 'isactive' => true, + 'lang' => 'de' + ], + [ + 'title' => 'English ‎(fr)‎', + 'text' => 'English ‎(fr)‎', + 'link' => true, + 'isactive' => false, + 'lang' => 'fr' + ], + ], + ], + ], + ]; + } + + /** + * Test the custom menu getter to confirm the nodes gets generated and are returned correctly. + * + * @dataProvider custom_menu_provider + * @param string $config + * @param array $expected + */ + public function test_get_custom_menu(string $config, array $expected) { + global $CFG, $PAGE; + $CFG->custommenuitems = $config; + $output = new primary($PAGE); + $method = new ReflectionMethod('core\navigation\output\primary', 'get_custom_menu'); + $method->setAccessible(true); + $renderer = $PAGE->get_renderer('core'); + $this->assertEquals($expected, $method->invoke($output, $renderer)); + } + + /** + * Provider for test_get_custom_menu + * + * @return array + */ + public function custom_menu_provider(): array { + return [ + 'Simple custom menu' => [ + "Course search|/course/search.php + Google|https://google.com.au/ + Netflix|https://netflix.com/au", [ + (object) [ + 'text' => 'Course search', + 'url' => 'https://www.example.com/moodle/course/search.php', + 'title' => '', + 'sort' => 1, + 'children' => [], + 'haschildren' => false, + ], + (object) [ + 'text' => 'Google', + 'url' => 'https://google.com.au/', + 'title' => '', + 'sort' => 2, + 'children' => [], + 'haschildren' => false, + ], + (object) [ + 'text' => 'Netflix', + 'url' => 'https://netflix.com/au', + 'title' => '', + 'sort' => 3, + 'children' => [], + 'haschildren' => false, + ], + ] + ], + 'Complex, nested custom menu' => [ + "Moodle community|http://moodle.org + -Moodle free support|http://moodle.org/support + -Moodle development|http://moodle.org/development + --Moodle Tracker|http://tracker.moodle.org + --Moodle Docs|https://docs.moodle.org + -Moodle News|http://moodle.org/news + Moodle company + -Moodle commercial hosting|http://moodle.com/hosting + -Moodle commercial support|http://moodle.com/support", [ + (object) [ + 'text' => 'Moodle community', + 'url' => 'http://moodle.org', + 'title' => '', + 'sort' => 1, + 'children' => [ + (object) [ + 'text' => 'Moodle free support', + 'url' => 'http://moodle.org/support', + 'title' => '', + 'sort' => 2, + 'children' => [], + 'haschildren' => false, + ], + (object) [ + 'text' => 'Moodle development', + 'url' => 'http://moodle.org/development', + 'title' => '', + 'sort' => 3, + 'children' => [ + (object) [ + 'text' => 'Moodle Tracker', + 'url' => 'http://tracker.moodle.org', + 'title' => '', + 'sort' => 4, + 'children' => [], + 'haschildren' => false, + ], + (object) [ + 'text' => 'Moodle Docs', + 'url' => 'https://docs.moodle.org', + 'title' => '', + 'sort' => 5, + 'children' => [], + 'haschildren' => false, + ], + ], + 'haschildren' => true, + ], + (object) [ + 'text' => 'Moodle News', + 'url' => 'http://moodle.org/news', + 'title' => '', + 'sort' => 6, + 'children' => [], + 'haschildren' => false, + ], + ], + 'haschildren' => true, + ], + (object) [ + 'text' => 'Moodle company', + 'url' => null, + 'title' => '', + 'sort' => 7, + 'children' => [ + (object) [ + 'text' => 'Moodle commercial hosting', + 'url' => 'http://moodle.com/hosting', + 'title' => '', + 'sort' => 8, + 'children' => [], + 'haschildren' => false, + ], + (object) [ + 'text' => 'Moodle commercial support', + 'url' => 'http://moodle.com/support', + 'title' => '', + 'sort' => 9, + 'children' => [], + 'haschildren' => false, + ], + ], + 'haschildren' => true, + ], + ] + ] + ]; + } +} diff --git a/lib/tests/navigation/views/primary_test.php b/lib/tests/navigation/views/primary_test.php index 41f8e9efa9d..2da990d8afa 100644 --- a/lib/tests/navigation/views/primary_test.php +++ b/lib/tests/navigation/views/primary_test.php @@ -58,9 +58,9 @@ class primary_test extends \advanced_testcase { */ public function test_setting_initialise_provider() { return [ - 'Testing as a guest user' => ['guest', ['home', 'courses']], - 'Testing as an admin' => ['admin', ['home', 'myhome', 'courses', 'siteadminnode']], - 'Testing as a regular user' => ['user', ['home', 'myhome', 'courses']] + 'Testing as a guest user' => ['guest', ['courses']], + 'Testing as an admin' => ['admin', ['myhome', 'courses', 'siteadminnode']], + 'Testing as a regular user' => ['user', ['myhome', 'courses']] ]; } } diff --git a/lib/tests/navigation/views/secondary_test.php b/lib/tests/navigation/views/secondary_test.php index fa5ef8d85a8..fa511af14b5 100644 --- a/lib/tests/navigation/views/secondary_test.php +++ b/lib/tests/navigation/views/secondary_test.php @@ -106,7 +106,7 @@ class secondary_test extends \advanced_testcase { $assign = $this->getDataGenerator()->create_module('assign', ['course' => $pagecourse->id]); $cm = get_coursemodule_from_id('assign', $assign->cmid); $contextrecord = \context_module::instance($cm->id); - $pageurl = new \moodle_url('/mod/assign/view.php', ['id' => $cm->instance]); + $pageurl = new \moodle_url('/mod/assign/view.php', ['id' => $cm->id]); $PAGE->set_cm($cm); break; case 'system': @@ -135,7 +135,131 @@ class secondary_test extends \advanced_testcase { return [ 'Testing in a course context' => ['course', 'coursehome', 'courseheader', 'Course page'], 'Testing in a module context' => ['module', 'modulepage', 'activityheader', 'Activity'], - 'Testing in a site admin' => ['system', 'siteadminnode', 'menu', 'Site administration'], + 'Testing in a site admin' => ['system', 'siteadminnode', 'homeheader', 'Site administration'], + ]; + } + + /** + * Test the force_nodes_into_more_menu method. + * + * @param array $secondarynavnodesdata The array which contains the data used to generate the secondary navigation + * @param array $defaultmoremenunodes The array containing the keys of the navigation nodes which should be added + * to the "more" menu by default + * @param array $expecedmoremenunodes The array containing the keys of the expected navigation nodes which are + * forced into the "more" menu + * @dataProvider test_force_nodes_into_more_menu_provider + */ + public function test_force_nodes_into_more_menu(array $secondarynavnodesdata, array $defaultmoremenunodes, + array $expecedmoremenunodes) { + global $PAGE; + + // Create a dummy secondary navigation. + $secondary = new secondary($PAGE); + foreach ($secondarynavnodesdata as $nodedata) { + $secondary->add($nodedata['text'], '#', secondary::TYPE_SETTING, null, $nodedata['key']); + } + + $method = new ReflectionMethod('core\navigation\views\secondary', 'force_nodes_into_more_menu'); + $method->setAccessible(true); + $method->invoke($secondary, $defaultmoremenunodes); + + $actualmoremenunodes = []; + foreach ($secondary->children as $node) { + if ($node->forceintomoremenu) { + $actualmoremenunodes[] = $node->key; + } + } + // Assert that the actual nodes forced into the "more" menu matches the expected ones. + $this->assertEquals($expecedmoremenunodes, $actualmoremenunodes); + } + + /** + * Data provider for the test_force_nodes_into_more_menu function. + * + * @return array + */ + public function test_force_nodes_into_more_menu_provider(): array { + return [ + 'The total number of navigation nodes exceeds the max display limit (5); ' . + 'navnode2 and navnode4 are forced into "more" menu by default.' => + [ + [ + [ 'text' => 'Navigation node 1', 'key' => 'navnode1'], + [ 'text' => 'Navigation node 2', 'key' => 'navnode2'], + [ 'text' => 'Navigation node 3', 'key' => 'navnode3'], + [ 'text' => 'Navigation node 4', 'key' => 'navnode4'], + [ 'text' => 'Navigation node 5', 'key' => 'navnode5'], + [ 'text' => 'Navigation node 6', 'key' => 'navnode6'], + [ 'text' => 'Navigation node 7', 'key' => 'navnode7'], + [ 'text' => 'Navigation node 8', 'key' => 'navnode8'], + [ 'text' => 'Navigation node 9', 'key' => 'navnode9'], + ], + [ + 'navnode2', + 'navnode4', + ], + [ + 'navnode2', + 'navnode4', + 'navnode8', + 'navnode9', + ], + ], + 'The total number of navigation nodes does not exceed the max display limit (5); ' . + 'navnode2 and navnode4 are forced into "more" menu by default.' => + [ + [ + [ 'text' => 'Navigation node 1', 'key' => 'navnode1'], + [ 'text' => 'Navigation node 2', 'key' => 'navnode2'], + [ 'text' => 'Navigation node 3', 'key' => 'navnode3'], + [ 'text' => 'Navigation node 4', 'key' => 'navnode4'], + [ 'text' => 'Navigation node 5', 'key' => 'navnode5'], + ], + [ + 'navnode2', + 'navnode4', + ], + [ + 'navnode2', + 'navnode4', + ], + ], + 'The total number of navigation nodes exceeds the max display limit (5); ' . + 'no forced navigation nodes into "more" menu by default.' => + [ + [ + [ 'text' => 'Navigation node 1', 'key' => 'navnode1'], + [ 'text' => 'Navigation node 2', 'key' => 'navnode2'], + [ 'text' => 'Navigation node 3', 'key' => 'navnode3'], + [ 'text' => 'Navigation node 4', 'key' => 'navnode4'], + [ 'text' => 'Navigation node 5', 'key' => 'navnode5'], + [ 'text' => 'Navigation node 6', 'key' => 'navnode6'], + [ 'text' => 'Navigation node 7', 'key' => 'navnode7'], + [ 'text' => 'Navigation node 8', 'key' => 'navnode8'], + ], + [], + [ + 'navnode6', + 'navnode7', + 'navnode8', + ], + ], + 'The total number of navigation nodes does not exceed the max display limit (5); ' . + 'no forced navigation nodes into "more" menu by default.' => + [ + [ + [ 'text' => 'Navigation node 1', 'key' => 'navnode1'], + [ 'text' => 'Navigation node 2', 'key' => 'navnode2'], + [ 'text' => 'Navigation node 3', 'key' => 'navnode3'], + [ 'text' => 'Navigation node 4', 'key' => 'navnode4'], + [ 'text' => 'Navigation node 5', 'key' => 'navnode5'], + [ 'text' => 'Navigation node 6', 'key' => 'navnode6'], + ], + [], + [ + 'navnode6', + ], + ], ]; } } diff --git a/lib/tests/navigationlib_test.php b/lib/tests/navigationlib_test.php index 0848ee8effa..47dd2eabde6 100644 --- a/lib/tests/navigationlib_test.php +++ b/lib/tests/navigationlib_test.php @@ -545,6 +545,58 @@ class core_navigationlib_testcase extends advanced_testcase { // Test it's empty again! $this->assertEquals(0, count($navigationnodecollection->get_key_list())); } + + /** + * Test the set_force_into_more_menu method. + * + * @param bool $haschildren Whether the navigation node has children nodes + * @param bool $forceintomoremenu Whether to force the navigation node and its children into the "more" menu + * @dataProvider test_set_force_into_more_menu_provider + */ + public function test_set_force_into_more_menu(bool $haschildren, bool $forceintomoremenu) { + // Create a navigation node. + $node = new navigation_node(['text' => 'Navigation node', 'key' => 'navnode']); + + // If required, add some children nodes to the navigation node. + if ($haschildren) { + for ($i = 1; $i <= 3; $i++) { + $node->add("Child navigation node {$i}"); + } + } + + $node->set_force_into_more_menu($forceintomoremenu); + // Assert that the expected value has been assigned to the 'forceintomoremenu' property + // in the navigation node and its children. + $this->assertEquals($forceintomoremenu, $node->forceintomoremenu); + foreach ($node->children as $child) { + $this->assertEquals($forceintomoremenu, $child->forceintomoremenu); + } + } + + /** + * Data provider for the test_set_force_into_more_menu function. + * + * @return array + */ + public function test_set_force_into_more_menu_provider(): array { + return [ + 'Navigation node without any children nodes; Force into "more" menu => true.' => + [ + false, + true, + ], + 'Navigation node with children nodes; Force into "more" menu => true.' => + [ + true, + true, + ], + 'Navigation node with children nodes; Force into "more" menu => false.' => + [ + true, + false, + ], + ]; + } } diff --git a/mod/assign/classes/local/views/secondary.php b/mod/assign/classes/local/views/secondary.php new file mode 100644 index 00000000000..d78577c9c7c --- /dev/null +++ b/mod/assign/classes/local/views/secondary.php @@ -0,0 +1,44 @@ +. + +namespace mod_assign\local\views; + +use core\navigation\views\secondary as core_secondary; + +/** + * Class secondary_navigation_view. + * + * Custom implementation for a plugin. + * + * @package mod_assign + * @category navigation + * @copyright 2021 onwards Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class secondary extends core_secondary { + protected function get_default_module_mapping(): array { + return [ + self::TYPE_SETTING => [ + 'modedit' => 1, + "mod_{$this->page->activityname}_useroverrides" => 2, // Overrides are module specific. + "mod_{$this->page->activityname}_groupoverrides" => 3, + ], + self::TYPE_CUSTOM => [ + 'advgrading' => 4, + ], + ]; + } +} diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php index d35131f5091..05c71bcbe5a 100644 --- a/mod/assign/locallib.php +++ b/mod/assign/locallib.php @@ -4564,18 +4564,19 @@ class assign { $gradingoptionsform->set_data($gradingoptionsdata); $actionformtext = $this->get_renderer()->render($gradingactions); + + $currenturl = new moodle_url('/mod/assign/view.php', ['id' => $this->get_course_module()->id, 'action' => 'grading']); + $header = new assign_header($this->get_instance(), $this->get_context(), false, $this->get_course_module()->id, get_string('grading', 'assign'), - $actionformtext); + $actionformtext, + '', + $currenturl); $o .= $this->get_renderer()->render($header); - $currenturl = $CFG->wwwroot . - '/mod/assign/view.php?id=' . - $this->get_course_module()->id . - '&action=grading'; $o .= groups_print_activity_menu($this->get_course_module(), $currenturl, true); diff --git a/mod/assign/renderable.php b/mod/assign/renderable.php index 936be1649af..295fc3c93f0 100644 --- a/mod/assign/renderable.php +++ b/mod/assign/renderable.php @@ -671,6 +671,8 @@ class assign_header implements renderable { public $preface = ''; /** @var string $postfix optional postfix (text to show after the intro) */ public $postfix = ''; + /** @var moodle_url $subpageurl link for the subpage */ + public $subpageurl = null; /** * Constructor @@ -688,7 +690,8 @@ class assign_header implements renderable { $coursemoduleid, $subpage='', $preface='', - $postfix='') { + $postfix='', + moodle_url $subpageurl = null) { $this->assign = $assign; $this->context = $context; $this->showintro = $showintro; @@ -696,6 +699,7 @@ class assign_header implements renderable { $this->subpage = $subpage; $this->preface = $preface; $this->postfix = $postfix; + $this->subpageurl = $subpageurl; } } diff --git a/mod/assign/renderer.php b/mod/assign/renderer.php index 19476ed5a6c..f32e978ff64 100644 --- a/mod/assign/renderer.php +++ b/mod/assign/renderer.php @@ -233,7 +233,7 @@ class mod_assign_renderer extends plugin_renderer_base { $o = ''; if ($header->subpage) { - $this->page->navbar->add($header->subpage); + $this->page->navbar->add($header->subpage, $header->subpageurl); $args = ['contextname' => $header->context->get_context_name(false, true), 'subpage' => $header->subpage]; $title = get_string('subpagetitle', 'assign', $args); } else { diff --git a/mod/assign/version.php b/mod/assign/version.php index 86058277cd8..08be89e031e 100644 --- a/mod/assign/version.php +++ b/mod/assign/version.php @@ -25,5 +25,5 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics). -$plugin->version = 2021052502; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2021052503; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2021052500; // Requires this Moodle version. diff --git a/mod/book/tests/behat/reorganize_chapters.feature b/mod/book/tests/behat/reorganize_chapters.feature index be0c0883248..4a4601b4921 100644 --- a/mod/book/tests/behat/reorganize_chapters.feature +++ b/mod/book/tests/behat/reorganize_chapters.feature @@ -51,7 +51,8 @@ Feature: In a book, chapters and subchapters can be rearranged Scenario: Moving chapters down rearranges them properly Given I click on "Move chapter down \"1. Originally first chapter\"" "link" - When I follow "Test book" + When I am on "Course 1" course homepage + And I follow "Test book" Then I should see "1. A great second chapter" And I should see "#2 chapter content" And I should see "1.1. Second chapter, subchapter 1" @@ -61,7 +62,8 @@ Feature: In a book, chapters and subchapters can be rearranged Scenario: Moving chapters up rearranges them properly Given I click on "Move chapter up \"3. There aren't 2 without 3\"" "link" - When I follow "Test book" + When I am on "Course 1" course homepage + And I follow "Test book" Then I should see "1. Originally first chapter" And I should see "#1 chapter content" And I should see "2. There aren't 2 without 3" @@ -71,30 +73,26 @@ Feature: In a book, chapters and subchapters can be rearranged Scenario: Moving subchapters down within chapter rearranges them properly Given I click on "Move chapter down \"2.1. Second chapter, subchapter 1\"" "link" - When I follow "Test book" - Then I should see "2.1. Second chapter, subchapter 2" - And I should see "2.2. Second chapter, subchapter 1" + When I should see "2.1. Second chapter, subchapter 2" + Then I should see "2.2. Second chapter, subchapter 1" Scenario: Moving subchapters down out of chapter rearranges them properly Given I click on "Move chapter down \"2.2. Second chapter, subchapter 2\"" "link" - When I follow "Test book" - Then I should see "3.1. Second chapter, subchapter 2" - And I click on "Move chapter down \"3. There aren't 2 without 3\"" "link" + When I should see "3.1. Second chapter, subchapter 2" + Then I click on "Move chapter down \"3. There aren't 2 without 3\"" "link" And I should not see "4. There aren't 2 without 3" And I should see "3. There aren't 2 without 3" And I should see "3.1. Second chapter, subchapter 2" Scenario: Moving subchapters up within chapter rearranges them properly Given I click on "Move chapter up \"2.2. Second chapter, subchapter 2\"" "link" - When I follow "Test book" - Then I should see "2.1. Second chapter, subchapter 2" - And I should see "2.2. Second chapter, subchapter 1" + When I should see "2.1. Second chapter, subchapter 2" + Then I should see "2.2. Second chapter, subchapter 1" Scenario: Moving subchapters up out of chapter rearranges them properly Given I click on "Move chapter up \"2.1. Second chapter, subchapter 1\"" "link" - When I follow "Test book" - Then I should see "1.1. Second chapter, subchapter 1" - And I click on "Move chapter up \"1.1. Second chapter, subchapter 1\"" "link" + When I should see "1.1. Second chapter, subchapter 1" + Then I click on "Move chapter up \"1.1. Second chapter, subchapter 1\"" "link" And I should not see "1.1. Second chapter, subchapter 1" And I should see "1. Second chapter, subchapter 1" And I should see "2. Originally first chapter" diff --git a/mod/choice/tests/behat/behat_mod_choice.php b/mod/choice/tests/behat/behat_mod_choice.php index 2ba915656b7..f002f87276f 100644 --- a/mod/choice/tests/behat/behat_mod_choice.php +++ b/mod/choice/tests/behat/behat_mod_choice.php @@ -46,7 +46,7 @@ class behat_mod_choice extends behat_base { * @return array */ public function I_choose_option_from_activity($option, $choiceactivity) { - $this->execute("behat_navigation::i_am_on_page_instance", [$this->escape($choiceactivity), 'choice activity']); + $this->execute('behat_navigation::i_am_on_page_instance', [$this->escape($choiceactivity), 'choice activity']); $this->execute('behat_forms::i_set_the_field_to', array( $this->escape($option), 1)); diff --git a/mod/feedback/tests/behat/templates.feature b/mod/feedback/tests/behat/templates.feature index 93bc0af7efe..dfa0f6d4b84 100644 --- a/mod/feedback/tests/behat/templates.feature +++ b/mod/feedback/tests/behat/templates.feature @@ -57,7 +57,6 @@ Feature: Saving, using and deleting feedback templates And I press "Use this template" And I should see "this is a multiple choice 1" And I press "Save changes" - And I follow "Edit questions" And I should see "this is a multiple choice 1" # Make sure this template is not available in another course And I am on the "Learning experience course 2" "feedback activity" page @@ -85,7 +84,6 @@ Feature: Saving, using and deleting feedback templates And I press "Use this template" And I set the field "Append new items" to "1" And I press "Save changes" - And I follow "Edit questions" Then "What is your favourite subject" "text" should appear before "this is a multiple choice 1" "text" # Import template replacing items And I follow "Templates" @@ -93,6 +91,7 @@ Feature: Saving, using and deleting feedback templates And I press "Use this template" And I set the field "Delete old items" to "1" And I press "Save changes" + And I am on the "Another feedback in course 1" "feedback activity" page And I follow "Edit questions" And I should not see "What is your favourite subject" And I should see "this is a multiple choice 1" @@ -110,7 +109,6 @@ Feature: Saving, using and deleting feedback templates And I press "Use this template" Then I should see "this is a multiple choice 1" And I press "Save changes" - And I follow "Edit questions" And I should see "this is a multiple choice 1" Scenario: Teacher can delete course templates but can not delete public templates diff --git a/mod/forum/search.php b/mod/forum/search.php index 8bd1bb3f7a3..f72e33b56ac 100644 --- a/mod/forum/search.php +++ b/mod/forum/search.php @@ -139,8 +139,10 @@ $strpage = get_string("page"); if (!$search || $showform) { - $PAGE->navbar->add($strforums, new moodle_url('/mod/forum/index.php', array('id'=>$course->id))); - $PAGE->navbar->add(get_string('advancedsearch', 'forum')); + $url = new moodle_url('/mod/forum/index.php', array('id' => $course->id)); + $PAGE->navbar->add($strforums, $url); + $url = new moodle_url('/mod/forum/search.php', array('id' => $course->id)); + $PAGE->navbar->add(get_string('advancedsearch', 'forum'), $url); $PAGE->set_title($strsearch); $PAGE->set_heading($course->fullname); diff --git a/mod/forum/subscribers.php b/mod/forum/subscribers.php index 55665799ae9..2e62c44df5a 100644 --- a/mod/forum/subscribers.php +++ b/mod/forum/subscribers.php @@ -97,7 +97,7 @@ if (data_submitted()) { } $strsubscribers = get_string("subscribers", "forum"); -$PAGE->navbar->add($strsubscribers); +$PAGE->navbar->add($strsubscribers, $url); $PAGE->set_title($strsubscribers); $PAGE->set_heading($COURSE->fullname); if (has_capability('mod/forum:managesubscriptions', $context) && \mod_forum\subscriptions::is_forcesubscribed($forum) === false) { diff --git a/mod/forum/tests/behat/behat_mod_forum.php b/mod/forum/tests/behat/behat_mod_forum.php index f6025b6ec62..f0244dea444 100644 --- a/mod/forum/tests/behat/behat_mod_forum.php +++ b/mod/forum/tests/behat/behat_mod_forum.php @@ -113,7 +113,7 @@ class behat_mod_forum extends behat_base { */ public function i_reply_post_from_forum_using_an_inpage_reply_with($postsubject, $forumname, TableNode $table) { // Navigate to forum. - $this->execute('behat_navigation::i_am_on_page_instance', [$this->escape($forumname), 'forum activity']); + $this->execute("behat_navigation::go_to_breadcrumb_location", $this->escape($forumname)); $this->execute('behat_general::click_link', $this->escape($postsubject)); $this->execute('behat_general::click_link', get_string('reply', 'forum')); @@ -132,7 +132,7 @@ class behat_mod_forum extends behat_base { */ public function i_navigate_to_post_in_forum($postsubject, $forumname) { // Navigate to forum discussion. - $this->execute('behat_navigation::i_am_on_page_instance', [$this->escape($forumname), 'forum activity']); + $this->execute("behat_navigation::go_to_breadcrumb_location", $this->escape($forumname)); $this->execute('behat_general::click_link', $this->escape($postsubject)); } @@ -493,7 +493,7 @@ class behat_mod_forum extends behat_base { */ protected function add_new_discussion_inline($forumname, TableNode $table, $buttonstr) { // Navigate to forum. - $this->execute('behat_navigation::i_am_on_page_instance', [$this->escape($forumname), 'forum activity']); + $this->execute("behat_navigation::go_to_breadcrumb_location", $this->escape($forumname)); $this->execute('behat_general::click_link', $buttonstr); $this->fill_new_discussion_form($table); } diff --git a/mod/forum/tests/behat/discussion_subscriptions.feature b/mod/forum/tests/behat/discussion_subscriptions.feature index 8bf50661194..fc8e8ba3a67 100644 --- a/mod/forum/tests/behat/discussion_subscriptions.feature +++ b/mod/forum/tests/behat/discussion_subscriptions.feature @@ -341,7 +341,6 @@ Feature: A user can control their own subscription preferences for a discussion And I follow "You are not subscribed to this discussion. Click to subscribe" And I should see "Student One will be notified of new posts in 'Test post subject one' of 'Test forum name'" And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row" - And I follow "Test forum name" And I navigate to "Subscribe to this forum" in current page administration And I should see "Student One will be notified of new posts in 'Test forum name'" And "Unsubscribe from this forum" "link" should exist in current page administration diff --git a/mod/forum/tests/behat/edit_post_teacher.feature b/mod/forum/tests/behat/edit_post_teacher.feature index 4875a8d648c..6a9ec280d7e 100644 --- a/mod/forum/tests/behat/edit_post_teacher.feature +++ b/mod/forum/tests/behat/edit_post_teacher.feature @@ -57,6 +57,7 @@ Feature: Teachers can edit or delete any forum post And I should see "Edited by Teacher 1 - original submission" Scenario: A student can't edit or delete another user's posts - When I follow "Teacher post subject" + When I follow "Test forum name" + And I follow "Teacher post subject" Then I should not see "Edit" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ')][contains(., 'Teacher post subject')]" "xpath_element" And I should not see "Delete" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ')][contains(., 'Teacher post subject')]" "xpath_element" diff --git a/mod/forum/tests/behat/private_replies.feature b/mod/forum/tests/behat/private_replies.feature index eb2d8eda5ab..364dc3d7fd6 100644 --- a/mod/forum/tests/behat/private_replies.feature +++ b/mod/forum/tests/behat/private_replies.feature @@ -36,7 +36,8 @@ Feature: Forum posts can be replied to in private | Reply privately | 1 | Scenario: As a teacher I can see my own response - Given I follow "Answers to the homework" + Given I follow "Study discussions" + And I follow "Answers to the homework" Then I should see "How about you and I have a meeting after class about plagiarism?" Scenario: As a fellow teacher I can see the other teacher's response diff --git a/mod/glossary/tests/behat/behat_mod_glossary.php b/mod/glossary/tests/behat/behat_mod_glossary.php index bf84100a699..9ff7c2afb12 100644 --- a/mod/glossary/tests/behat/behat_mod_glossary.php +++ b/mod/glossary/tests/behat/behat_mod_glossary.php @@ -61,7 +61,7 @@ class behat_mod_glossary extends behat_base { */ public function i_add_a_glossary_entries_category_named($categoryname) { - $this->execute("behat_general::click_link", get_string('categoryview', 'mod_glossary')); + $this->execute("behat_navigation::go_to_breadcrumb_location", get_string('categoryview', 'mod_glossary')); $this->execute("behat_forms::press_button", get_string('editcategories', 'mod_glossary')); diff --git a/mod/glossary/tests/behat/entries_require_approval.feature b/mod/glossary/tests/behat/entries_require_approval.feature index cc04cb760a2..f45b3a7e45a 100644 --- a/mod/glossary/tests/behat/entries_require_approval.feature +++ b/mod/glossary/tests/behat/entries_require_approval.feature @@ -43,7 +43,7 @@ Feature: A teacher can choose whether glossary entries require approval And I follow "Waiting approval" Then I should see "(this entry is currently hidden)" And I follow "Approve" - And I click on "Test glossary name" "link" in the "page-header" "region" + And I am on the "Test glossary name" "glossary activity" page Then I should see "Concept definition" And I log out # Check that the entry can now be viewed by students. diff --git a/mod/lesson/tests/behat/lesson_student_resume.feature b/mod/lesson/tests/behat/lesson_student_resume.feature index e3d8c4185e0..8e207dfedcb 100644 --- a/mod/lesson/tests/behat/lesson_student_resume.feature +++ b/mod/lesson/tests/behat/lesson_student_resume.feature @@ -89,6 +89,7 @@ Feature: In a lesson activity a student should And I should see "Second page contents" And I press "Next page" And I should see "Third page contents" + And I am on "Course 1" course homepage And I follow "Test lesson name" And I should see "You have seen more than one page of this lesson already." And I should see "Do you want to start at the last page you saw?" @@ -98,6 +99,7 @@ Feature: In a lesson activity a student should And I wait "1" seconds And I press "Next page" And I should see "Paper is made from trees." + And I am on "Course 1" course homepage And I follow "Test lesson name" And I should see "You have seen more than one page of this lesson already." And I should see "Do you want to start at the last page you saw?" @@ -108,6 +110,7 @@ Feature: In a lesson activity a student should And I press "Submit" And I press "Continue" And I should see "Kermit is a frog" + And I am on "Course 1" course homepage And I follow "Test lesson name" And I should see "You have seen more than one page of this lesson already." And I should see "Do you want to start at the last page you saw?" @@ -165,6 +168,7 @@ Feature: In a lesson activity a student should And I wait "1" seconds And I press "Next page" And I should see "Third page contents" + And I am on "Course 1" course homepage And I follow "Test lesson name" Then I should see "You have seen more than one page of this lesson already." And I should see "Do you want to start at the last page you saw?" @@ -286,6 +290,7 @@ Feature: In a lesson activity a student should And I press "Submit" And I press "Continue" And I should see "2+2=4" + And I am on "Course 1" course homepage And I follow "Test lesson name" And I should see "You have seen more than one page of this lesson already." Then I should see "Do you want to start at the last page you saw?" @@ -298,6 +303,7 @@ Feature: In a lesson activity a student should And I press "Submit" And I press "Continue" And I should see "Second content page" + And I am on "Course 1" course homepage And I follow "Test lesson name" And I should see "You have seen more than one page of this lesson already." And I should see "Do you want to start at the last page you saw?" @@ -399,6 +405,7 @@ Feature: In a lesson activity a student should And I press "Submit" And I press "Continue" And I should see "2+2=4" + And I am on "Course 1" course homepage And I follow "Test lesson name" Then I should see "You have seen more than one page of this lesson already." And I should see "Do you want to start at the last page you saw?" diff --git a/mod/lti/tests/behat/backup_restore.feature b/mod/lti/tests/behat/backup_restore.feature index 7ae20306771..9da97b66c9d 100644 --- a/mod/lti/tests/behat/backup_restore.feature +++ b/mod/lti/tests/behat/backup_restore.feature @@ -27,13 +27,12 @@ Feature: Restoring Moodle 2 backup restores LTI configuration And I navigate to "Plugins > Activity modules > External tool > Manage tools" in site administration And "This tool has not yet been used" "text" should exist in the "//div[contains(@id,'tool-card-container') and contains(., 'My site tool')]" "xpath_element" And I am on site homepage - And I follow "Course 1" + And I am on "Course 1" course homepage And I turn editing mode on And I add a "External tool" to section "1" and I fill the form with: | Activity name | My LTI module | | Preconfigured tool | My site tool | | Launch container | Embed | - And I follow "Course 1" And I should see "My LTI module" And I backup "Course 1" course using this options: | Confirmation | Filename | test_backup.mbz | diff --git a/mod/quiz/tests/behat/quiz_activity_completion.feature b/mod/quiz/tests/behat/quiz_activity_completion.feature index 6879206e7af..2f6c097b826 100644 --- a/mod/quiz/tests/behat/quiz_activity_completion.feature +++ b/mod/quiz/tests/behat/quiz_activity_completion.feature @@ -54,6 +54,7 @@ Feature: View activity completion in the quiz activity And user "student1" has attempted "Test quiz name" with responses: | slot | response | | 1 | False | + And I am on "Course 1" course homepage And I follow "Test quiz name" And the "View" completion condition of "Test quiz name" is displayed as "done" And the "Make attempts: 1" completion condition of "Test quiz name" is displayed as "done" diff --git a/mod/workshop/allocation.php b/mod/workshop/allocation.php index 5901bcff703..ebc5abaff02 100644 --- a/mod/workshop/allocation.php +++ b/mod/workshop/allocation.php @@ -46,7 +46,7 @@ require_capability('mod/workshop:allocate', $context); $PAGE->set_title($workshop->name); $PAGE->set_heading($course->fullname); -$PAGE->navbar->add(get_string('allocation', 'workshop')); +$PAGE->navbar->add(get_string('allocation', 'workshop'), $workshop->allocation_url($method)); $allocator = $workshop->allocator_instance($method); $initresult = $allocator->init(); diff --git a/mod/workshop/allocation/manual/tests/behat/behat_workshopallocation_manual.php b/mod/workshop/allocation/manual/tests/behat/behat_workshopallocation_manual.php index 4ad7fd94bf1..bc33974f6b4 100644 --- a/mod/workshop/allocation/manual/tests/behat/behat_workshopallocation_manual.php +++ b/mod/workshop/allocation/manual/tests/behat/behat_workshopallocation_manual.php @@ -87,7 +87,7 @@ class behat_workshopallocation_manual extends behat_base { * @param TableNode $table should have one column with title 'Reviewer' and another with title 'Participant' (or 'Reviewee') */ public function i_allocate_submissions_in_workshop_as($workshopname, TableNode $table) { - $this->execute("behat_general::i_click_on_in_the", [$this->escape($workshopname), 'link', 'page', 'region']); + $this->execute("behat_navigation::go_to_breadcrumb_location", $workshopname); $this->execute('behat_navigation::i_navigate_to_in_current_page_administration', get_string('allocate', 'workshop')); $rows = $table->getRows(); $reviewer = $participant = null; diff --git a/mod/workshop/tests/behat/behat_mod_workshop.php b/mod/workshop/tests/behat/behat_mod_workshop.php index 734bb8c49f3..ca2f6e4f88f 100644 --- a/mod/workshop/tests/behat/behat_mod_workshop.php +++ b/mod/workshop/tests/behat/behat_mod_workshop.php @@ -52,7 +52,7 @@ class behat_mod_workshop extends behat_base { $xpath = "//*[@class='userplan']/descendant::div[./span[contains(.,$phaseliteral)]]"; $continue = $this->escape(get_string('continue')); - $this->execute("behat_general::i_click_on_in_the", [$this->escape($workshopname), 'link', 'page', 'region']); + $this->execute("behat_navigation::go_to_breadcrumb_location", $workshopname); $this->execute('behat_general::i_click_on_in_the', array('a.action-icon', "css_element", $this->escape($xpath), "xpath_element") @@ -73,7 +73,7 @@ class behat_mod_workshop extends behat_base { $savechanges = $this->escape(get_string('savechanges')); $xpath = "//div[contains(concat(' ', normalize-space(@class), ' '), ' ownsubmission ')]/descendant::*[@type='submit']"; - $this->execute("behat_navigation::i_am_on_page_instance", [$this->escape($workshopname), 'workshop activity']); + $this->execute("behat_navigation::i_am_on_page_instance", [$workshopname, 'workshop activity']); $this->execute("behat_general::i_click_on", array($xpath, "xpath_element")); @@ -116,7 +116,7 @@ class behat_mod_workshop extends behat_base { $assess = $this->escape(get_string('assess', 'workshop')); $saveandclose = $this->escape(get_string('saveandclose', 'workshop')); - $this->execute("behat_navigation::i_am_on_page_instance", [$workshopname, 'workshop activity']); + $this->execute("behat_navigation::go_to_breadcrumb_location", $workshopname); $this->execute('behat_general::i_click_on_in_the', array($assess, "button", $xpath, "xpath_element") diff --git a/pix/i/arrow-left.svg b/pix/i/arrow-left.svg new file mode 100644 index 00000000000..a5058fc73d3 --- /dev/null +++ b/pix/i/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pix/i/arrow-right.svg b/pix/i/arrow-right.svg new file mode 100644 index 00000000000..939b57c5b54 --- /dev/null +++ b/pix/i/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/report/outline/tests/behat/user.feature b/report/outline/tests/behat/user.feature index 41de4ce28a1..dbeac31bf9f 100644 --- a/report/outline/tests/behat/user.feature +++ b/report/outline/tests/behat/user.feature @@ -35,14 +35,14 @@ Feature: View the user page for the outline report And I am on "Course 1" course homepage # We want to view this multiple times, to make sure the count is working. And I follow "Folder name" - And I follow "Folder name" - And I follow "Folder name" - And I follow "Folder name" - And I am on "Course 1" course homepage + And I reload the page + And I reload the page + And I reload the page # We want to view this multiple times, to make sure the count is working. + And I am on "Course 1" course homepage And I follow "URL name" - And I follow "URL name" - And I follow "URL name" + And I reload the page + And I reload the page And I follow "Profile" in the user menu And I click on "Course 1" "link" in the "region-main" "region" When I follow "Outline report" @@ -63,14 +63,14 @@ Feature: View the user page for the outline report And I am on "Course 1" course homepage # We want to view this multiple times, to make sure the count is working. And I follow "Folder name" - And I follow "Folder name" - And I follow "Folder name" - And I follow "Folder name" + And I reload the page + And I reload the page + And I reload the page And I am on "Course 1" course homepage # We want to view this multiple times, to make sure the count is working. And I follow "URL name" - And I follow "URL name" - And I follow "URL name" + And I reload the page + And I reload the page And I follow "Profile" in the user menu And I click on "Course 1" "link" in the "region-main" "region" When I follow "Outline report" @@ -93,14 +93,14 @@ Feature: View the user page for the outline report And I am on "Course 1" course homepage # We want to view this multiple times, to make sure the count is working. And I follow "Folder name" - And I follow "Folder name" - And I follow "Folder name" - And I follow "Folder name" + And I reload the page + And I reload the page + And I reload the page And I am on "Course 1" course homepage # We want to view this multiple times, to make sure the count is working. And I follow "URL name" - And I follow "URL name" - And I follow "URL name" + And I reload the page + And I reload the page And I follow "Profile" in the user menu And I click on "Course 1" "link" in the "region-main" "region" When I follow "Outline report" diff --git a/tag/index.php b/tag/index.php index b0373168bd6..a5857b19b00 100644 --- a/tag/index.php +++ b/tag/index.php @@ -78,7 +78,8 @@ if ($ctx && ($context = context::instance_by_id($ctx, IGNORE_MISSING)) && $conte $tagcollid = $tag->tagcollid; -$PAGE->set_url($tag->get_view_url($exclusivemode, $fromctx, $ctx, $rec)); +$pageurl = $tag->get_view_url($exclusivemode, $fromctx, $ctx, $rec); +$PAGE->set_url($pageurl); $PAGE->set_subpage($tag->id); $tagnode = $PAGE->navigation->find('tags', null); $tagnode->make_active(); @@ -97,7 +98,7 @@ if ($PAGE->user_allowed_editing()) { $buttons .= $OUTPUT->edit_button(clone($PAGE->url)); } -$PAGE->navbar->add($tagname); +$PAGE->navbar->add($tagname, $pageurl); $PAGE->set_title(get_string('tag', 'tag') .' - '. $tag->get_display_name()); $PAGE->set_heading($COURSE->fullname); $PAGE->set_button($buttons); diff --git a/tag/tests/behat/collections.feature b/tag/tests/behat/collections.feature index 50bdd8bf9cb..a1deb030b3e 100644 --- a/tag/tests/behat/collections.feature +++ b/tag/tests/behat/collections.feature @@ -86,7 +86,7 @@ Feature: Managers can create and manage tag collections And I should see "Tag3" And I should not see "Tag1" And I should not see "Tag2" - And I follow "Manage tags" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" # Tag "Swimming" was not standard and was moved completely. And I should not see "Swimming" diff --git a/tag/tests/behat/delete_tag.feature b/tag/tests/behat/delete_tag.feature index 18eb048f16a..b29b1469ac8 100644 --- a/tag/tests/behat/delete_tag.feature +++ b/tag/tests/behat/delete_tag.feature @@ -61,6 +61,7 @@ Feature: Manager is able to delete tags And I press "Yes" And I should see "Tag(s) deleted" And I should not see "Dog" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And I should not see "Dog" And I follow "Cat" @@ -94,6 +95,7 @@ Feature: Manager is able to delete tags And I should see "Tag(s) deleted" And I should not see "Dog" And I should not see "Neverusedtag" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And I should not see "Dog" And I should not see "Neverusedtag" diff --git a/tag/tests/behat/edit_tag.feature b/tag/tests/behat/edit_tag.feature index c3bc35e51ec..21fd143a93e 100644 --- a/tag/tests/behat/edit_tag.feature +++ b/tag/tests/behat/edit_tag.feature @@ -123,7 +123,6 @@ Feature: Users can edit tags to add description or rename | Related tags | Dog, Turtle,Fish | | Standard | 0 | And I press "Update" - Then "Default collection" "link" should exist in the ".breadcrumb" "css_element" And I follow "Kitten" And "Description of tag 1" "text" should exist in the ".tag-description" "css_element" And I should see "Related tags:" in the ".tag_list" "css_element" @@ -143,12 +142,10 @@ Feature: Users can edit tags to add description or rename And I set the following fields to these values: | Tag name | Kitten | And I press "Update" - Then "Default collection" "text" should exist in the ".breadcrumb" "css_element" And I click on "Edit this tag" "link" in the "Kitten" "table_row" And I set the following fields to these values: | Tag name | KITTEN | And I press "Update" - And "Default collection" "text" should exist in the ".breadcrumb" "css_element" And I should see "KITTEN" And I should not see "Kitten" @@ -161,6 +158,7 @@ Feature: Users can edit tags to add description or rename And I set the field "Edit tag name" in the "Cat" "table_row" to "Kitten" Then I should not see "Cat" And "New name for tag" "field" should not exist + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And I should see "Kitten" And I should not see "Cat" @@ -172,6 +170,7 @@ Feature: Users can edit tags to add description or rename And I should see "Turtle" And I should see "Dog" And I should not see "DOG" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And I should see "Turtle" And I should see "Dog" @@ -183,6 +182,7 @@ Feature: Users can edit tags to add description or rename And "New name for tag" "field" should not exist And I should see "Turtle" And I should not see "Penguin" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And I should see "Turtle" And I should not see "Penguin" diff --git a/tag/tests/behat/flag_tags.feature b/tag/tests/behat/flag_tags.feature index 7eda63891eb..b9fade17cf9 100644 --- a/tag/tests/behat/flag_tags.feature +++ b/tag/tests/behat/flag_tags.feature @@ -78,6 +78,7 @@ Feature: Users can flag tags and manager can reset flags And "(1)" "text" should exist in the "//tr[contains(.,'Nicetag')]//td[contains(@class,'col-flag')]" "xpath_element" And "(" "text" should not exist in the "//tr[contains(.,'Badtag')]//td[contains(@class,'col-flag')]" "xpath_element" And "(" "text" should not exist in the "//tr[contains(.,'Neverusedtag')]//td[contains(@class,'col-flag')]" "xpath_element" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And "Nicetag" "link" should appear before "Sweartag" "link" And "Sweartag" "link" should appear before "Badtag" "link" diff --git a/tag/tests/behat/standard_tags.feature b/tag/tests/behat/standard_tags.feature index ef06919ff1a..dbc7e98bb0f 100644 --- a/tag/tests/behat/standard_tags.feature +++ b/tag/tests/behat/standard_tags.feature @@ -63,6 +63,7 @@ Feature: Manager can add standard tags and change the tag type of existing tags And "Remove from standard tags" "link" should exist in the "Tag1" "table_row" And "Make standard" "link" should exist in the "Tag2" "table_row" And "Make standard" "link" should exist in the "Tag3" "table_row" + And I navigate to "Appearance > Manage tags" in site administration And I follow "Default collection" And "Make standard" "link" should exist in the "Tag0" "table_row" And "Remove from standard tags" "link" should exist in the "Tag1" "table_row" diff --git a/theme/boost/classes/boostnavbar.php b/theme/boost/classes/boostnavbar.php new file mode 100644 index 00000000000..30dc2f95262 --- /dev/null +++ b/theme/boost/classes/boostnavbar.php @@ -0,0 +1,180 @@ +. + +namespace theme_boost; + +/** + * Creates a navbar for boost that allows easy control of the navbar items. + * + * @package theme_boost + * @copyright 2021 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class boostnavbar implements \renderable { + + /** @var array The individual items of the navbar. */ + protected $items = []; + + /** + * Takes a navbar object and picks the necessary parts for display. + * + * @param \navbar $navbar The navigation bar. + */ + public function __construct(\navbar $navbar) { + foreach ($navbar->get_items() as $item) { + $this->items[] = $item; + } + $this->prepare_nodes_for_boost(); + } + + /** + * Prepares the navigation nodes for use with boost. + */ + protected function prepare_nodes_for_boost(): void { + // Don't display the navbar if we are in the site navigation. + if (!is_null($this->get_item('root'))) { + $this->clear_items(); + return; + } + + $this->remove('myhome'); // Dashboard. + $this->remove('home'); + + // Set the designated one path for courses. + $mycoursesnode = $this->get_item('mycourses'); + if (!is_null($mycoursesnode)) { + // TODO: Once MDL-70801 lands point this to the new page. + $url = new \moodle_url('/course/'); + $mycoursesnode->action = $url; + $mycoursesnode->text = get_string('courses'); + } + + $this->remove_no_link_items(); + + // Don't display the navbar if there is only one item. Apparently this is bad UX design. + if ($this->item_count() <= 1) { + $this->clear_items(); + return; + } + + // Make sure that the last item is not a link. Not sure if this is always a good idea. + $this->remove_last_item_action(); + } + + /** + * Get all the boostnavbaritem elements. + * + * @return boostnavbaritem[] Boost navbar items. + */ + public function get_items(): array { + return $this->items; + } + + /** + * Removes all navigation items out of this boost navbar + */ + protected function clear_items(): void { + $this->items = []; + } + + /** + * Retrieve a single navbar item. + * + * @param string|int $key The identifier of the navbar item to return. + * @return \breadcrumb_navigation_node|null The navbar item. + */ + protected function get_item($key): ?\breadcrumb_navigation_node { + foreach ($this->items as $item) { + if ($key === $item->key) { + return $item; + } + } + return null; + } + + /** + * Counts all of the navbar items. + * + * @return int How many navbar items there are. + */ + protected function item_count(): int { + return count($this->items); + } + + /** + * Remove a boostnavbaritem from the boost navbar. + * + * @param string|int $itemkey An identifier for the boostnavbaritem + */ + protected function remove($itemkey): void { + + $itemfound = false; + foreach ($this->items as $key => $item) { + if ($item->key === $itemkey) { + unset($this->items[$key]); + $itemfound = true; + break; + } + } + if (!$itemfound) { + return; + } + + $itemcount = $this->item_count(); + if ($itemcount <= 0) { + return; + } + + $this->items = array_values($this->items); + // Set the last item to last item if it is not. + $lastitem = $this->items[$itemcount - 1]; + if (!$lastitem->is_last()) { + $lastitem->set_last(true); + } + } + + /** + * Removes the action from the last item of the boostnavbaritem. + */ + protected function remove_last_item_action(): void { + $item = end($this->items); + $item->action = null; + reset($this->items); + } + + /** + * Returns the second last navbar item. This is for use in the mobile view where we are showing just the second + * last item in the breadcrumb navbar. + * + * @return breakcrumb_navigation_node|null The second last navigation node. + */ + public function get_penultimate_item(): ?\breadcrumb_navigation_node { + $number = $this->item_count() - 2; + return ($number >= 0) ? $this->items[$number] : null; + } + + /** + * Remove items that are categories or have no actions associated with them. + */ + protected function remove_no_link_items(): void { + foreach ($this->items as $key => $value) { + if (!$value->has_action() || $value->type == \navigation_node::TYPE_SECTION) { + unset($this->items[$key]); + } + } + $this->items = array_values($this->items); + } +} diff --git a/theme/boost/classes/output/context_header.php b/theme/boost/classes/output/context_header.php new file mode 100644 index 00000000000..8e2a25ed6f5 --- /dev/null +++ b/theme/boost/classes/output/context_header.php @@ -0,0 +1,239 @@ +. + +namespace theme_boost\output; + +use moodle_url; +use get_string; + +defined('MOODLE_INTERNAL') || die; + +/** + * The context header for the boost theme. + * + * @package theme_boost + * @copyright 2021 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class context_header implements \renderable, \templatable { + + /** \moodle_page This current page information. */ + protected $page; + + /** array Header data. */ + protected $headerinfo; + + /** int What level the header should be i.e. 1 =

. */ + protected $headinglevel; + + /** + * Constructs a new instance. + * + * @param \moodle_page $page This current page information. + * @param array $headerinfo Header data. + * @param int $headinglevel What level the header should be i.e. 1 =

. + */ + public function __construct(\moodle_page $page, array $headerinfo = null, int $headinglevel = 1) { + $this->page = $page; + $this->headerinfo = $headerinfo; + $this->headinglevel = $headinglevel; + } + + /** + * Adds an array element for a formatted image. + * + * @param array $buttons Buttons to format. + * @return array The formatted buttons. + */ + protected function format_button_images(array $buttons): array { + foreach ($buttons as $key => $button) { + // If no image is provided then just use the title. + if (!isset($button['image'])) { + $buttons[$key]['formattedimage'] = $button['title']; + } else { + // Check to see if this is an internal Moodle icon. + $internalimage = $this->page->theme->resolve_image_location('t/' . $button['image'], 'moodle'); + if ($internalimage) { + $buttons[$key]['formattedimage'] = 't/' . $button['image']; + } else { + // Treat as an external image. + $buttons[$key]['formattedimage'] = $button['image']; + } + } + + if (isset($button['linkattributes']['class'])) { + $class = $button['linkattributes']['class'] . ' btn'; + } else { + $class = 'btn'; + } + // Add the bootstrap 'btn' class for formatting. + $buttons[$key]['linkattributes'] = array_merge($button['linkattributes'], ['class' => $class]); + $temp = ''; + foreach ($buttons[$key]['linkattributes'] as $index => $value) { + $temp .= "{$index}=\"{$value}\" "; + } + $buttons[$key]['linkattributes'] = $temp; + } + return $buttons; + } + + /** + * Gets the logo data. + * + * @param \renderer_base $output Renderer output data. + * @return array Data to display a logo. + */ + protected function get_logo_data(\renderer_base $output): array { + global $SITE; + if (!$output->should_display_main_logo($this->headinglevel)) { + return []; + } + + $sitename = format_string($SITE->fullname, true, ['context' => \context_course::instance(SITEID)]); + if (!isset($heading)) { + $heading = $output->heading($this->page->heading, $this->headinglevel, 'sr-only'); + } else { + $heading = $output->heading($heading, $this->headinglevel, 'sr-only'); + } + + return [ + 'heading' => $heading, + 'image' => [ + 'src' => $output->get_logo_url(null, 150), + 'alt' => get_string('logoof', '', $sitename) + ] + ]; + } + + /** + * Organises all of the relevant data to display the context header. + * + * @param \renderer_base $output Renderer output data. + * @return array Data to display the context header. + */ + public function export_for_template(\renderer_base $output): array { + global $DB, $USER, $CFG; + + $page = $this->page; + $context = $page->context; + $heading = null; + $imagedata = null; + $userbuttons = null; + + // Make sure to use the heading if it has been set. + if (isset($this->headerinfo['heading'])) { + $heading = $this->headerinfo['heading']; + } else { + $heading = $page->heading; + } + + // The user context currently has images and buttons. Other contexts may follow. + if (isset($this->headerinfo['user']) || $context->contextlevel == CONTEXT_USER) { + if (isset($this->headerinfo['user'])) { + $user = $this->headerinfo['user']; + } else { + // Look up the user information if it is not supplied. + $user = $DB->get_record('user', ['id' => $context->instanceid]); + } + + // If the user context is set, then use that for capability checks. + if (isset($this->headerinfo['usercontext'])) { + $context = $this->headerinfo['usercontext']; + } + + // Only provide user information if the user is the current user, or a user which the current user can view. + // When checking user_can_view_profile(), either: + // If the page context is course, check the course context (from the page object) or; + // If page context is NOT course, then check across all courses. + $course = ($context->contextlevel == CONTEXT_COURSE) ? $page->course : null; + + if (user_can_view_profile($user, $course)) { + // Use the user's full name if the heading isn't set. + if (empty($heading)) { + $heading = fullname($user); + } + + $imagedata = $output->user_picture($user, ['size' => 100]); + + // Check to see if we should be displaying a message button. + if (!empty($CFG->messaging) && has_capability('moodle/site:sendmessage', $context)) { + $userbuttons[] = [ + 'buttontype' => 'message', + 'title' => get_string('message', 'message'), + 'url' => new moodle_url('/message/index.php', ['id' => $user->id]), + 'image' => 'message', + 'linkattributes' => \core_message\helper::messageuser_link_params($user->id), + 'page' => (isset($page)) + ]; + \core_message\helper::togglecontact_requirejs(); + + if ($USER->id != $user->id) { + $iscontact = \core_message\api::is_contact($USER->id, $user->id); + $contacttitle = $iscontact ? 'removefromyourcontacts' : 'addtoyourcontacts'; + $contacturlaction = $iscontact ? 'removecontact' : 'addcontact'; + $contactimage = $iscontact ? 'removecontact' : 'addcontact'; + $userbuttons[] = [ + 'buttontype' => 'togglecontact', + 'title' => get_string($contacttitle, 'message'), + 'url' => new moodle_url('/message/index.php', [ + 'user1' => $USER->id, + 'user2' => $user->id, + $contacturlaction => $user->id, + 'sesskey' => sesskey() + ]), + 'image' => $contactimage, + 'linkattributes' => \core_message\helper::togglecontact_link_params($user, $iscontact), + 'page' => (isset($page)) + ]; + \core_message\helper::messageuser_requirejs(); + } + + $page->requires->string_for_js('changesmadereallygoaway', 'moodle'); + $userbuttons = $this->format_button_images($userbuttons); + } + } else { + $heading = null; + } + } + + $prefix = null; + if ($context->contextlevel == CONTEXT_MODULE) { + $heading = $page->cm->name; + $imagedata = $output->pix_icon('icon', '', $page->activityname); + $prefix = get_string('modulename', $page->activityname); + } + + $logo = $this->get_logo_data($output); + if (empty($logo)) { + $logo = ''; + $headingdisplay = $this->headinglevel + 1; + $headingdisplay = 'h' . $headingdisplay; + if (!isset($heading)) { + $heading = $output->heading($page->heading, $this->headinglevel, $headingdisplay); + } else { + $heading = $output->heading($heading, $this->headinglevel, $headingdisplay); + } + } + + return [ + 'logo' => $logo, + 'heading' => $heading, + 'imagedata' => $imagedata, + 'userbuttons' => $userbuttons, + 'prefix' => $prefix + ]; + } +} diff --git a/theme/boost/classes/output/core_renderer.php b/theme/boost/classes/output/core_renderer.php index 05c2ef8c68e..e5ad7952896 100644 --- a/theme/boost/classes/output/core_renderer.php +++ b/theme/boost/classes/output/core_renderer.php @@ -17,6 +17,8 @@ namespace theme_boost\output; use moodle_url; +use html_writer; +use get_string; defined('MOODLE_INTERNAL') || die; @@ -42,4 +44,204 @@ class core_renderer extends \core_renderer { return $this->render_single_button($button); } + /** + * Renders the "breadcrumb" for all pages in boost. + * + * @return string the HTML for the navbar. + */ + public function navbar(): string { + $newnav = new \theme_boost\boostnavbar($this->page->navbar); + return $this->render_from_template('core/navbar', $newnav); + } + + /** + * Renders the context header for the page. + * + * @param array $headerinfo Heading information. + * @param int $headinglevel What 'h' level to make the heading. + * @return string A rendered context header. + */ + public function context_header($headerinfo = null, $headinglevel = 1): string { + global $DB, $USER, $CFG, $SITE; + require_once($CFG->dirroot . '/user/lib.php'); + $context = $this->page->context; + $heading = null; + $imagedata = null; + $subheader = null; + $userbuttons = null; + + // Make sure to use the heading if it has been set. + if (isset($headerinfo['heading'])) { + $heading = $headerinfo['heading']; + } else { + $heading = $this->page->heading; + } + + // The user context currently has images and buttons. Other contexts may follow. + if (isset($headerinfo['user']) || $context->contextlevel == CONTEXT_USER) { + if (isset($headerinfo['user'])) { + $user = $headerinfo['user']; + } else { + // Look up the user information if it is not supplied. + $user = $DB->get_record('user', array('id' => $context->instanceid)); + } + + // If the user context is set, then use that for capability checks. + if (isset($headerinfo['usercontext'])) { + $context = $headerinfo['usercontext']; + } + + // Only provide user information if the user is the current user, or a user which the current user can view. + // When checking user_can_view_profile(), either: + // If the page context is course, check the course context (from the page object) or; + // If page context is NOT course, then check across all courses. + $course = ($this->page->context->contextlevel == CONTEXT_COURSE) ? $this->page->course : null; + + if (user_can_view_profile($user, $course)) { + // Use the user's full name if the heading isn't set. + if (empty($heading)) { + $heading = fullname($user); + } + + $imagedata = $this->user_picture($user, array('size' => 100)); + + // Check to see if we should be displaying a message button. + if (!empty($CFG->messaging) && has_capability('moodle/site:sendmessage', $context)) { + $userbuttons = array( + 'messages' => array( + 'buttontype' => 'message', + 'title' => get_string('message', 'message'), + 'url' => new moodle_url('/message/index.php', array('id' => $user->id)), + 'image' => 'message', + 'linkattributes' => \core_message\helper::messageuser_link_params($user->id), + 'page' => $this->page + ) + ); + + if ($USER->id != $user->id) { + $iscontact = \core_message\api::is_contact($USER->id, $user->id); + $contacttitle = $iscontact ? 'removefromyourcontacts' : 'addtoyourcontacts'; + $contacturlaction = $iscontact ? 'removecontact' : 'addcontact'; + $contactimage = $iscontact ? 'removecontact' : 'addcontact'; + $userbuttons['togglecontact'] = array( + 'buttontype' => 'togglecontact', + 'title' => get_string($contacttitle, 'message'), + 'url' => new moodle_url('/message/index.php', array( + 'user1' => $USER->id, + 'user2' => $user->id, + $contacturlaction => $user->id, + 'sesskey' => sesskey()) + ), + 'image' => $contactimage, + 'linkattributes' => \core_message\helper::togglecontact_link_params($user, $iscontact), + 'page' => $this->page + ); + } + + $this->page->requires->string_for_js('changesmadereallygoaway', 'moodle'); + } + } else { + $heading = null; + } + } + + $prefix = null; + if ($context->contextlevel == CONTEXT_MODULE) { + $heading = $this->page->cm->name; + $imagedata = $this->pix_icon('icon', '', $this->page->activityname); + $prefix = get_string('modulename', $this->page->activityname); + } + + if ($this->should_display_main_logo($headinglevel)) { + $sitename = format_string($SITE->fullname, true, ['context' => \context_course::instance(SITEID)]); + // Logo. + $html = html_writer::div( + html_writer::empty_tag('img', [ + 'src' => $this->get_logo_url(null, 150), + 'alt' => get_string('logoof', '', $sitename), + 'class' => 'img-fluid' + ]), + 'logo' + ); + // Heading. + if (!isset($heading)) { + $html .= $this->heading($this->page->heading, $headinglevel, 'sr-only'); + } else { + $html .= $this->heading($heading, $headinglevel, 'sr-only'); + } + return $html; + } + + $contextheader = new \context_header($heading, $headinglevel, $imagedata, $userbuttons, $prefix); + return $this->render_context_header($contextheader); + } + + /** + * Renders the header bar. + * + * @param context_header $contextheader Header bar object. + * @return string HTML for the header bar. + */ + protected function render_context_header(\context_header $contextheader) { + + // Generate the heading first and before everything else as we might have to do an early return. + if (!isset($contextheader->heading)) { + $heading = $this->heading($this->page->heading, $contextheader->headinglevel, 'h2'); + } else { + $heading = $this->heading($contextheader->heading, $contextheader->headinglevel, 'h2'); + } + + $showheader = empty($this->page->layout_options['nocontextheader']); + if (!$showheader) { + // Return the heading wrapped in an sr-only element so it is only visible to screen-readers. + return html_writer::div($heading, 'sr-only'); + } + + // All the html stuff goes here. + $html = html_writer::start_div('page-context-header'); + + // Image data. + if (isset($contextheader->imagedata)) { + // Header specific image. + $html .= html_writer::div($contextheader->imagedata, 'page-header-image icon-size-6'); + } + + // Headings. + if (isset($contextheader->prefix)) { + $prefix = html_writer::div($contextheader->prefix, 'text-muted text-uppercase'); + $heading = $prefix . $heading; + } + $html .= html_writer::tag('div', $heading, array('class' => 'page-header-headings')); + + // Buttons. + if (isset($contextheader->additionalbuttons)) { + $html .= html_writer::start_div('btn-group header-button-group'); + foreach ($contextheader->additionalbuttons as $button) { + if (!isset($button->page)) { + // Include js for messaging. + if ($button['buttontype'] === 'togglecontact') { + \core_message\helper::togglecontact_requirejs(); + } + if ($button['buttontype'] === 'message') { + \core_message\helper::messageuser_requirejs(); + } + $image = $this->pix_icon($button['formattedimage'], $button['title'], 'moodle', array( + 'class' => 'iconsmall', + 'role' => 'presentation' + )); + $image .= html_writer::span($button['title'], 'header-button-title'); + } else { + $image = html_writer::empty_tag('img', array( + 'src' => $button['formattedimage'], + 'role' => 'presentation' + )); + } + $html .= html_writer::link($button['url'], html_writer::tag('span', $image), $button['linkattributes']); + } + $html .= html_writer::end_div(); + } + $html .= html_writer::end_div(); + + return $html; + } } diff --git a/theme/boost/layout/columns2.php b/theme/boost/layout/columns2.php index 384158d94e5..9ffd403fe75 100644 --- a/theme/boost/layout/columns2.php +++ b/theme/boost/layout/columns2.php @@ -42,6 +42,20 @@ $hasblocks = strpos($blockshtml, 'data-block=') !== false; $buildregionmainsettings = !$PAGE->include_region_main_settings_in_header_actions(); // If the settings menu will be included in the header then don't add it here. $regionmainsettingsmenu = $buildregionmainsettings ? $OUTPUT->region_main_settings_menu() : false; + +$secondarynavigation = false; +if (!defined('BEHAT_SITE_RUNNING')) { + $buildsecondarynavigation = $PAGE->has_secondary_navigation(); + if ($buildsecondarynavigation) { + $moremenu = new \core\navigation\output\more_menu($PAGE->secondarynav, 'nav-tabs'); + $secondarynavigation = $moremenu->export_for_template($OUTPUT); + } +} + +$primary = new core\navigation\output\primary($PAGE); +$renderer = $PAGE->get_renderer('core'); +$primarymenu = $primary->export_for_template($renderer); + $templatecontext = [ 'sitename' => format_string($SITE->shortname, true, ['context' => context_course::instance(SITEID), "escape" => false]), 'output' => $OUTPUT, @@ -50,11 +64,13 @@ $templatecontext = [ 'bodyattributes' => $bodyattributes, 'navdraweropen' => $navdraweropen, 'regionmainsettingsmenu' => $regionmainsettingsmenu, - 'hasregionmainsettingsmenu' => !empty($regionmainsettingsmenu) + 'hasregionmainsettingsmenu' => !empty($regionmainsettingsmenu), + 'primarymoremenu' => $primarymenu['moremenu'], + 'secondarymoremenu' => $secondarynavigation, + 'usermenu' => $primarymenu['user'], + 'langmenu' => $primarymenu['lang'], ]; - $nav = $PAGE->flatnav; $templatecontext['flatnavigation'] = $nav; $templatecontext['firstcollectionlabel'] = $nav->get_collectionlabel(); echo $OUTPUT->render_from_template('theme_boost/columns2', $templatecontext); - diff --git a/theme/boost/layout/drawers.php b/theme/boost/layout/drawers.php index e2d08c68ab9..64ba52ab045 100644 --- a/theme/boost/layout/drawers.php +++ b/theme/boost/layout/drawers.php @@ -69,6 +69,19 @@ $buildregionmainsettings = !$PAGE->include_region_main_settings_in_header_action // If the settings menu will be included in the header then don't add it here. $regionmainsettingsmenu = $buildregionmainsettings ? $OUTPUT->region_main_settings_menu() : false; +$secondarynavigation = false; +if (!defined('BEHAT_SITE_RUNNING')) { + $buildsecondarynavigation = $PAGE->has_secondary_navigation(); + if ($buildsecondarynavigation) { + $moremenu = new \core\navigation\output\more_menu($PAGE->secondarynav, 'nav-tabs'); + $secondarynavigation = $moremenu->export_for_template($OUTPUT); + } +} + +$primary = new core\navigation\output\primary($PAGE); +$renderer = $PAGE->get_renderer('core'); +$primarymenu = $primary->export_for_template($renderer); + $templatecontext = [ 'sitename' => format_string($SITE->shortname, true, ['context' => context_course::instance(SITEID), "escape" => false]), 'output' => $OUTPUT, @@ -80,7 +93,11 @@ $templatecontext = [ 'blockdraweropen' => $blockdraweropen, 'regionmainsettingsmenu' => $regionmainsettingsmenu, 'courseindex' => $courseindex, - 'hasregionmainsettingsmenu' => !empty($regionmainsettingsmenu) + 'hasregionmainsettingsmenu' => !empty($regionmainsettingsmenu), + 'primarymoremenu' => $primarymenu['moremenu'], + 'secondarymoremenu' => $secondarynavigation, + 'usermenu' => $primarymenu['user'], + 'langmenu' => $primarymenu['lang'], ]; $nav = $PAGE->flatnav; diff --git a/theme/boost/scss/moodle.scss b/theme/boost/scss/moodle.scss index 38a65025af4..641ddcf5eb4 100644 --- a/theme/boost/scss/moodle.scss +++ b/theme/boost/scss/moodle.scss @@ -45,3 +45,6 @@ $breadcrumb-divider-rtl: "◀" !default; @import "moodle/navbar"; @import "moodle/reportbuilder"; @import "moodle/courseindex"; +@import "moodle/moremenu"; +@import "moodle/primarynavigation"; +@import "moodle/secondarynavigation"; diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 1ba4486ee7e..fe42c6447ea 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -379,10 +379,12 @@ img.resize { .action-menu .dropdown-toggle { text-decoration: none; + display: inline-block; } .action-menu { white-space: nowrap; + display: inline; } .block img.resize { @@ -2076,7 +2078,8 @@ nav.navbar .logo img { .page-header-image { margin-right: 1em; - margin-bottom: 1em; + margin-bottom: 0.5em; + margin-top: 0.7em; & > a { display: inline-block; } @@ -2706,7 +2709,7 @@ $picker-emojis-per-row: 7 !default; } } } - .nav-tabs, + .nav-tabs:not(.more-nav), .nav-pills { margin: 0; border: 0; diff --git a/theme/boost/scss/moodle/moremenu.scss b/theme/boost/scss/moodle/moremenu.scss new file mode 100644 index 00000000000..a4aa8aaa2e0 --- /dev/null +++ b/theme/boost/scss/moodle/moremenu.scss @@ -0,0 +1,39 @@ +.moremenu { + opacity: 0; + height: $moremenu-height; + &.observed { + opacity: 1; + } + .nav-link { + height: $moremenu-height; + display: flex; + align-items: center; + } + .nav-tabs { + margin-left: 0; + } + // Styling for dropdown menus inside the MoreButton. + .dropdownmoremenu > .dropdown-menu { + & > .dropdown-item { + padding: 0; + } + .dropdown-menu { + position: static; + padding: 0; + border: 0; + &.show { + display: block; + } + .dropdown-item { + background-color: $gray-100; + @include hover-focus() { + color: $dropdown-link-hover-color; + @include gradient-bg($dropdown-link-active-bg); + } + } + .dropdown-divider { + display: none; + } + } + } +} diff --git a/theme/boost/scss/moodle/navbar.scss b/theme/boost/scss/moodle/navbar.scss index 2d134fa42f6..712e2fa4043 100644 --- a/theme/boost/scss/moodle/navbar.scss +++ b/theme/boost/scss/moodle/navbar.scss @@ -33,6 +33,57 @@ .dropdown-toggle::after { display: none; } + .dropdown-menu { + min-width: 235px; + .carousel-navigation-link { + > * { + pointer-events: none; + } + } + .dropdown-item { + padding: .25rem 1.75rem .25rem .75rem; + &.carousel-navigation-link::after { + font-family: FontAwesome; + content: $fa-var-caret-right; + font-size: 1rem; + right: .75rem; + position: absolute; + } + } + .submenu { + .header { + padding: .25rem .75rem; + font-size: .975rem; + .icon { + font-size: 20px; + height: 20px; + width: 20px; + margin: 0; + } + } + .items { + .dropdown-item { + &[aria-current="true"]::before { + font-family: FontAwesome; + content: $fa-var-check; + font-size: 0.75rem; + padding-left: .25rem; + } + } + } + } + } + } + .langmenu { + .dropdown-menu { + .dropdown-item { + &[aria-current="true"]::before { + content: "\f00c"; + font-size: 0.75rem; + padding-left: .25rem; + } + } + } } .moodle-actionmenu .menubar, .action-menu-trigger .dropdown { @@ -40,6 +91,33 @@ display: flex; } } +.dir-rtl .navbar.fixed-top { + .usermenu { + .dropdown-menu { + .dropdown-item { + &.carousel-navigation-link::after { + content: $fa-var-caret-left; + } + } + .carousel { + .carousel-inner { + .carousel-item-prev.carousel-item-right, + .carousel-item-next.carousel-item-left { + transform: translateX(0); + } + .carousel-item-next, + .carousel-item-right.active { + transform: translateX(-100%); + } + .carousel-item-prev, + .carousel-item-left.active { + transform: translateX(100%); + } + } + } + } + } +} #page { margin-top: $navbar-height; } diff --git a/theme/boost/scss/moodle/primarynavigation.scss b/theme/boost/scss/moodle/primarynavigation.scss new file mode 100644 index 00000000000..e6f55f217a4 --- /dev/null +++ b/theme/boost/scss/moodle/primarynavigation.scss @@ -0,0 +1,8 @@ +.navbar.fixed-top { + .moremenu { + height: $navbar-height; + .nav-link { + height: $navbar-height; + } + } +} diff --git a/theme/boost/scss/moodle/secondarynavigation.scss b/theme/boost/scss/moodle/secondarynavigation.scss new file mode 100644 index 00000000000..0fbf2165446 --- /dev/null +++ b/theme/boost/scss/moodle/secondarynavigation.scss @@ -0,0 +1,6 @@ +.secondarynavigation { + z-index: 1; + .moremenu { + margin-bottom: -1px; + } +} diff --git a/theme/boost/scss/moodle/variables.scss b/theme/boost/scss/moodle/variables.scss index 70f1be5febf..3b322a2e4ef 100644 --- a/theme/boost/scss/moodle/variables.scss +++ b/theme/boost/scss/moodle/variables.scss @@ -27,3 +27,5 @@ $course-content-maxwidth: 800px; $box-shadow-drawer-left: -0.25rem .25rem .8rem rgba($black, .025) !default; $box-shadow-drawer-right: 0 .25rem .8rem rgba($black, .025) !default; + +$moremenu-height: 40px !default; diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index c2f45a435fd..c1864e85ff6 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -9990,10 +9990,12 @@ img.resize { width: 1em; } .action-menu .dropdown-toggle { - text-decoration: none; } + text-decoration: none; + display: inline-block; } .action-menu { - white-space: nowrap; } + white-space: nowrap; + display: inline; } .block img.resize { height: 0.9em; @@ -11353,7 +11355,8 @@ nav.navbar .logo img { position: relative; } .page-context-header .page-header-image { margin-right: 1em; - margin-bottom: 1em; } + margin-bottom: 0.5em; + margin-top: 0.7em; } .page-context-header .page-header-image > a { display: inline-block; } .page-context-header .page-header-headings, @@ -11852,42 +11855,42 @@ input[disabled] { border: 0; } #page-header .card .card-body { padding: 0; } - .nav-tabs, + .nav-tabs:not(.more-nav), .nav-pills { margin: 0; border: 0; padding: 0.125rem; background-color: #e9ecef; } - .nav-tabs .nav-item, + .nav-tabs:not(.more-nav) .nav-item, .nav-pills .nav-item { flex: 1 1 auto; text-align: center; } - .nav-tabs .nav-link, + .nav-tabs:not(.more-nav) .nav-link, .nav-pills .nav-link { background: #fff; border: 0; margin: 0.125rem; } - .nav-tabs .nav-link.active, + .nav-tabs:not(.more-nav) .nav-link.active, .nav-pills .nav-link.active { color: #6c757d; border-color: #6c757d; border-color: #6c757d; } - .nav-tabs .nav-link.active:hover, + .nav-tabs:not(.more-nav) .nav-link.active:hover, .nav-pills .nav-link.active:hover { color: #fff; background-color: #6c757d; border-color: #6c757d; } - .nav-tabs .nav-link.active:focus, .nav-tabs .nav-link.active.focus, + .nav-tabs:not(.more-nav) .nav-link.active:focus, .nav-tabs:not(.more-nav) .nav-link.active.focus, .nav-pills .nav-link.active:focus, .nav-pills .nav-link.active.focus { box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); } - .nav-tabs .nav-link.active.disabled, .nav-tabs .nav-link.active:disabled, + .nav-tabs:not(.more-nav) .nav-link.active.disabled, .nav-tabs:not(.more-nav) .nav-link.active:disabled, .nav-pills .nav-link.active.disabled, .nav-pills .nav-link.active:disabled { color: #6c757d; background-color: transparent; } - .nav-tabs .nav-link.active:not(:disabled):not(.disabled):active, .nav-tabs .nav-link.active:not(:disabled):not(.disabled).active, - .show > .nav-tabs .nav-link.active.dropdown-toggle, + .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled):active, .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled).active, + .show > .nav-tabs:not(.more-nav) .nav-link.active.dropdown-toggle, .nav-pills .nav-link.active:not(:disabled):not(.disabled):active, .nav-pills .nav-link.active:not(:disabled):not(.disabled).active, .show > @@ -11895,8 +11898,8 @@ input[disabled] { color: #fff; background-color: #6c757d; border-color: #6c757d; } - .nav-tabs .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-tabs .nav-link.active:not(:disabled):not(.disabled).active:focus, - .show > .nav-tabs .nav-link.active.dropdown-toggle:focus, + .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled).active:focus, + .show > .nav-tabs:not(.more-nav) .nav-link.active.dropdown-toggle:focus, .nav-pills .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-pills .nav-link.active:not(:disabled):not(.disabled).active:focus, .show > @@ -20005,11 +20008,55 @@ div.editor_atto_toolbar button .icon { align-items: center; } .navbar.fixed-top .usermenu .dropdown-toggle::after { display: none; } + .navbar.fixed-top .usermenu .dropdown-menu { + min-width: 235px; } + .navbar.fixed-top .usermenu .dropdown-menu .carousel-navigation-link > * { + pointer-events: none; } + .navbar.fixed-top .usermenu .dropdown-menu .dropdown-item { + padding: .25rem 1.75rem .25rem .75rem; } + .navbar.fixed-top .usermenu .dropdown-menu .dropdown-item.carousel-navigation-link::after { + font-family: FontAwesome; + content: ""; + font-size: 1rem; + right: .75rem; + position: absolute; } + .navbar.fixed-top .usermenu .dropdown-menu .submenu .header { + padding: .25rem .75rem; + font-size: .975rem; } + .navbar.fixed-top .usermenu .dropdown-menu .submenu .header .icon { + font-size: 20px; + height: 20px; + width: 20px; + margin: 0; } + .navbar.fixed-top .usermenu .dropdown-menu .submenu .items .dropdown-item[aria-current="true"]::before { + font-family: FontAwesome; + content: ""; + font-size: 0.75rem; + padding-left: .25rem; } + .navbar.fixed-top .langmenu .dropdown-menu .dropdown-item[aria-current="true"]::before { + content: "\f00c"; + font-size: 0.75rem; + padding-left: .25rem; } .navbar.fixed-top .moodle-actionmenu .menubar, .navbar.fixed-top .action-menu-trigger .dropdown { height: 100%; display: flex; } +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .dropdown-item.carousel-navigation-link::after { + content: ""; } + +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-prev.carousel-item-right, +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-next.carousel-item-left { + transform: translateX(0); } + +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-next, +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-right.active { + transform: translateX(-100%); } + +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-prev, +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-left.active { + transform: translateX(100%); } + #page { margin-top: 50px; } @@ -20091,6 +20138,43 @@ div.editor_atto_toolbar button .icon { height: 1rem; margin: 0.5rem 0; } +.moremenu { + opacity: 0; + height: 40px; } + .moremenu.observed { + opacity: 1; } + .moremenu .nav-link { + height: 40px; + display: flex; + align-items: center; } + .moremenu .nav-tabs { + margin-left: 0; } + .moremenu .dropdownmoremenu > .dropdown-menu > .dropdown-item { + padding: 0; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu { + position: static; + padding: 0; + border: 0; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu.show { + display: block; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-item { + background-color: #f8f9fa; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-item:hover, .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-item:focus { + color: #fff; + background-color: #0f6fc5; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-divider { + display: none; } + +.navbar.fixed-top .moremenu { + height: 50px; } + .navbar.fixed-top .moremenu .nav-link { + height: 50px; } + +.secondarynavigation { + z-index: 1; } + .secondarynavigation .moremenu { + margin-bottom: -1px; } + body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/theme/boost/templates/columns2.mustache b/theme/boost/templates/columns2.mustache index c198024ea18..3db9a36ce4e 100644 --- a/theme/boost/templates/columns2.mustache +++ b/theme/boost/templates/columns2.mustache @@ -62,7 +62,11 @@
{{{ output.full_header }}} - +
+ {{#secondarymoremenu}} + {{> core/moremenu}} + {{/secondarymoremenu}} +
{{#hasregionmainsettingsmenu}} diff --git a/theme/boost/templates/context_header.mustache b/theme/boost/templates/context_header.mustache new file mode 100644 index 00000000000..748bb1c7338 --- /dev/null +++ b/theme/boost/templates/context_header.mustache @@ -0,0 +1,81 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template theme_boost/context_header + + Context variables required for this template: + * logo - Information to display a logo + * imagedata - Data for an image if the header has one + * prefix - Information displayed before the heading. + * heading - The heading. + * userbuttons - Additional buttons. Currently only shown when displaying the user. + + + Example context (json): + { + "logo": { + "image": { + "src": "https://www.example.com/feeds/news/kitchen.jpg", + "alt": "Alternate text" + }, + "heading": "Name of the site." + }, + "imagedata": "Image data", + "prefix": "Assignment", + "heading": "Title of the assignment", + "userbuttons": { + "page": true, + "formattedimage": "formatted image", + "title": "Press me" + } + } +}} +{{#logo}} + + {{{heading}}} +{{/logo}} +{{^logo}} +
+ {{#imagedata}} +
{{{imagedata}}}
+ {{/imagedata}} +
+ {{#prefix}} +
{{prefix}}
+ {{/prefix}} + + {{{heading}}} +
+ {{#userbuttons}} + + {{/userbuttons}} +
+{{/logo}} diff --git a/theme/boost/templates/drawers.mustache b/theme/boost/templates/drawers.mustache index 63bad60ad06..fea71591a31 100644 --- a/theme/boost/templates/drawers.mustache +++ b/theme/boost/templates/drawers.mustache @@ -110,6 +110,11 @@ {{/hasblocks}}
{{{ output.full_header }}} +
+ {{#secondarymoremenu}} + {{> core/moremenu}} + {{/secondarymoremenu}} +
{{#hasregionmainsettingsmenu}} diff --git a/theme/boost/templates/language_menu.mustache b/theme/boost/templates/language_menu.mustache new file mode 100644 index 00000000000..305f8a85c63 --- /dev/null +++ b/theme/boost/templates/language_menu.mustache @@ -0,0 +1,63 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template theme_boost/language_menu + + Language menu template. + + Context variables required for this template: + * title - The title of the menu (displays the text of the currently active language). + * items - Array of data representing the available languages to be displayed in the language menu. + * link - If a link is provided render it. + * title - The title of the language item. + * text - The text displayed for the language item. + * url - The url link to activate the given language. + * isactive - Whether the language is currently active. + + Example context (json): + { + "title": "English (en)", + "items": { + "link": { + "title": "English (en)", + "text": "English (en)", + "url": "http://example.com", + "isactive": 0 + } + } + } +}} +
+ +
diff --git a/theme/boost/templates/navbar.mustache b/theme/boost/templates/navbar.mustache index 993d899b7a9..3afad628389 100644 --- a/theme/boost/templates/navbar.mustache +++ b/theme/boost/templates/navbar.mustache @@ -52,20 +52,26 @@ {{/ output.should_display_navbar_logo }} {{{ sitename }}} + {{#primarymoremenu}} + {{> core/moremenu }} + {{/primarymoremenu}} diff --git a/theme/boost/tests/behat/behat_theme_boost_behat_navigation.php b/theme/boost/tests/behat/behat_theme_boost_behat_navigation.php new file mode 100644 index 00000000000..7d3c9508622 --- /dev/null +++ b/theme/boost/tests/behat/behat_theme_boost_behat_navigation.php @@ -0,0 +1,91 @@ +. + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. +// For that reason, we can't even rely on $CFG->admin being available here. + +require_once(__DIR__ . '/../../../../lib/tests/behat/behat_navigation.php'); + +use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; +use Behat\Mink\Exception\ExpectationException as ExpectationException; + +/** + * Step definitions related to the navigation in the Boost theme. + * + * @package theme_boost + * @category test + * @copyright 2021 Mihail Geshoski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_theme_boost_behat_navigation extends behat_navigation { + /** + * Checks whether a node is active in the navbar. + * + * @override i should see :name is active in navigation + * + * @throws ElementNotFoundException + * @param string $element The name of the nav elemnent to look for. + * @return void + */ + public function i_should_see_is_active_in_navigation($element) { + $this->execute("behat_general::assert_element_contains_text", + [$element, '.navbar .nav-link.active', 'css_element']); + } + + /** + * Checks whether the language selector menu is present in the navbar. + * + * @Given language selector menu should exist in the navbar + * @Given language selector menu should :not exist in the navbar + * + * @throws ElementNotFoundException + * @param string|null $not Instructs to checks whether the element does not exist in the user menu, if defined + * @return void + */ + public function lang_menu_should_exist($not = null) { + $callfunction = is_null($not) ? 'should_exist' : 'should_not_exist'; + $this->execute("behat_general::{$callfunction}", [$this->get_lang_menu_xpath(), 'xpath_element']); + } + + /** + * Checks whether an item exists in the language selector menu. + * + * @Given :itemtext :selectortype should exist in the language selector menu + * @Given :itemtext :selectortype should :not exist in the language selector menu + * + * @throws ElementNotFoundException + * @param string $itemtext The menu item to find + * @param string $selectortype The selector type + * @param string|null $not Instructs to checks whether the element does not exist in the user menu, if defined + * @return void + */ + public function should_exist_in_lang_menu($itemtext, $selectortype, $not = null) { + $callfunction = is_null($not) ? 'should_exist_in_the' : 'should_not_exist_in_the'; + $this->execute("behat_general::{$callfunction}", + [$itemtext, $selectortype, $this->get_lang_menu_xpath(), 'xpath_element']); + } + + /** + * Return the xpath for the language selector menu element. + * + * @return string The xpath + */ + protected function get_lang_menu_xpath() { + return "//nav[contains(concat(' ', @class, ' '), ' navbar ')]" . + "//div[contains(concat(' ', @class, ' '), ' langmenu ')]" . + "//div[contains(concat(' ', @class, ' '), ' dropdown-menu ')]"; + } +} diff --git a/theme/boost/tests/behat/languagemenu.feature b/theme/boost/tests/behat/languagemenu.feature new file mode 100644 index 00000000000..94eac0609b0 --- /dev/null +++ b/theme/boost/tests/behat/languagemenu.feature @@ -0,0 +1,79 @@ +@javascript @theme_boost +Feature: Language selector menu + To be able to set the preferred language for the site + As a user + I need to be presented with a language selector menu + + Background: + Given the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + 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 I log in as "admin" + And I navigate to "Language > Language packs" in site administration + And I set the field "Available language packs" to "en_ar" + And I press "Install selected language pack(s)" + And the "Installed language packs" select box should contain "en_ar" + And I log out + + Scenario: Logged user is presented with a language selector which is placed within the user menu + Given I log in as "teacher1" + And I am on site homepage + # The language selector menu is not present in the navbar when a user is logged in. + And language selector menu should not exist in the navbar + # The language selector is present within the user menu. + And "Language" "link" should exist in the user menu + When I follow "Language" in the user menu + Then I should see "Language selector" user submenu + And "English ‎(en)‎" "link" should exist in the "Language selector" user submenu + And "English (pirate) ‎(en_ar)‎" "link" should exist in the "Language selector" user submenu + + Scenario: Non-logged user is presented with a language selector which is placed within the navbar + Given I am on site homepage + # The language selector menu is present in the navbar when a user is not logged in. + And language selector menu should exist in the navbar + And "English ‎(en)‎" "link" should exist in the language selector menu + And "English (pirate) ‎(en_ar)‎" "link" should exist in the language selector menu + + Scenario: Logged user is not presented with a language selector in a course if a language is forced in that context + Given I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Edit settings" in current page administration + And I expand all fieldsets + And I set the following fields to these values: + | id_lang | en | + And I press "Save and display" + # The language selector is not present within the user menu in the course context when a language is enforced. + When I am on "Course 1" course homepage + And "Language" "link" should not exist in the user menu + # The language selector is present within the user menu in other contexts. + And I am on site homepage + And "Language" "link" should exist in the user menu + + Scenario: Logged user is not presented with a language selector if there is less than two installed languages + Given I log in as "admin" + And I navigate to "Language > Language packs" in site administration + And I set the field "Installed language packs" to "en_ar" + And I press "Uninstall selected language pack(s)" + And I click on "Yes" "button" in the "Uninstall selected language pack(s)" "dialogue" + And the "Installed language packs" select box should not contain "en_ar" + When I am on site homepage + # The language selector is not present within the user menu. + And "Language" "link" should not exist in the user menu + + Scenario: Non-logged user is not presented with a language selector if there is less than two installed languages + Given I log in as "admin" + And I navigate to "Language > Language packs" in site administration + And I set the field "Installed language packs" to "en_ar" + And I press "Uninstall selected language pack(s)" + And I click on "Yes" "button" in the "Uninstall selected language pack(s)" "dialogue" + And the "Installed language packs" select box should not contain "en_ar" + And I log out + When I am on site homepage + # The language selector menu is not present in the navbar. + Then language selector menu should not exist in the navbar diff --git a/theme/boost/version.php b/theme/boost/version.php index 4284349dd4c..5864b20f47b 100644 --- a/theme/boost/version.php +++ b/theme/boost/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2021052500; +$plugin->version = 2021052505; $plugin->requires = 2021052500; $plugin->component = 'theme_boost'; diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 7d90c458cef..a2c424db9d6 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -10202,10 +10202,12 @@ img.resize { width: 1em; } .action-menu .dropdown-toggle { - text-decoration: none; } + text-decoration: none; + display: inline-block; } .action-menu { - white-space: nowrap; } + white-space: nowrap; + display: inline; } .block img.resize { height: 0.9em; @@ -11571,7 +11573,8 @@ nav.navbar .logo img { position: relative; } .page-context-header .page-header-image { margin-right: 1em; - margin-bottom: 1em; } + margin-bottom: 0.5em; + margin-top: 0.7em; } .page-context-header .page-header-image > a { display: inline-block; } .page-context-header .page-header-headings, @@ -12073,42 +12076,42 @@ input[disabled] { border: 0; } #page-header .card .card-body { padding: 0; } - .nav-tabs, + .nav-tabs:not(.more-nav), .nav-pills { margin: 0; border: 0; padding: 0.125rem; background-color: #e9ecef; } - .nav-tabs .nav-item, + .nav-tabs:not(.more-nav) .nav-item, .nav-pills .nav-item { flex: 1 1 auto; text-align: center; } - .nav-tabs .nav-link, + .nav-tabs:not(.more-nav) .nav-link, .nav-pills .nav-link { background: #fff; border: 0; margin: 0.125rem; } - .nav-tabs .nav-link.active, + .nav-tabs:not(.more-nav) .nav-link.active, .nav-pills .nav-link.active { color: #6e7377; border-color: #6e7377; border-color: #6e7377; } - .nav-tabs .nav-link.active:hover, + .nav-tabs:not(.more-nav) .nav-link.active:hover, .nav-pills .nav-link.active:hover { color: #fff; background-color: #6e7377; border-color: #6e7377; } - .nav-tabs .nav-link.active:focus, .nav-tabs .nav-link.active.focus, + .nav-tabs:not(.more-nav) .nav-link.active:focus, .nav-tabs:not(.more-nav) .nav-link.active.focus, .nav-pills .nav-link.active:focus, .nav-pills .nav-link.active.focus { box-shadow: 0 0 0 0.2rem rgba(110, 115, 119, 0.5); } - .nav-tabs .nav-link.active.disabled, .nav-tabs .nav-link.active:disabled, + .nav-tabs:not(.more-nav) .nav-link.active.disabled, .nav-tabs:not(.more-nav) .nav-link.active:disabled, .nav-pills .nav-link.active.disabled, .nav-pills .nav-link.active:disabled { color: #6e7377; background-color: transparent; } - .nav-tabs .nav-link.active:not(:disabled):not(.disabled):active, .nav-tabs .nav-link.active:not(:disabled):not(.disabled).active, - .show > .nav-tabs .nav-link.active.dropdown-toggle, + .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled):active, .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled).active, + .show > .nav-tabs:not(.more-nav) .nav-link.active.dropdown-toggle, .nav-pills .nav-link.active:not(:disabled):not(.disabled):active, .nav-pills .nav-link.active:not(:disabled):not(.disabled).active, .show > @@ -12116,8 +12119,8 @@ input[disabled] { color: #fff; background-color: #6e7377; border-color: #6e7377; } - .nav-tabs .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-tabs .nav-link.active:not(:disabled):not(.disabled).active:focus, - .show > .nav-tabs .nav-link.active.dropdown-toggle:focus, + .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-tabs:not(.more-nav) .nav-link.active:not(:disabled):not(.disabled).active:focus, + .show > .nav-tabs:not(.more-nav) .nav-link.active.dropdown-toggle:focus, .nav-pills .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-pills .nav-link.active:not(:disabled):not(.disabled).active:focus, .show > @@ -20196,11 +20199,55 @@ div.editor_atto_toolbar button .icon { align-items: center; } .navbar.fixed-top .usermenu .dropdown-toggle::after { display: none; } + .navbar.fixed-top .usermenu .dropdown-menu { + min-width: 235px; } + .navbar.fixed-top .usermenu .dropdown-menu .carousel-navigation-link > * { + pointer-events: none; } + .navbar.fixed-top .usermenu .dropdown-menu .dropdown-item { + padding: .25rem 1.75rem .25rem .75rem; } + .navbar.fixed-top .usermenu .dropdown-menu .dropdown-item.carousel-navigation-link::after { + font-family: FontAwesome; + content: ""; + font-size: 1rem; + right: .75rem; + position: absolute; } + .navbar.fixed-top .usermenu .dropdown-menu .submenu .header { + padding: .25rem .75rem; + font-size: .975rem; } + .navbar.fixed-top .usermenu .dropdown-menu .submenu .header .icon { + font-size: 20px; + height: 20px; + width: 20px; + margin: 0; } + .navbar.fixed-top .usermenu .dropdown-menu .submenu .items .dropdown-item[aria-current="true"]::before { + font-family: FontAwesome; + content: ""; + font-size: 0.75rem; + padding-left: .25rem; } + .navbar.fixed-top .langmenu .dropdown-menu .dropdown-item[aria-current="true"]::before { + content: "\f00c"; + font-size: 0.75rem; + padding-left: .25rem; } .navbar.fixed-top .moodle-actionmenu .menubar, .navbar.fixed-top .action-menu-trigger .dropdown { height: 100%; display: flex; } +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .dropdown-item.carousel-navigation-link::after { + content: ""; } + +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-prev.carousel-item-right, +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-next.carousel-item-left { + transform: translateX(0); } + +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-next, +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-right.active { + transform: translateX(-100%); } + +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-prev, +.dir-rtl .navbar.fixed-top .usermenu .dropdown-menu .carousel .carousel-inner .carousel-item-left.active { + transform: translateX(100%); } + #page { margin-top: 50px; } @@ -20283,6 +20330,43 @@ div.editor_atto_toolbar button .icon { height: 1rem; margin: 0.5rem 0; } +.moremenu { + opacity: 0; + height: 40px; } + .moremenu.observed { + opacity: 1; } + .moremenu .nav-link { + height: 40px; + display: flex; + align-items: center; } + .moremenu .nav-tabs { + margin-left: 0; } + .moremenu .dropdownmoremenu > .dropdown-menu > .dropdown-item { + padding: 0; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu { + position: static; + padding: 0; + border: 0; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu.show { + display: block; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-item { + background-color: #f8f9fa; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-item:hover, .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-item:focus { + color: #fff; + background-color: #0f6fc5; } + .moremenu .dropdownmoremenu > .dropdown-menu .dropdown-menu .dropdown-divider { + display: none; } + +.navbar.fixed-top .moremenu { + height: 50px; } + .navbar.fixed-top .moremenu .nav-link { + height: 50px; } + +.secondarynavigation { + z-index: 1; } + .secondarynavigation .moremenu { + margin-bottom: -1px; } + body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/theme/classic/tests/behat/blacklist.json b/theme/classic/tests/behat/blacklist.json index 1589fae200a..dc484070395 100644 --- a/theme/classic/tests/behat/blacklist.json +++ b/theme/classic/tests/behat/blacklist.json @@ -1,4 +1,7 @@ { + "contexts": [ + "behat_theme_boost_behat_navigation" + ], "features": [ "lib/tests/behat/action_menu.feature", "blocks/tests/behat/hide_blocks.feature", diff --git a/user/lib.php b/user/lib.php index 6332648c790..85e5cf3a679 100644 --- a/user/lib.php +++ b/user/lib.php @@ -823,6 +823,16 @@ function user_get_user_navigation_info($user, $page, $options = array()) { $returnobject->navitems = array(); $returnobject->metadata = array(); + $guest = isguestuser(); + if (!isloggedin() || $guest) { + $returnobject->unauthenticateduser = [ + 'guest' => $guest, + 'content' => $guest ? 'loggedinasguest' : 'loggedinnot', + ]; + + return $returnobject; + } + $course = $page->course; // Query the environment. diff --git a/user/tests/behat/set_default_homepage.feature b/user/tests/behat/set_default_homepage.feature index e2e6df503c4..a82add7237e 100644 --- a/user/tests/behat/set_default_homepage.feature +++ b/user/tests/behat/set_default_homepage.feature @@ -11,6 +11,9 @@ Feature: Set the site home page and dashboard as the default home page And the following "users" exist: | username | firstname | lastname | email | | user1 | User | One | user1@example.com | + And the following "course enrolments" exist: + | user | course | role | + | user1 | C1 | student | Scenario: Admin sets the site page and then the dashboard as the default home page # This functionality does not work without the administration block. @@ -28,14 +31,16 @@ Feature: Set the site home page and dashboard as the default home page And I am on site homepage And I follow "Make this my home page" And I should not see "Make this my home page" - And I am on "Course 1" course homepage - And "Home" "text" should exist in the ".breadcrumb" "css_element" +# The following lines should be changed once MDL-72110 is resolved. +# And I am on "Course 1" course homepage +# And "Home" "text" should exist in the ".breadcrumb" "css_element" And I am on site homepage And I follow "Dashboard" And I follow "Make this my home page" And I should not see "Make this my home page" - And I am on "Course 1" course homepage - Then "Dashboard" "text" should exist in the ".breadcrumb" "css_element" +# The following lines should be changed once MDL-72110 is resolved. +# And I am on "Course 1" course homepage +# Then "Dashboard" "text" should exist in the ".breadcrumb" "css_element" Scenario: User cannot configure their preferred default home page unless allowed by admin Given I log in as "user1" @@ -53,7 +58,9 @@ Feature: Set the site home page and dashboard as the default home page And I follow "Home page" And I set the field "Home page" to "" And I press "Save changes" - Then "" "text" should exist in the ".breadcrumb" "css_element" + And I log out + And I log in as "user1" + Then I should see "" is active in navigation Examples: | preference | breadcrumb | diff --git a/user/tests/userlib_test.php b/user/tests/userlib_test.php index aaca2547d21..68a383fb4a2 100644 --- a/user/tests/userlib_test.php +++ b/user/tests/userlib_test.php @@ -583,6 +583,7 @@ class core_userliblib_testcase extends advanced_testcase { $PAGE->set_url('/'); $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); $opts = user_get_user_navigation_info($user, $PAGE, array('avatarsize' => $testsize)); $avatarhtml = $opts->metadata['useravatar']; diff --git a/version.php b/version.php index 403b2c4aee5..ed80c32b00e 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021082000.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021082000.03; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20210820)'; // Human-friendly version name