Skip to content

Commit

Permalink
feat: dropdown and text filters (#388)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasvinclav committed May 18, 2024
1 parent 05c9981 commit bfd11b5
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 14 deletions.
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Did you decide to start using Unfold but you don't have time to make the switch
- **Dependencies:** completely based only on `django.contrib.admin`
- **Actions:** multiple ways how to define actions within different parts of admin
- **WYSIWYG:** built-in support for WYSIWYG (Trix)
- **Custom filters:** widgets for filtering number & datetime values
- **Filters:** custom dropdown, numeric, datetime, and text fields
- **Dashboard:** custom components for rapid dashboard development
- **Model tabs:** define custom tab navigations for models
- **Fieldset tabs:** merge several fielsets into tabs in change form
Expand All @@ -50,6 +50,8 @@ Did you decide to start using Unfold but you don't have time to make the switch
- [Action handler functions](#action-handler-functions)
- [Action examples](#action-examples)
- [Filters](#filters)
- [Text filters](#text-filters)
- [Dropdown filters](#dropdown-filters)
- [Numeric filters](#numeric-filters)
- [Date/time filters](#datetime-filters)
- [Display decorator](#display-decorator)
Expand Down Expand Up @@ -457,6 +459,68 @@ By default, Django admin handles all filters as regular HTML links pointing at t

**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.

### Text filters

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`.

`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)`.

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.

```python
from django.contrib import admin
from django.contrib.auth.models import User
from django.core.validators import EMPTY_VALUES
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold.contrib.filters.admin import TextFilter, FieldTextFilter

class CustomTextFilter(TextFilter):
title = _("Custom filter")
parameter_name = "query_param_in_uri"

def queryset(self, request, queryset):
if self.value() not in EMPTY_VALUES:
# Here write custom query
return queryset.filter(your_field=self.value())

return queryset


@admin.register(User)
class MyAdmin(ModelAdmin):
list_filter_submit = True
list_filter = [
("model_charfield", FieldTextFilter),
CustomTextFilter
]
```

### Dropdown filters

Dropdown filters will display a select field with a list of options. Unfold contains two types of dropdowns: `ChoicesDropdownFilter` and `RelatedDropdownFilter`.

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.

**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.

```python
# admin.py

from django.contrib import admin
from django.contrib.auth.models import User
from unfold.admin import ModelAdmin
from unfold.contrib.filters.admin import ChoicesDropdownFilter, RelatedDropdownFilter

@admin.register(User)
class MyAdmin(ModelAdmin):
list_filter_submit = True
list_filter = [
("modelfield_with_choices", ChoicesDropdownFilter),
("modelfield_with_foreign_key", RelatedDropdownFilter)
]
```

### Numeric filters

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.
Expand Down
6 changes: 3 additions & 3 deletions src/unfold/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
UnfoldAdminMoneyWidget,
UnfoldAdminNullBooleanSelectWidget,
UnfoldAdminRadioSelectWidget,
UnfoldAdminSelect,
UnfoldAdminSelectWidget,
UnfoldAdminSingleDateWidget,
UnfoldAdminSingleTimeWidget,
UnfoldAdminSplitDateTimeWidget,
Expand Down Expand Up @@ -294,7 +294,7 @@ def formfield_for_choice_field(
radio_style=self.radio_fields[db_field.name]
)
else:
kwargs["widget"] = UnfoldAdminSelect()
kwargs["widget"] = UnfoldAdminSelectWidget()

kwargs["choices"] = db_field.get_choices(
include_blank=db_field.blank, blank_choice=[("", _("Select value"))]
Expand All @@ -313,7 +313,7 @@ def formfield_for_foreignkey(
db_field.name not in self.get_autocomplete_fields(request)
and db_field.name not in self.radio_fields
):
kwargs["widget"] = UnfoldAdminSelect()
kwargs["widget"] = UnfoldAdminSelectWidget()
kwargs["empty_label"] = _("Select value")

return super().formfield_for_foreignkey(db_field, request, **kwargs)
Expand Down
98 changes: 98 additions & 0 deletions src/unfold/contrib/filters/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,114 @@
from django.forms import ValidationError
from django.http import HttpRequest
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _

from .forms import (
DropdownForm,
RangeDateForm,
RangeDateTimeForm,
RangeNumericForm,
SearchForm,
SingleNumericForm,
SliderNumericForm,
)


class ValueMixin:
def value(self) -> Optional[str]:
return (
self.lookup_val[0]
if self.lookup_val not in EMPTY_VALUES
and isinstance(self.lookup_val, List)
and len(self.lookup_val) > 0
else self.lookup_val
)


class DropdownMixin:
template = "unfold/filters/filters_field.html"
form_class = DropdownForm
all_option = ["", _("All")]

def queryset(self, request, queryset) -> QuerySet:
if self.value() not in EMPTY_VALUES:
return super().queryset(request, queryset)

return queryset


class TextFilter(admin.SimpleListFilter):
template = "unfold/filters/filters_field.html"
form_class = SearchForm

def has_output(self) -> bool:
return True

def lookups(self, request: HttpRequest, model_admin: ModelAdmin) -> Tuple:
return ()

def choices(self, changelist: ChangeList) -> Tuple[Dict[str, Any], ...]:
return (
{
"form": self.form_class(
name=self.parameter_name,
label=_("By {}").format(self.title),
data={self.parameter_name: self.value()},
),
},
)


class FieldTextFilter(ValueMixin, admin.FieldListFilter):
template = "unfold/filters/filters_field.html"
form_class = SearchForm

def __init__(self, field, request, params, model, model_admin, field_path):
self.lookup_kwarg = f"{field_path}__icontains"
self.lookup_val = params.get(self.lookup_kwarg)
super().__init__(field, request, params, model, model_admin, field_path)

def expected_parameters(self) -> List[str]:
return [self.lookup_kwarg]

def choices(self, changelist: ChangeList) -> Tuple[Dict[str, Any], ...]:
return (
{
"form": self.form_class(
label=_("By {}").format(self.title),
name=self.lookup_kwarg,
data={self.lookup_kwarg: self.value()},
),
},
)


class ChoicesDropdownFilter(ValueMixin, DropdownMixin, admin.ChoicesFieldListFilter):
def choices(self, changelist: ChangeList):
choices = [self.all_option, *self.field.flatchoices]

yield {
"form": self.form_class(
label=_("By {}").format(self.title),
name=self.lookup_kwarg,
choices=choices,
data={self.lookup_kwarg: self.value()},
),
}


class RelatedDropdownFilter(ValueMixin, DropdownMixin, admin.RelatedFieldListFilter):
def choices(self, changelist: ChangeList):
yield {
"form": self.form_class(
label=_("By {}").format(self.title),
name=self.lookup_kwarg,
choices=[self.all_option, *self.lookup_choices],
data={self.lookup_kwarg: self.value()},
),
}


class SingleNumericFilter(admin.FieldListFilter):
request = None
parameter_name = None
Expand Down
30 changes: 29 additions & 1 deletion src/unfold/contrib/filters/forms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
from django import forms
from django.utils.translation import gettext_lazy as _

from ...widgets import INPUT_CLASSES, UnfoldAdminSplitDateTimeVerticalWidget
from ...widgets import (
INPUT_CLASSES,
UnfoldAdminSelectWidget,
UnfoldAdminSplitDateTimeVerticalWidget,
UnfoldAdminTextInputWidget,
)


class SearchForm(forms.Form):
def __init__(self, name, label, *args, **kwargs):
super().__init__(*args, **kwargs)

self.fields[name] = forms.CharField(
label=label,
required=False,
widget=UnfoldAdminTextInputWidget,
)


class DropdownForm(forms.Form):
def __init__(self, name, label, choices, *args, **kwargs):
super().__init__(*args, **kwargs)

self.fields[name] = forms.ChoiceField(
label=label,
required=False,
choices=choices,
widget=UnfoldAdminSelectWidget,
)


class SingleNumericForm(forms.Form):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{% with choices.0 as choice %}
<div class="flex flex-col mb-6">
<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>
<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>

<div class="flex flex-col space-y-4">
{% for field in choice.form %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{% with choices.0 as choice %}
<div class="flex flex-col mb-6">
<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>
<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>

<div class="flex flex-col space-y-4">
{% for field in choice.form %}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% load i18n %}

{% with choices.0 as choice %}
{% for field in choice.form %}
{% include "unfold/helpers/field.html" %}
{% endfor %}
{% endwith %}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{% with choices.0 as choice %}
<div class="flex flex-col mb-6">
<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>
<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>

<div class="flex flex-row gap-4">
{% for field in choice.form %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{% with choices.0 as choice %}
<div class="flex flex-col mb-6">
<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>
<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>

{% for field in choice.form %}
<div class="flex flex-row flex-wrap group relative{% if field.errors %} errors{% endif %}">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{% with choices.0 as choice %}
<div class="admin-numeric-filter-wrapper mb-6">
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">
<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>

Expand Down
4 changes: 2 additions & 2 deletions src/unfold/templates/admin/filter.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{% load i18n unfold %}

<div class="mb-6">
<h3 class="font-medium mb-4 text-gray-700 text-sm dark:text-gray-200">
<h3 class="font-medium mb-2 text-gray-700 text-sm dark:text-gray-200">
{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}
</h3>

{% for choice in choices %}
{% if choice.selected %}
{% if choice.selected and spec.lookup_val.0 %}
<input type="hidden" name="{{ spec.lookup_kwarg }}" value="{{ spec.lookup_val.0 }}" />
{% endif %}
{% endfor %}
Expand Down
4 changes: 2 additions & 2 deletions src/unfold/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ def __init__(self, attrs=None):
super().__init__(attrs)


class UnfoldAdminSelect(Select):
class UnfoldAdminSelectWidget(Select):
def __init__(self, attrs=None, choices=()):
if attrs is None:
attrs = {}
Expand Down Expand Up @@ -482,7 +482,7 @@ class UnfoldAdminMoneyWidget(MoneyWidget):
def __init__(self, *args, **kwargs):
super().__init__(
amount_widget=UnfoldAdminTextInputWidget,
currency_widget=UnfoldAdminSelect(choices=CURRENCY_CHOICES),
currency_widget=UnfoldAdminSelectWidget(choices=CURRENCY_CHOICES),
)

except ImportError:
Expand Down

0 comments on commit bfd11b5

Please sign in to comment.