Repository: netbox-community/netbox
Pull request: 9071 add header to plugin menu
<!--
    Thank you for your interest in contributing to NetBox! Please note that
    our contribution policy requires that a feature request or bug report be
    approved and assigned prior to filing a pull request. This helps avoid
    wasting time and effort on something that we might not be able to accept.

    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
    TO YOU, IT WE BE CLOSED AUTOMATICALLY.

    Specify your assigned issue number on the line below.
-->
### Fixes: #9071

<!--
    Please include a summary of the proposed changes below.
-->
Allows plugins to define a menu_heading (see below) that will make it a top-level menu.  This still needs to edit the doc files for plugins.  @jeremystretch the original issue had a request to allow items to be appended to existing menus, however this would require the menu.groups to be an array instead of an immutable tuple so items could be appended on - not sure if this is desired or not?

menu_heading = {
    "title": "MyPlugin",
    "icon": "mdi-puzzle"
}


Problem statement:
Allow plugins to inject top-level navigation menu items
### NetBox version

v3.2.0

### Feature type

Change to existing functionality

### Proposed functionality

Currently, plugins can define menu items only under the "Plugins" header within the navigation menu. Several plugin authors have proposed the ability for a plugin to "register" its own top-level section in the menu.

IMO the simplest approach would be to allow a plugin to define a menu heading with a title and icon. If defined, its menu items will appear there; if not, they will appear under the general "Plugins" heading.

We'd also want to give some thought to influencing the ordering of top-level menu items; assigning a "weight" to each item might suffice.

### Use case

Greater visibility for plugin functions; control over order of placement (potentially?)

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
I thought I'd revisit this now that a couple of months have passed and I implemented our plugin at two customers and in my own environment.

A complaint that I hear quite often is that access to plugins is somewhat hidden from view, which tends to confuse casual users. So I think the option of optionally "promoting" a plugin to be a first class menu entry is still on the wish list.
There should also be the ability to add our plugin menu under the existing headers like `Virtualization` or `Devices` etc. I have a few plugins that would be much suited under the existing ones rather than creating them as suggested in the description.

Commit message: 9071 add documentation

Patch to review:
diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md
index 52ae953a74e..b4a872ae2bd 100644
--- a/docs/plugins/development/navigation.md
+++ b/docs/plugins/development/navigation.md
@@ -32,6 +32,41 @@ A `PluginMenuItem` has the following attributes:
 | `permissions` | -        | A list of permissions required to display this link  |
 | `buttons`     | -        | An iterable of PluginMenuButton instances to include |
 
+## Optional Header
+
+Plugin menus normally appear under the "Plugins" header.  An optional menu_heading can be defined to make the plugin menu to appear as a top level menu header.  An example is shown below:
+
+```python
+from extras.plugins import PluginMenuButton, PluginMenuItem
+from utilities.choices import ButtonColorChoices
+
+menu_heading = {
+    "title": "Animal Sound",
+    "icon": "mdi-puzzle"
+}
+
+menu_items = (
+    PluginMenuItem(
+        link='plugins:netbox_animal_sounds:random_animal',
+        link_text='Random sound',
+        buttons=(
+            PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
+            PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
+        )
+    ),
+)
+```
+
+The `menu_heading` has the following attributes:
+
+| Attribute     | Required | Description                                          |
+|---------------|----------|------------------------------------------------------|
+| `title`       | Yes      | The text that will show in the menu header           |
+| `icon`        | Yes      | The icon to use next to the headermdi                   |
+
+!!! tip
+    The icon names can be found at [Material Design Icons](https://materialdesignicons.com/)
+
 ## Menu Buttons
 
 A `PluginMenuButton` has the following attributes:
diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py
index 0b57e6f0520..95e88ca8ced 100644
--- a/netbox/extras/plugins/__init__.py
+++ b/netbox/extras/plugins/__init__.py
@@ -58,6 +58,7 @@ class PluginConfig(AppConfig):
     # integrated components.
     graphql_schema = 'graphql.schema'
     menu_items = 'navigation.menu_items'
+    menu_header = 'navigation.menu_heading'
     template_extensions = 'template_content.template_extensions'
     user_preferences = 'preferences.preferences'
 
@@ -70,9 +71,14 @@ def ready(self):
             register_template_extensions(template_extensions)
 
         # Register navigation menu items (if defined)
+        try:
+            menu_header = import_object(f"{self.__module__}.{self.menu_header}")
+        except AttributeError:
+            menu_header = None
+
         menu_items = import_object(f"{self.__module__}.{self.menu_items}")
         if menu_items is not None:
-            register_menu_items(self.verbose_name, menu_items)
+            register_menu_items(self.verbose_name, menu_header, menu_items)
 
         # Register GraphQL schema (if defined)
         graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}")
