Closes: #19793 - Nav menu link customization (#19794)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run

* Support menu items that are callables

* Fix quote on add button

* Clarify docstring to differentiate link and url

* Back out support for callables but keep alternate prerendered url param

* Make url a property on MenuItem/PluginMenuItem etc, overridable via a setter

* Use reverse_lazy instead of reverse

* Use reverse_lazy instead of reverse
This commit is contained in:
bctiemann 2025-07-14 10:39:24 -04:00 committed by GitHub
parent f05897d61a
commit f5d32b1bf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 58 additions and 4 deletions

View File

@ -1,6 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Sequence, Optional from typing import Sequence, Optional
from django.urls import reverse_lazy
__all__ = ( __all__ = (
'get_model_item', 'get_model_item',
@ -22,20 +24,46 @@ class MenuItemButton:
link: str link: str
title: str title: str
icon_class: str icon_class: str
_url: Optional[str] = None
permissions: Optional[Sequence[str]] = () permissions: Optional[Sequence[str]] = ()
color: Optional[str] = None color: Optional[str] = None
def __post_init__(self):
if self.link:
self._url = reverse_lazy(self.link)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
@dataclass @dataclass
class MenuItem: class MenuItem:
link: str link: str
link_text: str link_text: str
_url: Optional[str] = None
permissions: Optional[Sequence[str]] = () permissions: Optional[Sequence[str]] = ()
auth_required: Optional[bool] = False auth_required: Optional[bool] = False
staff_only: Optional[bool] = False staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = () buttons: Optional[Sequence[MenuItemButton]] = ()
def __post_init__(self):
if self.link:
self._url = reverse_lazy(self.link)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
@dataclass @dataclass
class MenuGroup: class MenuGroup:

View File

@ -1,3 +1,4 @@
from django.urls import reverse_lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -32,17 +33,23 @@ class PluginMenuItem:
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
specifying additional link buttons that appear to the right of the item in the van menu. specifying additional link buttons that appear to the right of the item in the van menu.
Links are specified as Django reverse URL strings. Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
Buttons are each specified as a list of PluginMenuButton instances. Buttons are each specified as a list of PluginMenuButton instances.
""" """
permissions = [] permissions = []
buttons = [] buttons = []
_url = None
def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None): def __init__(
self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
):
self.link = link self.link = link
self.link_text = link_text self.link_text = link_text
self.auth_required = auth_required self.auth_required = auth_required
self.staff_only = staff_only self.staff_only = staff_only
if link:
self._url = reverse_lazy(link)
if permissions is not None: if permissions is not None:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list.")) raise TypeError(_("Permissions must be passed as a tuple or list."))
@ -52,6 +59,14 @@ class PluginMenuItem:
raise TypeError(_("Buttons must be passed as a tuple or list.")) raise TypeError(_("Buttons must be passed as a tuple or list."))
self.buttons = buttons self.buttons = buttons
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
class PluginMenuButton: class PluginMenuButton:
""" """
@ -60,11 +75,14 @@ class PluginMenuButton:
""" """
color = ButtonColorChoices.DEFAULT color = ButtonColorChoices.DEFAULT
permissions = [] permissions = []
_url = None
def __init__(self, link, title, icon_class, color=None, permissions=None): def __init__(self, link, title, icon_class, color=None, permissions=None):
self.link = link self.link = link
self.title = title self.title = title
self.icon_class = icon_class self.icon_class = icon_class
if link:
self._url = reverse_lazy(link)
if permissions is not None: if permissions is not None:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list.")) raise TypeError(_("Permissions must be passed as a tuple or list."))
@ -73,3 +91,11 @@ class PluginMenuButton:
if color not in ButtonColorChoices.values(): if color not in ButtonColorChoices.values():
raise ValueError(_("Button color must be a choice within ButtonColorChoices.")) raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
self.color = color self.color = color
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value

View File

@ -41,11 +41,11 @@
</div> </div>
{% for item, buttons in items %} {% for item, buttons in items %}
<div class="dropdown-item d-flex justify-content-between ps-3 py-0"> <div class="dropdown-item d-flex justify-content-between ps-3 py-0">
<a href="{% url item.link %}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a> <a href="{{ item.url }}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
{% if buttons %} {% if buttons %}
<div class="btn-group ms-1"> <div class="btn-group ms-1">
{% for button in buttons %} {% for button in buttons %}
<a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}"> <a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
<i class="{{ button.icon_class }}"></i> <i class="{{ button.icon_class }}"></i>
</a> </a>
{% endfor %} {% endfor %}