Skip to content

Commit 1a12d4c

Browse files
committed
feat: changelist dropdowns in display decorator
1 parent 2173def commit 1a12d4c

File tree

8 files changed

+159
-28
lines changed

8 files changed

+159
-28
lines changed

docs/decorators/display.md

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ order: 1
66

77
# Unfold @display decorator
88

9-
Unfold introduces it's own `unfold.decorators.display` decorator. By default it has exactly same behavior as native `django.contrib.admin.decorators.display` but it adds same customizations which helps to extends default logic.
9+
Unfold introduces its own `unfold.decorators.display` decorator. By default, it has exactly the same behavior as the native `django.contrib.admin.decorators.display` but it adds customizations which help to extend the default logic.
1010

11-
`@display(label=True)`, `@display(label={"value1": "success"})` displays a result as a label. This option fits for different types of statuses. Label can be either boolean indicating we want to use label with default color or dict where the dict is responsible for displaying labels in different colors. At the moment these color combinations are supported: success(green), info(blue), danger(red) and warning(orange).
11+
`@display(label=True)`, `@display(label={"value1": "success"})` displays a result as a label. This option fits for different types of statuses. The label can be either a boolean indicating we want to use a label with the default color, or a dict where the dict is responsible for displaying labels in different colors. At the moment these color combinations are supported: success (green), info (blue), danger (red) and warning (orange).
1212

13-
`@display(header=True)` displays in results list two information in one table cell. Good example is when we want to display customer information, first line is going to be customer's name and right below the name display corresponding email address. Method with such a decorator is supposed to return a list with two elements `return "Full name", "E-mail address"`. There is a third optional argument, which is type of the string and its value is displayed in a circle before first two values on the front end. Its optimal usage is for displaying initials.
13+
`@display(header=True)` displays two pieces of information in one table cell in the results list. A good example is when we want to display customer information - the first line will be the customer's name and right below the name, the corresponding email address is displayed. A method with such a decorator is supposed to return a list with two elements `return "Full name", "E-mail address"`. There is a third optional argument, which is the type of string and its value is displayed in a circle before the first two values on the front end. Its optimal usage is for displaying initials.
1414

1515
```python
1616
# admin.py
@@ -80,3 +80,80 @@ class UserAdmin(ModelAdmin):
8080
}
8181
]
8282
```
83+
84+
## Dropdown support
85+
86+
For the changelist, it is possible to apply `dropdown=True` which will display a clickable link. After clicking on the link, a dropdown will appear. There are two supported options for rendering the content of the dropdown:
87+
88+
- Providing a list of `items`. This will render a classic list of items, which is a good option for displaying a list of related objects.
89+
- Defining the `content` attribute which will display your custom content in the dropdown. This is handy for rendering complex layouts in the dropdown.
90+
91+
### Rendering list of options
92+
93+
The following example demonstrates how to create a dropdown with a list of items. The dropdown configuration accepts these options:
94+
95+
- `title` (required) - The text displayed in the column that users click to open the dropdown
96+
- `items` (required) - List of items to display in the dropdown menu. Each item should have:
97+
- `title` - Text to display for the item
98+
- `link` (optional) - URL the item links to
99+
- `striped` (optional) - Boolean to enable alternating background colors for items
100+
- `height` (optional) - Maximum height in pixels before scrolling is enabled
101+
- `width` (optional) - Width of the dropdown in pixels
102+
103+
The dropdown will be positioned below the clicked element and will close when clicking outside or selecting an item.
104+
105+
106+
```python
107+
class UserAdmin(ModelAdmin):
108+
list_display = [
109+
"display_dropdown",
110+
]
111+
112+
@display(description=_("Status"), dropdown=True)
113+
def display_dropdown(self, obj):
114+
return {
115+
# Clickable title displayed in the column
116+
"title": "Custom dropdown title",
117+
# Striped design for the items
118+
"striped": True, # Optional
119+
# Dropdown height. Will display scrollbar for longer content
120+
"height": 200, # Optional
121+
# Dropdown width
122+
"width": 240, # Optional
123+
"items": [
124+
{
125+
"title": "First title",
126+
"link": "#" # Optional
127+
},
128+
{
129+
"title": "Second title",
130+
"link": "#" # Optional
131+
},
132+
]
133+
}
134+
```
135+
136+
### Custom dropdown template
137+
138+
You can also render a custom template inside a dropdown. Just pass the `content` parameter with template content. If you want to render more complex content, use `render_to_string`.
139+
140+
The dropdown configuration accepts these options when using custom template content:
141+
142+
- `title` (required) - The text displayed in the column that users click to open the dropdown
143+
- `content` (required) - HTML content or template string to display in the dropdown
144+
145+
The dropdown will be positioned below the clicked element and will close when clicking outside. The content can include any valid HTML or Django template syntax.
146+
147+
```python
148+
class UserAdmin(ModelAdmin):
149+
list_display = [
150+
"display_dropdown",
151+
]
152+
153+
@display(description=_("Status"), dropdown=True)
154+
def display_dropdown(self, obj):
155+
return {
156+
"title": "Custom dropdown title",
157+
"content": "template content",
158+
}
159+
```

