Skip to content

Commit

Permalink
feat: custom tab navigations (#1029)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasvinclav authored Feb 5, 2025
1 parent d872753 commit e564d84
Show file tree
Hide file tree
Showing 11 changed files with 385 additions and 31 deletions.
49 changes: 49 additions & 0 deletions docs/tabs/changeform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: Changeform tabs
order: 2
description: Learn how to configure and customize tab navigation in Django Unfold admin changeform views, including model-specific tabs and permission-based access control.
---

# Changeform tabs

In changeform view, it is possible to add custom tab navigation. It can consist from various custom links which can point at another registered admin models. The configuration is done in `UNFOLD` dictionary in `settings.py`.

Actually, the changeform tab navigation configuration is the same as the changelist tab navigation configuration. The only difference is that in `models` section it is required to specify model name as dictionary with `detail` key set to `True`.

```python
# settings.py

from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _

UNFOLD = {
"TABS": [
{
# Which changeform models are going to display tab navigation
"models": [
{
"app_label.model_name_in_lowercase",
"detail": True, # Displays tab navigation on changeform page
},
],
# List of tab items
"items": [
{
"title": _("Your custom title"),
"link": reverse_lazy("admin:app_label_model_name_changelist"),
"permission": "sample_app.permission_callback",
},
{
"title": _("Another custom title"),
"link": reverse_lazy("admin:app_label_another_model_name_changelist"),
"permission": "sample_app.permission_callback",
},
],
},
],
}

# Permission callback for tab item
def permission_callback(request):
return request.user.has_perm("sample_app.change_model")
```
5 changes: 3 additions & 2 deletions docs/tabs/changelist.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
title: Changelist
title: Changelist tabs
order: 1
description: Tab navigation in changelist view.
description: Learn how to configure and customize tab navigation in Django Unfold admin changelist views, including model-specific tabs and permission-based access control.
---

# Changelist tabs

In changelist view, it is possible to add custom tab navigation. It can consist from various custom links which can point at another registered admin models. The configuration is done in `UNFOLD` dictionary in `settings.py`.

```python
Expand Down
87 changes: 87 additions & 0 deletions docs/tabs/dynamic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: Dynamic tabs
order: 6
description: Learn how to dynamically generate tab navigation in Django Unfold admin using custom callbacks and render tabs in custom templates.
---

# Dynamic tabs

Unfold provides a way to dynamically generate tab navigation. It is possible to use your own logic to generate tab navigation. The tab navigation configuration can be defined as importable string which will call a function with `HttpRequest` object as an argument. In this function it is possible to build own tabs navigation structure.

```python
# settings.py

UNFOLD = {
"TABS": "your_project.admin.tabs_callback"
}
```

Below is an example of how to build own tabs navigation structure in tabs callback function. Based on the request object it is possible to write own logic for the tab navigation structure.

```python
# admin.py

from django.http import HttpRequest


def tabs_callback(request: HttpRequest) -> list[dict[str, Any]]:
return [
{
# Unique tab identifier to render tabs in custom templates
"page": "custom_page",

# Applies for the changeform view
"models": [
{
"name": "app_label.model_name_in_lowercase",
"detail": True
},
],
"items": [
{
"title": _("Your custom title"),
"link": reverse_lazy("admin:app_label_model_name_changelist"),
"is_active": True # Configure active tab
},
],
},
],
```

## Rendering tabs in custom templates

Unfold provides a `tab_list` template tag which can be used to render tabs in custom templates. The only required argument is the `page` name which is defined in `TABS` structure on particular tab navigation. Configure `page` key to something unique and then use `tab_list` template tag in your custom template where the first parameter is the unique `page` name.

```python
# settings.py

from django.http import HttpRequest

UNFOLD = {
"TABS": [
{
"page": "custom_page", # Unique tab identifier
"items": [
{
"title": _("Your custom title"),
"link": reverse_lazy("admin:app_label_model_name_changelist"),
},
],
}
]
}
```

Below is an example of how to render tabs in custom templates. It is important to load `unfold` template tags before using `tab_list` template tag.

```html
{% extends "admin/base_site.html" %}

{% load unfold %}

{% block content %}
{% tab_list "custom_page" %}
{% endblock %}
```

**Note:** When it comes which tab item is active on custom page, it is not up to Unfold to find out a way how to mark links as active. The tab configuration provides `is_active` key which you can use to set active tab item.
6 changes: 3 additions & 3 deletions docs/tabs/fieldsets.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Fieldsets
order: 2
description: Fieldsets with tab navigation.
title: Fieldsets tabs
order: 3
description: Learn how to organize Django admin fieldsets into tabs for better form organization and user experience by using CSS classes to group related fields into tabbed navigation.
---

# Fieldsets tabs
Expand Down
6 changes: 3 additions & 3 deletions docs/tabs/inline.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Inlines
order: 3
description: Change form tab navigation from inlines.
title: Inlines tabs
order: 4
description: Learn how to organize Django admin inlines into tabs by using the tab attribute in inline classes, enabling better form organization and user experience in changeform views.
---

# Inlines tabs
Expand Down
1 change: 0 additions & 1 deletion src/unfold/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,6 @@ def wrapper(*args, **kwargs):
)

def _path_from_custom_url(self, custom_url) -> URLPattern:
# TODO: wrap()
return path(
custom_url[0],
self.admin_site.admin_view(custom_url[2]),
Expand Down
15 changes: 10 additions & 5 deletions src/unfold/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,10 @@ def get_sidebar_list(self, request: HttpRequest) -> list[dict[str, Any]]:
return results

def get_tabs_list(self, request: HttpRequest) -> list[dict[str, Any]]:
tabs = get_config(self.settings_name)["TABS"]
tabs = self._get_config("TABS", request)

if not tabs:
return []

for tab in tabs:
allowed_items = []
Expand All @@ -291,9 +294,11 @@ def get_tabs_list(self, request: HttpRequest) -> list[dict[str, Any]]:
if isinstance(item["link"], Callable):
item["link_callback"] = lazy(item["link"])(request)

item["active"] = self._get_is_active(
request, item.get("link_callback") or item["link"], True
)
if "active" not in item:
item["active"] = self._get_is_active(
request, item.get("link_callback") or item["link"], True
)

allowed_items.append(item)

tab["items"] = allowed_items
Expand Down Expand Up @@ -341,7 +346,7 @@ def _get_is_active(
if link_path == request.path == index_path:
return True

if link_path in request.path and link_path != index_path:
if link_path != "" and link_path in request.path and link_path != index_path:
query_params = parse_qs(urlparse(link).query)
request_params = parse_qs(request.GET.urlencode())

Expand Down
8 changes: 7 additions & 1 deletion src/unfold/templates/unfold/helpers/tab_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
{% for item in tabs_list %}
{% if item.has_permission %}
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
<a href="{% if item.link_callback %}{{ item.link_callback }}{% else %}{{ item.link }}{% endif %}" class="block px-3 py-2 md:py-4 md:px-0 dark:border-base-800 {% if item.active %} border-b font-semibold -mb-px text-primary-600 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-500 md:border-primary-500 dark:md:!border-primary-600{% else %}font-medium hover:text-primary-600 dark:hover:text-primary-500{% endif %}">
<a href="{% if item.link_callback %}{{ item.link_callback }}{% else %}{{ item.link }}{% endif %}"
class="block px-3 py-2 md:py-4 md:px-0 dark:border-base-800 {% if item.active and not item.inline %} border-b font-semibold -mb-px text-primary-600 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-500 md:border-primary-500 dark:md:!border-primary-600{% else %}font-medium hover:text-primary-600 dark:hover:text-primary-500{% endif %}"
{% if item.inline %}
x-on:click="activeTab = '{{ item.inline }}'"
x-bind:class="{'border-b border-base-200 dark:border-base-800 md:border-primary-500 dark:md:!border-primary-600 font-semibold -mb-px text-primary-600 dark:text-primary-500': activeTab == '{{ item.inline }}'}"
{% endif %}
>
{{ item.title }}
</a>
</li>
Expand Down
2 changes: 1 addition & 1 deletion src/unfold/templates/unfold/layouts/skeleton.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
{% endblock %}
</head>

<body class="antialiased bg-white font-sans text-font-default-light text-sm dark:bg-base-900 dark:text-font-default-dark {% if is_popup %}popup {% endif %}{% block bodyclass %}{% endblock %}" data-admin-utc-offset="{% now "Z" %}" x-data="{ mainWidth: 0, activeTab: 'general', sidebarMobileOpen: false, sidebarDesktopOpen: {% if request.session.toggle_sidebar == False %}false{% else %}true{% endif %} }" x-init="activeTab = window.location.hash?.replace('#', '') || 'general'">
<body class="antialiased bg-white font-sans text-font-default-light text-sm dark:bg-base-900 dark:text-font-default-dark {% if is_popup %}popup {% endif %}{% block bodyclass %}{% endblock %}" data-admin-utc-offset="{% now "Z" %}" x-data="{ mainWidth: 0, {% if opts %}activeTab: 'general',{% endif %} sidebarMobileOpen: false, sidebarDesktopOpen: {% if request.session.toggle_sidebar == False %}false{% else %}true{% endif %} }" x-init="activeTab = {% if opts %}window.location.hash?.replace('#', '') || 'general'{% else %}''{% endif %}">
{% if colors %}
<style id="unfold-theme-colors">
:root {
Expand Down
62 changes: 47 additions & 15 deletions src/unfold/templatetags/unfold.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django import template
from django.contrib.admin.helpers import AdminForm, Fieldset
from django.contrib.admin.views.main import ChangeList
from django.db.models.options import Options
from django.forms import Field
from django.http import HttpRequest
from django.template import Context, Library, Node, RequestContext, TemplateSyntaxError
Expand All @@ -16,9 +17,44 @@
register = Library()


@register.simple_tag(name="tab_list", takes_context=True)
def tab_list(context, page, opts) -> str:
def _get_tabs_list(
context: RequestContext, page: str, opts: Optional[Options] = None
) -> list:
tabs_list = []
page_id = None

if page not in ["changeform", "changelist"]:
page_id = page

for tab in context.get("tab_list", []):
if page_id:
if tab.get("page") == page_id:
tabs_list = tab["items"]
break

continue

if "models" not in tab:
continue

for tab_model in tab["models"]:
if isinstance(tab_model, str):
if str(opts) == tab_model and page == "changelist":
tabs_list = tab["items"]
break
elif isinstance(tab_model, dict) and str(opts) == tab_model["name"]:
is_detail = tab_model.get("detail", False)

if (page == "changeform" and is_detail) or (
page == "changelist" and not is_detail
):
tabs_list = tab["items"]
break
return tabs_list


@register.simple_tag(name="tab_list", takes_context=True)
def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None) -> str:
inlines_list = []

data = {
Expand All @@ -27,22 +63,18 @@ def tab_list(context, page, opts) -> str:
"actions_list": context.get("actions_list"),
"actions_items": context.get("actions_items"),
"is_popup": context.get("is_popup"),
"tabs_list": _get_tabs_list(context, page, opts),
}

for tab in context.get("tab_list", []):
if str(opts) in tab["models"]:
tabs_list = tab["items"]
break

if page == "changelist":
data["tabs_list"] = tabs_list

for inline in context.get("inline_admin_formsets", []):
if hasattr(inline.opts, "tab"):
inlines_list.append(inline)
# If the changeform is rendered and there are no custom tab navigation
# specified, check for inlines to put into tabs
if page == "changeform" and len(data["tabs_list"]) == 0:
for inline in context.get("inline_admin_formsets", []):
if opts and hasattr(inline.opts, "tab"):
inlines_list.append(inline)

if page == "changeform" and len(inlines_list) > 0:
data["inlines_list"] = inlines_list
if len(inlines_list) > 0:
data["inlines_list"] = inlines_list

return render_to_string(
"unfold/helpers/tab_list.html",
Expand Down
Loading

0 comments on commit e564d84

Please sign in to comment.