Creating Wagtail Streamfield StructBlocks with a Customised Editor Interface

Introduction

One of the first things you'll learn on any Wagtail introduction course is how to create your own Streamfield StructBlocks as a compound block using built-in Wagtail block types. What happens when you have a need that goes beyond those built-in blocks such as dynamic front-end behaviour on the editing interface?

Wagtail allows you to create your own editing forms and add JavaScript for dynamic behaviour and active responses to editor actions. In this article we'll go through both using the methods using the example I gave in the previous discussion, augmenting a text area with the ability to read text files through either drag/drop or file input button. If you're needing an explanation of the file reading process, I recommend reading that article first.

Finally, I'll show how to use this as a building block for other compound StructBlocks.

Note

The example below is intended as a demonstration of the technique to add JavaScript interaction with a StructBlock form rather than a solution to the case of adding file reading ability to a text area. A better solution for that case using a custom widget is given in the next article.

Wagtail 6.1

The first two minor releases of Wagtail 6.1 (<6.1.2) had a change so that only the first HTML node in a widget was rendered. If the widget had more than one top-level node, the subsequent nodes would be dropped from the render. This was fixed in 6.1.2, but it means the code on this page is not compatible with 6.1.0 or 6.1.1.

Customising the Wagtail StructBlock Form

Form Classname

To customize the styling of a StructBlock in the page editor, you can define a form_classname attribute. Specify it as a keyword argument in the StructBlock constructor or within a Meta subclass to override the default struct-block value.

Copy
class SomeBlock(blocks.StructBlock):
    some_var = blocks.CharBlock()
    other_var = blocks.CharBlock()

    class Meta:
        form_classname = 'struct-block some-block'

You can now add a CSS class definition for your custom class (some-block in the example above) to override any default styling for your block.

Warning

The struct-block class style is already included in Wagtail's editor styling. You must remember to specify the struct-block as well since if you supply a value for form_classname, it will override any classes that are already assigned to StructBlock.

Overriding the Form Template

You can modify the form_template attribute of the class Meta to give your own template path for more complex customizations that need modifications to the HTML markup as well.

Copy
class SomeBlock(blocks.StructBlock):
    some_var = blocks.CharBlock()
    other_var = blocks.CharBlock()

    class Meta:
        form_template = 'someapp/blocks/forms/some-block.html'

The template provides access to the following variables:

  • children: An OrderedDict of BoundBlocks representing the child blocks within this StructBlock.
  • help_text: The specified help text for this block.
  • classname: The form_classname passed (defaults to 'struct-block').
  • block_definition: The instance defining this StructBlock.
  • prefix: The unique prefix used for form fields in this block instance.

The output of Wagtail's render_form template tag for each child block in the children dict must be included in the form template for a StructBlock within a container element with a data-contentpath attribute equal to the block's name. The commenting system makes use of this feature to associate comments with the appropriate fields. Labels for each field are also shown by the StructBlock's form template, although this and all other HTML can be changed as appropriate.

The template fragment below can be used to replicate Wagtail's StructBlock rendering as a base for your customisation:

Copy
{% load wagtailadmin_tags %}

<div class="{{ classname }}">
    {% if help_text %}
        <span>
            <div class="help">
                {% icon name="help" classname="default" %}
                {{ help_text }}
            </div>
        </span>
    {% endif %}

    {% for child in children.values %}
        <div class="w-field" data-field data-contentpath="{{ child.block.name }}">
            {% if child.block.label %}
                <label class="w-field__label" {% if child.id_for_label %}for="{{ child.id_for_label }}"{% endif %}>
                    {{ child.block.label }}{% if child.block.required %}<span class="w-required-mark">*</span>{% endif %}
                </label>
            {% endif %}
            {{ child.render_form }}
        </div>
    {% endfor %}
</div>

In the example later on, I'll go through customising this template to add a file input button and some extra HTML.

Additional Variables

You can add additional parameters to your StructBlock in your class initialisation and pass these and any other variables through to the template by overriding the get_form_context method.