poetry.lock

Lines changed: 16 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/unfold/decorators.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def display(
8484
ordering: Optional[Union[str, Combinable, BaseExpression]] = None,
8585
description: Optional[str] = None,
8686
empty_value: Optional[str] = None,
87+
dropdown: Optional[bool] = None,
8788
label: Optional[Union[bool, str, dict[str, str]]] = None,
8889
header: Optional[bool] = None,
8990
) -> Callable:
@@ -107,6 +108,8 @@ def decorator(func: Callable[[Model], Any]) -> Callable:
107108
func.label = label
108109
if header is not None:
109110
func.header = header
111+
if dropdown is not None:
112+
func.dropdown = dropdown
110113

111114
return func
112115

src/unfold/static/unfold/css/styles.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/unfold/templates/unfold/components/table.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ <h3 class="font-semibold mb-1 text-font-important-light text-sm dark:text-font-i
1212
<table class="block border-spacing-none border-separate w-full lg:table">
1313
{% if table.headers %}
1414
<thead class="text-base-900 dark:text-base-100 {% if height %}sticky top-0{% endif %}">
15-
<tr class="bg-base-50 dark:bg-white/[.02]">
15+
<tr class="bg-base-50 dark:bg-base-900">
1616
{% for header in table.headers %}
17-
<th class="align-middle border-b border-base-200 font-semibold py-2 text-left text-sm whitespace-nowrap sortable column-description hidden px-3 lg:table-cell dark:border-base-800 {% if card_included == 1 %}first:pl-6 last:pr-6{% endif %}">
17+
<th class="align-middle border-b border-base-200 font-semibold py-2 text-left text-sm whitespace-nowrap sortable column-description hidden px-3 lg:table-cell dark:border-base-800 dark:bg-white/[.02] {% if card_included == 1 %}first:pl-6 last:pr-6{% endif %}">
1818
{{ header|capfirst }}
1919
</th>
2020
{% endfor %}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{% with dropdown_id=instance.pk|cut:"-"|add:field_name %}
2+
{% with total=value.items|length %}
3+
<div x-data="{ openDropdownId{{ dropdown_id }}: false }">
4+
<div class="flex flex-row gap-1.5 items-center {% if total > 0 or value.content %}cursor-pointer{% endif %}" {% if total > 0 or value.content %}x-ref="rowDropdown{{ dropdown_id }}" x-on:click="openDropdownId{{ dropdown_id }} = !openDropdownId{{ dropdown_id }}"{% endif %}>
5+
{% if total > 0 or value.content %}
6+
<span class="material-symbols-outlined">unfold_more</span>
7+
{% endif %}
8+
9+
<span>
10+
{{ value.title }}
11+
</span>
12+
</div>
13+
14+
{% if total > 0 or value.content %}
15+
<template x-teleport="body">
16+
{% if value.content %}
17+
<div class="bg-white border overflow-y-auto overflow-x-hidden p-3 rounded shadow-lg text-sm top-7 z-50 w-48 dark:bg-base-800 dark:border-base-700" data-simplebar x-cloak x-transition x-anchor.bottom-start.offset.4="$refs.rowDropdown{{ dropdown_id }}" x-show="openDropdownId{{ dropdown_id }}"x-on:click.outside="openDropdownId{{ dropdown_id }} = false" {% if value.width or value.height %}style="{% if value.width %}width: {{ value.width }}px;{% endif %}{% if value.height %}height: {{ value.height }}px;{% endif %}"{% endif %}>
18+
{{ value.content }}
19+
</div>
20+
{% else %}
21+
<nav class="bg-white border overflow-y-auto overflow-x-hidden flex flex-col py-1 rounded shadow-lg text-sm top-7 z-50 w-48 dark:bg-base-800 dark:border-base-700" data-simplebar x-cloak x-transition x-anchor.bottom-start.offset.4="$refs.rowDropdown{{ dropdown_id }}" x-show="openDropdownId{{ dropdown_id }}"x-on:click.outside="openDropdownId{{ dropdown_id }} = false" {% if value.width or value.height %}style="{% if value.width %}width: {{ value.width }}px;{% endif %}{% if value.height %}height: {{ value.height }}px;{% endif %}"{% endif %}>
22+
{% for item in value.items %}
23+
<{% if item.link %}a{% else %}span{% endif %} {% if item.link %}href="{{ item.link }}"{% endif %} class="flex items-center gap-2 mx-1 px-3 py-2 max-h-[30px] rounded hover:bg-base-100 dark:hover:bg-base-700 dark:hover:text-base-200 {% if value.striped %}{% cycle '' 'bg-base-50 dark:bg-white/[.04]' %}{% endif %}">
24+
<span class="grow truncate">{{ item.title }}</span>
25+
</{% if item.link %}a{% else %}span{% endif %}>
26+
{% endfor %}
27+
</nav>
28+
{% endif %}
29+
</template>
30+
{% endif %}
31+
</div>
32+
{% endwith %}
33+
{% endwith %}

