Skip to content

Commit e564d84

Browse files
authored
feat: custom tab navigations (#1029)
1 parent d872753 commit e564d84

File tree

11 files changed

+385
-31
lines changed

11 files changed

+385
-31
lines changed

docs/tabs/changeform.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
title: Changeform tabs
3+
order: 2
4+
description: Learn how to configure and customize tab navigation in Django Unfold admin changeform views, including model-specific tabs and permission-based access control.
5+
---
6+
7+
# Changeform tabs
8+
9+
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`.
10+
11+
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`.
12+
13+
```python
14+
# settings.py
15+
16+
from django.urls import reverse_lazy
17+
from django.utils.translation import gettext_lazy as _
18+
19+
UNFOLD = {
20+
"TABS": [
21+
{
22+
# Which changeform models are going to display tab navigation
23+
"models": [
24+
{
25+
"app_label.model_name_in_lowercase",
26+
"detail": True, # Displays tab navigation on changeform page
27+
},
28+
],
29+
# List of tab items
30+
"items": [
31+
{
32+
"title": _("Your custom title"),
33+
"link": reverse_lazy("admin:app_label_model_name_changelist"),
34+
"permission": "sample_app.permission_callback",
35+
},
36+
{
37+
"title": _("Another custom title"),
38+
"link": reverse_lazy("admin:app_label_another_model_name_changelist"),
39+
"permission": "sample_app.permission_callback",
40+
},
41+
],
42+
},
43+
],
44+
}
45+
46+
# Permission callback for tab item
47+
def permission_callback(request):
48+
return request.user.has_perm("sample_app.change_model")
49+
```

docs/tabs/changelist.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
---
2-
title: Changelist
2+
title: Changelist tabs
33
order: 1
4-
description: Tab navigation in changelist view.
4+
description: Learn how to configure and customize tab navigation in Django Unfold admin changelist views, including model-specific tabs and permission-based access control.
55
---
66

77
# Changelist tabs
8+
89
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`.
910

1011
```python

docs/tabs/dynamic.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
title: Dynamic tabs
3+
order: 6
4+
description: Learn how to dynamically generate tab navigation in Django Unfold admin using custom callbacks and render tabs in custom templates.
5+
---
6+
7+
# Dynamic tabs
8+
9+
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.
10+
11+
```python
12+
# settings.py
13+
14+
UNFOLD = {
15+
"TABS": "your_project.admin.tabs_callback"
16+
}
17+
```
18+
19+
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.
20+
21+
```python
22+
# admin.py
23+
24+
from django.http import HttpRequest
25+
26+
27+
def tabs_callback(request: HttpRequest) -> list[dict[str, Any]]:
28+
return [
29+
{
30+
# Unique tab identifier to render tabs in custom templates
31+
"page": "custom_page",
32+
33+
# Applies for the changeform view
34+
"models": [
35+
{
36+
"name": "app_label.model_name_in_lowercase",
37+
"detail": True
38+
},
39+
],
40+
"items": [
41+
{
42+
"title": _("Your custom title"),
43+
"link": reverse_lazy("admin:app_label_model_name_changelist"),
44+
"is_active": True # Configure active tab
45+
},
46+
],
47+
},
48+
],
49+
```
50+
51+
## Rendering tabs in custom templates
52+
53+
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.
54+
55+
```python
56+
# settings.py
57+
58+
from django.http import HttpRequest
59+
60+
UNFOLD = {
61+
"TABS": [
62+
{
63+
"page": "custom_page", # Unique tab identifier
64+
"items": [
65+
{
66+
"title": _("Your custom title"),
67+
"link": reverse_lazy("admin:app_label_model_name_changelist"),
68+
},
69+
],
70+
}
71+
]
72+
}
73+
```
74+
75+
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.
76+
77+
```html
78+
{% extends "admin/base_site.html" %}
79+
80+
{% load unfold %}
81+
82+
{% block content %}
83+
{% tab_list "custom_page" %}
84+
{% endblock %}
85+
```
86+
87+
**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.

docs/tabs/fieldsets.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
2-
title: Fieldsets
3-
order: 2
4-
description: Fieldsets with tab navigation.
2+
title: Fieldsets tabs
3+
order: 3
4+
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.
55
---
66

77
# Fieldsets tabs

docs/tabs/inline.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
2-
title: Inlines
3-
order: 3
4-
description: Change form tab navigation from inlines.
2+
title: Inlines tabs
3+
order: 4
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.
55
---
66

77
# Inlines tabs

src/unfold/admin.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,6 @@ def wrapper(*args, **kwargs):
409409
)
410410