Copy
class SomeBlock(StructBlock):
    def __init__(self, local_blocks=None, some_arg="", **kwargs):
        super().__init__(local_blocks, **kwargs)
        self.some_arg = some_arg

    some_var = blocks.CharBlock()
    other_var = blocks.CharBlock()

    class Meta:
        form_template = 'someapp/blocks/forms/some-block.html'

    def get_form_context(self, value, prefix="", errors=None):
        context = super().get_form_context(value, prefix, errors)
        context["some_arg"] = self.some_arg
        context["some_text"] = _(
            "Some translated text."
        )
        return context

We can access these in the template using the standard Django format (e.g. {{ some_arg }})

Adding JavaScript to StructBlock Forms

StreamField leverages the telepath library to establish a mapping between Python block classes like StructBlock and their corresponding JavaScript implementations.

Defining the StructBlockAdapter

To customize our own StructBlock, we must define a telepath StructBlockAdapter. This adapter allows us to replace the default StructBlockDefinition with our own JavaScript class.

The StructBlockAdapter requires the js_constructor attribute, which specifies the identifier for the JavaScript class (use the path to your StructBlock class), and a definition to include your custom JavaScript file in the form media attribute. You can also define additional CSS here.

Finally, the adapter and the corresponding StructBlock are registered in Telepath. You would normally do all of this in the same module as the StructBlock.

Copy
from django import forms
from django.utils.functional import cached_property
from wagtail.blocks import StructBlock, CharBlock
from wagtail.blocks.struct_block import StructBlockAdapter
from wagtail.telepath import register

class SomeBlock(StructBlock):
    ....

class SomeBlockAdapter(StructBlockAdapter):
    js_constructor = "someapp.blocks.SomeBlock"

    @cached_property
    def media(self):
        structblock_media = super().media
        return forms.Media(
            js=structblock_media._js + ["js/some-block.js"],
            css={"all": ("css/some-block.css",)},
        )

register(SomeBlockAdapter(), SomeBlock)
If you're not using any additional css, use css=structblock_media._css instead

Adding the Telepath JavaScript Class

Your JavaScript file must include a class that inherits StructBlockDefinition and registers that class against the identifier you defined for js_constructor above.

Normally, you will be taking the output of the default render method, assigning that to a variable, adding your custom JavaScript code, then returning that variable.

For example, the following JavaScript class converts any text entered into the some_var field and converts it to upper case. It's then registered against the name "someapp.blocks.SomeBlock" that we defined in the StructBlockAdapter example above:

Copy
// js/some-block.js

class ImportTextBlockDefinition extends window.wagtailStreamField.blocks
    .StructBlockDefinition {
    render(placeholder, prefix, initialState, initialError) {
        const block = super.render(
            placeholder,
            prefix,
            initialState,
            initialError,
        );

        const someVarField = document.getElementById(prefix + '-some_var');
        someVarField.addEventListener('input', (event) => {
            event.target.value = event.target.value.toUpperCase();
        });

        return block;
    }
}

window.telepath.register('blocks.models.ImportTextBlock', ImportTextBlockDefinition);
Note: to access the some_var input field for that field, we need to use (prefix + '-some_var') where prefix will be the UID for that block instance.

Example: Creating the ImportTextBlock

By utilizing the techniques described above, we can now create a custom StructBlock that incorporates a text area that provides the flexibility to import text through drag and drop functionality or a file input button.

Step 1. StructBlock and StructBlockAdapter Classes

Since this StructBlock is reusable, we only need to define the text field within the StructBlock class and include the corresponding form template.

To enhance the StructBlock's functionality, we introduce an optional parameter to specify a file type filter for the file input element. We pass this parameter as context to the template, along with translatable text to present to the editor.

Next, we proceed to define the StructBlockAdapter, assigning it a named identifier. Additionally, we include the necessary custom JavaScript and CSS files required for the editor form. Finally, we register this adapter with Telepath.

blocks/import_text.py
Copy
from django import forms
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import StructBlock, TextBlock
from wagtail.blocks.struct_block import StructBlockAdapter
from wagtail.telepath import register

