Skip to content

Commit bfd11b5

Browse files
authored
feat: dropdown and text filters (#388)
1 parent 05c9981 commit bfd11b5

File tree

12 files changed

+211
-14
lines changed

12 files changed

+211
-14
lines changed

README.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Did you decide to start using Unfold but you don't have time to make the switch
2828
- **Dependencies:** completely based only on `django.contrib.admin`
2929
- **Actions:** multiple ways how to define actions within different parts of admin
3030
- **WYSIWYG:** built-in support for WYSIWYG (Trix)
31-
- **Custom filters:** widgets for filtering number & datetime values
31+
- **Filters:** custom dropdown, numeric, datetime, and text fields
3232
- **Dashboard:** custom components for rapid dashboard development
3333
- **Model tabs:** define custom tab navigations for models
3434
- **Fieldset tabs:** merge several fielsets into tabs in change form
@@ -50,6 +50,8 @@ Did you decide to start using Unfold but you don't have time to make the switch
5050
- [Action handler functions](#action-handler-functions)
5151
- [Action examples](#action-examples)
5252
- [Filters](#filters)
53+
- [Text filters](#text-filters)
54+
- [Dropdown filters](#dropdown-filters)
5355
- [Numeric filters](#numeric-filters)
5456
- [Date/time filters](#datetime-filters)
5557
- [Display decorator](#display-decorator)
@@ -457,6 +459,68 @@ By default, Django admin handles all filters as regular HTML links pointing at t
457459

458460
**Note:** when implementing a filter which contains input fields, there is a no way that user can submit the values, because default filters does not contain submit button. To implement submit button, `unfold.admin.ModelAdmin` contains boolean `list_filter_submit` flag which enables submit button in filter form.
459461

462+
### Text filters
463+
464+
Text input field which allows filtering by the free string submitted by the user. There are two different variants of this filter: `FieldTextFilter` and `TextFilter`.
465+
466+
`FieldTextFilter` requires just a model field name and the filter will make `__icontains` search on this field. There are no other things to configure so the integration in `list_filter` will be just one new row looking like `("model_field_name", FieldTextFilter)`.
467+
468+
In the case of the `TextFilter`, it is needed the write a whole new class inheriting from `TextFilter` with a custom implementation of the `queryset` method and the `parameter_name` attribute. This attribute will be a representation of the search query parameter name in URI. The benefit of the `TextFilter` is the possibility of writing complex queries.
469+
470+
```python
471+
from django.contrib import admin
472+
from django.contrib.auth.models import User
473+
from django.core.validators import EMPTY_VALUES
474+
from django.utils.translation import gettext_lazy as _
475+
from unfold.admin import ModelAdmin
476+
from unfold.contrib.filters.admin import TextFilter, FieldTextFilter
477+
478+
class CustomTextFilter(TextFilter):
479+
title = _("Custom filter")
480+
parameter_name = "query_param_in_uri"
481+
482+
def queryset(self, request, queryset):
483+
if self.value() not in EMPTY_VALUES:
484+
# Here write custom query
485+
return queryset.filter(your_field=self.value())
486+
487+
return queryset
488+
489+
490+
@admin.register(User)
491+
class MyAdmin(ModelAdmin):
492+
list_filter_submit = True
493+
list_filter = [
494+
("model_charfield", FieldTextFilter),
495+
CustomTextFilter
496+
]
497+
```
498+
499+
### Dropdown filters
500+
501+
Dropdown filters will display a select field with a list of options. Unfold contains two types of dropdowns: `ChoicesDropdownFilter` and `RelatedDropdownFilter`.
502+
503+
The difference between them is that `ChoicesDropdownFilter` will collect a list of options based on the `choices` attribute of the model field so most commonly it will be used in combination with `CharField` with specified `choices`. On the other side, `RelatedDropdownFilter` needs a one-to-many or many-to-many foreign key to display options.
504+
505+
**Note:** At the moment Unfold does not implement a dropdown with an autocomplete functionality, so it is important not to use dropdowns displaying large datasets.
506+
507+
```python
508+
# admin.py
509+
510+
from django.contrib import admin
511+
from django.contrib.auth.models import User
512+
from unfold.admin import ModelAdmin
513+
from unfold.contrib.filters.admin import ChoicesDropdownFilter, RelatedDropdownFilter
514+
515+
@admin.register(User)
516+
class MyAdmin(ModelAdmin):
517+
list_filter_submit = True
518+
list_filter = [
519+
("modelfield_with_choices", ChoicesDropdownFilter),
520+
("modelfield_with_foreign_key", RelatedDropdownFilter)
521+
]
522+
```
523+
460524
### Numeric filters
461525

462526
Currently, Unfold implements numeric filters inside `unfold.contrib.filters` application. In order to use these filters, it is required to add this application into `INSTALLED_APPS` in `settings.py` right after `unfold` application.

src/unfold/admin.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
UnfoldAdminMoneyWidget,
6363
UnfoldAdminNullBooleanSelectWidget,
6464
UnfoldAdminRadioSelectWidget,
65-
UnfoldAdminSelect,
65+
UnfoldAdminSelectWidget,
6666
UnfoldAdminSingleDateWidget,
6767
UnfoldAdminSingleTimeWidget,
6868
UnfoldAdminSplitDateTimeWidget,
@@ -294,7 +294,7 @@ def formfield_for_choice_field(
294294
radio_style=self.radio_fields[db_field.name]
295295
)
296296
else:
297-
kwargs["widget"] = UnfoldAdminSelect()
297+
kwargs["widget"] = UnfoldAdminSelectWidget()
298298

299299
kwargs["choices"] = db_field.get_choices(
300300
include_blank=db_field.blank, blank_choice=[("", _("Select value"))]
@@ -313,7 +313,7 @@ def formfield_for_foreignkey(
313313
db_field.name not in self.get_autocomplete_fields(request)
314314
and db_field.name not in self.radio_fields
315315
):
316-
kwargs["widget"] = UnfoldAdminSelect()
316+
kwargs["widget"] = UnfoldAdminSelectWidget()
317317
kwargs["empty_label"] = _("Select value")
318318

319319
return super().formfield_for_foreignkey(db_field, request, **kwargs)

src/unfold/contrib/filters/admin.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,114 @@
1717
from django.forms import ValidationError
1818
from django.http import HttpRequest
1919
from django.utils.dateparse import parse_datetime
20+
from django.utils.translation import gettext_lazy as _
2021

2122
from .forms import (
23+
DropdownForm,
2224
RangeDateForm,
2325
RangeDateTimeForm,
2426
RangeNumericForm,
27+
SearchForm,
2528
SingleNumericForm,
2629
SliderNumericForm,
2730
)
2831

2932

33+
class ValueMixin:
34+
def value(self) -> Optional[str]:
35+
return (
36+
self.lookup_val[0]
37+
if self.lookup_val not in EMPTY_VALUES
38+
and isinstance(self.lookup_val, List)
39+
and len(self.lookup_val) > 0
40+
else self.lookup_val
41+
)
42+
43+
44+
class DropdownMixin:
45+
template = "unfold/filters/filters_field.html"
46+
form_class = DropdownForm
47+
all_option = ["", _("All")]
48+
49+
def queryset(self, request, queryset) -> QuerySet:
50+
if self.value() not in EMPTY_VALUES:
51+
return super().queryset(request, queryset)
52+
53+
return queryset
54+
55+
56+
class TextFilter(admin.SimpleListFilter):
57+
template = "unfold/filters/filters_field.html"
58+
form_class = SearchForm
59+
60+
def has_output(self) -> bool:
61+
return True
62+
63+
def lookups(self, request: HttpRequest, model_admin: ModelAdmin) -> Tuple:
64+
return ()
65+
66+
def choices(self, changelist: ChangeList) -> Tuple[Dict[str, Any], ...]:
67+
return (
68+
{
69+
"form": self.form_class(
70+
name=self.parameter_name,
71+
label=_("By {}").format(self.title),
72+
data={self.parameter_name: self.value()},
73+
),
74+
},
75+
)
76+
77+
78+
class FieldTextFilter(ValueMixin, admin.FieldListFilter):
79+
template = "unfold/filters/filters_field.html"
80+
form_class = SearchForm
81+
82+
def __init__(self, field, request, params, model, model_admin, field_path):
83+
self.lookup_kwarg = f"{field_path}__icontains"
84+
self.lookup_val = params.get(self.lookup_kwarg)
85+
super().__init__(field, request, params, model, model_admin, field_path)
86+
87+
def expected_parameters(self) -> List[str]:
88+
return [self.lookup_kwarg]
89+
90+
def choices(self, changelist: ChangeList) -> Tuple[Dict[str, Any], ...]:
91+
return (
92+
{
93+
"form": self.form_class(
94+
label=_("By {}").format(self.title),
95+
name=self.lookup_kwarg,
96+
data={self.lookup_kwarg: self.value()},
97+
),
98+
},
99+
)
100+
101+
102+
class ChoicesDropdownFilter(ValueMixin, DropdownMixin, admin.ChoicesFieldListFilter):
103+
def choices(self, changelist: ChangeList):
104+
choices = [self.all_option, *self.field.flatchoices]
105+
106+
yield {
107+
"form": self.form_class(
108+
label=_("By {}").format(self.title),
109+
name=self.lookup_kwarg,
110+
choices=choices,
111+
data={self.lookup_kwarg: self.value()},
112+
),
113+
}
114+
115+
116+
class RelatedDropdownFilter(ValueMixin, DropdownMixin, admin.RelatedFieldListFilter):
117+
def choices(self, changelist: ChangeList):
118+
yield {
119+
"form": self.form_class(
120+
label=_("By {}").format(self.title),
121+
name=self.lookup_kwarg,
122+
choices=[self.all_option, *self.lookup_choices],
123+
data={self.lookup_kwarg: self.value()},
124+
),
125+
}
126+
127+
30128
class SingleNumericFilter(admin.FieldListFilter):
31129
request = None
32130
parameter_name = None

src/unfold/contrib/filters/forms.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
11
from django import forms
22
from django.utils.translation import gettext_lazy as _
33

4-
from ...widgets import INPUT_CLASSES, UnfoldAdminSplitDateTimeVerticalWidget
4+
from ...widgets import (
5+
INPUT_CLASSES,
6+
UnfoldAdminSelectWidget,
7+
UnfoldAdminSplitDateTimeVerticalWidget,
8+
UnfoldAdminTextInputWidget,
9+
)
10+
11+
12+
class SearchForm(forms.Form):
13+
def __init__(self, name, label, *args, **kwargs):
14+
super().__init__(*args, **kwargs)
15+
16+
self.fields[name] = forms.CharField(
17+
label=label,
18+
required=False,
19+
widget=UnfoldAdminTextInputWidget,
20+
)
21+
22+
23+
class DropdownForm(forms.Form):
24+
def __init__(self, name, label, choices, *args, **kwargs):
25+
super().__init__(*args, **kwargs)
26+
27+
self.fields[name] = forms.ChoiceField(
28+
label=label,
29+
required=False,
30+
choices=choices,
31+
widget=UnfoldAdminSelectWidget,
32+
)
533

634

735
class SingleNumericForm(forms.Form):

src/unfold/contrib/filters/templates/unfold/filters/filters_date_range.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
{% with choices.0 as choice %}
44
<div class="flex flex-col mb-6">
5-
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
5+
<h3 class="font-medium mb-2 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
66

77
<div class="flex flex-col space-y-4">
88
{% for field in choice.form %}

src/unfold/contrib/filters/templates/unfold/filters/filters_datetime_range.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
{% with choices.0 as choice %}
44
<div class="flex flex-col mb-6">
5-
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
5+
<h3 class="font-medium mb-2 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
66

77
<div class="flex flex-col space-y-4">
88
{% for field in choice.form %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% load i18n %}
2+
3+
{% with choices.0 as choice %}
4+
{% for field in choice.form %}
5+
{% include "unfold/helpers/field.html" %}
6+
{% endfor %}
7+
{% endwith %}

src/unfold/contrib/filters/templates/unfold/filters/filters_numeric_range.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
{% with choices.0 as choice %}
44
<div class="flex flex-col mb-6">
5-
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
5+
<h3 class="font-medium mb-2 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
66

77
<div class="flex flex-row gap-4">
88
{% for field in choice.form %}

src/unfold/contrib/filters/templates/unfold/filters/filters_numeric_single.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
{% with choices.0 as choice %}
44
<div class="flex flex-col mb-6">
5-
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
5+
<h3 class="font-medium mb-2 text-gray-700 text-sm dark:text-gray-200">{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}</h3>
66

77
{% for field in choice.form %}
88
<div class="flex flex-row flex-wrap group relative{% if field.errors %} errors{% endif %}">

src/unfold/contrib/filters/templates/unfold/filters/filters_numeric_slider.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
{% with choices.0 as choice %}
55
<div class="admin-numeric-filter-wrapper mb-6">
6-
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">
6+
<h3 class="font-medium mb-2 text-gray-700 text-sm dark:text-gray-200">
77
{% blocktrans with filter_title=title %}By {{ filter_title }}{% endblocktrans %}
88
</h3>
99

0 commit comments

Comments
 (0)