Skip to content

Commit 9913a45

Browse files
authored
feat: checkbox & radio filters (#1178)
1 parent 5c2b3a2 commit 9913a45

File tree

11 files changed

+350
-49
lines changed

11 files changed

+350
-49
lines changed

docs/filters/checkbox-radio.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
---
2+
title: Checkbox and radio filters
3+
order: 6
4+
description: Enhance your Django admin with checkbox and radio button filters for model fields with choices, boolean fields, and related fields, providing a more intuitive and user-friendly filtering experience.
5+
---
6+
7+
# Checkbox and radio filters
8+
9+
Checkbox and radio filters display all options at once for easier selection, ideal for scenarios with a manageable number of filtering options. Unfold provides radio filters (single selection), checkbox filters (multiple selection), and specialized versions for different field types, making filtering more intuitive and accessible than traditional dropdowns.
10+
11+
12+
## Radio or checkbox filters for fields with choices
13+
14+
Unfold provides specialized filters for model fields that have predefined choices. These filters enhance the user experience by displaying the choices as either radio buttons or checkboxes instead of the default dropdown.
15+
16+
- These filters work with any model field that contains the `choices` property (typically `CharField` with choices)
17+
- The library supports both radio button inputs (`ChoicesRadioFilter`) and checkbox inputs (`ChoicesCheckboxFilter`)
18+
- When using the radio button filter (`ChoicesRadioFilter`), an "All" option is automatically added at the beginning of the list, allowing users to clear their selection
19+
- The checkbox filter (`ChoicesCheckboxFilter`) allows for selecting multiple options simultaneously
20+
21+
These filters are particularly useful when you have a reasonable number of choices that would benefit from being all visible at once, rather than hidden in a dropdown menu.
22+
23+
```python
24+
from unfold.contrib.filters.admin import ChoicesRadioFilter, ChoicesCheckboxFilter
25+
26+
class SampleModelAdmin(ModelAdmin):
27+
list_filter = [
28+
("status", ChoicesCheckboxFilter),
29+
("status", ChoicesRadioFilter)
30+
]
31+
```
32+
33+
## Radio filter for BooleanField
34+
35+
For boolean fields (`django.db.models.BooleanField`), Unfold provides a specialized filter called `BooleanRadioFilter`. This filter enhances the user experience by displaying the boolean options (Yes/No) as radio inputs, making it more intuitive and visually appealing compared to the default dropdown.
36+
37+
The `BooleanRadioFilter` automatically includes an "All" option, allowing users to clear their selection and view all records regardless of the boolean field value. This is particularly useful when filtering through large datasets where you need to toggle between filtered and unfiltered views.
38+
39+
Here's how to implement the `BooleanRadioFilter` in your admin configuration:
40+
41+
```python
42+
from unfold.contrib.filters.admin import BooleanRadioFilter
43+
44+
45+
class SampleModelAdmin(ModelAdmin):
46+
list_filter = [
47+
("is_active", BooleanRadioFilter)
48+
]
49+
```
50+
51+
## Checkbox related field filter
52+
53+
The `RelatedCheckboxFilter` is designed to work with foreign key relationships in your models. This filter displays related objects as a list of checkboxes, allowing users to select multiple values simultaneously. It's particularly useful when filtering by related models where you want to provide a more visual and accessible interface than a standard dropdown.
54+
55+
```python
56+
from unfold.contrib.filters.admin import RelatedCheckboxFilter
57+
58+
59+
class SampleModelAdmin(ModelAdmin):
60+
list_filter = [
61+
("country", RelatedCheckboxFilter)
62+
]
63+
```
64+
65+
66+
## Displaying all values in field
67+
68+
The `AllValuesCheckboxFilter` provides a checkbox interface that automatically displays all distinct values found in the database column for the specified field. This filter functions similarly to Django's built-in `AllValuesFieldListFilter`, but enhances the user experience by presenting all available options as checkboxes instead of a dropdown menu. This approach allows users to see all possible values at once and select multiple options simultaneously, making it particularly useful for fields with a moderate number of distinct values that users frequently need to filter by.
69+
70+
```python
71+
from unfold.contrib.filters.admin import AllValuesCheckboxFilter
72+
73+
74+
class SampleModelAdmin(ModelAdmin):
75+
list_filter = [
76+
("option", AllValuesCheckboxFilter)
77+
]
78+
```
79+
80+
## Custom checkbox or radio filter
81+
82+
For custom filtering requirements, Unfold allows you to create your own checkbox or radio filters by extending the base filter classes. This gives you complete control over the filter's behavior, appearance, and the underlying query logic.
83+
84+
You can create custom filters by extending either the `RadioFilter` or `CheckboxFilter` base classes, depending on whether you want single or multiple selection capability.
85+
86+
```python
87+
from unfold.contrib.filters.admin import RadioFilter
88+
89+
90+
class CustomRadioFilter(RadioFilter):
91+
title = _("Custom radio filter")
92+
parameter_name = "query_param_in_uri"
93+
94+
def lookups(self, request, model_admin):
95+
return [
96+
["option_1", _("Option 1")],
97+
["option_2", _("Option 2")],
98+
]
99+
100+
def queryset(self, request, queryset):
101+
if self.value() not in EMPTY_VALUES:
102+
# Here write custom query
103+
return queryset.filter(your_field=self.value())
104+
105+
return queryset
106+
```

src/unfold/contrib/filters/admin/__init__.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
AutocompleteSelectFilter,
33
AutocompleteSelectMultipleFilter,
44
)
5+
from unfold.contrib.filters.admin.choice_filters import (
6+
AllValuesCheckboxFilter,
7+
BooleanRadioFilter,
8+
CheckboxFilter,
9+
ChoicesCheckboxFilter,
10+
ChoicesRadioFilter,
11+
RadioFilter,
12+
RelatedCheckboxFilter,
13+
)
514
from unfold.contrib.filters.admin.datetime_filters import (
615
RangeDateFilter,
716
RangeDateTimeFilter,
@@ -11,7 +20,6 @@
1120
DropdownFilter,
1221
MultipleChoicesDropdownFilter,
1322
MultipleDropdownFilter,
14-
MultipleRelatedDropdownFilter,
1523
RelatedDropdownFilter,
1624
)
1725
from unfold.contrib.filters.admin.numeric_filters import (
@@ -23,12 +31,19 @@
2331
from unfold.contrib.filters.admin.text_filters import FieldTextFilter, TextFilter
2432

2533
__all__ = [
34+
"AllValuesCheckboxFilter",
35+
"BooleanRadioFilter",
36+
"CheckboxFilter",
37+
"ChoicesCheckboxFilter",
38+
"ChoicesRadioFilter",
39+
"MultipleRelatedCheckboxFilter",
40+
"RadioFilter",
2641
"ChoicesDropdownFilter",
2742
"MultipleChoicesDropdownFilter",
2843
"DropdownFilter",
2944
"RelatedDropdownFilter",
3045
"MultipleDropdownFilter",
31-
"MultipleRelatedDropdownFilter",
46+
"RelatedCheckboxFilter",
3247
"FieldTextFilter",
3348
"TextFilter",
3449
"RangeDateFilter",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from collections.abc import Generator
2+
from typing import Any
3+
4+
from django.contrib import admin
5+
from django.contrib.admin.views.main import ChangeList
6+
from django.core.validators import EMPTY_VALUES
7+
from django.db.models import QuerySet
8+
from django.http import HttpRequest
9+
from django.utils.translation import gettext_lazy as _
10+
11+
from unfold.contrib.filters.admin.mixins import MultiValueMixin, ValueMixin
12+
from unfold.contrib.filters.forms import CheckboxForm, HorizontalRadioForm, RadioForm
13+
14+
15+
class RadioFilter(admin.SimpleListFilter):
16+
template = "unfold/filters/filters_field.html"
17+
form_class = RadioForm
18+
all_option = ["", _("All")]
19+
20+
def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
21+
if self.all_option:
22+
choices = [self.all_option, *self.lookup_choices]
23+
else:
24+
choices = self.lookup_choices
25+
26+
return (
27+
{
28+
"form": self.form_class(
29+
label=_(" By %(filter_title)s ") % {"filter_title": self.title},
30+
name=self.parameter_name,
31+
choices=choices,
32+
data={self.parameter_name: self.value()},
33+
),
34+
},
35+
)
36+
37+
38+
class CheckboxFilter(RadioFilter):
39+
form_class = CheckboxForm
40+
all_option = None
41+
42+
43+
class ChoicesMixin:
44+
template = "unfold/filters/filters_field.html"
45+
46+
def choices(self, changelist: ChangeList) -> Generator[dict[str, Any], None, None]:
47+
if self.all_option:
48+
choices = [self.all_option, *self.field.flatchoices]
49+
else:
50+
choices = self.field.flatchoices
51+
52+
yield {
53+
"form": self.form_class(
54+
label=_(" By %(filter_title)s ") % {"filter_title": self.title},
55+
name=self.lookup_kwarg,
56+
choices=choices,
57+
data={self.lookup_kwarg: self.value()},
58+
),
59+
}
60+
61+
62+
class ChoicesRadioFilter(ValueMixin, ChoicesMixin, admin.ChoicesFieldListFilter):
63+
form_class = RadioForm
64+
all_option = ["", _("All")]
65+
66+
67+
class ChoicesCheckboxFilter(ValueMixin, ChoicesMixin, admin.ChoicesFieldListFilter):
68+
form_class = CheckboxForm
69+
all_option = None
70+
71+
72+
class BooleanRadioFilter(ValueMixin, admin.BooleanFieldListFilter):
73+
template = "unfold/filters/filters_field.html"
74+
form_class = HorizontalRadioForm
75+
all_option = ["", _("All")]
76+
77+
def choices(self, changelist: ChangeList) -> Generator[dict[str, Any], None, None]:
78+
choices = [
79+
self.all_option,
80+
*[
81+
("1", _("Yes")),
82+
("0", _("No")),
83+
],
84+
]
85+
86+
yield {
87+
"form": self.form_class(
88+
label=_(" By %(filter_title)s ") % {"filter_title": self.title},
89+
name=self.lookup_kwarg,
90+
choices=choices,
91+
data={self.lookup_kwarg: self.value()},
92+
),
93+
}
94+
95+
96+
class RelatedCheckboxFilter(MultiValueMixin, admin.RelatedFieldListFilter):
97+
template = "unfold/filters/filters_field.html"
98+
form_class = CheckboxForm
99+
100+
def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
101+
if self.value() not in EMPTY_VALUES:
102+
return super().queryset(request, queryset)
103+
104+
return queryset
105+
106+
def choices(self, changelist: ChangeList) -> Generator[dict[str, Any], None, None]:
107+
yield {
108+
"form": self.form_class(
109+
label=_(" By %(filter_title)s ") % {"filter_title": self.title},
110+
name=self.lookup_kwarg,
111+
choices=self.lookup_choices,
112+
data={self.lookup_kwarg: self.value()},
113+
),
114+
}
115+
116+
117+
class AllValuesCheckboxFilter(MultiValueMixin, admin.AllValuesFieldListFilter):
118+
template = "unfold/filters/filters_field.html"
119+
form_class = CheckboxForm
120+
121+
def choices(self, changelist: ChangeList) -> Generator[dict[str, Any], None, None]:
122+
choices = [[i, val] for i, val in enumerate(self.lookup_choices)]
123+
124+
if len(choices) == 0:
125+
return
126+
127+
yield {
128+
"form": self.form_class(
129+
label=_(" By %(filter_title)s ") % {"filter_title": self.title},
130+
name=self.lookup_kwarg,
131+
choices=[[i, val] for i, val in enumerate(self.lookup_choices)],
132+
data={self.lookup_kwarg: self.value()},
133+
),
134+
}

src/unfold/contrib/filters/forms.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
from django import forms
2+
from django.contrib.admin.options import HORIZONTAL
23
from django.contrib.admin.widgets import AutocompleteSelect, AutocompleteSelectMultiple
34
from django.db.models import Field as ModelField
4-
from django.forms import ChoiceField, ModelMultipleChoiceField, MultipleChoiceField
5+
from django.forms import (
6+
ChoiceField,
7+
ModelMultipleChoiceField,
8+
MultipleChoiceField,
9+
)
510
from django.http import HttpRequest
611
from django.utils.translation import gettext_lazy as _
712

813
from unfold.admin import ModelAdmin
9-
10-
from ...widgets import (
14+
from unfold.widgets import (
1115
INPUT_CLASSES,
16+
UnfoldAdminCheckboxSelectMultiple,
17+
UnfoldAdminRadioSelectWidget,
1218
UnfoldAdminSelectMultipleWidget,
1319
UnfoldAdminSelectWidget,
1420
UnfoldAdminSplitDateTimeVerticalWidget,
@@ -71,6 +77,38 @@ class Media:
7177
}
7278

7379

80+
class CheckboxForm(forms.Form):
81+
field = MultipleChoiceField
82+
widget = UnfoldAdminCheckboxSelectMultiple
83+
84+
def __init__(
85+
self,
86+
name: str,
87+
label: str,
88+
choices: tuple,
89+
*args,
90+
**kwargs,
91+
) -> None:
92+
super().__init__(*args, **kwargs)
93+
94+
self.fields[name] = self.field(
95+
label=label,
96+
required=False,
97+
choices=choices,
98+
widget=self.widget,
99+
)
100+
101+
102+
class RadioForm(CheckboxForm):
103+
field = ChoiceField
104+
widget = UnfoldAdminRadioSelectWidget
105+
106+
107+
class HorizontalRadioForm(RadioForm):
108+
horizontal = True
109+
widget = UnfoldAdminRadioSelectWidget(radio_style=HORIZONTAL)
110+
111+
74112
class DropdownForm(forms.Form):
75113
widget = UnfoldAdminSelectWidget(
76114
attrs={

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/admin/change_list.html

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,23 @@
7474
{% include cl.model_admin.list_before_template %}
7575
{% endif %}
7676

77-
<div class="flex flex-col gap-4 mb-4 sm:flex-row empty:hidden lg:border lg:border-base-200 lg:dark:border-base-800 lg:-mb-8 lg:p-3 lg:pb-11 lg:rounded-t">
78-
{% block search %}
79-
{% search_form cl %}
80-
{% endblock %}
77+
{% spaceless %}
78+
<div class="flex flex-col gap-4 mb-4 sm:flex-row empty:hidden lg:border lg:border-base-200 lg:dark:border-base-800 lg:-mb-8 lg:p-3 lg:pb-11 lg:rounded-t">
79+
{% block search %}
80+
{% search_form cl %}
81+
{% endblock %}
8182

82-
{% block filters %}
83-
{% if cl.has_filters %}
84-
<a class="{% if cl.has_active_filters %}bg-primary-600 border-primary-600 text-white{% else %}bg-white border-base-200 hover:text-primary-600 dark:bg-base-900 dark:border-base-700 dark:hover:text-primary-500{% endif %} border cursor-pointer flex font-medium gap-2 group items-center px-3 py-2 rounded shadow-sm text-sm lg:ml-auto md:mt-0 {% if not cl.model_admin.list_filter_sheet %}2xl:hidden{% endif %}" x-on:click="filterOpen = true" x-on:keydown.escape.window="filterOpen = false">
85-
{% trans "Filters" %}
83+
{% block filters %}
84+
{% if cl.has_filters %}
85+
<a class="{% if cl.has_active_filters %}bg-primary-600 border-primary-600 text-white{% else %}bg-white border-base-200 hover:text-primary-600 dark:bg-base-900 dark:border-base-700 dark:hover:text-primary-500{% endif %} border cursor-pointer flex font-medium gap-2 group items-center px-3 py-2 rounded shadow-sm text-sm lg:ml-auto md:mt-0 {% if not cl.model_admin.list_filter_sheet %}2xl:hidden{% endif %}" x-on:click="filterOpen = true" x-on:keydown.escape.window="filterOpen = false">
86+
{% trans "Filters" %}
8687

87-
<span class="material-symbols-outlined md-18 ml-auto">filter_list</span>
88-
</a>
89-
{% endif %}
90-
{% endblock %}
91-
</div>
88+
<span class="material-symbols-outlined md-18 ml-auto">filter_list</span>
89+
</a>
90+
{% endif %}
91+
{% endblock %}
92+
</div>
93+
{% endspaceless %}
9294

9395
<form id="changelist-form" class="group" method="post"{% if cl.formset and cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %} novalidate>
9496
{% csrf_token %}

0 commit comments

Comments
 (0)