class ImportTextBlock(StructBlock):
    def __init__(self, local_blocks=None, file_type_filter="", **kwargs):
        super().__init__(local_blocks, **kwargs)
        self.accept = file_type_filter

    text = TextBlock()

    class Meta:
        form_template = "blocks/forms/import_text_block_form.html"

    def get_form_context(self, value, prefix="", errors=None):
        context = super().get_form_context(value, prefix, errors)
        context["instructions"] = _(
            "Use 'Choose File' or drag/drop to import data from file."
        )
        context["accept"] = self.accept
        return context

class ImportTextBlockAdapter(StructBlockAdapter):
    js_constructor = "blocks.models.ImportTextBlock"

    @cached_property
    def media(self):
        structblock_media = super().media
        return forms.Media(
            js=structblock_media._js + ["js/import-text-block.js"],
            css={"all": ("css/import-text-block.css",)},
        )

register(ImportTextBlockAdapter(), ImportTextBlock)

Step 2. Custom Form Template

Next, we take the template described previously and add some code to render a file input button with some instructions for the editor. We also include some custom CSS classes in that code that we'll use to style the render appropriately.

Since text is the only field defined in the StructBlock, it will only loop through the for loop once. The custom code is added immediately after the Wagtail render_form template tag has been called to keep the controls tight together. If you're creating a custom StructBlock with multiple fields, you'll need to handle this in the for loop.