411411
def _path_from_custom_url(self, custom_url) -> URLPattern:
412-
# TODO: wrap()
413412
return path(
414413
custom_url[0],
415414
self.admin_site.admin_view(custom_url[2]),

src/unfold/sites.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,10 @@ def get_sidebar_list(self, request: HttpRequest) -> list[dict[str, Any]]:
278278
return results
279279

280280
def get_tabs_list(self, request: HttpRequest) -> list[dict[str, Any]]:
281-
tabs = get_config(self.settings_name)["TABS"]
281+
tabs = self._get_config("TABS", request)
282+
283+
if not tabs:
284+
return []
282285

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

294-
item["active"] = self._get_is_active(
295-
request, item.get("link_callback") or item["link"], True
296-
)
297+
if "active" not in item:
298+
item["active"] = self._get_is_active(
299+
request, item.get("link_callback") or item["link"], True
300+
)
301+
297302
allowed_items.append(item)
298303

299304
tab["items"] = allowed_items
@@ -341,7 +346,7 @@ def _get_is_active(
341346
if link_path == request.path == index_path:
342347
return True
343348

344-
if link_path in request.path and link_path != index_path:
349+
if link_path != "" and link_path in request.path and link_path != index_path:
345350
query_params = parse_qs(urlparse(link).query)
346351
request_params = parse_qs(request.GET.urlencode())
347352

src/unfold/templates/unfold/helpers/tab_list.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
{% for item in tabs_list %}
99
{% if item.has_permission %}
1010
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
11-
<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 %}">
11+
<a href="{% if item.link_callback %}{{ item.link_callback }}{% else %}{{ item.link }}{% endif %}"
12+
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 %}"
13+
{% if item.inline %}
14+
x-on:click="activeTab = '{{ item.inline }}'"
15+
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 }}'}"
16+
{% endif %}
17+
>
1218
{{ item.title }}
1319
</a>
1420
</li>

src/unfold/templates/unfold/layouts/skeleton.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
{% endblock %}
6060
</head>
6161

62-
<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'">
62+
<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 %}">
6363
{% if colors %}
6464
<style id="unfold-theme-colors">
6565
:root {

src/unfold/templatetags/unfold.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django import template
55
from django.contrib.admin.helpers import AdminForm, Fieldset
66
from django.contrib.admin.views.main import ChangeList
7+
from django.db.models.options import Options
78
from django.forms import Field
89
from django.http import HttpRequest
910
from django.template import Context, Library, Node, RequestContext, TemplateSyntaxError
@@ -16,9 +17,44 @@
1617
register = Library()
1718

1819

19-
@register.simple_tag(name="tab_list", takes_context=True)
20-
def tab_list(context, page, opts) -> str:
20+
def _get_tabs_list(
21+
context: RequestContext, page: str, opts: Optional[Options] = None
22+
) -> list:
2123
tabs_list = []
24+
page_id = None
25+
26+
if page not in ["changeform", "changelist"]:
27+
page_id = page
28+
29+
for tab in context.get("tab_list", []):
30+
if page_id:
31+
if tab.get("page") == page_id:
32+
tabs_list = tab["items"]
33+
break
34+
35+
continue
36+
37+
if "models" not in tab:
38+
continue
39+
40+
for tab_model in tab["models"]:
41+
if isinstance(tab_model, str):
42+
if str(opts) == tab_model and page == "changelist":
43+
tabs_list = tab["items"]
44+
break
45+
elif isinstance(tab_model, dict) and str(opts) == tab_model["name"]:
46+
is_detail = tab_model.get("detail", False)
47+
48+
if (page == "changeform" and is_detail) or (
49+
page == "changelist" and not is_detail
50+
):
51+
tabs_list = tab["items"]
52+
break
53+
return tabs_list
54+
55+
56+
@register.simple_tag(name="tab_list", takes_context=True)
57+
def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None) -> str:
2258
inlines_list = []
2359

2460
data = {
@@ -27,22 +63,18 @@ def tab_list(context, page, opts) -> str:
2763
"actions_list": context.get("actions_list"),
2864
"actions_items": context.get("actions_items"),
2965
"is_popup": context.get("is_popup"),
66+
"tabs_list": _get_tabs_list(context, page, opts),
3067
}
3168

32-
for tab in context.get("tab_list", []):
33-
if str(opts) in tab["models"]:
34-
tabs_list = tab["items"]
35-
break
36-
37-
if page == "changelist":
38-
data["tabs_list"] = tabs_list
39-
40-
for inline in context.get("inline_admin_formsets", []):
41-
if hasattr(inline.opts, "tab"):
42-
inlines_list.append(inline)
69+
# If the changeform is rendered and there are no custom tab navigation
70+
# specified, check for inlines to put into tabs
71+
if page == "changeform" and len(data["tabs_list"]) == 0:
72+
for inline in context.get("inline_admin_formsets", []):
73+
if opts and hasattr(inline.opts, "tab"):
74+
inlines_list.append(inline)
4375

44-
if page == "changeform" and len(inlines_list) > 0:
45-
data["inlines_list"] = inlines_list
76+
if len(inlines_list) > 0:
77+
data["inlines_list"] = inlines_list
4678

4779
return render_to_string(
4880
"unfold/helpers/tab_list.html",

0 commit comments

Comments
 (0)