Custom choosers in Wagtail
Wagtail 4.0 introduced the concept of generic viewset which allows a bundle of views to be defined collectively, and their URLs to be registered in a single operation.
This is most useful for choosers, but is not limited to it. Consider the following use case:
# myapp/models.py
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.models import Page
class Organisation(models.Model):
name = models.CharField(max_length=255)
class Person(models.Model):
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
organisation = models.ForeignKey(
Organisation, blank=True, null=True, on_delete=models.SET_NULL
)
def __str__(self):
return f"{self.first_name} {self.last_name}"
class MyPage(Page):
author = models.ForeignKey(Person, blank=True, null=True, on_delete=models.SET_NULL)
content_panels = Page.content_panels + [FieldPanel("author")]
We have a basic Person
with the expected first and last name, and a link to an Organisation
.
The Wagtail interface will use a regular HTML select for the author field on MyPage
, which may be OK for a few entries, but certainly does not scale.
This is where the generic viewsets come in handy. To create a custom chooser we need to define our viewset:
# myapp/views.py
from wagtail.admin.viewsets.chooser import ChooserViewSet
from .models import Person
class PersonChooserViewSet(ChooserViewSet):
model = Person
icon = "user"
choose_one_text = "Choose a person"
choose_another_text = "Choose another person"
edit_item_text = "Edit this person"
person_chooser_viewset = PersonChooserViewSet("person_chooser")
This defines a simple chooser which shows a list of people’s names. If you want to add a “Create” tab, and thus allow creating Person
instances
from the chooser, define the form_fields
attribute.
# myapp/views.py
from wagtail.admin.viewsets.chooser import ChooserViewSet
from .models import Person
class PersonChooserViewSet(ChooserViewSet):
...
form_fields = ["first_name", "last_name"]
...
Then you need to register the new viewset:
from wagtail import hooks
from .views import person_chooser_viewset
@hooks.register("register_admin_viewset")
def register_viewset():
return person_chooser_viewset
Et voilà!
But wait, what if you want to display the organisation name in a separate column? The Viewsets reference page has all the information you need.
# myapp/views.py
from wagtail.admin.ui.tables import Column
from wagtail.admin.views.generic.chooser import ChooseView
from wagtail.admin.viewsets.chooser import ChooserViewSet
from .models import Person
class PersonChooseView(ChooseView):
def get_object_list(self):
return Person.objects.select_related("organisation").only(
"first_name", "last_name", "organisation__name"
)
@property
def columns(self):
return super().columns + [
Column("organisation", label="Organisation", accessor="organisation"),
]
class PersonChooserViewSet(ChooserViewSet):
model = Person
icon = "user"
choose_view_class = PersonChooseView
choose_one_text = "Choose a person"
choose_another_text = "Choose another person"
edit_item_text = "Edit this person"
person_chooser_viewset = PersonChooserViewSet("person_chooser")
Should you need to customise the Organisation
column, you can define a Column
class and use that instead.
# myapp/views.py
...
class OrganisationColumn(Column):
def get_value(self, instance):
return instance.organisation.name if instance.organisation else "No company"
class ChooseView(ChooseView):
@property
def columns(self):
return super().columns + [
OrganisationColumn("organisation", label="Organisation"),
]
...
If you want to enable search in the chooser, then either your model needs to inherit from wagtail.search.index.Indexed
and have a search_fields
definition
as per documentation or define a filter_form_class
attribute.
Your code could looks something like:
# myapp/models.python
...
from wagtail.search import index
...
class Person(index.Indexed, models.Model):
...
search_fields = [
index.SearchField("first_name", partial_match=True),
index.SearchField("last_name", partial_match=True),
index.RelatedFields(
"organisation",
[
index.SearchField("name", partial_match=True),
],
)
]
# myapp/views.py
from wagtail.admin.ui.tables import Column
from wagtail.admin.views.generic.chooser import ChooseResultsView, ChooseView, ChosenView
from wagtail.admin.viewsets.chooser import ChooserViewSet
from .models import Person
class PersonChooserMixin:
# Note: Using a mixin to apply both to the chooser view and the chooser
# search results view.
def get_object_list(self):
# We override this method to fetch the related organisation field
# to avoid additional queries when evaluating the queryset.
return Person.objects.select_related("organisation").only(
"first_name", "last_name", "organisation__name"
)
@property
def columns(self):
return super().columns + [
Column("organisation", label="Organisation", accessor="organisation")
]
class PersonChooseView(PersonChooserMixin, ChooseView):
pass
class PersonChooseResultsView(PersonChooserMixin, ChooseResultsView):
pass
class PersonChosenView(ChosenView):
# Note: while not stricly necessary, https://github.com/Tijani-Dia/dj-tracker
# highlights another optimisation.
def get_object(self, pk):
return Person.objects.only("first_name", "last_name").get(pk=pk)
class PersonChooserViewSet(ChooserViewSet):
model = Person
icon = "user"
choose_view_class = PersonChooseView
chosen_view_class = PersonChosenView
choose_results_view_class = PersonChooseResultsView
choose_one_text = "Choose a person"
choose_another_text = "Choose another person"
edit_item_text = "Edit this person"
person_chooser_viewset = PersonChooserViewSet("person_chooser")
Kudos to Tidine Dia for inquiring on how to add new columns in custom choosers, as well as his great dj-tracker package which comes in handy to identify query optimisation opportunities.