/blocks/forms/import_text_block_form.html
Copy
{% load wagtailadmin_tags %}
<div class="{{ classname }} import-text-block">
    {% if help_text %}
        <span>
            <div class="help">
                {% icon name="help" classname="default" %}
                {{ help_text }}
            </div>
        </span>
    {% endif %}
    {% for child in children.values %}
        <div class="w-field" data-field data-contentpath="{{ child.block.name }}">
            {{ child.render_form }}
            {# -- Begin custom code for file input and instructions -- #}
            <div class="import-text-block-fileinput-container">
                <input type="file"
                       id="{{ prefix }}-fileinput"
                       class="import-text-block-fileinput"
                       {% if accept %}accept="{{ accept }}"{% endif %} />
                <span class="help">{{ instructions }}</span>
            </div>
            {# -- End custom code -- #}
        </div>
    {% endfor %}
</div>
Note the file input element gets the id "{{ prefix }}-fileinput". This will allow us to reference the correct element in the DOM in the custom JavaScript.

Step 3. Custom CSS

We need a little CSS to style the custom HTML in the template, tweak the default and add a maximum height of 25em to the text area to prevent it growing too large after importing a large text file.

static/css/import-text-block.css
Copy
.import-text-block .w-field__wrapper {
    margin-bottom: 0.5rem;
}

.import-text-block textarea {
    max-height: 25em;
    overflow-y: auto !important;
}

.import-text-block .help {
    padding-top: 0.3rem;
}

.import-text-block-fileinput {
    width: 10em !important;
    border: 0 !important;
    padding: 0 !important;
    margin-right: 1rem;
}

.import-text-block-fileinput::file-selector-button {
    width: 10em;
    padding: 0.3rem;
    border: 0;
    border-radius: 5px;
}

.import-text-block-fileinput-container {
    display: flex;
    flex-wrap: wrap;
    margin: -1rem 0 1rem 0;
}
I'm setting the input element and the file selector button to the same width in order to hide selected file text that the input element would normally display. Because we set the input value to to empty after each file read operation, this will always display "No file selected" and would possibly be confusing for the editor.

Step 4. Custom Telepath JavaScript Class

All that's left is to to add the JavaScript to define the custom StructBlockDefinition class and register this with Telepath.

We sublass StructBlockDefinition, assign the returned value from the default render method to the variable block and add appropriate initialisation and event listener code before returning the block variable.

Finally, we register the class against the identifier 'blocks.models.ImportTextBlock' which we defined in our StructBlockAdapter previously.

The class includes code to handle drag/drop, file input and resize events. As mentioned earlier, the code to handle this was described in the previous article where you can read a full explanation.
static/js/import-text-block.js
Copy
class ImportTextBlockDefinition extends window.wagtailStreamField.blocks
    .StructBlockDefinition {
    render(placeholder, prefix, initialState, initialError) {
        const block = super.render(
            placeholder,
            prefix,
            initialState,
            initialError,
        );

        const fileInput = document.getElementById(prefix + '-fileinput');
        const textField = document.getElementById(prefix + '-text');
        const textInitialHeight = textField.style.height
        if (textField.style.maxHeight == '') { textField.style.maxHeight = '30em'; }
        textField.style.overflowY = 'auto';

        const readFile = (source, target) => {
            const reader = new FileReader();
            reader.addEventListener('load', (event) => {
                target.value = event.target.result;
                target.style.height = textInitialHeight;
                target.style.height = target.scrollHeight 
                    + parseFloat(getComputedStyle(target).paddingTop) 
                    + parseFloat(getComputedStyle(target).paddingBottom) + 'px';
            });
            reader.readAsText(source);
        }

        fileInput.addEventListener('change', (event) => {
            event.preventDefault();
            const input = fileInput.files[0];
            readFile(input, textField)
            fileInput.value = '';
            fileInput.blur();
        });

        textField.parentElement.addEventListener('dragover', (event) => {
            event.stopPropagation();
            event.preventDefault();
            event.dataTransfer.dropEffect = 'copy';
        });
        textField.parentElement.addEventListener('drop', (event) => {
            event.stopPropagation();
            event.preventDefault();
            const input = event.dataTransfer.files[0];
            readFile(input, textField)
        });
        
        return block;
    }
}

window.telepath.register('blocks.models.ImportTextBlock', ImportTextBlockDefinition);

Using the ImportTextBlock

The intended use for this particular StructBlock is as a component of other StructBlocks rather than being presented directly to the editor.

Below is partial code for a StructBlock that renders CSV data as an HTML table. The data variable is defined as an ImportTextBlock with file filter .csv to limit the file types offered if the editor selects the file input button.

Copy
class CSVTableBlock(StructBlock):
    title = HeadingBlock(required=False, label=_("Table Title"))
    data = ImportTextBlock(
        label=_("Comma Separated Data"),
        help_text=_("Paste in CSV data or import from .csv file"),
        file_type_filter='.csv'
    )
    precision = IntegerBlock(
        label=_("Float Precision"),
        default=2, help_text=_("Number of decimal places to display for float type.")
    )
    ....

The custom block is rendered inside the CSVTableBlock as follows:

Rendered custom Wagtail StructBlock editor form
Rendered ImportTextBlock in the CSVTableBlock editor form
 

To access the value of the ImportTextBlock, we need to drill down into the data variable to read the underlying text attribute of the ImportTextBlock.

In a template, that would look like:

Copy
{{ self.data.text }}

While in a template tag, you would access it via variable['data']['text'], e.g.

Copy
{{ self|render_html_table }}
Copy
@register.filter
def render_html_table(table_block):
    df = pd.read_csv(
        StringIO(table_block["data"]["text"]),
        header=("infer" if table_block["column_headers"] else None),
    )
    ....

Further Reading

The following articles can be useful to research the concepts covered in the article in greater detail:

Conclusion

This article has covered the process of creating custom Wagtail Streamfield StructBlocks with a customized editor interface. By customizing the Wagtail StructBlock form, you can enhance its functionality and styling.

We explored how to define a form_classname to customize the styling of a StructBlock and override the default form template by specifying a custom template path with form_template.

Additional variables can be added to the StructBlock and passed to the template using the get_form_context method.

Furthermore, we discussed the inclusion of JavaScript in StructBlock forms by utilizing the telepath library. This involved defining a StructBlockAdapter and associating it with a custom JavaScript class. The required JavaScript and CSS files were registered with Telepath for integration.

In the example provided, we created an Import Text Block that allows importing text through drag and drop or a file input button. We went through the step-by-step process of creating the StructBlock and StructBlockAdapter classes, custom form template, CSS styling, and JavaScript class.

Overall, this tutorial gives you an introduction into extending the capabilities of Wagtail StructBlocks and exploring more advanced data types. By leveraging these techniques, you can create dynamic and customized editing forms that meet your specific requirements.

Remember to adapt these concepts to suit your needs and explore the possibilities for further customization and integration within your Wagtail projects.


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