Skip to content

Commit 610e4fd

Browse files
authored
feat: action variants (#1077)
1 parent 52c3632 commit 610e4fd

File tree

11 files changed

+234
-87
lines changed

11 files changed

+234
-87
lines changed

docs/actions/introduction.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Introduction to actions
33
order: 0
4-
description: Run custom admin actions from different places.
4+
description: Create and customize powerful Django admin actions with Unfold, featuring icon support and color variants for enhanced user experience.
55
---
66

77
# Actions
@@ -16,7 +16,7 @@ from django.contrib import admin
1616
from django.db.models import QuerySet
1717
from django.http import HttpRequest
1818
from unfold.admin import ModelAdmin
19-
from unfold.decorators import action # Import @action decorator from Unfold
19+
from unfold.decorators import action
2020

2121
@admin.register(User)
2222
class UserAdmin(ModelAdmin):
@@ -27,6 +27,50 @@ class UserAdmin(ModelAdmin):
2727
pass
2828
```
2929

30+
## Icon support
31+
32+
Unfold supports custom icons for actions. Icons are supported for all actions types. You can set the icon for an action by providing `icon` parameter to the `@action` decorator.
33+
34+
```python
35+
# admin.py
36+
37+
from django.db.models import QuerySet
38+
from django.http import HttpRequest
39+
40+
from unfold.decorators import action
41+
42+
@action(description="Custom action", icon="person")
43+
def custom_action(self, request: HttpRequest, queryset: QuerySet):
44+
pass
45+
```
46+
47+
## Action variants
48+
49+
In Unfold it is possible to change a color of the action. Unfold supports different variants of actions. You can set the variant for an action by providing `variant` parameter to the `@action` decorator.
50+
51+
```python
52+
# admin.py
53+
54+
from django.db.models import QuerySet
55+
from django.http import HttpRequest
56+
57+
from unfold.decorators import action
58+
# Import ActionVariant enum from Unfold to set action variant
59+
from unfold.enums import ActionVariant
60+
61+
# class ActionVariant(Enum):
62+
# DEFAULT = "default"
63+
# PRIMARY = "primary"
64+
# SUCCESS = "success"
65+
# INFO = "info"
66+
# WARNING = "warning"
67+
# DANGER = "danger"
68+
69+
@action(description="Custom action", variant=ActionVariant.PRIMARY)
70+
def custom_action(self, request: HttpRequest, queryset: QuerySet):
71+
pass
72+
```
73+
3074
## Actions overview
3175

3276
Besides traditional actions selected from dropdown, Unfold supports several other types of actions. Following table gives overview of all available actions together with their recommended usage:

src/unfold/dataclasses.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from dataclasses import dataclass
22
from typing import Callable, Optional, Union
33

4+
from unfold.enums import ActionVariant
5+
46
from .typing import ActionFunction
57

68

@@ -13,6 +15,7 @@ class UnfoldAction:
1315
attrs: Optional[dict] = None
1416
object_id: Optional[Union[int, str]] = None
1517
icon: Optional[str] = None
18+
variant: Optional[ActionVariant] = ActionVariant.DEFAULT
1619

1720

1821
@dataclass

src/unfold/decorators.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from django.db.models.expressions import BaseExpression, Combinable
88
from django.http import HttpRequest, HttpResponse
99

10+
from unfold.enums import ActionVariant
11+
1012
from .typing import ActionFunction
1113

1214

@@ -18,6 +20,7 @@ def action(
1820
url_path: Optional[str] = None,
1921
attrs: Optional[dict[str, Any]] = None,
2022
icon: Optional[str] = None,
23+
variant: Optional[ActionVariant] = ActionVariant.DEFAULT,
2124
) -> ActionFunction:
2225
def decorator(func: Callable) -> ActionFunction:
2326
def inner(
@@ -59,6 +62,11 @@ def inner(
5962
if icon is not None:
6063
inner.icon = icon
6164

65+
if variant is not None:
66+
inner.variant = variant
67+
else:
68+
inner.variant = ActionVariant.DEFAULT
69+
6270
inner.attrs = attrs or {}
6371
return inner
6472

src/unfold/enums.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from enum import Enum
2+
3+
4+
class ActionVariant(Enum):
5+
DEFAULT = "default"
6+
PRIMARY = "primary"
7+
SUCCESS = "success"
8+
INFO = "info"
9+
WARNING = "warning"
10+
DANGER = "danger"

src/unfold/mixins/action_model_admin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.urls import reverse
88

99
from unfold.dataclasses import UnfoldAction
10+
from unfold.enums import ActionVariant
1011
from unfold.exceptions import UnfoldException
1112

1213

@@ -119,6 +120,7 @@ def get_unfold_action(self, action: str) -> UnfoldAction:
119120
path=getattr(method, "url_path", action),
120121
attrs=method.attrs if hasattr(method, "attrs") else None,
121122
icon=method.icon if hasattr(method, "icon") else None,
123+
variant=method.variant if hasattr(method, "variant") else None,
122124
)
123125

124126
def get_actions_list(self, request: HttpRequest) -> list[UnfoldAction]:
@@ -265,6 +267,7 @@ def get_action_attrs(action: UnfoldAction) -> dict:
265267
return {
266268
"title": action.description,
267269
"icon": action.icon,
270+
"variant": action.variant,
268271
"attrs": action.method.attrs,
269272
"path": get_action_path(action),
270273
}
@@ -276,6 +279,7 @@ def build_dropdown(nav_item: dict) -> Optional[dict]:
276279
dropdown = {
277280
"title": nav_item["title"],
278281
"icon": nav_item.get("icon"),
282+
"variant": nav_item.get("variant", ActionVariant.DEFAULT),
279283
"items": [],
280284
}
281285

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.
Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
{% load unfold %}
22

3-
<li class="border-b flex-grow relative text-center md:border-b-0 md:border-r last:border-0 dark:border-base-700" {% if not link %}x-data="{actionDropdownOpen: false}"{% endif %}>
4-
<a {% if link %}href="{{ link }}"{% endif %}class="cursor-pointer flex items-center gap-2 px-4 py-2 text-left whitespace-nowrap hover:text-primary-600 dark:hover:text-primary-500"{% if blank %} target="_blank"{% endif %} {% include "unfold/helpers/attrs.html" with attrs=action.attrs %} {% if not link %}x-on:click="actionDropdownOpen = !actionDropdownOpen" x-bind:class="{'text-primary-600 dark:text-primary-500': actionDropdownOpen }"{% endif %}>
5-
3+
<li class="{% action_item_classes action %}" {% if not link %}x-data="{actionDropdownOpen: false}" x-ref="actionDropdown{{ action.method_name }}"{% endif %}>
4+
<a {% if link %}href="{{ link }}"{% endif %}class="cursor-pointer flex items-center gap-2 px-3 py-2 text-left whitespace-nowrap" {% if blank %} target="_blank"{% endif %} {% include "unfold/helpers/attrs.html" with attrs=action.attrs %}
5+
{% if not link %}
6+
x-on:click="actionDropdownOpen = !actionDropdownOpen"
7+
x-bind:class="{'{% if action.variant.value == "default" %}text-primary-600 dark:text-primary-500{% endif %}': actionDropdownOpen }"
8+
{% endif %}>
69
{% if action.icon %}
710
<span class="material-symbols-outlined">
811
{{ action.icon }}
@@ -12,27 +15,29 @@
1215
{{ title }}
1316

1417
{% if not link %}
15-
<span class="material-symbols-outlined rotate-90">
18+
<span class="material-symbols-outlined ml-auto rotate-90">
1619
chevron_right
1720
</span>
1821
{% endif %}
1922
</a>
2023

2124
{% if not link %}
22-
<nav class="absolute bg-white border flex flex-col -mr-px py-1 right-0 rounded shadow-lg top-10 w-52 z-50 dark:bg-base-800 dark:border-base-700" x-transition x-show="actionDropdownOpen" x-on:click.outside="actionDropdownOpen = false">
23-
{% for item in action.items %}
24-
<a href="{{ item.path }}" class="flex items-center font-normal gap-2 max-h-[30px] mx-1 px-3 py-2 rounded text-left hover:bg-base-100 hover:text-base-700 dark:hover:bg-base-700 dark:hover:text-base-200"{% if blank %} target="_blank"{% endif %} {% include "unfold/helpers/attrs.html" with attrs=action.attrs %}>
25-
{% if item.icon %}
26-
<span class="material-symbols-outlined">
27-
{{ item.icon }}
28-
</span>
29-
{% endif %}
25+
<template x-teleport="body">
26+
<nav x-anchor.bottom-end.offset.4="$refs.actionDropdown{{ action.method_name }}" class="absolute bg-white border flex flex-col -mr-px py-1 right-0 rounded shadow-lg top-10 w-52 z-50 dark:bg-base-800 dark:border-base-700" x-transition x-show="actionDropdownOpen" x-on:click.outside="actionDropdownOpen = false">
27+
{% for item in action.items %}
28+
<a href="{{ item.path }}" class="flex items-center font-normal gap-2 max-h-[30px] mx-1 px-3 py-2 rounded text-left hover:bg-base-100 hover:text-base-700 dark:hover:bg-base-700 dark:hover:text-base-200"{% if blank %} target="_blank"{% endif %} {% include "unfold/helpers/attrs.html" with attrs=action.attrs %}>
29+
{% if item.icon %}
30+
<span class="material-symbols-outlined">
31+
{{ item.icon }}
32+
</span>
33+
{% endif %}
3034

31-
<span class="grow truncate">
32-
{{ item.title }}
33-
</span>
34-
</a>
35-
{% endfor %}
36-
</nav>
35+
<span class="grow truncate">
36+
{{ item.title }}
37+
</span>
38+
</a>
39+
{% endfor %}
40+
</nav>
41+
</template>
3742
{% endif %}
3843
</li>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% if actions_list or actions_detail or actions_items or nav_global %}
2+
<ul class="flex flex-col font-medium mb-4 ml-auto mt-2 shadow-sm md:flex-row md:mb-2 md:mt-0 max-md:w-full">
3+
{% for action in actions_list %}
4+
{% include "unfold/helpers/tab_action.html" with title=action.title link=action.path %}
5+
{% endfor %}
6+
7+
{% for action in actions_detail %}
8+
{% include "unfold/helpers/tab_action.html" with title=action.title link=action.path %}
9+
{% endfor %}
10+
11+
{% if actions_items %}
12+
{{ actions_items }}
13+
{% endif %}
14+
15+
{% if nav_global %}
16+
{{ nav_global }}
17+
{% endif %}
18+
</ul>
19+
{% endif %}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{% load i18n %}
2+
3+
{% if inlines_list or tabs_list %}
4+
<ul class="border rounded flex flex-col max-md:w-full md:flex-row md:border-b-0 md:border-t-0 md:border-l-0 md:border-r-0 dark:border-base-800">
5+
{% for item in tabs_list %}
6+
{% if item.has_permission %}
7+
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
8+
<a href="{% if item.link_callback %}{{ item.link_callback }}{% else %}{{ item.link }}{% endif %}{% if item.inline %}#{{ item.inline }}{% endif %}"
9+
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 %}"
10+
{% if item.inline %}
11+
x-on:click="activeTab = '{{ item.inline }}'"
12+
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 }}'}"
13+
{% endif %}
14+
>
15+
{{ item.title }}
16+
</a>
17+
</li>
18+
{% endif %}
19+
{% endfor %}
20+
21+
{% if inlines_list %}
22+
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
23+
<a class="block cursor-pointer font-medium px-3 py-2 md:py-4 md:px-0"
24+
href="#general"
25+
x-on:click="activeTab = 'general'"
26+
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 == 'general', 'hover:text-primary-600 dark:hover:text-primary-500 dark:border-base-800': activeTab != 'general'}">
27+
{% trans "General" %}
28+
</a>
29+
</li>
30+
31+
{% for inline in inlines_list %}
32+
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
33+
<a class="block cursor-pointer font-medium px-3 py-2 md:py-4 md:px-0"
34+
href="#{{ inline.opts.verbose_name|slugify }}"
35+
x-on:click="activeTab = '{{ inline.opts.verbose_name|slugify }}'"
36+
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 == '{{ inline.opts.verbose_name|slugify }}', 'hover:text-primary-600 dark:hover:text-primary-500 dark:border-base-800': activeTab != '{{ inline.opts.verbose_name|slugify }}'}">
37+
{% if inline.formset.max_num == 1 %}
38+
{{ inline.opts.verbose_name|capfirst }}
39+
{% else %}
40+
{{ inline.opts.verbose_name_plural|capfirst }}
41+
{% endif %}
42+
</a>
43+
</li>
44+
{% endfor %}
45+
{% endif %}
46+
</ul>
47+
{% endif %}

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

Lines changed: 3 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,73 +2,10 @@
22

33
{% if not is_popup %}
44
{% if tabs_list or inlines_list or actions_list or actions_detail or actions_items or nav_global %}
5-
<div class="flex items-start flex-col mb-4 text-sm w-full md:border-b dark:md:border-base-800 md:border-l-0 md:flex-row md:items-center md:justify-end">
6-
{% if inlines_list or tabs_list %}
7-
<ul class="border rounded flex flex-col w-full md:flex-row md:border-b-0 md:border-t-0 md:border-l-0 md:border-r-0 dark:border-base-800">
8-
{% for item in tabs_list %}
9-
{% if item.has_permission %}
10-
<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 %}{% if item.inline %}#{{ item.inline }}{% 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-
>
18-
{{ item.title }}
19-
</a>
20-
</li>
21-
{% endif %}
22-
{% endfor %}
5+
<div class="flex items-start flex-col mb-4 md:border-b dark:md:border-base-800 md:border-l-0 md:flex-row md:items-center">
6+
{% include "unfold/helpers/tab_items.html" %}
237

24-
{% if inlines_list %}
25-
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
26-
<a class="block cursor-pointer font-medium px-3 py-2 md:py-4 md:px-0"
27-
href="#general"
28-
x-on:click="activeTab = 'general'"
29-
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 == 'general', 'hover:text-primary-600 dark:hover:text-primary-500 dark:border-base-800': activeTab != 'general'}">
30-
{% trans "General" %}
31-
</a>
32-
</li>
33-
34-
{% for inline in inlines_list %}
35-
<li class="border-b last:border-b-0 md:border-b-0 md:mr-8 dark:border-base-800">
36-
<a class="block cursor-pointer font-medium px-3 py-2 md:py-4 md:px-0"
37-
href="#{{ inline.opts.verbose_name|slugify }}"
38-
x-on:click="activeTab = '{{ inline.opts.verbose_name|slugify }}'"
39-
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 == '{{ inline.opts.verbose_name|slugify }}', 'hover:text-primary-600 dark:hover:text-primary-500 dark:border-base-800': activeTab != '{{ inline.opts.verbose_name|slugify }}'}">
40-
{% if inline.formset.max_num == 1 %}
41-
{{ inline.opts.verbose_name|capfirst }}
42-
{% else %}
43-
{{ inline.opts.verbose_name_plural|capfirst }}
44-
{% endif %}
45-
</a>
46-
</li>
47-
{% endfor %}
48-
{% endif %}
49-
</ul>
50-
{% endif %}
51-
52-
{% if actions_list or actions_detail or actions_items or nav_global %}
53-
<ul class="border flex flex-col font-medium mb-4 mt-2 rounded shadow-sm md:flex-row md:mb-2 md:mt-0 dark:border-base-700 max-md:w-full">
54-
{% for action in actions_list %}
55-
{% include "unfold/helpers/tab_action.html" with title=action.title link=action.path %}
56-
{% endfor %}
57-
58-
{% for action in actions_detail %}
59-
{% include "unfold/helpers/tab_action.html" with title=action.title link=action.path %}
60-
{% endfor %}
61-
62-
{% if actions_items %}
63-
{{ actions_items }}
64-
{% endif %}
65-
66-
{% if nav_global %}
67-
{{ nav_global }}
68-
{% endif %}
69-
</ul>
70-
{% endif %}
8+
{% include "unfold/helpers/tab_actions.html" %}
719
</div>
7210
{% endif %}
73-
7411
{% endif %}

0 commit comments

Comments
 (0)