Wagtail Streamfields - Propagating the `required` Attribute on Nested Blocks
Introduction
There are often times where you want to construct reusable StructBlocks to nest within other StructBlocks. These nested blocks may be required or optional in the parent block (defined by setting the required
parameter) and you need to construct your class to handle both cases.
In the Wagtail framework, the required
attribute within the Block
class is an immutable property. This means it can only be set during the class declaration and cannot be altered during instantiation. This rigidity can present challenges, particularly when flexibility is needed based on dynamic requirements.
One option around this is to set everything in your nested block to required=False
and leave it to the parent block to validate in the clean()
method of that block. This will lead to a lot of custom validation being repeated with the risk of bloating your code block. You'll also not be catering for the option of calling your StructBlock directly in the StreamField.
To overcome the limitation posed by the read-only required
attribute, this article introduces a method implementing the little documented local_blocks
feature. This method provides a straightforward workaround by moving block declaration into the class initialiser, enabling the manipulation of block requirements without the need for extensive validation code within each affected block.
The Scenario
A common Structblock that I use as a nested block across my sites is an image block with an additional text field to add a contextual alt
text to the image. The alt
text is not only essential for screen readers but also important for search engines to determine relevance.
Two reasons for the additional text field:
- Wagtail will, by default, render the image with the title as the alt text. The title defaults to the filename and what ends up happening is you have a site with lots of images with meaningless alt values such as Screenshot 2024-02-04 100327. People rarely take the time put something relevant here, it's too easy to just upload and leave it at default values.
- The alt text is contextual, meaning that it should be relative to its usage, not just a generic description of the image contents. This means the alt text should be specific to each usage on the site.
The starting point for the block is simply:
class SEOImageChooserBlock(StructBlock):
image = ImageChooserBlock(label=_("Image"))
description = CharBlock(
label=_("Image Description"),
help_text=_(
"A contextual description of the image for screen readers and search engines"
),
)
class Meta:
icon = "image"
label_format = '{description}'
template = 'blocks/image_block.html'
form_classname = "seo-image-chooser-block"
The problem here is that image
and description
are both required=True
(the default for Wagtail). If the SEOImageChooserBlock is initialised with required=False
, this does not propagate to the nested fields. The form will invalidate on save if no values were entered as these fields are still required even though the parent StructBlock isn't.
image
and description
can both be set to required=False
in the SEOImageChooserBlock declaration, but now you have the opposite problem in that if the block is initialised with required=True
, it will pass validation even without anything entered in these fields. This will also be the case if SEOImageChooserBlock was added as a StreamField top level block.
One way around it is to write custom validation in the clean method of the calling StructBlock. This means repeating the same validation code every time it's called. It also gets messy when blocks are nested 3, 4 or more layers deep.
Another way is to keep two versions of the StructBlock, one for required, one for optional. You're now maintaining two sets of code and relying on future developers to import the right one.
Introducing local_blocks
In any official documentation for Wagtail (and elsewhere that I could find), child blocks of StructBlock classes are declared as class attributes as per the example above.
Lurking in the initialiser of the BaseStructBlock class (which all StructBlocks inherit) is a parameter called local_blocks
which takes a set of block definitions and appends them to the StructBlock's child_blocks
collection:
class BaseStructBlock(Block):
def __init__(self, local_blocks=None, **kwargs):
self._constructor_kwargs = kwargs
super().__init__(**kwargs)
# create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
self.child_blocks = self.base_blocks.copy()
if local_blocks:
for name, block in local_blocks:
block.set_name(name)
self.child_blocks[name] = block
....
It might not seem that significant at first, but what it means is that you can defer the declaration of the child blocks until the StructBlock instance is initialised. Attributes of that instance can be used in those child block declarations such as conditionally adding blocks or passing parameters into the child block definition.
In other words, declaring child blocks as
- class attributes means the attributes of those blocks are shared amongst all instances of that class
- local variables in the class initialiser means those blocks are specific to the instance of that class.
In the case highlighted in this article, declaring the child blocks as local variables allows us to pass in the required
attribute of the StructBlock into the child blocks that we want to inherit this value.
Creating a Workaround
For the SEOImageChooserBlock example, we want both fields to inherit the required
attribute. We can move their declarations into the initialiser as follows:
class SEOImageChooserBlock(StructBlock):
def __init__(self, required=True, **kwargs):
local_blocks = (
("image", ImageChooserBlock(
label=_("Image"),
required=required
)),
("description", CharBlock(
label=_("Description"),
help_text=_(
"A contextual description of the image for screen readers and search engines"
),
required=required
)),
)
super().__init__(local_blocks, **kwargs)
class Meta:
icon = "image"
label_format = '{seo_title}'
template = 'blocks/image_block.html'
form_classname = "structblock seo-image-chooser-block"
That's all that's needed - there's no need to declare the blocks as class attributes. If the StructBlock was declared with required=True
, then both image
and description
will also be required and vice versa.
Order of Child Blocks in the Admin Form
In the initialiser of the BaseStructBlock, the blocks declared in local_blocks
are appended to the child_blocks
declared as class attributes. This means that any 'local' blocks will appear after 'class' blocks in the admin interface. For example, an optional caption
field is added to SEOImageChooserBlock as a class attribute:
class SEOImageChooserBlock(StructBlock):
def __init__(self, required=True, **kwargs):
local_blocks = (
("image", ImageChooserBlock(
label=_("Image"),
required=required
)),
("description", CharBlock(
label=_("Description"),
help_text=_(
"A contextual description of the image for screen readers and search engines"
),
required=required
)),
)
super().__init__(local_blocks, **kwargs)
caption = CharBlock(label=_("Image"), required=False)
In this case, when the admin form renders, caption
will be the first field in the rendered StructBlock form, before image
and description
.
This can work to your advantage in some cases - I use such a case to append a common set of options to related StructBlocks.
If I wanted to have caption
after image
and description
, then I can just add it to the local_blocks
variable instead with the same parameters the class variable was declared with:
class SEOImageChooserBlock(StructBlock):
def __init__(self, required=True, **kwargs):
local_blocks = (
("image", ImageChooserBlock(
label=_("Image"),
required=required
)),
("description", CharBlock(
label=_("Description"),
help_text=_(
"A contextual description of the image for screen readers and search engines"
),
required=required
)),
("caption", CharBlock(
label=_("Image"),
required=False
)),
)
super().__init__(local_blocks, **kwargs)
Adding Custom Validation
In this example, I want the text description to be compulsory whenever an image is selected, regardless of the StructBlock required
value. Setting this field to required=True
during declaration would make it always required even if SEOImageChooserBlock was called with required=False
. In the modified block below, in the clean()
method, and after super().clean(value)
is called, I check if image
has a value set, if so then raise a validation error if there is not also a value for description
. I only need do this if the StructBlock required
was set to False
since the super().clean(value)
method will have already invalidated the form for a missing value in description
if it had been set to True
.
class SEOImageChooserBlock(StructBlock):
....
def clean(self, value):
# standard form validation
cleaned_data = super().clean(value)
# custom validation to run if standard form validation passes
if not self.required and (bool(value['image']) and not bool(value['description'])):
raise StructBlockValidationError(
block_errors={'description': ErrorList(
[_("Please enter a text description for the image.")])}
)
return cleaned_data
- Allow the default validation to run before the custom validation.
- The custom validation will only be run if that passes.
So in this way, we can see default and custom validation is easily handled with the dynamic block declarations.
Further Customising the StructBlockDefinition
As an aside, the SEOImageChooserBlock I've used as an example in this article has its own StructBlockDefinition class that hides the description field if the StructBlock is optional and no image has been selected. This section isn't required for standard usage, the child blocks you have set to inherit the required
attribute will already render correctly.
The StructBlockDefinition is the JavaScript Wagtail uses to render StructBlocks on the admin page and can be extended to add extra functionality. It's particularly useful for adding reactive features and methods that extend beyond the basic form.
If you aren't familiar with the StructBlockAdapter and StructBlockDefinition, I give a basic example here. And there's wagtail docs:
- How to build custom StreamField blocks - Wagtail documentation
- Form widget client-side API - Wagtail documentation
- Telepath Documentation - Covering the basics of the Telepath library including a useful tutorial project
In this case, I just need a StructBlockAdapter that sets the js_constructor
class and add my custom JavaScript file to the form media. Finally, I need to register the adapter and StructBlock with telepath.
In the same file as I define SEOImageChooserBlock:
from wagtail.blocks.struct_block import StructBlockAdapter
from wagtail.telepath import register
....
class SEOImageChooserBlock(StructBlock):
....
class SEOImageChooserBlockAdapter(StructBlockAdapter):
js_constructor = "blocks.seo_image_chooser.SEOImageChooserBlock"
@cached_property
def media(self):
from django import forms
structblock_media = super().media
return forms.Media(
js=structblock_media._js + ["js/seo-image-chooser-block.js"],
)
register(SEOImageChooserBlockAdapter(), SEOImageChooserBlock)
The custom StructBlockDefinition has two jobs to do if the StructBlock is optional:
- Hide the
description
field if no image has been selected (since this field is redundant in this case). - Mark the
description
field as required (thedescription
field will only be shown if an image is selected, if an image is selected, thedescription
field is required).
In the StructBlockDefinition class, you can find all the child block definitions in this.childBlockDefs
. In the meta
object of each child block is the required
attribute for that child block. By setting this to true before the block is rendered, the field will be marked with the '*' required star and the input field rendered with required="required"
. Note, this only affects the form rendering and has no effect on the underlying form validation.
Normally, if the StructBlock is a nested StructBlock, you can retrieve the required
attribute for the StructBlock from this.meta
. There's a bit of inconsistency in Wagtail here however - if the StructBlock was declared in a StreamField or StreamBlock, the required
attribute will be set to false even though it is treated as a required block. In the example case here, if SEOImageChooserBlock is called in a StreamField, this.meta.required
will be false
while the meta.required
for image
and description
will both be true
.
We can use this however by defining a new class variable this.required
which takes the meta.required
value of the description
child block. Once we have that, we can set the description
child block to required=true
and then conditionally hide or show the description
section if this.required=false
.
Finally, the custom StructBlockDefinition needs to be registered with Telepath with the js_constructor
value used in the StructBlockAdapter above:
class SEOImageChooserBlockDefinition extends window.wagtailStreamField.blocks
.StructBlockDefinition {
// hide description field if required=False and no image selected
// apply required mark to description as will always be required if shown
render(placeholder, prefix, initialState, initialError) {
// set description field required=true - only affects rendering
this.childBlockDefs.find(obj => obj.name === 'description').meta.required = true;
const block = super.render(
placeholder,
prefix,
initialState,
initialError,
);
if (!this.required) {
// StructBlock is optional, hide description section if no image value
const structBlock = block.container[0];
const imageInput = structBlock.querySelector(`#${prefix}-image`);
const descriptionSection = structBlock.querySelector('[data-contentpath="description"]');
const updateDisplay = () => {
descriptionSection.style.display = imageInput.value.trim() === "" ? "none" : "block";
};
imageInput.addEventListener("change", updateDisplay);
updateDisplay();
}
return block;
}
}
window.telepath.register('blocks.seo_image_chooser.SEOImageChooserBlock', SEOImageChooserBlockDefinition);
Conclusion
The article discusses the challenge of constructing reusable StructBlocks to nest within other StructBlocks in the Wagtail framework where the required
attribute must be propagated to child blocks. Topics covered included:
- Immutability Challenge: The
required
attribute in Wagtail’sBlock
class is immutable, meaning it cannot be changed after the class is declared, posing challenges for dynamic requirements. - Dynamic Requirements: The
local_blocks
initialiser variable enables dynamic manipulation of child blocks within aStructBlock
, including propagating therequired
status. - Validation Simplification: This approach simplifies validation by ensuring consistency across the structure of nested blocks, enhancing code reusability and reducing the need for custom validation.
Using the local_blocks
initialiser variable in this manner has other practical uses including:
- Conditionally adding child blocks based on passed parameters.
- Creating a common set of child blocks to append to related StructBlocks.
- Creating recursive StructBlocks down to the nth level in a simple loop.
I'll show an example of conditionally adding child blocks in the following article.
I'll give examples for common and recursive blocks in a later series on building StreamField based menus.