MDL-75897 output: safer checking of course reuse action nodes.
[moodle.git] / lib / classes / navigation / views / secondary.php
blob743e5e570c35bd5ae5884d5ddbfe1195a9e76771
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 namespace core\navigation\views;
19 use navigation_node;
20 use url_select;
21 use settings_navigation;
23 /**
24 * Class secondary_navigation_view.
26 * The secondary navigation view is a stripped down tweaked version of the
27 * settings_navigation/navigation
29 * @package core
30 * @category navigation
31 * @copyright 2021 onwards Peter Dias
32 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 class secondary extends view {
35 /** @var string $headertitle The header for this particular menu*/
36 public $headertitle;
38 /** @var int The maximum limit of navigation nodes displayed in the secondary navigation */
39 const MAX_DISPLAYED_NAV_NODES = 5;
41 /** @var navigation_node The course overflow node. */
42 protected $courseoverflownode = null;
44 /**
45 * Defines the default structure for the secondary nav in a course context.
47 * In a course context, we are curating nodes from the settingsnav and navigation objects.
48 * The following mapping construct specifies which object we are fetching it from, the type of the node, the key
49 * and in what order we want the node - defined as per the mockups.
51 * @return array
53 protected function get_default_course_mapping(): array {
54 $nodes = [];
55 $nodes['settings'] = [
56 self::TYPE_CONTAINER => [
57 'coursereports' => 3,
58 'questionbank' => 4,
60 self::TYPE_SETTING => [
61 'editsettings' => 0,
62 'review' => 1.1,
63 'manageinstances' => 1.2,
64 'groups' => 1.3,
65 'override' => 1.4,
66 'roles' => 1.5,
67 'permissions' => 1.6,
68 'otherusers' => 1.7,
69 'gradebooksetup' => 2.1,
70 'outcomes' => 2.2,
71 'coursecompletion' => 6,
72 'coursebadges' => 7.1,
73 'newbadge' => 7.2,
74 'filtermanagement' => 9,
75 'unenrolself' => 10,
76 'coursetags' => 11,
77 'download' => 12,
78 'contextlocking' => 13,
81 $nodes['navigation'] = [
82 self::TYPE_CONTAINER => [
83 'participants' => 1,
85 self::TYPE_SETTING => [
86 'grades' => 2,
87 'badgesview' => 7,
88 'competencies' => 8,
90 self::TYPE_CUSTOM => [
91 'contentbank' => 5,
92 'participants' => 1, // In site home, 'participants' is classified differently.
96 return $nodes;
99 /**
100 * Defines the default structure for the secondary nav in a module context.
102 * In a module context, we are curating nodes from the settingsnav object.
103 * The following mapping construct specifies the type of the node, the key
104 * and in what order we want the node - defined as per the mockups.
106 * @return array
108 protected function get_default_module_mapping(): array {
109 return [
110 self::TYPE_SETTING => [
111 'modedit' => 1,
112 "mod_{$this->page->activityname}_useroverrides" => 3, // Overrides are module specific.
113 "mod_{$this->page->activityname}_groupoverrides" => 4,
114 'roleassign' => 7.2,
115 'filtermanage' => 6,
116 'roleoverride' => 7,
117 'rolecheck' => 7.1,
118 'logreport' => 8,
119 'backup' => 9,
120 'restore' => 10,
121 'competencybreakdown' => 11,
123 self::TYPE_CUSTOM => [
124 'advgrading' => 2,
125 'contentbank' => 12,
131 * Defines the default structure for the secondary nav in a category context.
133 * In a category context, we are curating nodes from the settingsnav object.
134 * The following mapping construct specifies the type of the node, the key
135 * and in what order we want the node - defined as per the mockups.
137 * @return array
139 protected function get_default_category_mapping(): array {
140 return [
141 self::TYPE_SETTING => [
142 'edit' => 1,
143 'permissions' => 2,
144 'roles' => 2.1,
145 'rolecheck' => 2.2,
151 * Define the keys of the course secondary nav nodes that should be forced into the "more" menu by default.
153 * @return array
155 protected function get_default_category_more_menu_nodes(): array {
156 return ['addsubcat', 'roles', 'permissions', 'contentbank', 'cohort', 'filters', 'restorecourse'];
159 * Define the keys of the course secondary nav nodes that should be forced into the "more" menu by default.
161 * @return array
163 protected function get_default_course_more_menu_nodes(): array {
164 return [];
168 * Define the keys of the module secondary nav nodes that should be forced into the "more" menu by default.
170 * @return array
172 protected function get_default_module_more_menu_nodes(): array {
173 return ['roleoverride', 'rolecheck', 'logreport', 'roleassign', 'filtermanage', 'backup', 'restore',
174 'competencybreakdown', "mod_{$this->page->activityname}_useroverrides",
175 "mod_{$this->page->activityname}_groupoverrides"];
179 * Define the keys of the admin secondary nav nodes that should be forced into the "more" menu by default.
181 * @return array
183 protected function get_default_admin_more_menu_nodes(): array {
184 return [];
188 * Initialise the view based navigation based on the current context.
190 * As part of the initial restructure, the secondary nav is only considered for the following pages:
191 * 1 - Site admin settings
192 * 2 - Course page - Does not include front_page which has the same context.
193 * 3 - Module page
195 public function initialise(): void {
196 global $SITE;
198 if (during_initial_install() || $this->initialised) {
199 return;
201 $this->id = 'secondary_navigation';
202 $context = $this->context;
203 $this->headertitle = get_string('menu');
204 $defaultmoremenunodes = [];
205 $maxdisplayednodes = self::MAX_DISPLAYED_NAV_NODES;
207 switch ($context->contextlevel) {
208 case CONTEXT_COURSE:
209 $this->headertitle = get_string('courseheader');
210 if ($this->page->course->format === 'singleactivity') {
211 $this->load_single_activity_course_navigation();
212 } else {
213 $this->load_course_navigation();
214 $defaultmoremenunodes = $this->get_default_course_more_menu_nodes();
216 break;
217 case CONTEXT_MODULE:
218 $this->headertitle = get_string('activityheader');
219 if ($this->page->course->format === 'singleactivity') {
220 $this->load_single_activity_course_navigation();
221 } else {
222 $this->load_module_navigation($this->page->settingsnav);
223 $defaultmoremenunodes = $this->get_default_module_more_menu_nodes();
225 break;
226 case CONTEXT_COURSECAT:
227 $this->headertitle = get_string('categoryheader');
228 $this->load_category_navigation();
229 $defaultmoremenunodes = $this->get_default_category_more_menu_nodes();
230 break;
231 case CONTEXT_SYSTEM:
232 $this->headertitle = get_string('homeheader');
233 $this->load_admin_navigation();
234 // If the site administration navigation was generated after load_admin_navigation().
235 if ($this->has_children()) {
236 // Do not explicitly limit the number of navigation nodes displayed in the site administration
237 // navigation menu.
238 $maxdisplayednodes = null;
240 $defaultmoremenunodes = $this->get_default_admin_more_menu_nodes();
241 break;
244 $this->remove_unwanted_nodes($this);
246 // Don't need to show anything if only the view node is available. Remove it.
247 if ($this->children->count() == 1) {
248 $this->children->remove('modulepage');
250 // Force certain navigation nodes to be displayed in the "more" menu.
251 $this->force_nodes_into_more_menu($defaultmoremenunodes, $maxdisplayednodes);
252 // Search and set the active node.
253 $this->scan_for_active_node($this);
254 $this->initialised = true;
258 * Returns a node with the action being from the first found child node that has an action (Recursive).
260 * @param navigation_node $node The part of the node tree we are checking.
261 * @param navigation_node $basenode The very first node to be used for the return.
262 * @return navigation_node|null
264 protected function get_node_with_first_action(navigation_node $node, navigation_node $basenode): ?navigation_node {
265 $newnode = null;
266 if (!$node->has_children()) {
267 return null;
270 // Find the first child with an action and update the main node.
271 foreach ($node->children as $child) {
272 if ($child->has_action()) {
273 $newnode = $basenode;
274 $newnode->action = $child->action;
275 return $newnode;
278 if (is_null($newnode)) {
279 // Check for children and go again.
280 foreach ($node->children as $child) {
281 if ($child->has_children()) {
282 $newnode = $this->get_node_with_first_action($child, $basenode);
284 if (!is_null($newnode)) {
285 return $newnode;
290 return null;
294 * Some nodes are containers only with no action. If this container has an action then nothing is done. If it does not have
295 * an action then a search is done through the children looking for the first node that has an action. This action is then given
296 * to the parent node that is initially provided as a parameter.
298 * @param navigation_node $node The navigation node that we want to ensure has an action tied to it.
299 * @return navigation_node The node intact with an action to use.
301 protected function get_first_action_for_node(navigation_node $node): ?navigation_node {
302 // If the node does not have children and has no action then no further processing is needed.
303 $newnode = null;
304 if ($node->has_children() && !$node->has_action()) {
305 // We want to find the first child with an action.
306 // We want to check all children on this level before going further down.
307 // Note that new node gets changed here.
308 $newnode = $this->get_node_with_first_action($node, $node);
309 } else if ($node->has_action()) {
310 $newnode = $node;
312 return $newnode;
316 * Recursive call to add all custom navigation nodes to secondary
318 * @param navigation_node $node The node which should be added to secondary
319 * @param navigation_node $basenode The original parent node
320 * @param navigation_node|null $root The parent node nodes are to be added/removed to.
321 * @param bool $forceadd Whether or not to bypass the external action check and force add all nodes
323 protected function add_external_nodes_to_secondary(navigation_node $node, navigation_node $basenode,
324 ?navigation_node $root = null, bool $forceadd = false) {
325 $root = $root ?? $this;
326 // Add the first node.
327 if ($node->has_action() && !$this->get($node->key)) {
328 $root->add_node(clone $node);
331 // If the node has an external action add all children to the secondary navigation.
332 if (!$node->has_internal_action() || $forceadd) {
333 if ($node->has_children()) {
334 foreach ($node->children as $child) {
335 if ($child->has_children()) {
336 $this->add_external_nodes_to_secondary($child, $basenode, $root, true);
337 } else if ($child->has_action() && !$this->get($child->key)) {
338 // Check whether the basenode matches a child's url.
339 // This would have happened in get_first_action_for_node.
340 // In these cases, we prefer the specific child content.
341 if ($basenode->has_action() && $basenode->action()->compare($child->action())) {
342 $root->children->remove($basenode->key, $basenode->type);
344 $root->add_node(clone $child);
352 * Returns a list of all expected nodes in the course administration.
354 * @return array An array of keys for navigation nodes in the course administration.
356 protected function get_expected_course_admin_nodes(): array {
357 $expectednodes = [];
358 foreach ($this->get_default_course_mapping()['settings'] as $value) {
359 foreach ($value as $nodekey => $notused) {
360 $expectednodes[] = $nodekey;
363 foreach ($this->get_default_course_mapping()['navigation'] as $value) {
364 foreach ($value as $nodekey => $notused) {
365 $expectednodes[] = $nodekey;
368 $othernodes = ['users', 'gradeadmin', 'coursereports', 'coursebadges'];
369 $leftovercourseadminnodes = ['backup', 'restore', 'import', 'copy', 'reset'];
370 $expectednodes = array_merge($expectednodes, $othernodes);
371 $expectednodes = array_merge($expectednodes, $leftovercourseadminnodes);
372 return $expectednodes;
376 * Load the course secondary navigation. Since we are sourcing all the info from existing objects that already do
377 * the relevant checks, we don't do it again here.
379 * @param navigation_node|null $rootnode The node where the course navigation nodes should be added into as children.
380 * If not explicitly defined, the nodes will be added to the secondary root
381 * node by default.
383 protected function load_course_navigation(?navigation_node $rootnode = null): void {
384 global $SITE;
386 $rootnode = $rootnode ?? $this;
387 $course = $this->page->course;
388 // Initialise the main navigation and settings nav.
389 // It is important that this is done before we try anything.
390 $settingsnav = $this->page->settingsnav;
391 $navigation = $this->page->navigation;
393 if ($course->id == $SITE->id) {
394 $firstnodeidentifier = get_string('home'); // The first node in the site course nav is called 'Home'.
395 $frontpage = $settingsnav->get('frontpage'); // The site course nodes are children of a dedicated 'frontpage' node.
396 $settingsnav = $frontpage ?: $settingsnav;
397 $courseadminnode = $frontpage ?: null; // Custom nodes for the site course are also children of the 'frontpage' node.
398 } else {
399 $firstnodeidentifier = get_string('course'); // Regular courses have a first node called 'Course'.
400 $courseadminnode = $settingsnav->get('courseadmin'); // Custom nodes for regular courses live under 'courseadmin'.
403 // Add the known nodes from settings and navigation.
404 $nodes = $this->get_default_course_mapping();
405 $nodesordered = $this->get_leaf_nodes($settingsnav, $nodes['settings'] ?? []);
406 $nodesordered += $this->get_leaf_nodes($navigation, $nodes['navigation'] ?? []);
407 $this->add_ordered_nodes($nodesordered, $rootnode);
409 // Try to get any custom nodes defined by plugins, which may include containers.
410 if ($courseadminnode) {
411 $expectedcourseadmin = $this->get_expected_course_admin_nodes();
412 foreach ($courseadminnode->children as $other) {
413 if (array_search($other->key, $expectedcourseadmin, true) === false) {
414 $othernode = $this->get_first_action_for_node($other);
415 $recursivenode = $othernode && !$rootnode->get($othernode->key) ? $othernode : $other;
416 // Get the first node and check whether it's been added already.
417 // Also check if the first node is an external link. If it is, add all children.
418 $this->add_external_nodes_to_secondary($recursivenode, $recursivenode, $rootnode);
423 // Move some nodes into a 'course reuse' node.
424 $overflownode = $this->get_course_overflow_nodes($rootnode);
425 if (!is_null($overflownode)) {
426 $actionnode = $this->get_first_action_for_node($overflownode);
427 if ($actionnode) {
428 // All additional nodes will be available under the 'Course reuse' page.
429 $text = get_string('coursereuse');
430 $rootnode->add($text, $actionnode->action, navigation_node::TYPE_COURSE, null, 'coursereuse',
431 new \pix_icon('t/edit', $text));
435 // Add the respective first node, provided there are other nodes included.
436 if (!empty($nodekeys = $rootnode->children->get_key_list())) {
437 $rootnode->add_node(
438 navigation_node::create($firstnodeidentifier, new \moodle_url('/course/view.php', ['id' => $course->id]),
439 self::TYPE_COURSE, null, 'coursehome'), reset($nodekeys)
445 * Gets the overflow navigation nodes for the course administration category.
447 * @param navigation_node|null $rootnode The node from where the course overflow nodes should be obtained.
448 * If not explicitly defined, the nodes will be obtained from the secondary root
449 * node by default.
450 * @return navigation_node The course overflow nodes.
452 protected function get_course_overflow_nodes(?navigation_node $rootnode = null): ?navigation_node {
453 global $SITE;
455 $rootnode = $rootnode ?? $this;
456 // This gets called twice on some pages, and so trying to create this navigation node twice results in no children being
457 // present the second time this is called.
458 if (isset($this->courseoverflownode)) {
459 return $this->courseoverflownode;
462 // Start with getting the base node for the front page or the course.
463 $node = null;
464 if ($this->page->course->id == $SITE->id) {
465 $node = $this->page->settingsnav->find('frontpage', navigation_node::TYPE_SETTING);
466 } else {
467 $node = $this->page->settingsnav->find('courseadmin', navigation_node::TYPE_COURSE);
469 $coursesettings = $node ? $node->get_children_key_list() : [];
470 $thissettings = $rootnode->get_children_key_list();
471 $diff = array_diff($coursesettings, $thissettings);
473 // Remove our specific created elements (user - participants, badges - coursebadges, grades - gradebooksetup,
474 // grades - outcomes).
475 $shortdiff = array_filter($diff, function($value) {
476 return !($value == 'users' || $value == 'coursebadges' || $value == 'gradebooksetup' ||
477 $value == 'outcomes');
480 // Permissions may be in play here that ultimately will show no overflow.
481 if (empty($shortdiff)) {
482 return null;
485 $firstitem = array_shift($shortdiff);
486 $navnode = $node->get($firstitem);
487 foreach ($shortdiff as $key) {
488 $courseadminnodes = $node->get($key);
489 if ($courseadminnodes) {
490 if ($courseadminnodes->parent->key == $node->key) {
491 $navnode->add_node($courseadminnodes);
495 $this->courseoverflownode = $navnode;
496 return $navnode;
501 * Recursively looks for a match to the current page url.
503 * @param navigation_node $node The node to look through.
504 * @return navigation_node|null The node that matches this page's url.
506 protected function nodes_match_current_url(navigation_node $node): ?navigation_node {
507 $pagenode = $this->page->url;
508 if ($node->has_action()) {
509 // Check this node first.
510 if ($node->action->compare($pagenode)) {
511 return $node;
514 if ($node->has_children()) {
515 foreach ($node->children as $child) {
516 $result = $this->nodes_match_current_url($child);
517 if ($result) {
518 return $result;
522 return null;
526 * Returns a url_select object with overflow navigation nodes.
527 * This looks to see if the current page is within the course administration, or some other page that requires an overflow
528 * select object.
530 * @return url_select|null The overflow menu data.
532 public function get_overflow_menu_data(): ?url_select {
534 if (!$this->page->get_navigation_overflow_state()) {
535 return null;
538 $issingleactivitycourse = $this->page->course->format === 'singleactivity';
539 $rootnode = $issingleactivitycourse ? $this->find('course', self::TYPE_COURSE) : $this;
540 $activenode = $this->find_active_node();
541 $incourseadmin = false;
543 if (!$activenode || ($issingleactivitycourse && $activenode->key === 'course')) {
544 // Could be in the course admin section.
545 $courseadmin = $this->page->settingsnav->find('courseadmin', navigation_node::TYPE_COURSE);
546 if (!$courseadmin) {
547 return null;
550 $activenode = $courseadmin->find_active_node();
551 if (!$activenode) {
552 return null;
554 $incourseadmin = true;
557 if ($activenode->key === 'coursereuse' || $incourseadmin) {
558 $courseoverflownode = $this->get_course_overflow_nodes($rootnode);
559 if (is_null($courseoverflownode)) {
560 return null;
562 if ($incourseadmin) {
563 // Validate whether the active node is part of the expected course overflow nodes.
564 if (($activenode->key !== $courseoverflownode->key) &&
565 !$courseoverflownode->find($activenode->key, $activenode->type)) {
566 return null;
569 $menuarray = static::create_menu_element([$courseoverflownode]);
570 if ($activenode->key != 'coursereuse') {
571 $inmenu = false;
572 foreach ($menuarray as $key => $value) {
573 if ($this->page->url->out(false) == $key) {
574 $inmenu = true;
577 if (!$inmenu) {
578 return null;
581 $menuselect = new url_select($menuarray, $this->page->url, null);
582 $menuselect->set_label(get_string('browsecourseadminindex', 'course'), ['class' => 'sr-only']);
583 return $menuselect;
584 } else {
585 return $this->get_other_overflow_menu_data($activenode);
590 * Gets overflow menu data for third party plugin settings.
592 * @param navigation_node $activenode The node to gather the children for to put into the overflow menu.
593 * @return url_select|null The overflow menu in a url_select object.
595 protected function get_other_overflow_menu_data(navigation_node $activenode): ?url_select {
596 if (!$activenode->has_action()) {
597 return null;
600 if (!$activenode->has_children()) {
601 return null;
604 // If the setting is extending the course navigation then the page being redirected to should be in the course context.
605 // It was decided on the issue that put this code here that plugins that extend the course navigation should have the pages
606 // that are redirected to, be in the course context or module context depending on which callback was used.
607 // Third part plugins were checked to see if any existing plugins had settings in a system context and none were found.
608 // The request of third party developers is to keep their settings within the specified context.
609 if ($this->page->context->contextlevel != CONTEXT_COURSE
610 && $this->page->context->contextlevel != CONTEXT_MODULE
611 && $this->page->context->contextlevel != CONTEXT_COURSECAT) {
612 return null;
615 // These areas have their own code to retrieve added plugin navigation nodes.
616 if ($activenode->key == 'coursehome' || $activenode->key == 'questionbank' || $activenode->key == 'coursereports') {
617 return null;
620 $menunode = $this->page->settingsnav->find($activenode->key, null);
622 if (!$menunode instanceof navigation_node) {
623 return null;
625 // Loop through all children and try and find a match to the current url.
626 $matchednode = $this->nodes_match_current_url($menunode);
627 if (is_null($matchednode)) {
628 return null;
630 if (!isset($menunode) || !$menunode->has_children()) {
631 return null;
633 $selectdata = static::create_menu_element([$menunode], false);
634 $urlselect = new url_select($selectdata, $matchednode->action->out(false), null);
635 $urlselect->set_label(get_string('browsesettingindex', 'course'), ['class' => 'sr-only']);
636 return $urlselect;
640 * Get the module's secondary navigation. This is based on settings_nav and would include plugin nodes added via
641 * '_extend_settings_navigation'.
642 * It populates the tree based on the nav mockup
644 * If nodes change, we will have to explicitly call the callback again.
646 * @param settings_navigation $settingsnav The settings navigation object related to the module page
647 * @param navigation_node|null $rootnode The node where the module navigation nodes should be added into as children.
648 * If not explicitly defined, the nodes will be added to the secondary root
649 * node by default.
651 protected function load_module_navigation(settings_navigation $settingsnav, ?navigation_node $rootnode = null): void {
652 $rootnode = $rootnode ?? $this;
653 $mainnode = $settingsnav->find('modulesettings', self::TYPE_SETTING);
654 $nodes = $this->get_default_module_mapping();
656 if ($mainnode) {
657 $url = new \moodle_url('/mod/' . $settingsnav->get_page()->activityname . '/view.php',
658 ['id' => $settingsnav->get_page()->cm->id]);
659 $setactive = $url->compare($settingsnav->get_page()->url, URL_MATCH_BASE);
660 $node = $rootnode->add(get_string('modulename', $settingsnav->get_page()->activityname), $url,
661 null, null, 'modulepage');
662 if ($setactive) {
663 $node->make_active();
665 // Add the initial nodes.
666 $nodesordered = $this->get_leaf_nodes($mainnode, $nodes);
667 $this->add_ordered_nodes($nodesordered, $rootnode);
669 // We have finished inserting the initial structure.
670 // Populate the menu with the rest of the nodes available.
671 $this->load_remaining_nodes($mainnode, $nodes, $rootnode);
676 * Load the course category navigation.
678 protected function load_category_navigation(): void {
679 $settingsnav = $this->page->settingsnav;
680 $mainnode = $settingsnav->find('categorysettings', self::TYPE_CONTAINER);
681 $nodes = $this->get_default_category_mapping();
683 if ($mainnode) {
684 $url = new \moodle_url('/course/index.php', ['categoryid' => $this->context->instanceid]);
685 $this->add(get_string('category'), $url, self::TYPE_CONTAINER, null, 'categorymain');
687 // Add the initial nodes.
688 $nodesordered = $this->get_leaf_nodes($mainnode, $nodes);
689 $this->add_ordered_nodes($nodesordered);
691 // We have finished inserting the initial structure.
692 // Populate the menu with the rest of the nodes available.
693 $this->load_remaining_nodes($mainnode, $nodes);
698 * Load the site admin navigation
700 protected function load_admin_navigation(): void {
701 global $PAGE, $SITE;
703 $settingsnav = $this->page->settingsnav;
704 $node = $settingsnav->find('root', self::TYPE_SITE_ADMIN);
705 // We need to know if we are on the main site admin search page. Here the navigation between tabs are done via
706 // anchors and page reload doesn't happen. On every nested admin settings page, the secondary nav needs to
707 // exist as links with anchors appended in order to redirect back to the admin search page and the corresponding
708 // tab. Note this value refers to being present on the page itself, before a search has been performed.
709 $isadminsearchpage = $PAGE->url->compare(new \moodle_url('/admin/search.php', ['query' => '']), URL_MATCH_PARAMS);
710 if ($node) {
711 $siteadminnode = $this->add(get_string('general'), "#link$node->key", null, null, 'siteadminnode');
712 if ($isadminsearchpage) {
713 $siteadminnode->action = false;
714 $siteadminnode->tab = "#link$node->key";
715 } else {
716 $siteadminnode->action = new \moodle_url("/admin/search.php", [], "link$node->key");
718 foreach ($node->children as $child) {
719 if ($child->display && !$child->is_short_branch()) {
720 // Mimic the current boost behaviour and pass down anchors for the tabs.
721 if ($isadminsearchpage) {
722 $child->action = false;
723 $child->tab = "#link$child->key";
724 } else {
725 $child->action = new \moodle_url("/admin/search.php", [], "link$child->key");
727 $this->add_node(clone $child);
728 } else {
729 $siteadminnode->add_node(clone $child);
736 * Adds the indexed nodes to the current view or a given node. The key should indicate it's position in the tree.
737 * Any sub nodes needs to be numbered appropriately, e.g. 3.1 would make the identified node be listed under #3 node.
739 * @param array $nodes An array of navigation nodes to be added.
740 * @param navigation_node|null $rootnode The node where the nodes should be added into as children. If not explicitly
741 * defined, the nodes will be added to the secondary root node by default.
743 protected function add_ordered_nodes(array $nodes, ?navigation_node $rootnode = null): void {
744 $rootnode = $rootnode ?? $this;
745 ksort($nodes);
746 foreach ($nodes as $key => $node) {
747 // If the key is a string then we are assuming this is a nested element.
748 if (is_string($key)) {
749 $parentnode = $nodes[floor($key)] ?? null;
750 if ($parentnode) {
751 $parentnode->add_node(clone $node);
753 } else {
754 $rootnode->add_node(clone $node);
760 * Find the remaining nodes that need to be loaded into secondary based on the current context or a given node.
762 * @param navigation_node $completenode The original node that we are sourcing information from
763 * @param array $nodesmap The map used to populate secondary nav in the given context
764 * @param navigation_node|null $rootnode The node where the remaining nodes should be added into as children. If not
765 * explicitly defined, the nodes will be added to the secondary root node by
766 * default.
768 protected function load_remaining_nodes(navigation_node $completenode, array $nodesmap,
769 ?navigation_node $rootnode = null): void {
770 $flattenednodes = [];
771 $rootnode = $rootnode ?? $this;
772 foreach ($nodesmap as $nodecontainer) {
773 $flattenednodes = array_merge(array_keys($nodecontainer), $flattenednodes);
776 $populatedkeys = $this->get_children_key_list();
777 $existingkeys = $completenode->get_children_key_list();
778 $leftover = array_diff($existingkeys, $populatedkeys);
779 foreach ($leftover as $key) {
780 if (!in_array($key, $flattenednodes, true) && $leftovernode = $completenode->get($key)) {
781 // Check for nodes with children and potentially no action to direct to.
782 if ($leftovernode->has_children()) {
783 $leftovernode = $this->get_first_action_for_node($leftovernode);
786 // We have found the first node with an action.
787 if ($leftovernode) {
788 $this->add_external_nodes_to_secondary($leftovernode, $leftovernode, $rootnode);
795 * Force certain secondary navigation nodes to be displayed in the "more" menu.
797 * @param array $defaultmoremenunodes Array with navigation node keys of the pre-defined nodes that
798 * should be added into the "more" menu by default
799 * @param int|null $maxdisplayednodes The maximum limit of navigation nodes displayed in the secondary navigation
801 protected function force_nodes_into_more_menu(array $defaultmoremenunodes = [], ?int $maxdisplayednodes = null) {
802 // Counter of the navigation nodes that are initially displayed in the secondary nav
803 // (excludes the nodes from the "more" menu).
804 $displayednodescount = 0;
805 foreach ($this->children as $child) {
806 // Skip if the navigation node has been already forced into the "more" menu.
807 if ($child->forceintomoremenu) {
808 continue;
810 // If the navigation node is in the pre-defined list of nodes that should be added by default in the
811 // "more" menu or the maximum limit of displayed navigation nodes has been reached (if defined).
812 if (in_array($child->key, $defaultmoremenunodes) ||
813 (!is_null($maxdisplayednodes) && $displayednodescount >= $maxdisplayednodes)) {
814 // Force the node and its children into the "more" menu.
815 $child->set_force_into_more_menu(true);
816 continue;
818 $displayednodescount++;
823 * Recursively remove navigation nodes that should not be displayed in the secondary navigation.
825 * @param navigation_node $node The starting navigation node.
827 protected function remove_unwanted_nodes(navigation_node $node) {
828 foreach ($node->children as $child) {
829 if (!$child->showinsecondarynavigation) {
830 $child->remove();
831 continue;
833 if (!empty($child->children)) {
834 $this->remove_unwanted_nodes($child);
840 * Takes the given navigation nodes and searches for children and formats it all into an array in a format to be used by a
841 * url_select element.
843 * @param navigation_node[] $navigationnodes Navigation nodes to format into a menu.
844 * @param bool $forceheadings Whether the returned array should be forced to use headings.
845 * @return array|null A url select element for navigating through the navigation nodes.
847 public static function create_menu_element(array $navigationnodes, bool $forceheadings = false): ?array {
848 if (empty($navigationnodes)) {
849 return null;
852 // If one item, do we put this into a url_select?
853 if (count($navigationnodes) < 2) {
854 // Check if there are children.
855 $navnode = array_shift($navigationnodes);
856 $menudata = [];
857 if (!$navnode->has_children()) {
858 // Just one item.
859 if (!$navnode->has_action()) {
860 return null;
862 $menudata[$navnode->action->out(false)] = static::format_node_text($navnode);
863 } else {
864 if (static::does_menu_need_headings($navnode) || $forceheadings) {
865 // Let's do headings.
866 $menudata = static::get_headings_nav_array($navnode);
867 } else {
868 // Simple flat nav.
869 $menudata = static::get_flat_nav_array($navnode);
872 return $menudata;
873 } else {
874 // We have more than one navigation node to handle. Put each node in it's own heading.
875 $menudata = [];
876 $titledata = [];
877 foreach ($navigationnodes as $navigationnode) {
878 if ($navigationnode->has_children()) {
879 $menuarray = [];
880 // Add a heading and flatten out everything else.
881 if ($navigationnode->has_action()) {
882 $menuarray[static::format_node_text($navigationnode)][$navigationnode->action->out(false)] =
883 static::format_node_text($navigationnode);
884 $menuarray[static::format_node_text($navigationnode)] += static::get_whole_tree_flat($navigationnode);
885 } else {
886 $menuarray[static::format_node_text($navigationnode)] = static::get_whole_tree_flat($navigationnode);
889 $titledata += $menuarray;
890 } else {
891 // Add with no heading.
892 if (!$navigationnode->has_action()) {
893 return null;
895 $menudata[$navigationnode->action->out(false)] = static::format_node_text($navigationnode);
898 $menudata += [$titledata];
899 return $menudata;
904 * Recursively goes through the provided navigation node and returns a flat version.
906 * @param navigation_node $navigationnode The navigationnode.
907 * @return array The whole tree flat.
909 protected static function get_whole_tree_flat(navigation_node $navigationnode): array {
910 $nodes = [];
911 foreach ($navigationnode->children as $child) {
912 if ($child->has_action()) {
913 $nodes[$child->action->out()] = $child->text;
915 if ($child->has_children()) {
916 $childnodes = static::get_whole_tree_flat($child);
917 $nodes = array_merge($nodes, $childnodes);
920 return $nodes;
924 * Checks to see if the provided navigation node has children and determines if we want headings for a url select element.
926 * @param navigation_node $navigationnode The navigation node we are checking.
927 * @return bool Whether we want headings or not.
929 protected static function does_menu_need_headings(navigation_node $navigationnode): bool {
930 if (!$navigationnode->has_children()) {
931 return false;
933 foreach ($navigationnode->children as $child) {
934 if ($child->has_children()) {
935 return true;
938 return false;
942 * Takes the navigation node and returns it in a flat fashion. This is not recursive.
944 * @param navigation_node $navigationnode The navigation node that we want to format into an array in a flat structure.
945 * @return array The flat navigation array.
947 protected static function get_flat_nav_array(navigation_node $navigationnode): array {
948 $menuarray = [];
949 if ($navigationnode->has_action()) {
950 $menuarray[$navigationnode->action->out(false)] = static::format_node_text($navigationnode);
953 foreach ($navigationnode->children as $child) {
954 if ($child->has_action()) {
955 $menuarray[$child->action->out(false)] = static::format_node_text($child);
958 return $menuarray;
962 * For any navigation node that we have determined needs headings we return a more tree like array structure.
964 * @param navigation_node $navigationnode The navigation node to use for the formatted array structure.
965 * @return array The headings navigation array structure.
967 protected static function get_headings_nav_array(navigation_node $navigationnode): array {
968 $menublock = [];
969 // We know that this single node has headings, so grab this for the first heading.
970 $firstheading = [];
971 if ($navigationnode->has_action()) {
972 $firstheading[static::format_node_text($navigationnode)][$navigationnode->action->out(false)] =
973 static::format_node_text($navigationnode);
974 $firstheading[static::format_node_text($navigationnode)] += static::get_more_child_nodes($navigationnode, $menublock);
975 } else {
976 $firstheading[static::format_node_text($navigationnode)] = static::get_more_child_nodes($navigationnode, $menublock);
978 return [$firstheading + $menublock];
982 * Recursively goes and gets all children nodes.
984 * @param navigation_node $node The node to get the children of.
985 * @param array $menublock Used to put all child nodes in its own container.
986 * @return array The additional child nodes.
988 protected static function get_more_child_nodes(navigation_node $node, array &$menublock): array {
989 $nodes = [];
990 foreach ($node->children as $child) {
991 if (!$child->has_children()) {
992 if (!$child->has_action()) {
993 continue;
995 $nodes[$child->action->out(false)] = static::format_node_text($child);
996 } else {
997 $newarray = [];
998 if ($child->has_action()) {
999 $newarray[static::format_node_text($child)][$child->action->out(false)] = static::format_node_text($child);
1000 $newarray[static::format_node_text($child)] += static::get_more_child_nodes($child, $menublock);
1001 } else {
1002 $newarray[static::format_node_text($child)] = static::get_more_child_nodes($child, $menublock);
1004 $menublock += $newarray;
1007 return $nodes;
1011 * Returns the navigation node text in a string.
1013 * @param navigation_node $navigationnode The navigationnode to return the text string of.
1014 * @return string The navigation node text string.
1016 protected static function format_node_text(navigation_node $navigationnode): string {
1017 return (is_a($navigationnode->text, 'lang_string')) ? $navigationnode->text->out() : $navigationnode->text;
1021 * Load the single activity course secondary navigation.
1023 protected function load_single_activity_course_navigation(): void {
1024 $page = $this->page;
1025 $course = $page->course;
1027 // Create 'Course' navigation node.
1028 $coursesecondarynode = navigation_node::create(get_string('course'), null, self::TYPE_COURSE, null, 'course');
1029 $this->load_course_navigation($coursesecondarynode);
1030 // Remove the unnecessary 'Course' child node generated in load_course_navigation().
1031 $coursehomenode = $coursesecondarynode->find('coursehome', self::TYPE_COURSE);
1032 if (!empty($coursehomenode)) {
1033 $coursehomenode->remove();
1036 // Add the 'Course' node to the secondary navigation only if this node has children nodes.
1037 if (count($coursesecondarynode->children) > 0) {
1038 $this->add_node($coursesecondarynode);
1039 // Once all the items have been added to the 'Course' secondary navigation node, set the 'showchildreninsubmenu'
1040 // property to true. This is required to force the template to output these items within a dropdown menu.
1041 $coursesecondarynode->showchildreninsubmenu = true;
1044 // Create 'Activity' navigation node.
1045 $activitysecondarynode = navigation_node::create(get_string('activity'), null, self::TYPE_ACTIVITY, null, 'activity');
1047 // We should display the module related navigation in the course context as well. Therefore, we need to
1048 // re-initialize the page object and manually set the course module to the one that it is currently visible in
1049 // the course in order to obtain the required module settings navigation.
1050 if ($page->context instanceof \context_course) {
1051 $this->page->set_secondary_active_tab($coursesecondarynode->key);
1052 // Get the currently used module in the single activity course.
1053 $module = current(array_filter(get_course_mods($course->id), function ($module) {
1054 return $module->visible == 1;
1055 }));
1056 // If the default module for the single course format has not been set yet, skip displaying the module
1057 // related navigation in the secondary navigation.
1058 if (!$module) {
1059 return;
1061 $page = new \moodle_page();
1062 $page->set_cm($module, $course);
1063 $page->set_url(new \moodle_url('/mod/' . $page->activityname . '/view.php', ['id' => $page->cm->id]));
1066 $this->load_module_navigation($page->settingsnav, $activitysecondarynode);
1068 // Add the 'Activity' node to the secondary navigation only if this node has more that one child node.
1069 if (count($activitysecondarynode->children) > 1) {
1070 // Set the 'showchildreninsubmenu' property to true to later output the the module navigation items within
1071 // a dropdown menu.
1072 $activitysecondarynode->showchildreninsubmenu = true;
1073 $this->add_node($activitysecondarynode);
1074 if ($this->context instanceof \context_module) {
1075 $this->page->set_secondary_active_tab($activitysecondarynode->key);
1077 } else { // Otherwise, add the 'View activity' node to the secondary navigation.
1078 $viewactivityurl = new \moodle_url('/mod/' . $page->activityname . '/view.php', ['id' => $page->cm->id]);
1079 $this->add(get_string('modulename', $page->activityname), $viewactivityurl, null, null, 'modulepage');
1080 if ($this->context instanceof \context_module) {
1081 $this->page->set_secondary_active_tab('modulepage');