Wagtail - Creating Custom Choosers with Viewsets
Introduction
Wagtail has a number of basic views for common activities like generating / modifying model instances and chooser modals. Because these frequently involve several related views with shared properties, Wagtail also implements the concept of a viewset
, which allows a group of views to be defined and their URLs to be registered with the admin app as a single operation via the register_admin_viewset
hook.
This article demonstrates how to create a custom chooser modal for the User
model and add responsive filtering to search for records based on a defined set of columns.
As well as presenting fields in the modal, it's also possible to use properties/methods for column values providing there is no required parameter. In this example, we will use User
.get_full_name()
for one column.
ChooserViewSet
The views that comprise a modal chooser interface, allowing users to pick from a list of model instances to populate a ForeignKey
field, are provided by the wagtail.admin.viewsets.chooser.ChooserViewSet
class.
To this, we can add a BaseChooseView
to define the columns that will be presented to the user in the chooser modal and add a custom filter class.
Creating the User Chooser Modal
For the User
modal, we'll present the editor with two fields directly and one method as a field: username
, get_full_name()
, email
.
Because of the order of inheritance, we'll need to define the custom filter and the BaseChooseView
before defining the ChooserViewSet
.
All subsequent code, excluding the hook, should go into your apps views.py
.
Imports
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms.choosers import BaseFilterForm, LocaleFilterMixin
from wagtail.admin.ui.tables import TitleColumn
from wagtail.admin.views.generic.chooser import (BaseChooseView,
ChooseResultsViewMixin,
ChooseViewMixin,
CreationFormMixin)
from wagtail.admin.viewsets.chooser import ChooserViewSet
from wagtail.models import TranslatableMixin
Custom Filter
The custom filter is defined as a mixin inheriting the standard Django form with a single CharField
. Wagtail will expect a method called filter()
to perform the search and return the result set. We add the mixin to the ChooserViewSet
in the next step.
Although we will be presenting the full name as a singe column, we need to filter on model data stored in the database. The filter will search in the User
model username
, first_name
, last_name
and email
fields.
class UserSearchFilterMixin(forms.Form):
q = forms.CharField(
label=_("Search term"),
widget=forms.TextInput(attrs={"placeholder": _("Search")}),
required=False,
)
def filter(self, objects):
objects = super().filter(objects)
search_query = self.cleaned_data.get("q")
if search_query:
objects = objects.filter(
Q(username__icontains=search_query) |
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query)
)
self.is_searching = True
self.search_query = search_query
return objects
The use of q
for the search field is arbitrary, you just need to make sure you match the get
method in the filter.
The filter
method is triggered by an update event in the search text field, meaning on every keystroke or paste.
In this example, we use the Django Q object to look for a partial match in any of the four fields mentioned above. The method returns any records that meet the criteria.
If the search text field is empty, the full recordset is returned.
If your model inherits wagtail.search.index.Indexed
, you can also use Wagtail's search engine to provide the filtering. In that case, you would use something like the following to return the filtered recordset:
s = get_search_backend()
objects = s.search(q, objects)
See Indexing custom models in the Wagtail docs for more information.
Customising the BaseChooseView
Here, we define the columns that we want to present to the editor in the modal and override the default filter with our custom filter defined above.
class BaseUserChooseView(BaseChooseView):
@property
def columns(self):
return [
TitleColumn(
name="username",
label=_("Username"),
url_name=self.chosen_url_name,
link_attrs={"data-chooser-modal-choice": True},
),
TitleColumn(
name="get_full_name",
label=_("Name"),
url_name=self.chosen_url_name,
link_attrs={"data-chooser-modal-choice": True},
),
TitleColumn(
name="email",
label=_("Email"),
url_name=self.chosen_url_name,
link_attrs={"data-chooser-modal-choice": True},
),
]
def get_filter_form_class(self):
bases = [UserSearchFilterMixin, BaseFilterForm]
i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
if i18n_enabled and issubclass(self.model_class, TranslatableMixin):
bases.insert(0, LocaleFilterMixin)
return type(
"FilterForm",
tuple(bases),
{},
)
columns
is defined as a property returning a list of TitleColumn
instances.
- In each
TitleColumn
,name
must match the field or property/method being called. For methods, omit the parentheses (as above). - Use
label
to add a column header. url_name
andlink_attrs
are required to make the column linkable to the record instance, just use the examples above in most cases.
get_filter_form_class
allows us to override the default filter with the one we defined earlier.
- Note the order of inheritance, the custom filter mixin should come before the
BaseFilterForm
. - This example adds a test to see if localization is active (for multi-lingual sites). Omit this if you're not using it.
Ordering the ChooserViewSet
At the time of writing, ChooserViewSet
doesn't support interactive ordering (that is, clicking on the column header to sort by that column).
To do this, you can add an ordering attribute to the BaseChooseView
which must be a field name, or list of field names, to sort by.
To sort our user list in the chooser by surname then first name, edit the BaseUserChooseView
we created earlier and add:
class BaseUserChooseView(BaseChooseView):
ordering = ["last_name", "first_name"]
....
Adding the Custom ChooserViewSet
This is where we define the model that this viewset is linked to, along with an icon to show on the edit page and text for the buttons.
Before we define the customised ChooserViewSet
, we need to create a ChooseView
and ChooseResultsView
, defined by assembling mixins, including our BaseUserChooseView
that we have just created above.
We add both of these to our custom ChooserViewSet
:
class UserChooseView(ChooseViewMixin, CreationFormMixin, BaseUserChooseView):
pass
class UserChooseResultsView(ChooseResultsViewMixin, CreationFormMixin, BaseUserChooseView):
pass
class UserChooserViewSet(ChooserViewSet):
model = User
choose_view_class = UserChooseView
choose_results_view_class = UserChooseResultsView
icon = "user"
choose_one_text = _("Choose a user")
choose_another_text = _("Choose another user")
edit_item_text = _("Edit this user")
user_chooser_viewset = UserChooserViewSet("user_chooser")
icon
can be any named icon found in the Wagtail style guide or that you have registered on your site.
Note that after we define the class, we create an instance of that class and assign it to a module level variable. This is used to register the viewset in the hook below.
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms.choosers import BaseFilterForm, LocaleFilterMixin
from wagtail.admin.ui.tables import TitleColumn
from wagtail.admin.views.generic.chooser import (BaseChooseView,
ChooseResultsViewMixin,
ChooseViewMixin,
CreationFormMixin)
from wagtail.admin.viewsets.chooser import ChooserViewSet
from wagtail.models import TranslatableMixin
class UserSearchFilterMixin(forms.Form):
q = forms.CharField(
label=_("Search term"),
widget=forms.TextInput(attrs={"placeholder": _("Search")}),
required=False,
)
def filter(self, objects):
objects = super().filter(objects)
search_query = self.cleaned_data.get("q")
if search_query:
objects = objects.filter(
Q(username__icontains=search_query) |
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query)
)
self.is_searching = True
self.search_query = search_query
return objects
class BaseUserChooseView(BaseChooseView):
ordering = ["last_name", "first_name"]
@property
def columns(self):
return [
TitleColumn(
name="username",
label=_("Username"),
accessor="username",
url_name=self.chosen_url_name,
link_attrs={"data-chooser-modal-choice": True},
),
TitleColumn(
name="get_full_name",
label=_("Name"),
accessor="get_full_name",
url_name=self.chosen_url_name,
link_attrs={"data-chooser-modal-choice": True},
),
TitleColumn(
name="email",
label=_("Email"),
accessor="email",
url_name=self.chosen_url_name,
link_attrs={"data-chooser-modal-choice": True},
),
]
def get_filter_form_class(self):
bases = [UserSearchFilterMixin, BaseFilterForm]
i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
if i18n_enabled and issubclass(self.model_class, TranslatableMixin):
bases.insert(0, LocaleFilterMixin)
return type(
"FilterForm",
tuple(bases),
{},
)
class UserChooseView(ChooseViewMixin, CreationFormMixin, BaseUserChooseView):
pass
class UserChooseResultsView(
ChooseResultsViewMixin, CreationFormMixin, BaseUserChooseView
):
pass
class UserChooserViewSet(ChooserViewSet):
model = User
choose_view_class = UserChooseView
choose_results_view_class = UserChooseResultsView
icon = "user"
choose_one_text = _("Choose a user")
choose_another_text = _("Choose another user")
edit_item_text = _("Edit this user")
user_chooser_viewset = UserChooserViewSet("user_chooser")
Registering the ChooserViewSet
All that's left now is to register the custom ChooserViewSet
in our app's wagtail_hooks.py
using the module variable we defined at the end of the last step:
from wagtail import hooks
from .views import user_chooser_viewset
@hooks.register('register_admin_viewset')
def register_user_chooser_viewset():
return user_chooser_viewset
Using the Custom Chooser Modal
Defining the Chooser in a FieldPanel
When you register a chooser viewset, you'll also get a chooser widget that will display any time a ForeignKey
field from that model occurs in a WagtailAdminModelForm
. This implies that a panel definition like FieldPanel("owner")
on a sub-classed Page
model, where owner
is a foreign key to the User
model, will use this chooser interface by default.
As an example, to add a form field for the owner
field of the Wagtail Page
model, we just need the following:
class BlogPage(Page):
....
content_panels = Page.content_panels + [
FieldPanel('owner'),
....
]
Since the owner
field is a foreign key reference to the User
model, and we've registered our own chooser modal for that model, then that custom modal will be automatically displayed.
Accessing the Chooser Widget Directly
The chooser widget class may also be accessed directly as the widget_class
field on the viewset (for usage in standard Django forms, for example). Code similar to the following may be used to make the chooser widget available:
from .views import user_chooser_viewset
UserChooserWidget = user_chooser_viewset.widget_class
Custom Chooser Block Class
The viewset additionally provides a StreamField
chooser block class via the function get_block_class
. The following code will enable the implementation of a chooser block in StreamField
definitions:
from .views import user_chooser_viewset
UserChooserBlock = user_chooser_viewset.get_block_class(
# update 'someApp' in the line below with the app name you add this module to
name="UserChooserBlock", module_path="someApp.blocks"
)
Conclusion
In this article, we described how to create a custom chooser modal in Wagtail and add responsive filtering to that modal.
We also briefly looked at how to access that chooser as a widget and also construct a custom Streamfield block from that chooser.