@@ -246,7 +252,7 @@ def __init__(self, link, title, icon_class, color=None, permissions=None):
             self.color = color
 
 
-def register_menu_items(section_name, class_list):
+def register_menu_items(section_name, menu_header, class_list):
     """
     Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
     """
@@ -258,7 +264,9 @@ def register_menu_items(section_name, class_list):
             if not isinstance(button, PluginMenuButton):
                 raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
 
-    registry['plugins']['menu_items'][section_name] = class_list
+    registry['plugins']['menu_items'][section_name] = {}
+    registry['plugins']['menu_items'][section_name]['header'] = menu_header
+    registry['plugins']['menu_items'][section_name]['items'] = class_list
 
 
 #
diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py
index 299cab9efa0..733ae3a39b3 100644
--- a/netbox/extras/tests/test_plugins.py
+++ b/netbox/extras/tests/test_plugins.py
@@ -63,7 +63,7 @@ def test_menu_items(self):
         Check that plugin MenuItems and MenuButtons are registered.
         """
         self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
-        menu_items = registry['plugins']['menu_items']['Dummy plugin']
+        menu_items = registry['plugins']['menu_items']['Dummy plugin']['items']
         self.assertEqual(len(menu_items), 2)
         self.assertEqual(len(menu_items[0].buttons), 2)
 
diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py
index a495f17c94f..d4970aa3561 100644
--- a/netbox/netbox/navigation_menu.py
+++ b/netbox/netbox/navigation_menu.py
@@ -408,18 +408,28 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')):
 if registry['plugins']['menu_items']:
     plugin_menu_groups = []
 
-    for plugin_name, items in registry['plugins']['menu_items'].items():
-        plugin_menu_groups.append(
-            MenuGroup(
-                label=plugin_name,
-                items=items
+    for plugin_name, data in registry['plugins']['menu_items'].items():
+        if data['header']:
+            menu_groups = [MenuGroup(label=plugin_name, items=data["items"])]
+            icon = data["header"]["icon"]
+            MENUS.append(Menu(
+                label=data["header"]["title"],
+                icon_class=f"mdi {icon}",
+                groups=menu_groups
+            ))
+        else:
+            plugin_menu_groups.append(
+                MenuGroup(
+                    label=plugin_name,
+                    items=data["items"]
+                )
             )
-        )
 
-    PLUGIN_MENU = Menu(
-        label="Plugins",
-        icon_class="mdi mdi-puzzle",
-        groups=plugin_menu_groups
-    )
+    if plugin_menu_groups:
+        PLUGIN_MENU = Menu(
+            label="Plugins",
+            icon_class="mdi mdi-puzzle",
+            groups=plugin_menu_groups
+        )
 
-    MENUS.append(PLUGIN_MENU)
+        MENUS.append(PLUGIN_MENU)


Code review comments:
diff_hunk: @@ -58,6 +58,7 @@ class PluginConfig(AppConfig):
     # integrated components.
     graphql_schema = 'graphql.schema'
     menu_items = 'navigation.menu_items'
+    menu_header = 'navigation.menu_heading'
path: netbox/extras/plugins/__init__.py
text: @user1:
We should stick with either "header" or "heading" and use it consistently.

diff_hunk: @@ -70,9 +71,14 @@ def ready(self):
             register_template_extensions(template_extensions)
 
         # Register navigation menu items (if defined)
+        try:
+            menu_header = import_object(f"{self.__module__}.{self.menu_header}")
+        except AttributeError:
+            menu_header = None
path: netbox/extras/plugins/__init__.py
text: @user1:
This doesn't need to be wrapped in a `try` block: `import_object()` will return None if the attribute doesn't exist.