Better user choosers in Wagtail

The Wagtail core choosers (page, images, documents, snippet and generic choosers) are pretty cool way of selecting a relation via a modal that can be searchable and filterable.

However, if you have a ForeignKey to model not covered by core, the default “chooser” interface is a select box. And if you have thousands of instances, that is not a nice setup to work with.

This is where generic choosers come in handy as they allow you to customise the modal chooser interface and adapt it to your specific needs. I wrote about them previously.

I recently needed to make a nicer User model chooser, which had a couple of extra tidbits:

# viewsets.py

from typing import TYPE_CHECKING

from django.contrib.admin.utils import quote
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.functional import cached_property
from wagtail.admin.forms.choosers import BaseFilterForm, SearchFilterMixin
from wagtail.admin.ui.tables import Column, StatusTagColumn
from wagtail.admin.utils import get_user_display_name
from wagtail.admin.views.generic.chooser import ChooseResultsView, ChooseView
from wagtail.admin.viewsets.chooser import ChooserViewSet
from wagtail.users.views.users import UserColumn, get_users_filter_query


# if you have a custom user model, you could import it directly from the app that provides it
User = get_user_model()


if TYPE_CHECKING:
    from django.db.models import QuerySet


# note: the filter form is needed to enable the search in the modal.
# The generic chooser make this work automatically if the target model is searchable 
# (that is, it inherits from the search index.Indexed), which the User model isn't.
# While you can technically make it indexable if you use a custom User model, it is probably better not to.
class UserFilterForm(SearchFilterMixin, BaseFilterForm):
    @cached_property
    def model_fields(self) -> set[str]:
        return {field.name for field in User._meta.get_fields()}

    def filter(self, objects: "QuerySet[User]") -> "QuerySet[User]":
        """The User model doesn't have search_fields.

        So we take the same approach as the core UserViewSet when it comes to searching
        """
        if search_query := self.cleaned_data.get("q"):
            conditions = get_users_filter_query(search_query, self.model_fields)
            return objects.filter(conditions)
        return objects


# Note: We use a mixin for both the "choose" view which is what one see when the chooser modal opens
# and the "choose results" view which is used when searching/filtering
class UserChooserMixin:
    filter_form_class = UserFilterForm

    @property
    def columns(self) -> list[Column]:
        return [
            UserColumn(
                "name",
                accessor=lambda u: get_user_display_name(u),
                label="Name",
                get_url=(
                    lambda obj: self.append_preserved_url_parameters(  # type: ignore[attr-defined]
                        reverse(self.chosen_url_name, args=(quote(obj.pk),))  # type: ignore[attr-defined]
                    )
                ),
                # this is part of the glue that makes the chooser understand that a user made a choice
                # when clicking on this column
                link_attrs={"data-chooser-modal-choice": True},
            ),
            Column(
                User.USERNAME_FIELD,
                accessor="get_username",
                label="Username",
                width="20%",
            ),
            StatusTagColumn(
                "is_active",
                accessor=lambda u: "Active" if u.is_active else "Inactive",
                primary=lambda u: u.is_active,
                label="Status",
                width="10%",
            ),
        ]

    def get_object_list(self) -> "QuerySet[User]":
        # UserColumn will try to show the user's avatar, which comes from the related UserProfile model.
        return User.objects.select_related("wagtail_userprofile")


class UserChooseView(UserChooserMixin, ChooseView): ...


class UserChooseResultsView(UserChooserMixin, ChooseResultsView): ...


class UserChooserViewSet(ChooserViewSet):
    model = User
    icon = "user"
    choose_view_class = UserChooseView
    choose_results_view_class = UserChooseResultsView
    choose_one_text = "Choose a user"
    choose_another_text = "Choose another user"
    edit_item_text = "Edit this user"


user_chooser_viewset = UserChooserViewSet("user_chooser")

and register the viewset with the register_admin_viewset hook:

# wagtail_hooks.py.py

from typing import TYPE_CHECKING

from wagtail import hooks

from .viewsets import user_chooser_viewset

if TYPE_CHECKING:
    from .viewsets import UserChooserViewSet


@hooks.register("register_admin_viewset")
def register_viewset() -> "UserChooserViewSet":
    return user_chooser_viewset