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:

  1. 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.
  2. 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:

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

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

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

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

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

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

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:

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

  1. Hide the description field if no image has been selected (since this field is redundant in this case).
  2. Mark the description field as required (the description field will only be shown if an image is selected, if an image is selected, the description 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:

js/seo-image-chooser-block.js
Copy
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’s Block 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 a StructBlock, including propagating the required 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.


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