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

views.py
Copy
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.

views.py
Copy
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.

Note

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:

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

views.py
Copy
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 and link_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:

Copy
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:

views.py
Copy
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.

Copy
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")
Limiting Actions on the Chooser

We don't want page editors to have access to the 'Edit this' option displayed by default on Wagtail choosers. There's no use case for editing users from the chooser, it's not something that would ever be desirable.

The ChooserViewSet doesn't provide a setting for this, but you can override the widget_class property and disable the edit link there:

Copy
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")

    @cached_property
    def widget_class(self):
        widget = super().widget_class
        try:
            widget.show_edit_link = False
        except:
            pass
        return widget

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:

wagtail_hooks.py
Copy
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:

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

wagtail custom chooser modal
Selecting a user with the custom chooser

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:

widgets.py
Copy
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:

blocks.py
Copy
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.


  Please feel free to leave any questions or comments below, or send me a message here