src/unfold/templatetags/unfold_list.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
from collections.abc import Generator
23
from typing import Any, Optional, Union
34

45
from django.contrib.admin.templatetags.admin_list import (
@@ -18,7 +19,6 @@
1819
from django.db import models
1920
from django.db.models import Model
2021
from django.forms import Form
21-
from django.http import HttpRequest
2222
from django.template import Library
2323
from django.template.base import Parser, Token
2424
from django.template.loader import render_to_string
@@ -28,6 +28,7 @@
2828
from django.utils.translation import gettext_lazy as _
2929

3030
from unfold.utils import (
31+
display_for_dropdown,
3132
display_for_field,
3233
display_for_header,
3334
display_for_label,
@@ -189,11 +190,9 @@ def make_qs_param(t, n):
189190
}
190191

191192

192-
def items_for_result(cl: ChangeList, result: HttpRequest, form) -> SafeText:
193-
"""
194-
Generate the actual list of data.
195-
"""
196-
193+
def items_for_result(
194+
cl: ChangeList, result: Model, form
195+
) -> Generator[SafeText, None, None]:
197196
def link_in_col(is_first: bool, field_name: str, cl: ChangeList) -> bool:
198197
if cl.list_display_links is None:
199198
return False
@@ -226,9 +225,14 @@ def link_in_col(is_first: bool, field_name: str, cl: ChangeList) -> bool:
226225
boolean = getattr(attr, "boolean", False)
227226
label = getattr(attr, "label", False)
228227
header = getattr(attr, "header", False)
228+
dropdown = getattr(attr, "dropdown", False)
229229

230230
if label:
231231
result_repr = display_for_label(value, empty_value_display, label)
232+
elif dropdown:
233+
result_repr = display_for_dropdown(
234+
result, field_name, value, empty_value_display
235+
)
232236
elif header:
233237
result_repr = display_for_header(value, empty_value_display)
234238
else:

src/unfold/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from django.conf import settings
88
from django.db import models
9+
from django.db.models import Model
910
from django.template.loader import render_to_string
1011
from django.utils import formats, timezone
1112
from django.utils.hashable import make_hashable
@@ -33,6 +34,19 @@ def display_for_header(value: Iterable, empty_value_display: str) -> SafeText:
3334
)
3435

3536

37+
def display_for_dropdown(
38+
result: Model, field_name: str, value: Iterable, empty_value_display: str
39+
) -> SafeText:
40+
return render_to_string(
41+
"unfold/helpers/display_dropdown.html",
42+
{
43+
"instance": result,
44+
"field_name": field_name,
45+
"value": value,
46+
},
47+
)
48+
49+
3650
def display_for_label(value: Any, empty_value_display: str, label: Any) -> SafeText:
3751
label_type = None
3852
multiple = False

0 commit comments

Comments
 (0)