diff --git a/lib/classes/navigation/views/secondary.php b/lib/classes/navigation/views/secondary.php index c4d44776be7..647d6b14406 100644 --- a/lib/classes/navigation/views/secondary.php +++ b/lib/classes/navigation/views/secondary.php @@ -57,6 +57,7 @@ class secondary extends view { 'gradebooksetup' => 2.1, 'outcomes' => 2.2, 'coursecompletion' => 6, + 'filtermanagement' => 9, ], ]; $nodes['navigation'] = [ @@ -197,13 +198,129 @@ class secondary extends view { $this->initialised = true; } + /** + * Recursively goes and gets all children nodes. + * + * @param navigation_node $node The node to get the children of. + * @return array The additional child nodes. + */ + protected function get_additional_child_nodes(navigation_node $node): array { + $nodes = []; + foreach ($node->children as $child) { + if ($child->has_action()) { + $nodes[$child->action->out()] = $child->text; + } + if ($child->has_children()) { + $childnodes = $this->get_additional_child_nodes($child); + $nodes = array_merge($nodes, $childnodes); + } + } + return $nodes; + } + + /** + * Returns an array of sections, actions, and text for a url select menu. + * + * @param navigation_node $node The node to use for a url select menu. + * @return array The menu array. + */ + protected function get_menu_array(navigation_node $node): array { + $urldata = []; + + // Check that children have children. + $additionalchildren = false; + $initialchildren = []; + if ($node->has_action()) { + $initialchildren[$node->action->out()] = $node->text; + } + foreach ($node->children as $child) { + $additionalnode = []; + if ($child->has_action()) { + $additionalnode[$child->action->out()] = $child->text; + } + + if ($child->has_children()) { + $additionalchildren = true; + $urldata[][$child->text] = $additionalnode + $this->get_additional_child_nodes($child); + } else { + $initialchildren += $additionalnode; + } + } + if ($additionalchildren) { + $urldata[][$node->text] = $initialchildren; + } else { + $urldata = $initialchildren; + } + + return $urldata; + } + + /** + * Returns a node with the action being from the first found child node that has an action (Recursive). + * + * @param navigation_node $node The part of the node tree we are checking. + * @param navigation_node $basenode The very first node to be used for the return. + * @return navigation_node|null + */ + protected function get_node_with_first_action(navigation_node $node, navigation_node $basenode): ?navigation_node { + $newnode = null; + if (!$node->has_children()) { + return null; + } + + // Find the first child with an action and update the main node. + foreach ($node->children as $child) { + if ($child->has_action()) { + $newnode = $basenode; + $newnode->action = $child->action; + return $newnode; + } + } + if (is_null($newnode)) { + // Check for children and go again. + foreach ($node->children as $child) { + if ($child->has_children()) { + $newnode = $this->get_node_with_first_action($child, $basenode); + + if (!is_null($newnode)) { + return $newnode; + } + } + } + } + return null; + } + + /** + * Some nodes are containers only with no action. If this container has an action then nothing is done. If it does not have + * an action then a search is done through the children looking for the first node that has an action. This action is then given + * to the parent node that is initially provided as a parameter. + * + * @param navigation_node $node The navigation node that we want to ensure has an action tied to it. + * @return navigation_node The node intact with an action to use. + */ + protected function get_first_action_for_node(navigation_node $node): ?navigation_node { + // If the node does not have children OR has an action no further processing needed. + $newnode = null; + if ($node->has_children()) { + if (!$node->has_action()) { + // We want to find the first child with an action. + // We want to check all children on this level before going further down. + // Note that new node gets changed here. + $newnode = $this->get_node_with_first_action($node, $node); + } else { + $newnode = $node; + } + } + return $newnode; + } + /** * Load the course secondary navigation. Since we are sourcing all the info from existing objects that already do * the relevant checks, we don't do it again here. */ protected function load_course_navigation(): void { $course = $this->page->course; - // Initialise the main navigation and settings nav. // It is important that this is done before we try anything. $settingsnav = $this->page->settingsnav; @@ -224,6 +341,74 @@ class secondary extends view { $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)); } + + // Try to get any custom nodes defined by a user which may include containers. + $expectedcourseadmin = ['editsettings', 'coursecompletion', 'users', 'coursereports', 'gradebooksetup', 'coursebadges', + 'backup', 'restore', 'import', 'copy', 'reset', 'questionbank']; + foreach ($settingsnav->children as $value) { + if ($value->key == 'courseadmin') { + foreach ($value->children as $other) { + if (array_search($other->key, $expectedcourseadmin) === false) { + $othernode = $this->get_first_action_for_node($other); + // Get the first node and check whether it's been added already. + if ($othernode && !$this->get($othernode->key)) { + $this->add_node($othernode); + } + } + } + } + } + } + + /** + * Recursively looks for a match to the current page url. + * + * @param navigation_node $node The node to look through. + * @return navigation_node|null The node that matches this page's url. + */ + protected function nodes_match_current_url(navigation_node $node): ?navigation_node { + $pagenode = $this->page->url; + if ($node->has_action()) { + // Check this node first. + if ($node->action->compare($pagenode)) { + return $node; + } + } + if ($node->has_children()) { + foreach ($node->children as $child) { + $result = $this->nodes_match_current_url($child); + if ($result) { + return $result; + } + } + } + return null; + } + + /** + * Returns a url_select object with overflow navigation nodes. + * + * @return \url_select|null The overflow menu data. + */ + public function get_overflow_menu_data(): ?\url_select { + $activenode = $this->find_active_node(); + if ($activenode && $activenode->has_action() && $activenode->has_children() && $activenode->key != 'coursehome') { + // This needs to be expanded to does the active node have children and does the page url match any of the children. + $menunode = $this->page->settingsnav->find($activenode->key, null); + if ($menunode instanceof navigation_node) { + // Loop through all children and try and find a match to the current url. + $matchednode = $this->nodes_match_current_url($menunode); + if (is_null($matchednode)) { + return null; + } + if (!isset($menunode) || !$menunode->has_children()) { + return null; + } + $selectdata = $this->get_menu_array($menunode); + return new \url_select($selectdata, $matchednode->action->out(), null); + } + } + return null; } /** @@ -342,7 +527,15 @@ class secondary extends view { $leftover = array_diff($existingkeys, $populatedkeys); foreach ($leftover as $key) { if (!in_array($key, $flattenednodes) && $leftovernode = $completenode->get($key)) { - $this->add_node($leftovernode); + // Check for nodes with children and potentially no action to direct to. + if ($leftovernode->has_children()) { + $leftovernode = $this->get_first_action_for_node($leftovernode); + } + + // Confirm we have a valid object to add. + if ($leftovernode) { + $this->add_node($leftovernode); + } } } } diff --git a/lib/navigationlib.php b/lib/navigationlib.php index 5d8e1e39ed4..45c324eb399 100644 --- a/lib/navigationlib.php +++ b/lib/navigationlib.php @@ -4516,7 +4516,8 @@ class settings_navigation extends navigation_node { // Manage filters if ($adminoptions->filters) { $url = new moodle_url('/filter/manage.php', array('contextid'=>$coursecontext->id)); - $coursenode->add(get_string('filters', 'admin'), $url, self::TYPE_SETTING, null, null, new pix_icon('i/filter', '')); + $coursenode->add(get_string('filters', 'admin'), $url, self::TYPE_SETTING, + null, 'filtermanagement', new pix_icon('i/filter', '')); } // View course reports. diff --git a/lib/tests/navigation/views/secondary_test.php b/lib/tests/navigation/views/secondary_test.php index ce335036bea..981ea79847c 100644 --- a/lib/tests/navigation/views/secondary_test.php +++ b/lib/tests/navigation/views/secondary_test.php @@ -404,4 +404,415 @@ class secondary_test extends \advanced_testcase { ], ]; } + + /** + * Recursive call to generate a navigation node given an array definition. + * + * @param array $structure + * @param string $parentkey + * @return navigation_node + */ + private function generate_node_tree_construct(array $structure, string $parentkey): navigation_node { + $node = navigation_node::create($parentkey, null, navigation_node::TYPE_CUSTOM, '', $parentkey); + foreach ($structure as $key => $value) { + if (is_array($value)) { + $children = $value['children'] ?? $value; + $child = $this->generate_node_tree_construct($children, $key); + if (isset($value['action'])) { + $child->action = new \moodle_url($value['action']); + } + $node->add_node($child); + } else { + $node->add($key, $value, navigation_node::TYPE_CUSTOM, '', $key); + } + } + + return $node; + } + + /** + * Test the nodes_match_current_url function. + * + * @param string $selectedurl + * @param string $expectednode + * @dataProvider test_nodes_match_current_url_provider + */ + public function test_nodes_match_current_url(string $selectedurl, string $expectednode) { + global $PAGE; + $structure = [ + 'parentnode1' => [ + 'child1' => '/my', + 'child2' => [ + 'child2.1' => '/view/course.php', + 'child2.2' => '/view/admin.php', + ] + ] + ]; + $node = $this->generate_node_tree_construct($structure, 'primarynode'); + $node->action = new \moodle_url('/'); + + $PAGE->set_url($selectedurl); + $secondary = new secondary($PAGE); + $method = new ReflectionMethod('core\navigation\views\secondary', 'nodes_match_current_url'); + $method->setAccessible(true); + $response = $method->invoke($secondary, $node); + + $this->assertSame($response->key ?? null, $expectednode); + } + + /** + * Provider for test_nodes_match_current_url + * + * @return \string[][] + */ + public function test_nodes_match_current_url_provider(): array { + return [ + "Match url to a node that is a deep nested" => [ + '/view/course.php', + 'child2.1', + ], + "Match url to a parent node with children" => [ + '/', 'primarynode' + ], + "Match url to a child node" => [ + '/my', 'child1' + ], + ]; + } + + /** + * Test the get_menu_array function + * + * @param string $selected + * @param array $expected + * @dataProvider test_get_menu_array_provider + */ + public function test_get_menu_array(string $selected, array $expected) { + global $PAGE; + + // Custom nodes - mimicing nodes added via 3rd party plugins. + $structure = [ + 'parentnode1' => [ + 'child1' => '/my', + 'child2' => [ + 'action' => '/test.php', + 'children' => [ + 'child2.1' => '/view/course.php?child=2', + 'child2.2' => '/view/admin.php?child=2', + 'child2.3' => '/test.php', + ] + ], + 'child3' => [ + 'child3.1' => '/view/course.php?child=3', + 'child3.2' => '/view/admin.php?child=3', + ] + ], + 'parentnode2' => "/view/module.php" + ]; + + $secondary = new secondary($PAGE); + $secondary->add_node($this->generate_node_tree_construct($structure, 'primarynode')); + $selectednode = $secondary->find($selected, null); + $method = new ReflectionMethod('core\navigation\views\secondary', 'get_menu_array'); + $method->setAccessible(true); + $response = $method->invoke($secondary, $selectednode); + + $this->assertSame($expected, $response); + } + + /** + * Provider for test_get_menu_array + * + * @return array[] + */ + public function test_get_menu_array_provider(): array { + return [ + "Fetch information from a node with action and no children" => [ + 'child1', + [ + 'https://www.example.com/moodle/my' => 'child1' + ], + ], + "Fetch information from a node with no action and children" => [ + 'child3', + [ + 'https://www.example.com/moodle/view/course.php?child=3' => 'child3.1', + 'https://www.example.com/moodle/view/admin.php?child=3' => 'child3.2' + ], + ], + "Fetch information from a node with children" => [ + 'child2', + [ + 'https://www.example.com/moodle/test.php' => 'child2', + 'https://www.example.com/moodle/view/course.php?child=2' => 'child2.1', + 'https://www.example.com/moodle/view/admin.php?child=2' => 'child2.2' + ], + ], + "Fetch information from a node with an action and no children" => [ + 'parentnode2', + ['https://www.example.com/moodle/view/module.php' => 'parentnode2'], + ], + "Fetch information from a node with an action and multiple nested children" => [ + 'parentnode1', + [ + [ + 'child2' => [ + 'https://www.example.com/moodle/test.php' => 'child2', + 'https://www.example.com/moodle/view/course.php?child=2' => 'child2.1', + 'https://www.example.com/moodle/view/admin.php?child=2' => 'child2.2', + ] + ], + [ + 'child3' => [ + 'https://www.example.com/moodle/view/course.php?child=3' => 'child3.1', + 'https://www.example.com/moodle/view/admin.php?child=3' => 'child3.2' + ] + ], + [ + 'parentnode1' => [ + 'https://www.example.com/moodle/my' => 'child1' + ] + ] + ], + ], + ]; + } + + /** + * Test the get_node_with_first_action function + * + * @param string $selectedkey + * @param string|null $expectedkey + * @dataProvider test_get_node_with_first_action_provider + */ + public function test_get_node_with_first_action(string $selectedkey, ?string $expectedkey) { + global $PAGE; + $structure = [ + 'parentnode1' => [ + 'child1' => [ + 'child1.1' => null + ], + 'child2' => [ + 'child2.1' => [ + 'child2.1.1' => [ + 'action' => '/test.php', + 'children' => [ + 'child2.1.1.1' => '/view/course.php?child=2', + 'child2.1.1.2' => '/view/admin.php?child=2', + ] + ] + ] + ], + 'child3' => [ + 'child3.1' => '/view/course.php?child=3', + 'child3.2' => '/view/admin.php?child=3', + ] + ], + 'parentnode2' => "/view/module.php" + ]; + + $nodes = $this->generate_node_tree_construct($structure, 'primarynode'); + $selectednode = $nodes->find($selectedkey, null); + + $expected = null; + // Expected response will be the parent node with the action updated. + if ($expectedkey) { + $expectedbasenode = clone $selectednode; + $actionfromnode = $nodes->find($expectedkey, null); + $expectedbasenode->action = $actionfromnode->action; + $expected = $expectedbasenode; + } + + $secondary = new secondary($PAGE); + $method = new ReflectionMethod('core\navigation\views\secondary', 'get_node_with_first_action'); + $method->setAccessible(true); + $response = $method->invoke($secondary, $selectednode, $selectednode); + $this->assertEquals($expected, $response); + } + + /** + * Provider for test_get_node_with_first_action + * + * @return array + */ + public function test_get_node_with_first_action_provider(): array { + return [ + "Search for action when parent has no action and multiple children with actions" => [ + "child3", + "child3.1", + ], + "Search for action when parent child is deeply nested." => [ + "child2", + "child2.1.1" + ], + "No navigation node returned when node has no children" => [ + "parentnode2", + null + ], + "No navigation node returned when node has children but no actions available." => [ + "child1", + null + ], + ]; + } + + /** + * Test for get_additional_child_nodes + * + * @param string $selectedkey + * @param array $expected + * @dataProvider test_get_additional_child_nodes_provider + */ + public function test_get_additional_child_nodes(string $selectedkey, array $expected) { + global $PAGE; + $structure = [ + 'parentnode1' => [ + 'child1' => '/my', + 'child2' => [ + 'action' => '/test.php', + 'children' => [ + 'child2.1' => '/view/course.php?child=2', + 'child2.2' => '/view/admin.php?child=2', + ] + ], + 'child3' => [ + 'child3.1' => '/view/course.php?child=3', + 'child3.2' => '/view/admin.php?child=3', + ] + ], + 'parentnode2' => "/view/module.php" + ]; + + $secondary = new secondary($PAGE); + $nodes = $this->generate_node_tree_construct($structure, 'primarynode'); + $selectednode = $nodes->find($selectedkey, null); + $method = new ReflectionMethod('core\navigation\views\secondary', 'get_additional_child_nodes'); + $method->setAccessible(true); + $response = $method->invoke($secondary, $selectednode); + + $this->assertSame($expected, $response); + } + + /** + * Provider for test_get_additional_child_nodes + * + * @return array[] + */ + public function test_get_additional_child_nodes_provider(): array { + return [ + "Get nodes with deep nested children" => [ + "parentnode1", + [ + 'https://www.example.com/moodle/my' => 'child1', + 'https://www.example.com/moodle/test.php' => 'child2', + 'https://www.example.com/moodle/view/course.php?child=2' => 'child2.1', + 'https://www.example.com/moodle/view/admin.php?child=2' => 'child2.2', + 'https://www.example.com/moodle/view/course.php?child=3' => 'child3.1', + 'https://www.example.com/moodle/view/admin.php?child=3' => 'child3.2', + ] + ], + "Get children from parent without action " => [ + "child3", + [ + 'https://www.example.com/moodle/view/course.php?child=3' => 'child3.1', + 'https://www.example.com/moodle/view/admin.php?child=3' => 'child3.2' + ] + ], + "Get children from parent with action " => [ + "child2", + [ + 'https://www.example.com/moodle/view/course.php?child=2' => 'child2.1', + 'https://www.example.com/moodle/view/admin.php?child=2' => 'child2.2' + ] + ], + ]; + } + + /** + * Test the get_overflow_menu_data function + * + * @param string $selectedurl + * @param bool $expectednull + * @param bool $emptynode + * @dataProvider test_get_overflow_menu_data_provider + */ + public function test_get_overflow_menu_data(string $selectedurl, bool $expectednull, bool $emptynode = false) { + global $PAGE; + // Custom nodes - mimicing nodes added via 3rd party plugins. + $structure = [ + 'parentnode1' => [ + 'child1' => '/my', + 'child2' => [ + 'action' => '/test.php', + 'children' => [ + 'child2.1' => '/view/course.php', + 'child2.2' => '/view/admin.php', + ] + ] + ], + 'parentnode2' => "/view/module.php" + ]; + $PAGE->set_url($selectedurl); + navigation_node::override_active_url(new \moodle_url($selectedurl)); + $node = $this->generate_node_tree_construct($structure, 'primarynode'); + $node->action = new \moodle_url('/'); + + $secondary = new secondary($PAGE); + $secondary->add_node($node); + $PAGE->settingsnav->add_node(clone $node); + $secondary->add('Course home', '/coursehome.php', navigation_node::TYPE_CUSTOM, '', 'coursehome'); + $secondary->add('Course settings', '/course/settings.php', navigation_node::TYPE_CUSTOM, '', 'coursesettings'); + + // Test for an empty node without children and action. + if ($emptynode) { + $node = $secondary->add('Course management', null, navigation_node::TYPE_CUSTOM, '', 'course'); + $node->make_active(); + } else { + // Set the correct node as active. + $method = new ReflectionMethod('core\navigation\views\secondary', 'scan_for_active_node'); + $method->setAccessible(true); + $method->invoke($secondary, $secondary); + } + + $method = new ReflectionMethod('core\navigation\views\secondary', 'get_overflow_menu_data'); + $method->setAccessible(true); + $response = $method->invoke($secondary); + if ($expectednull) { + $this->assertNull($response); + } else { + $this->assertIsObject($response); + $this->assertInstanceOf('url_select', $response); + } + } + + /** + * Data provider for test_get_overflow_menu_data + * + * @return string[] + */ + public function test_get_overflow_menu_data_provider(): array { + return [ + "Active node is the course home node" => [ + '/coursehome.php', + true + ], + "Active node is one with an action and no children" => [ + '/view/module.php', + false + ], + "Active node is one with an action and children" => [ + '/', + false + ], + "Active node is one without an action and children" => [ + '/', + true, + true, + ], + "Active node is one with an action and children but is NOT in settingsnav" => [ + '/course/settings.php', + true + ], + ]; + } } diff --git a/theme/boost/layout/drawers.php b/theme/boost/layout/drawers.php index 6d5bbd21214..8ade2b25db0 100644 --- a/theme/boost/layout/drawers.php +++ b/theme/boost/layout/drawers.php @@ -71,11 +71,19 @@ $buildregionmainsettings = !$PAGE->include_region_main_settings_in_header_action $regionmainsettingsmenu = $buildregionmainsettings ? $OUTPUT->region_main_settings_menu() : false; $secondarynavigation = false; +$overflow = false; if (!defined('BEHAT_SITE_RUNNING')) { $buildsecondarynavigation = $PAGE->has_secondary_navigation(); if ($buildsecondarynavigation) { - $moremenu = new \core\navigation\output\more_menu($PAGE->secondarynav, 'nav-tabs'); + $secondary = $PAGE->secondarynav; + $moremenu = new \core\navigation\output\more_menu($secondary, 'nav-tabs'); $secondarynavigation = $moremenu->export_for_template($OUTPUT); + + // Get the pre-content stuff. + $overflowdata = $secondary->get_overflow_menu_data(); + if (!is_null($overflowdata)) { + $overflow = $overflowdata->export_for_template($OUTPUT); + } } } @@ -100,6 +108,7 @@ $templatecontext = [ 'usermenu' => $primarymenu['user'], 'langmenu' => $primarymenu['lang'], 'forceblockdraweropen' => $forceblockdraweropen, + 'overflow' => $overflow ]; $nav = $PAGE->flatnav; diff --git a/theme/boost/templates/drawers.mustache b/theme/boost/templates/drawers.mustache index b76fb8a4741..ddb852f8000 100644 --- a/theme/boost/templates/drawers.mustache +++ b/theme/boost/templates/drawers.mustache @@ -129,6 +129,9 @@
{{/hasregionmainsettingsmenu}} {{{ output.course_content_header }}} + {{#overflow}} + {{> core/url_select}} + {{/overflow}} {{{ output.main_content }}} {{{ output.activity_navigation }}} {{{ output.course_content_footer }}}