Occasionally we as developers would like to add additional field validation to parts of our website. Let's talk about a common example: internal vs. external links.

When you want to create a custom link in a StreamField, a lot of times we'll give people the option select an existing Wagtail Page with a PageChooserPanel, and we'll give them a URLBlock. And then in the template, we'll check if the internal (Wagtail Page) was selected, and if it wasn't we'll use the external link (URLBlock). And both fields are optional so the user could enter both fields, one field, or no fields.

In this tutorial, let's cover just one of these situations and we want the user to enter just one field, but both fields are empty when the page is saved.

Let's take a look at our StructBlock StreamField.

The StreamField
from wagtail.core import blocks

class CTABlock(blocks.StructBlock):
    """A simple call to action section."""

    button_url = blocks.URLBlock(required=False)
    button_page = blocks.PageChooserBlock(required=False)

    class Meta:
        template = "streams/cta_block.html"
        icon = "placeholder"
        label = "Call to Action"
 video image Call to Action URLBlock and PageChooserBlock

Ok, so this is a pretty standard Call to Action block, with the option to enter a URLBlock, and another option for a PageChooserBlock.

The problem is when you save your page, the button_url and the button_page are optional, so they can both be filled and they can both be empty. Let's make sure at least one of these fields are filled out.

The way we make this happen is by invoking the clean() method on the StreamField. Let's take a look.

# ... other imports 

from django.core.exceptions import ValidationError
from django.forms.utils import ErrorList


class CTABlock(blocks.StructBlock):

    # ...

    def clean(self, value):
        errors = {}

        if not value.get('button_url') and value.get('button_page') is None:
            errors['button_url'] = ErrorList(['You must select a page, or enter a URL. Please fill just one of these fields.'])
            errors['button_page'] = ErrorList(['You must select a page, or enter a URL. Please fill just one of these fields.'])

        if errors:
            raise ValidationError('Validation error in StructBlock', params=errors)

        return super().clean(value)

What we're doing here is:

  1. Defining a local clean() method and passing in the value of the SteamField.
  2. Create an empty dict with errors = {}
  3. Check if the button_url is empty, and check if the button_page is not None
  4. If there are errors, add the error (ErrorList) to your errors dict.
  5. If there are errors, raise a ValidationError.

The code snippet above will technically raise 2 errors. We're showing the same error twice, but on both of the link fields.

This takes care of both fields (being optional in nature) being left empty. We're now forcing at least one of the fields to be filled. The end result, when both fields are left empty, will look like this:

 video image Both fields left empty raises this error on both fields.
What about when both fields are filled?

This is the next issue. As of right now, our StreamField will save perfectly fine when you select a Wagtail Page and enter a URL manually. At this point we need to create another conditional statement, where we check for both fields being filled out. Let's adjust our clean() method and add new errors to the errors dict.

class CTABlock(blocks.StructBlock):

    # ...

    def clean(self, value):
        errors = {}

        if not value.get('button_url') and value.get('button_page') is None:
            errors['button_url'] = ErrorList(['You must select a page, or enter a URL. Please fill just one of these.'])
            errors['button_page'] = ErrorList(['You must select a page, or enter a URL. Please fill just one of these.'])
        # We're adding the elif statement here. 
        elif value.get('button_url') and value.get('button_page'):
            errors['button_url'] = ErrorList(['Please select a page OR enter a URL (choose one)'])
            errors['button_page'] = ErrorList(['Please select a page OR enter a URL (choose one)'])

        if errors:
            raise ValidationError('Validation error in StructBlock', params=errors)

        return super().clean(value)

Now we're able to check if both fields are empty, and if both fields are filled. Here's what it looks like when you have both fields filled out.

 video image When both fields are filled, it throws an error.

In summary, we've alleviated the problem of having two optionally blank fields and having our future users and client become a bit confused when they're asked to enter an external URL or select an internal Wagtail Page. And we've given them some additional guidance via ValidationError's when something goes wrong. For a full example of the code, check out the gist below.

from django.core.exceptions import ValidationError
from django.forms.utils import ErrorList

from wagtail.core import blocks


class CTABlock(blocks.StructBlock):
    """A simple call to action section."""

    button_url = blocks.URLBlock(required=False)
    button_page = blocks.PageChooserBlock(required=False)

    class Meta:  # noqa
        template = "streams/cta_block.html"
        icon = "placeholder"
        label = "Call to Action"

    def clean(self, value):
        # Error container 
        errors = {}

        # Check if both fields are empty 
        if not value.get('button_url') and value.get('button_page') is None:
            errors['button_url'] = ErrorList(['You must select a page, or enter a URL. Please fill just one of these.'])
            errors['button_page'] = ErrorList(['You must select a page, or enter a URL. Please fill just one of these.'])
        # Check if both fields are filled
        elif value.get('button_url') and value.get('button_page'):
            errors['button_url'] = ErrorList(['Please select a page OR enter a URL (choose one)'])
            errors['button_page'] = ErrorList(['Please select a page OR enter a URL (choose one)'])

        # If there are errors, raise a validation with the errors dict from line 20. 
        if errors:
            raise ValidationError('Validation error in StructBlock', params=errors)

        # If no ValidationError was raised, perform a regular clean on this StreaemField. 
        return super().clean(value)
Sign up for our newsletter

Get notified about new lessons :)


Our Sites