Efficient Cascading Choices in Wagtail Admin: A Smart Chooser Panel Solution
Introduction
If you've ever worked with Wagtail, you might have encountered a situation where you need to select an item from a list that depends on another selection. For example, you might have a category and a subcategory field such as country and region or car manufacturer and model, and you want the subcategory options to change based on the category you choose. This is called a cascading or dependent selection, and it can be very useful for organising your content.
However, Wagtail does not provide any built-in way to create such a feature in the admin interface. You can use snippets or inline panels, but they are not reactive without modification.
This article will show you how to create a custom chooser panel that solves this problem. A chooser panel is a widget that lets you select an item from a pop-out list of options. We will create a chooser panel that takes a ClusterableModel/Orderable structure and displays the subcategories grouped by their categories, and also includes a partial match filter that lets you search for the items by typing part of their names. This way, you can easily find and select the subcategory you want with just one click.
Because the Orderable model instance (the subcategory) includes its parent as an attribute, we eliminate the need to make and store two selections as we only need store the chosen subcategory.
The panel works with the inputs you give it so, for the vast majority of cases, it will work for any given ClusterableModel/Orderable class pair without needing any modification.
There is a fair amount of code involved, but bear with me, the result is a very easy to use interface, particularly when applied to large datasets, which your editors will thank you for!
Online Store Example
For this article, I'll be using the example of an online homeware store. Our parent selection (category) will be store departments (bed, bath etc.). The dependent selection (subcategory) will be product types within each store department. The store has a Product
model for everything sold online, each product must have a product type with store department.
If you want to just use your own model, you can skip to the next section.
If you want to follow the example in this article, assuming you have a sandbox wagtail test site ready to go, create a product app with
python manage.py startapp product
Make sure to add 'product'
to your INSTALLED_APPS
.
To models.py
, add the following:
from django.db import models
from django.utils.translation import gettext_lazy as _
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.fields import RichTextField
from wagtail.models import Orderable
from wagtail.snippets.models import register_snippet
# from .panels import SubcategoryChooser
@register_snippet
class StoreDepartment(ClusterableModel):
code = models.CharField(
max_length=10,
unique=True,
verbose_name=_("Department Code"),
)
name = models.CharField(
max_length=50,
verbose_name=_("Name"),
)
panels = [
FieldPanel("code"),
FieldPanel("name"),
MultiFieldPanel(
[
InlinePanel("department_subcategories"),
],
heading=_("Department Subcategories"),
),
]
def __str__(self):
return self.name
class Meta:
verbose_name = _("Store Department")
verbose_name_plural = _("Store Departments")
class DepartmentSubcategory(Orderable):
department = ParentalKey(
"StoreDepartment",
related_name="department_subcategories"
)
code = models.CharField(
max_length=10,
unique=True,
verbose_name=_("Subcategory Code"),
)
name = models.CharField(
max_length=50,
verbose_name=_("Name"),
)
panels = [
FieldPanel("code"),
FieldPanel("name"),
]
def __str__(self):
return self.name
class Meta:
verbose_name = _("Department Subcategory")
verbose_name_plural = _("Department Subcategories")
constraints = [
models.UniqueConstraint(
fields=['department', 'name'],
name='unique_department_departmentsubcategory_name'
),
]
@register_snippet
class Product(models.Model):
icon = "cogs"
sku = models.CharField(max_length=10, unique=True, verbose_name=_("SKU"))
title = models.CharField(max_length=100, verbose_name=_("Product Title"))
description = RichTextField(verbose_name=_("Product Description"))
dept_subcategory = models.ForeignKey(
"product.DepartmentSubcategory",
null=True,
blank=True,
related_name="+",
on_delete=models.SET_NULL,
verbose_name=_("Department Subcategory"),
)
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
related_name="+",
on_delete=models.SET_NULL,
)
panels = [
FieldPanel("sku"),
FieldPanel("title"),
FieldPanel("description"),
# SubcategoryChooser(
# "dept_subcategory",
# category=StoreDepartment,
# subcategory=DepartmentSubcategory,
# subcategory_related_name="department_subcategories",
# search_text=_("Search Department Subcategories")
# ),
FieldPanel("image"),
]
def __str__(self):
return f"{self.sku} - {self.title}"
Note the commented code for SubcategoryChooser
. These lines will be uncommented once we complete building the custom panel.
If you're not familiar with clusterable models, they "allow working with 'clusters' of models as a single unit, independently of the database". You can add dependent child instances without first needing to save the parent instance to the database. You can read more on the GitHub page.
You should be familiar with the Orderable class. Often referred to as an Inline Model, it allows nesting of models within another while allowing re-ordering from the admin interface.
Migrate your changes and you'll now see Store Departments in the Snippets menu on your admin interface. Add a Store Department and you will see you're also able to add Department Subcategories on the same page. Each of those will have the new Store Department as parent.
If you want to quickly populate your dataset with the same as that used in the example, run the following from an interactive Python session running in your virtual environment (click to expand):
from product.models import StoreDepartment, DepartmentSubcategory
data = [
{'name': 'Bath', 'code': 'bath','subcategories':
[{'name': 'Towels and Bath Mats', 'code': 'bath001'},{'name': 'Personal Care', 'code': 'bath002'},{'name': 'Shower Curtains', 'code': 'bath003'},
{'name': 'Accessories', 'code': 'bath004'}]},
{'name': 'Electrical', 'code': 'elec', 'subcategories':
[{'name': 'Heaters', 'code': 'elec001'},{'name': 'Fans', 'code': 'elec002'},{'name': 'Dehumidifiers', 'code': 'elec003'},
{'name': 'Vacuum Cleaners', 'code': 'elec004'},{'name': 'Personal Care', 'code': 'elec005'},{'name': 'Laundry', 'code': 'elec006'},
{'name': 'Lighting', 'code': 'elec007'},{'name': 'Home Tech', 'code': 'elec008'}]},
{'name': 'Kitchen', 'code': 'kitc', 'subcategories':
[{'name': 'Appliances', 'code': 'kitc001'},{'name': 'Kitchen Tools', 'code': 'kitc002'},{'name': 'Bakeware', 'code': 'kitc003'},
{'name': 'Jugs, Bottles & Flasks', 'code': 'kitc004'},{'name': 'Knives and Blocks', 'code': 'kitc005'},
{'name': 'Microwave Plasticware', 'code': 'kitc006'},{'name': 'Pots & Pans', 'code': 'kitc007'},
{'name': 'Preparation', 'code': 'kitc008'},{'name': 'Storage', 'code': 'kitc009'},{'name': 'Kitchen Textiles', 'code': 'kitc010'}]},
{'name': 'Bedroom', 'code': 'bedr', 'subcategories':
[{'name': 'Bedding', 'code': 'bedr001'},{'name': 'Beds', 'code': 'bedr002'},{'name': 'Accessories', 'code': 'bedr003'},
{'name': 'Mirrors', 'code': 'bedr004'}]},
{'name': 'Dining', 'code': 'dini', 'subcategories':
[{'name': 'Drinkware', 'code': 'dini001'},{'name': 'Cutlery', 'code': 'dini002'},{'name': 'Dinnerware', 'code': 'dini003'},
{'name': 'Serveware', 'code': 'dini004'},{'name': 'Dinner Collections', 'code': 'dini005'},{'name': 'Outdoor Dining', 'code': 'dini006'},
{'name': 'Dining Textiles', 'code': 'dini007'}]},
{'name': 'Laundry', 'code': 'laun', 'subcategories':
[{'name': 'Car Care & Hardware', 'code': 'laun001'},{'name': 'Cleaning & Accessories', 'code': 'laun002'},
{'name': 'General & Refuse', 'code': 'laun003'},{'name': 'Storage', 'code': 'laun004'}]},
{'name': 'Decorating & Accessories', 'code': 'deco', 'subcategories':
[{'name': 'Candles', 'code': 'deco001'},{'name': 'Clocks', 'code': 'deco002'},{'name': 'Curtains & Blinds', 'code': 'deco003'},
{'name': 'Frames', 'code': 'deco004'},{'name': 'Rugs', 'code': 'deco005'},{'name': 'Cushions & Throws', 'code': 'deco006'},
{'name': 'Fragrance', 'code': 'deco007'},{'name': 'Artificial Plants', 'code': 'deco008'},{'name': 'Mirrors', 'code': 'deco009'},
{'name': 'Nursery', 'code': 'deco010'},{'name': 'Accessories', 'code': 'deco011'}]},
{'name': 'Furniture & Storage', 'code': 'furn', 'subcategories':
[{'name': 'Indoor Furniture', 'code': 'furn001'},{'name': 'Chairs', 'code': 'furn002'},{'name': 'Ottomans', 'code': 'furn003'},
{'name': 'Storage', 'code': 'furn004'},{'name': 'Bean Bags', 'code': 'furn005'},{'name': 'Occasional', 'code': 'furn006'}]},
{'name': 'Outdoor Living', 'code': 'outd', 'subcategories':
[{'name': "BBQ's", 'code': 'outd001'},{'name': 'Beach, Pool & Picnic', 'code': 'outd002'},{'name': 'Camping', 'code': 'outd003'},
{'name': 'Outdoor Dining', 'code': 'outd004'},{'name': 'Gardening', 'code': 'outd005'},{'name': 'Umbrellas & Gazebos', 'code': 'outd006'},
{'name': 'Outdoor Beanbags', 'code': 'outd007'},{'name': 'Outdoor Furniture', 'code': 'outd008'},{'name': 'Online Exclusives', 'code': 'outd009'}]},
{'name': 'Travel', 'code': 'trav', 'subcategories':
[{'name': 'Accessories', 'code': 'trav001'},{'name': 'Backpacks', 'code': 'trav002'},{'name': 'Bags', 'code': 'trav003'},
{'name': 'Suitcases', 'code': 'trav004'},{'name': 'Umbrellas', 'code': 'trav005'},{'name': 'Purses & Wallets', 'code': 'trav006'}]},
{'name': 'Heaters & Heating Solutions', 'code': 'heat', 'subcategories':
[{'name': 'Ceramic', 'code': 'heat001'},{'name': 'Oil Fin', 'code': 'heat002'},{'name': 'Radiant', 'code': 'heat003'},
{'name': 'Convection', 'code': 'heat004'},{'name': 'Fan', 'code': 'heat005'},{'name': 'Electric Blankets', 'code': 'heat006'}]}
]
for department_data in data:
subcategories_data = department_data.pop('subcategories', [])
department_instance = StoreDepartment.objects.create(**department_data)
for subcategory_data in subcategories_data:
DepartmentSubcategory.objects.create(department=department_instance, **subcategory_data)
Browse to (or refresh) your Store Departments list page under Snippets and you'll see it has now been populated with the data from above. Open any of the departments to see the related subcategories.
Accessing Parent and Child Related Objects
In the Orderable (subcategory) class, we can find the related parent (category) class using the ParentalKey field name. In the DepartmentSubcategory
example above, the ParentalKey field name is department
:
In [1]: DepartmentSubcategory.objects.get(name='Cutlery').department
Out[1]: <StoreDepartment: Dining>
To do the reverse and find all the child objects related to a parent, we can use the related name defined in the child ParentalKey with the parent instance. In our example above, the ParentalKey was defined with related_name='department_subcategories'
. Use this name along with the all()
operator (or other relevant QuerySet operator) to return a QuerySet of related DepartmentSubcategory
objects:
In [2]: StoreDepartment.objects.get(name='Dining').department_subcategories.all()
Out[2]: <QuerySet [<DepartmentSubcategory: Drinkware>, <DepartmentSubcategory: Cutlery>,
<DepartmentSubcategory: Dinnerware>, <DepartmentSubcategory: Serveware>,
<DepartmentSubcategory: Dinner Collections>, <DepartmentSubcategory: Outdoor Dining>,
<DepartmentSubcategory: Dining Textiles>]>
One thing to note, it's quite inefficient in terms of database queries to use nested for
loops to build out a list of categories with their subcategories. This is the infamous (n+1) problem where we make one query to get the category QuerySet, then query each category to get a list of subcategories.
Our data set above has 11 StoreDepartment objects, so we end up querying the database 12 times.
A more efficient way is to use the ORM prefetch_related
method and gather everything in one query:
from django.db.models import Prefetch
categories = StoreDepartment.objects.prefetch_related(
Prefetch('department_subcategories', queryset=DepartmentSubcategory.objects.all())
)
Now we can just iterate through the categories
variable without further calls to the database:
grouped_subcategories = [
{
'id': category.id,
'name': str(category),
'subcategories': [
{'id': subcategory.id, 'name': str(subcategory)}
for subcategory in category.department_subcategories.all()
]
}
for category in categories
]
The grouped_subcategories
in our case will hold:
[
{'id': 17,
'name': 'Bath',
'subcategories': [
{'id': 90, 'name': 'Towels and Bath Mats'},
{'id': 91, 'name': 'Personal Care'},
{'id': 92, 'name': 'Shower Curtains'},
{'id': 93, 'name': 'Accessories'}]
},
{'id': 18,
'name': 'Electrical',
'subcategories': [
{'id': 94, 'name': 'Heaters'},
{'id': 95, 'name': 'Fans'},
{'id': 96, 'name': 'Dehumidifiers'},
{'id': 97, 'name': 'Vacuum Cleaners'},
{'id': 98, 'name': 'Personal Care'},
{'id': 99, 'name': 'Laundry'},
{'id': 100, 'name': 'Lighting'},
{'id': 101, 'name': 'Home Tech'}]
},
....
]
The grouped subcategory list in the chooser modal will be built using this prefetch technique. For more information on prefetch_related
, see the Django docs.
Building the Custom Chooser
The chooser requires five parts;
- A custom FieldPanel to handle inputs, build the grouped subcategory list and return the rendered panel HTML to the form.
- A Django template to render the admin form panel and the chooser modal.
- JavaScript to provide reactive responses to interaction and return the result back to the input field.
- CSS to style the chooser and handle dynamic changes to the HTML in response to interaction
- Hooks the register the CSS & JavaScript on the admin interface.
FieldPanel
I'll assume you have a working knowledge of Wagtail's FieldPanel
for this, it's beyond the scope of this article to cover that in depth.
If not, in a nutshell, this is the class that provides the relevant form control for most field types in Wagtail's CMS. You can easily sublass this and add keyword attributes via the clone_kwargs()
method which will then be available to the BoundPanel
. BoundPanel
is, as the name suggests, where the form control is bound to the named field name in the model. The render_html()
method is where the magic happens to render the control on the editor form.
You can find more complete information on Wagtail's Panel API documentation.
Our FieldPanel
will use Django's HiddenInput
widget since the value of the chosen subcategory will not be displayed directly. Instead, because subcategory names might not be unique across categories, the chosen value will be displayed in the form of category - subcategory. This provides better contextual information to the editor.
Alongside any chosen value, we'll need a 'change' and a 'clear' button.
The 'change' and 'select' buttons open the modal, the clear just removes any value from the hidden input and clears the displayed chosen text.
If there is no chosen value, we just want to render a 'select' button on the admin page and hide the 'change' and 'clear' buttons.
If the field is required, we want to always hide the 'clear' button. To pass this information into the JavaScript class, we add a data-field-required
attribute to the field wrapper element.
We'll set up the panel with the following inputs:
category
- ClusterableModel class object (display value will be str representation of each instance)subcategory
- Orderable class object (display value will be str representation of each instance)subcategory_related_name
- the related_name value passed into the ParentalKey field definition in the subcategory modelsubcategory_sort_order
(optional) - sort order for the subcategory groups on the chooser, defaults toordering
meta valueadd_button_text
,change_button_text
,clear_button_text
,search_text
,cancel_text
,clear_filter_text
andfilter_no_results_text
(optional) - text for the panel and chooser included to allow custom text and multilingual support- standard
FieldPanel
kwargs with the exception that any widget value passed will be ignored
I add a check to run in debug mode only to assist the developer when adding the required parameters (category
, subcategory
and subcategory_related_name
)
In the BoundPanel
, we build the list of subcategories grouped by parent category (see the previous section for explanation of the code used). This is added to the panel form context through to our custom template. The rendered template is appended to the default HTML for the HiddenInput
widget inside the Wagtail wrapper element and returned to the calling form.
In the product app, create panels.py
and add the following code
from bs4 import BeautifulSoup
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Prefetch
from django.forms.widgets import HiddenInput
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel
from wagtail.models import Orderable
class SubcategoryChooser(FieldPanel):
"""
FieldPanel with pop-over chooser style form to select subcategories from a ClusterableModel/Orderable class pair where
the ClusterableModel is assumed to be the category and the orderable the subcategory.
Chosen category not returned since this is redundant and found by {subcategory}.{ParentalKey field name}
Chooser modal will present orderable instances (subcategories) grouped by parent ClusterableModel instance (category).
Search filter will partial match on subcategory names.
Returns pk of chosen subcategory to hidden input field and displays chosen value as text in form `{category} - {subcategory}`
Modal title will be the verbose_name used in the `field` definition (the BoundPanel.heading value)
Inputs:
category - ClusterableModel class object (display value will be str representation of each instance)
subcategory - Orderable class object (display value will be str representation of each instance)
subcategory_related_name - the related_name value passed into the ParentalKey field definition in the subcategory model
subcategory_sort_order (optional) - sort order for the subcategory groups on the chooser, defaults to 'ordering' meta value
add_button_text, change_button_text, clear_button_text, search_text, cancel_text, clear_filter_text,filter_no_results_text (optional)
- text for the panel and chooser
standard FieldPanel kwargs with the exception that any widget value passed will be ignored
"""
def __init__(
self,
field_name,
category,
subcategory,
subcategory_related_name,
subcategory_sort_order=None,
add_button_text=_("Select"),
change_button_text=_("Change"),
clear_button_text=_("Clear Choice"),
search_text=_("Search"),
cancel_text=_("Close without changes"),
clear_filter_text=_("Clear filter"),
filter_no_results_text=_("Sorry, no matching records found."),
widget=None,
disable_comments=None,
permission=None,
read_only=False,
**kwargs
):
super().__init__(
field_name=field_name,
widget=HiddenInput(), # override any widget with HiddenInput
disable_comments=disable_comments,
permission=permission,
read_only=read_only,
**kwargs
)
self.category = category
self.subcategory = subcategory
self.subcategory_related_name = subcategory_related_name
self.subcategory_sort_order = subcategory_sort_order or subcategory.Meta.ordering
self.add_button_text = add_button_text
self.change_button_text = change_button_text
self.clear_button_text = clear_button_text
self.search_text = search_text
self.cancel_text = cancel_text
self.clear_filter_text = clear_filter_text
self.filter_no_results_text = filter_no_results_text
def clone_kwargs(self):
kwargs = super().clone_kwargs()
kwargs.update(
category=self.category,
subcategory=self.subcategory,
subcategory_related_name=self.subcategory_related_name,
subcategory_sort_order=self.subcategory_sort_order,
add_button_text=self.add_button_text,
change_button_text=self.change_button_text,
clear_button_text=self.clear_button_text,
search_text=self.search_text,
cancel_text=self.cancel_text,
clear_filter_text=self.clear_filter_text,
filter_no_results_text=self.filter_no_results_text,
)
return kwargs
def on_model_bound(self):
"""
Check category, subcategory and subcategory_related_name relationship, only in DEBUG mode
"""
if settings.DEBUG:
if not issubclass(self.category, ClusterableModel):
raise ImproperlyConfigured(
"Category model must inherit from ClusterableModel.")
if not issubclass(self.subcategory, Orderable):
raise ImproperlyConfigured(
"Subcategory model must inherit from Orderable.")
related_field = getattr(
self.category, self.subcategory_related_name, False)
if not related_field:
raise ImproperlyConfigured(
f"""
subcategory_related_name {self.subcategory_related_name} is not the related_name of a ParentalKey field
in {self.subcategory} or is the related_name of a ParentalKey that does not point to {self.category}.
"""
)
elif not related_field.rel.remote_field.model == self.subcategory:
raise ImproperlyConfigured(
f"""
subcategory_related_name {self.subcategory_related_name} is not the related name of a
{self.subcategory.__name__} ParentalKey field that points to {self.category.__name__}.
"""
)
class BoundPanel(FieldPanel.BoundPanel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# display text values for the panel and chooser
self.opts = {
"field_id": str(self.id_for_label()),
"heading": self.heading,
"add_button_text": self.panel.add_button_text,
"change_button_text": self.panel.change_button_text,
"clear_button_text": self.panel.clear_button_text,
"search_text": self.panel.search_text,
"cancel_text": self.panel.cancel_text,
"clear_filter_text": self.panel.clear_filter_text,
"filter_no_results_text": self.panel.filter_no_results_text,
}
def get_categories(self):
"""
Returns a list of nested dictionaries of categories and associated subcategories
"""
# Prefetch all categories with related subcategories
if isinstance(self.panel.subcategory_sort_order, list):
sort_order = self.panel.subcategory_sort_order
else:
sort_order = [self.panel.subcategory_sort_order]
categories = self.panel.category.objects.prefetch_related(
Prefetch(
self.panel.subcategory_related_name,
queryset=self.panel.subcategory.objects.order_by(*sort_order)
)
)
# Iterate through categories and build the result list
return [
{
'id': category.id,
'name': str(category),
'subcategories': [
{'id': subcategory.id, 'name': str(subcategory)}
for subcategory in getattr(category, self.panel.subcategory_related_name).all()
]
}
for category in categories
]
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
# if instance has a subcategory value, construct initial display value
initial_value = getattr(self.instance, self.field_name)
if initial_value:
parental_related_field = getattr(
self.panel.category, self.panel.subcategory_related_name).field.name
initial_value = f'{str(getattr(initial_value, parental_related_field))} - {str(initial_value)}'
# add panel texts, category list and initial value to panel context
context.update(
{
"opts": self.opts,
"category_index": self.get_categories(),
"initial_value": initial_value
}
)
return context
@mark_safe
def render_html(self, parent_context=None):
# manipulate default FieldPanel HTML to append rendered panel template inside Wagtail field wrapper element
html = super().render_html(parent_context)
soup = BeautifulSoup(html, "html.parser")
# create uid on wrapper element
wrapper = soup.find(class_="w-field__wrapper")
wrapper["data-subcategory-chooser"] = self.opts["field_id"]
# add required attribute to wrapper if field required - used to hide 'clear choice' button
if self.bound_field.field.required:
wrapper["data-field-required"]=""
# add rendered chooser html to wrapper element
chooser_html = render_to_string(
"panels/subcategory_chooser.html",
self.get_context_data()
)
wrapper.append(BeautifulSoup(chooser_html, "html.parser"))
return str(soup)
If you run this panel on a multilingual sites, where you've added the TranslateableMixin
to the category and subcategory models, you'll not only get each category and their subcategories, you'll get those repeated for each translated version also.
Fortunately, we can get the locale of the object we are choosing the subcategory for from the self.instance
object in the BoundPanel and filter our category list in the prefetch accordingly.
In the BoundPanel get_categories
method, change the prefetch from
categories = self.panel.category.objects.prefetch_related(
Prefetch(
self.panel.subcategory_related_name,
queryset=self.panel.subcategory.objects.order_by(*sort_order)
)
)
to
categories = self.panel.category.filter(locale_id=self.instance.locale_id).prefetch_related(
Prefetch(
self.panel.subcategory_related_name,
queryset=self.panel.subcategory.objects.order_by(*sort_order)
)
)
There is no need to filter the subcategories since we are only fetching those objects related to the filtered queryset we pass into prefetch_related
.
Template
As much as possible, the template uses native Wagtail classes and SVG icons to avoid adding excessive resources.
The template has three jobs:
Render the admin form controls and chosen value
- Create a container to display the chosen value text. If the field has a value already, this will be rendered. The JavaScript for the chooser will write the chosen display text to this container.
- Add actions buttons for select, change and clear. Use a CSS class hide (to be defined) to hide change and clear if there is no chosen value, or hide select if there is a chosen value. The clear button is always hidden if the field is required.
Build the modal popover form
The modal will include:
- Title bar with icon, title and dismiss button.
- Search bar with input field and clear button
- Scrollable selection panel where the nested subcategory list is displayed.
- Each subcategory item will include the pk for that item to return to the field's input control if selected. We'll add a data attribute
data-subcategory-id
for this.
Initialise JavaScript class instance
- Finally we need to initialise a custom
subcategoryChooser
class (which will be defined below) with the fieldid
to ensure it is unique on the admin form.
<div class="subcategory-chooser">
{# chosen subcategory display text container - load initial value if present #}
<div class="chooser__title subcategory-chooser-chosen{% if not initial_value %} hide{% endif %}"
id="{{ self.id_for_label }}-choosen-display">
{% if initial_value %}{{ initial_value }}{% endif %}
</div>
{# display text & icon on chooser button set according to presence of chosen value #}
<div class="chooser-button-group">
<button type="button" class="button button-small button-secondary chooser__choose-button open-modal-button"
aria-describedby="{{ self.id_for_label }}-choosen-display">
<span class="add-subcategory{% if initial_value %} hide{% endif %}">
<svg class="icon icon-plus icon"><use href="#icon-plus"></use></svg> {{ opts.add_button_text }}
</span>
<span class="change-subcategory{% if not initial_value %} hide{% endif %}">
<svg class="icon icon-resubmit icon"><use href="#icon-resubmit"></use></svg> {{ opts.change_button_text }}
</span>
</button>
<button type="button"
class="button button-small button-secondary chooser__choose-button clear-choice-button
{% if not initial_value or field.field.required %} hide{% endif %}"
aria-describedby="{{ self.id_for_label }}-choosen-display">
<span class="clear-subcategory">
<svg class="icon icon-bin icon"><use href="#icon-bin"></use></svg> {{ opts.clear_button_text }}
</span>
</button>
</div>
</div>
{# subcategory Field Panel Modal form #}
<div class="modal-backdrop subcategory-chooser-modal">
<div class="modal-content modal-body modal-form">
<header class="w-header w-header--merged">
{# title and cancel button #}
<div>
<div class="left">
<div class="col modal-banner">
<svg class="icon modal-icon" aria-hidden="true">
<use href="#icon-tasks"></use>
</svg>
<h2 class="w-header__title modal-heading" id="header-title">{{ self.heading }}</h2>
</div>
</div>
<div class="right modal-dismiss-container">
<button type="button"
class="button button--icon text-replace modal-dismiss"
title="{{ opts.cancel_text }}">
<svg class="icon icon-cross" aria-hidden="true">
<use href="#icon-cross"></use>
</svg>
{{ opts.cancel_text }}
</button>
</div>
</div>
</header>
<div class="subcategory-chooser-modal-body">
{# search input #}
<div class="modal-search-container">
<input type="text"
placeholder="{{ opts.search_text }}"
class="modal-search" />
<a role="button" title="{{ opts.clear_filter_text }}">
<svg class="modal-search-dismiss icon icon-cross" aria-hidden="true">
<use href="#icon-cross"></use>
</svg>
</a>
</div>
{# selection panel #}
<div class="selection-panel-container">
<div class="selection-panel">
{# display no results text if filter has no match #}
<div class="no-results-text hide">{{ opts.filter_no_results_text }}</div>
{# for each category, loop through each associated subcategory #}
{% for category in category_index %}
<div class="category" aria-expanded="false" data-category-name="{{ category.name }}">
{# collapsible category banner #}
<div class="category-banner">
<svg class="icon icon-placeholder w-panel__icon expander" aria-hidden="true">
<use href="#icon-placeholder"></use>
</svg>
<h2 class="category-label w-panel__heading--label">{{ category.name }}</h2>
</div>
{# subcategory list #}
<ul class="subcategory-list">
{% for subcategory in category.subcategories %}
<li class="subcategory-label" data-subcategory-id={{ subcategory.id }}>
{{ subcategory.name }}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<script>new subcategoryChooser("{{ opts.field_id }}");</script>
JavaScript
We'll create a subcategoryChooser
class to handle all interaction. The class will take the field id
to ensure the class is initialised with the correct elements and allows for more than one instance of the chooser on one page.
The modal form will have two behaviour modes.
- The first, for unfiltered listing where each category header will be 'collapsed' hiding the related subcategories until expanded. Clicking on one category banner will expand that list while collapsing all others.
- The second, when the search is active is triggered by any value in the search input. This mode will expand all categories and display all subcategories that have a partial match to the search criteria. Any categories without any matching subcategories will be hidden. If no matches are found, the
no-results-text
container is shown to provide feedback to the editor. When a search is cleared, the behaviour reverts to unfiltered listing and any previously selected category is once again expanded.
When we initialise the class we'll take the following steps:
- Set up class variables for the form and modal elements.
- Add event listeners for form button click events to open the modal or clear the chosen value.
- Add event listeners for modal click events and search input. Rather than add event listeners for each clickable object (this can be unwieldy for a large dataset), we'll use a single click event listener for the modal form and match the clicked item to its type:
- Category banner
- Subcategory list item
- Search dismiss button
- Modal dismiss button (or area outside of modal clicked)
- If there is already a chosen item when the modal is opened, the item's category will be expanded and the chosen subcategory highlighted using a CSS class active. We use the
scrollIntoView()
to ensure the category and its subcategories are visible in the selection panel.
In the products app directory, create static/js/subcategory_chooser.js
and add the following:
class subcategoryChooser {
constructor(id) {
this.chooser = {};
this.initHTMLElements(id);
}
// set up class variables, add event listeners ==========================================================
initHTMLElements(id) {
// Wagtail admin form elements
this.chooser.wrapper = document.querySelector(`[data-subcategory-chooser="${id}"]`);
this.chooser.required = this.chooser.wrapper.hasAttribute('data-field-required')
this.chooser.formInput = this.chooser.wrapper.querySelector(`input#${id}`)
this.chooser.chosenItem = this.chooser.wrapper.querySelector('div.subcategory-chooser-chosen');
this.chooser.openModalBtn = this.chooser.wrapper.querySelector('button.open-modal-button');
this.chooser.clearChoiceBtn = this.chooser.wrapper.querySelector('button.clear-choice-button');
// modal form elements
this.chooser.modal = this.chooser.wrapper.querySelector('div.subcategory-chooser-modal');
this.chooser.modalForm = this.chooser.modal.querySelector('div.modal-form');
this.chooser.searchInput = this.chooser.modal.querySelector('input.modal-search');
this.chooser.noResults = this.chooser.modal.querySelector('div.no-results-text');
this.chooser.modalSelect = this.chooser.modal.querySelector('div.selection-panel');
this.chooser.categories = this.chooser.modal.querySelectorAll('div.category');
this.chooser.subcategories = this.chooser.modal.querySelectorAll('li.subcategory-label');
// open modal form ========================================================================
this.chooser.openModalBtn.addEventListener('click', () => {
// used to restore last open category after clearing filter
this.chooser.openCategory = null;
// clear any filters and collapse any open categories
this.clearFilter();
// show modal
this.chooser.modal.style.display = 'block';
// if pre-chosen subcategory show item and set style
this.showActiveSubCategory();
});
// clear chosen value =====================================================================
this.chooser.clearChoiceBtn.addEventListener('click', () => {
this.clearChosenItem();
});
// modal click events =====================================================================
this.chooser.modal.addEventListener('click', event => {
const clickedItem = event.target;
// expand/collapse category =================================================
if (clickedItem.closest('div.category-banner')) {
this.handleCategoryClick(clickedItem.closest('.category'));
}
// subcategory clicked - set select value and dismiss modal =================
else if (clickedItem.matches('li.subcategory-label')) {
this.setChosenItem(clickedItem);
this.dismissModal();
}
// clear search filter & set focus on search input ==========================
else if (clickedItem.closest('svg.modal-search-dismiss')) {
this.clearFilter();
this.chooser.searchInput.focus();
}
// dismiss button or area outside of modal clicked ==========================
else if (clickedItem.closest('button.modal-dismiss') || !this.chooser.modalForm.contains(clickedItem)) {
this.dismissModal();
}
});
// filter category list or clear if no input value ========================================
this.chooser.searchInput.addEventListener('input', () => {
this.filterItems();
});
}
// expand/collapse category, scroll subcategories into view if expanded =================================
handleCategoryClick(clickedCategory) {
this.chooser.categories.forEach(category => {
// toggle clicked category value so collapses if currently open, collapse all other categories
if (category == clickedCategory) {
category.setAttribute('aria-expanded', category.getAttribute('aria-expanded') !== 'true');
} else {
category.setAttribute('aria-expanded', 'false');
}
});
if (clickedCategory.getAttribute('aria-expanded') === 'true') {
// only remember clickedCategory if no search filter
if (!this.chooser.searchInput.value) {
this.chooser.openCategory = clickedCategory;
}
// ensure expanded category is scrolled into view fully so subcategories are visible
if (this.chooser.modalSelect.clientHeight > clickedCategory.clientHeight) {
const modalSelectBottom = this.chooser.modalSelect.scrollTop + this.chooser.modalSelect.clientHeight;
const categoryBottom = clickedCategory.offsetTop + clickedCategory.offsetHeight;
if (modalSelectBottom <= categoryBottom) {
clickedCategory.scrollIntoView({ block: 'end', behavior: 'smooth' });
}
} else {
clickedCategory.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
} else {
// there are no open categories
this.chooser.openCategory = null;
}
}
// set chosen item on Admin page ========================================================================
setChosenItem(clickedItem) {
const subcategoryID = clickedItem.getAttribute('data-subcategory-id');
const category = clickedItem.closest('div.category').getAttribute('data-category-name');
// set input widget value and display text for chosen item (category - subcategory)
this.chooser.formInput.value = subcategoryID;
this.chooser.chosenItem.innerText = `${category} - ${clickedItem.innerText}`;
// admin interface - change from 'add new' mode to 'edit/clear/display' mode
this.chooser.chosenItem.classList.remove('hide');
this.chooser.openModalBtn.querySelector('span.add-subcategory').classList.add('hide');
this.chooser.openModalBtn.querySelector('span.change-subcategory').classList.remove('hide');
// only show the 'clear' button on the admin form if the field is not required
if (!this.chooser.required) {
this.chooser.clearChoiceBtn.classList.remove('hide');
};
}
// clear chosen value and display text, revert admin panel to 'add new' mode ============================
clearChosenItem() {
this.chooser.formInput.value = '';
this.chooser.chosenItem.innerText = '';
this.chooser.chosenItem.classList.add('hide');
this.chooser.openModalBtn.querySelector('span.add-subcategory').classList.remove('hide');
this.chooser.openModalBtn.querySelector('span.change-subcategory').classList.add('hide');
this.chooser.clearChoiceBtn.classList.add('hide');
this.chooser.openCategory = null;
}
// if chosen value already when opening modal, display and highlight item in modal list =================
showActiveSubCategory() {
// remove any previous 'active' subcategories in case modal has been opened previously
this.chooser.modalSelect.querySelectorAll('li.active').forEach(item => {
item.classList.remove('active');
})
// find the subcategory element from the form field input value
const activeSubCategory = this.chooser.modalSelect.querySelector(
`li[data-subcategory-id="${this.chooser.formInput.value}"]`
);
if (activeSubCategory) {
// highlight chosen item
activeSubCategory.classList.add('active');
// expand parent category
this.chooser.openCategory = activeSubCategory.closest('div.category')
this.chooser.openCategory.setAttribute('aria-expanded', 'true');
// ensure chosen item visible on screen - modal must not be hidden for this
activeSubCategory.scrollIntoView({ block: 'end' });
}
}
// clear search value ===================================================================================
clearFilter() {
this.chooser.searchInput.value = '';
// unhide all categories and subcategories
this.chooser.modalSelect.querySelectorAll('.hide').forEach(item => {
item.classList.remove('hide');
});
// hide 'no results' text
this.chooser.noResults.classList.add('hide');
// show all categories, collapse all categories except last active if not null
this.chooser.categories.forEach(category => {
// collapse all category banners
category.setAttribute('aria-expanded', 'false');
// if a category was expanded before search, restore open and scroll to view
if (this.chooser.openCategory) {
this.chooser.openCategory.setAttribute('aria-expanded', 'true');
this.chooser.openCategory.scrollIntoView({ block: 'end' });
}
});
}
// filter items =========================================================================================
filterItems() {
if (this.chooser.searchInput.value === '') {
this.clearFilter();
} else {
// display partial matches, expand all categorys with results, hide those without
this.chooser.modalSelect.querySelectorAll('[aria-expanded="false"]').forEach(category => {
category.setAttribute('aria-expanded', 'true');
});
const searchText = this.chooser.searchInput.value.trim().toLowerCase();
// display or hide modal list item containers where item has partial match with search text
this.chooser.subcategories.forEach(subcategory => {
(subcategory.textContent.toLowerCase().includes(searchText))
? subcategory.classList.remove('hide')
: subcategory.classList.add('hide');
});
// hide any empty categories
let found = false;
this.chooser.categories.forEach(category => {
if (category.querySelectorAll('li.subcategory-label:not(.hide)').length == 0) {
category.classList.add('hide');
} else {
category.classList.remove('hide');
found = true;
}
});
// show 'no results' text if no matching records were found
this.chooser.noResults.classList.toggle('hide', found);
}
}
// hide modal - close button clicked, or click was outside of modal body ================================
dismissModal() {
this.chooser.modal.style.display = 'none';
}
}
CSS
As mentioned previously, where possible, Wagtail native admin CSS classes and variables have been used, compatible with both light and dark mode.
Rather than try to explain all the classes added below, there are inline comments to assist. If there are aspects you're unfamiliar with, I highly recommend searching the MDN Web Docs for the particular style in question.
I will point out that the subcategory lists are styled as a grid with the column widths set out using
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
Adjust this minmax value if you'd prefer to change the column widths.
/* CSS styles for the admin page */
/* ----------------------------- */
div.subcategory-chooser {
display: flex;
}
div.subcategory-chooser-chosen {
max-width: 70%;
padding-right: 1em;
}
div.subcategory-chooser .hide {
display: none !important;
}
/* action buttons on admin page */
div.subcategory-chooser>div.chooser-button-group {
display: flex;
flex-wrap: wrap;
}
div.subcategory-chooser button.chooser__choose-button {
margin-inline-start: 1rem;
}
div.subcategory-chooser button.chooser__choose-button>span {
display: flex;
align-items: center;
}
/* CSS styles for the modal form */
/* ----------------------------- */
/* hide modal initially */
div.subcategory-chooser-modal {
display: none;
}
/* set display on modal elements with hide class (search facility) */
div.subcategory-chooser-modal .hide {
display: none;
}
/* set modal display */
div.subcategory-chooser-modal div.modal-form {
width: 80%;
left: 10%;
height: 85vh;
display: flex;
flex-direction: column;
padding-bottom: 0;
}
/* modal title bar */
div.subcategory-chooser-modal div.modal-banner {
color: var(--w-color-text-label-menus-active);
display: flex;
align-items: center;
margin-inline-start: 2em;
}
div.subcategory-chooser-modal .modal-heading {
padding: 1rem;
display: inline;
}
header.w-header div.left { /* wagtail bug fix - stop dismiss wrapping on mobile */
max-width: 80%;
}
div.subcategory-chooser-modal svg.modal-icon {
display: inline;
max-width: 1.4em;
max-height: 1.4em;
}
div.subcategory-chooser-modal div.modal-dismiss-container {
padding: .7em .7em 0 0;
opacity: .7;
}
div.subcategory-chooser-modal button.modal-dismiss {
color: var(--w-color-text-label-menus-default) !important;
border-color: var(--w-color-text-label-menus-default) !important;
border-width: 1px;
border-style: solid;
border-radius: 5px;
background-color: transparent !important;
}
div.subcategory-chooser-modal button.modal-dismiss:hover {
color: var(--w-color-text-label-menus-active) !important;
border-color: var(--w-color-text-label-menus-active) !important;
}
/* modal body */
div.subcategory-chooser-modal-body {
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
border-radius: 0;
margin-top: 0;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
}
/* modal search */
div.subcategory-chooser-modal div.modal-search-container {
position: relative;
display: flex;
}
div.subcategory-chooser-modal input.modal-search {
margin-bottom: 0.6rem;
flex-grow: 1;
padding-right: 2em;
}
div.subcategory-chooser-modal svg.modal-search-dismiss {
color: var(--w-color-text-label);
height: 1em;
width: 1em;
position: absolute;
top: 50%;
right: 1em;
transform: translateY(-75%);
cursor: pointer;
opacity: .7;
}
div.subcategory-chooser-modal svg.modal-search-dismiss:hover {
color: var(--w-color-text-context);
}
div.no-results-text {
padding: 0 2em;
font-style: italic;
opacity: 0.8;
}
/* selection list container */
div.subcategory-chooser-modal div.selection-panel-container {
border: 1px solid var(--w-color-border-field-default);
border-radius: 0.3125rem;
background-color: #fefefe;
overflow: hidden;
display: flex;
margin-bottom: 0.6rem;
}
/* scrollable selection list */
div.subcategory-chooser-modal div.selection-panel {
background-color: var(--w-color-surface-field);
padding: 20px 0;
overflow-y: auto;
width: 100vw;
display: flex;
flex-direction: column;
height: calc(85vh - 170px);
gap: 0.5em 0;
}
/* animate category expander icon */
div.subcategory-chooser-modal div.category svg.expander {
transition: 200ms;
}
/* container for category display name and expander */
div.subcategory-chooser-modal div.category-banner {
cursor: pointer;
display: flex;
align-items: center;
padding: 0 1em;
}
div.subcategory-chooser-modal .category-label {
display: inline;
margin: 0 0 0 1em;
font-size: 1.2em;
}
/* category banners and subcategory labels hover/not hover */
div.subcategory-chooser-modal li.subcategory-label,
div.subcategory-chooser-modal div.category-banner {
color: var(--w-color-text-label);
}
div.subcategory-chooser-modal li.subcategory-label:hover,
div.subcategory-chooser-modal div.category-banner:hover,
div.subcategory-chooser-modal div.category-banner:hover .category-label {
color: var(--w-color-text-context);
background-color: var(--w-color-border-field-default);
}
/* subcategory ul list grid - hide unless expanded */
div.subcategory-chooser-modal ul.subcategory-list {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-column-gap: 1rem;
grid-row-gap: 0.5rem;
padding: 0.2rem 2rem 0 2.5rem;
display: none;
}
/* display subcategory list when parent category expanded */
div.subcategory-chooser-modal div.category[aria-expanded="true"] ul.subcategory-list {
display: grid;
}
/* style subcategory list item */
div.subcategory-chooser-modal li.subcategory-label {
height: 100%;
cursor: pointer;
font-size: large;
font-weight: 500;
padding: 0.3rem 0.8rem;
break-inside: avoid-column;
overflow-wrap: break-word;
border-radius: 0.5rem;
line-height: normal;
}
/* add border and check mark to pre-chosen subcategory */
div.subcategory-chooser-modal li.subcategory-label.active {
border-color: var(--w-color-text-label);
border-width: 1px;
}
div.subcategory-chooser-modal li.subcategory-label.active::after {
content: '✓';
}
Hooks
Finally, to wrap this up, we need to register the JavaScript and CSS so that they load with the admin page.
For this, we'll use the insert_global_admin_js
and insert_global_admin_css
wagtail hooks.
In the products app folder, create wagtail_hooks.py
and add the following:
from django.templatetags.static import static
from django.utils.safestring import mark_safe
from wagtail import hooks
@mark_safe
@hooks.register('insert_global_admin_js')
def register_admin_js():
subcategory_chooser_js = static('js/subcategory_chooser.js')
return f''
@mark_safe
@hooks.register('insert_global_admin_css')
def register_admin_css():
subcategory_chooser_css = static('css/subcategory_chooser.css')
return f''
Using the Subcategory Chooser
By now, if you're following the example case, you should have the following files:
├── static
│ ├── css
│ │ └── subcategory_chooser.css
│ └── js
│ └── subcategory_chooser.js
├── templates
│ └── panels
│ └── subcategory_chooser.html
├── models.py
├── panels.py
└── wagtail_hooks.py
Depending on how you are serving static files, since we have added two files to our static folder, you may need to run collectstatic before using the chooser:
python manage.py collectstatic
Going back to the models.py
we created earlier, uncomment the panel import
from .panels import SubcategoryChooser
and in the Product
model, in the panels
definition, uncomment the SubcategoryChooser
so that you have:
panels = [
FieldPanel("sku"),
FieldPanel("title"),
FieldPanel("description"),
SubcategoryChooser(
"dept_subcategory",
category=StoreDepartment,
subcategory=DepartmentSubcategory,
subcategory_related_name="department_subcategories",
search_text=_("Search Department Subcategories")
),
FieldPanel("image"),
]
In this definition:
dept_subcategory
is the field name in our Product model the Chooser will select for (as you would define for any FieldPanel). Remember that is it a ForeignKey field that points toDepartmentSubcategory
- The category model is
StoreDepartment
(the ClusterableModel). - The subcategory model is
DepartmentSubcategory
(the Orderable). - The subcategory_related_name is
"department_subcategories"
- this is therelated_name
value used in the subcategory model ParentalKey field that ties the Orderable to the ClusterableModel. - An additional
search_text
has been added as an example of overriding default chooser text.
In the example below, the selection process starts from blank and chooses Cutlery in the Dining store department.
The selection is then changed. When the chooser opens again, note the previous selection is highlighted. The editor then uses the search to filter on 'lo' to find the Knives and Blocks subcategory in the Kitchen.
Lastly, the selection is cleared using the Clear admin button and the chooser is opened again. The editor searched on 'tec' to find the Home Tech subcategory in Electrical.
Examining the Acme Widget Meter, we can see the subcategory stored in the dept_subcategory
field as expected, and that we can access the StoreDepartment category through that field via the ParentalKey field department
:
In [1]: p=Product.objects.get(title='Acme Widget Meter')
In [2]: p.dept_subcategory
Out[2]: <DepartmentSubcategory: Home Tech>
In [3]: p.dept_subcategory.department
Out[3]: <StoreDepartment: Electrical>
We're also able to reverse this to retrieve a list of Product objects filtered by subcategory and category in a similar manner:
In [4]: sc=DepartmentSubcategory.objects.get(name='Knives and Blocks')
In [5]: Product.objects.filter(dept_subcategory=sc)
Out[5]: <QuerySet [<Product: JKL - Whizzo Knife Sharpener>, <Product: DEF - Sweeney Todd Meat Cleaver>]>
In [6]: sd=StoreDepartment.objects.get(name='Electrical')
In [7]: Product.objects.filter(dept_subcategory__department=sd)
Out[7]: <QuerySet [<Product: ABC - Air Express Desktop Fan>, <Product: XYZ - Acme Widget Meter>]>
Conclusion
If you've made it this far without your eyes glazing over, well done 😉
In this article:
- We worked through one solution to providing cascading selections by employing a custom chooser panel that allows selecting a subcategory from a list that depends on a category. This feature can be useful for organising content in Wagtail, especially when dealing with large data sets.
- We have used a ClusterableModel / Orderable structure to represent the category and subcategory models, and a HiddenInput widget to store the chosen subcategory value.
- We have also used a Django template, JavaScript, CSS, and Wagtail hooks to render the chooser panel and the modal form, and to handle the user interaction and filtering.
The chooser panel is flexible and can work with any ClusterableModel / Orderable pair, as long as the required parameters are passed correctly.
I hope this article has been helpful and informative, and that you can use this technique to enhance your Wagtail projects.