Adding StreamField to a Wagtail page

In this article we will add a StreamField to a Wagtail page, allowing us to input text and inline images via the CMS editor.

July 8, 2020, 12:25 p.m.
Themes: Wagtail Streamfield options

Say you have installed Wagtail and have created a very basic home page. By using Wagtail's StreamField we can add text and images to pages in a very flexible way. We will create an article page model which will give an overview of all articles. The articles will be in reverse chronological order. Since the Wagtail docs contain a very good tutorial, we will directly dive in a bit deeper.

In models.py add a model ArticlePage (if you already have some imports, remove duplicates):

from django.db import models
from django.utils.translation import gettext_lazy as _
from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, StreamFieldPanel
from wagtail.core import blocks
from wagtail.core.fields import RichTextField, StreamField
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtailtrans.models import TranslatablePage
from .blocks import InlineImageBlock


class ArticlePage(TranslatablePage):
    intro = RichTextField(blank=True)
    image = models.ForeignKey(
        'wagtailimages.Image', blank=True, null=True, on_delete=models.SET_NULL, related_name='+', verbose_name=_("Image")
    )
    featured = models.BooleanField(default=False)
    body = StreamField([
        ('paragraph', blocks.RichTextBlock()),
        ('image', InlineImageBlock()),
   ])

    content_panels = TranslatablePage.content_panels + [
        FieldPanel('intro'),
        FieldPanel('featured'),
        ImageChooserPanel('image'),
        StreamFieldPanel('body'),
   ]

The image field is not a required field, therefore blank=True, null=True and on_delete=models.SET_NULL. We set related_name equal to '+', because we don't want to create a backwards relation from the image to the page. The featured field will be used to select articles for our home page. StreamField allows mixing text, images, videos, quotes, code snippets etc. in one entity. It consists of (name, block_type) tuples. Wagtail provides a lot of blocks out of the box. The RichTextBlock is standard Wagtail.

The InlineImageBlock is a custom block that we are going to create, using StructBlock. Although it is not strictly necessary, it is wise to put your StructBlock models in a separate file; Wagtail's reference demo site does it like that. So create a blocks.py in your app and add the following content:

from django.utils.translation import gettext_lazy as _
from wagtail.core import blocks
from wagtail.core.blocks import CharBlock
from wagtail.images.blocks import ImageChooserBlock


class InlineImageBlock(blocks.StructBlock):
    image = ImageChooserBlock(label=_("Image"))
    caption = CharBlock(required=False, label=_("Caption"))
    float = blocks.ChoiceBlock(
        required=False,
        choices=[('right', _("Right")), ('left', _("Left")), ('center', _("Center"))],
        default='right',
        label=_("Float"),
    )
    size = blocks.ChoiceBlock(
        required=False,
        choices=[('small', _("Small")), ('medium', _("Medium")), ('large', _("Large"))],
        default='small',
        label=_("Size"),
    )

    class Meta:
        icon = 'image'

All human readable strings are translated using gettext_lazy. image and caption speak for themselves. The field float will be used in the template to position the image on the right, left or center of the page; the parameter size works the same way. Both are ChoiceBlocks. The class Meta allows setting the icon argument. You can view a list of all available icons in the Wagtail styleguide; if you don't want to install that, search Wagtail StreamField icons on the web. For our purpose, image will do.

The model is ready, time to create the template. As we have seen earlier with the home page, the name of the template should be article_page.html. You can view the contents on Github, or alternatively watch the video in this article. The usual elements such as loading libraries, page title, intro, date are done as in home_page.html. The only new elements are the following:

{%  include "streamfield.html" %}
<a href="{{ page.get_parent.url }}">{% trans "Return to articles" %}</a>

i.e. a template streamfield.html and a link to the parent page (which we will create below). Both are wrapped in a container to make them look nicer on the page. The file streamfield.html will be used every time a block needs to be rendered and has the following content:

{% load wagtailcore_tags wagtailimages_tags %}

{% for block in page.body %}
    {% if block.block_type == 'image' %}
        <div class="block-{{ block.block_type }}-{{ block.value.float }}">
            <!-- make use of specific properties of Wagtail 'image' tag -->
            {% if block.value.size == "small" %}
                {% image block.value.image width-240 class="img-fluid" %}
            {% elif block.value.size == "medium" %}
                {% image block.value.image width-480 class="img-fluid" %}
            {% else %}
                {% image block.value.image width-2400 class="img-fluid" %}
            {% endif %}
            {{ block.value.caption }}
        </div>
   {% else %}
        <div class="block-{{ block.block_type }}">
            {% include_block block %}
        </div>
    {% endif %}
{% endfor %}

A distinction is made between the different types of blocks: image and other. We reference the individual fields of the StructBlocks via the value property; same for the block_type property. For images we make use of the image template tag and image rendering options of Wagtail. The img-fluid class of Bootstrap makes all images fit nicely in the viewport. The include_block tag takes care of the rendering of all other (non-image) blocks.

It's time to add some styling to our CSS (for the first time in our project, for those of you who have been following along). The essential styling elements for the templates above are (I left out some that are straightforward, e.g. block-paragraph, block-image-right/left, block-video-right/left, block-small/medium/large, they are in the repository):

.img-fluid {
    max-width: 100%;
    height: auto;
}
/* for the images and text inside streamfields */
.block-image-center {
    display: grid;
    /* justify-content works here because Wagtail creates an image of fixed dimensions */
    justify-content: center;
    overflow: hidden;
    font: italic 12px Georgia, serif;
}

Centering an image works best with justify-content because Wagtail creates an image of fixed dimensions which then is centered within the div with the justify-content property.

Our article page is ready now. Now we create an index page containing all the articles that we intend to write, in our models.py:

class ArticleIndexPage(TranslatablePage):
    intro = RichTextField(blank=True)

    # Specifies that only ArticlePage objects can live under this index page
    subpage_types = ['ArticlePage']

    # A method to access and reorder the children of the page (i.e. ArticlePage objects)
    def articlepages(self):
        return ArticlePage.objects.child_of(self).live().order_by('-first_published_at')

    def featured_articlepages(self):
        return self.articlepages().filter(featured=True)

    content_panels = TranslatablePage.content_panels + [
        FieldPanel('intro', classname='full'),
    ]

The parameter subpage_types makes sure that we only create ArticlePage instances under this index page. The method articlepages selects the live child pages of this index page and reorders them anti-chronologically. The field first_published_at is a standard field of the Page model. The method featured_articlepages is a subset: only those that are marked featured for the home page. We use the method articlepages in our template article_index_page.html; the interesting part of the template is:

{% for childpage in page.articlepages %}
    <div class="col-auto mb-3">
        <div class="card article">
            <a href="{{ childpage.url }}">
                {% if childpage.specific.image %}
                    {% image childpage.specific.image fill-320x240 class="img-front rounded" %}
                {% else %}
                    <img alt="" src="{% static 'images/transparent.png' %}" width="320" height="240" class="img-default rounded">
                {% endif %}
                <img alt="" src="{% static 'images/transparent.png' %}" width="320" height="240" class="img-background rounded">
                <div class="card-img-overlay">
                    <h5 class="card-title">{{ childpage.title }}</h5>
                    <p class="card-subtitle">{{ childpage.specific.intro|striptags|safe|truncatewords:15 }}</p>
                    <p class="card-text small">{{ childpage.specific.first_published_at }}</p>
                </div>
            </a>
        </div>
    </div>
{% endfor %}

For each child page a link is created in the form of a 320x240 pixel image. We use Bootstrap's classes for cards, such as card-img-overlay. On the image is the title of the page plus the first 15 words of the intro and the date. We use Django's filter striptags to strip away any html tags in the title, the filter safe to prevent escaping quotes in the text and the filter truncatewords to truncate the intro after 15 words. As explained in the Wagtail docs we have to use the method specific to reference the fields of the children of the ArticlePage instance.

If the child page contains an image then this image is used, if not then the image images/transparent.png is used. This is a fully transparent image (compare it to a glass sheet) that is easy to create or download; put it in the subdirectory images of cms/static/cms and add this directory to STATICFILES_DIRS in settings. By using the background-color property we can turn it into any color; the class image-default in our CSS settings will make it grey. The second image with the class img-background is an overlay that changes color when hovering over it with the mouse. Image overlay is achieved by the combination of the position: relative and position: absolute declarations. We use Bootstrap's class rounded to round the corner of the images. The CSS settings are as follows:

.article, .theme {
    width: 320px;
    height: 240px;
}
.img-front {
    position: relative;
    max-width: 100%;
}
.img-default {
    position: relative;
    max-width: 100%;
    background-color: grey;
}
.img-background {
    position: absolute;
    max-width: 100%;
    max-height: 100%;
    left: 0;
    background-color: navy;
    opacity: 0;
    transition: opacity 300ms;
}
a:hover .img-background {
    opacity: 0.6;
}

Now we want to have a link to the article index page plus the featured articles on our home page. To the HomePage model add:

article_section_title = models.CharField(
    null=True,
    blank=True,
    max_length=255,
    help_text=_("Title to display above the article section"),
)
article_section_intro = RichTextField(blank=True)
article_section = models.ForeignKey(
    TranslatablePage,
    null=True,
    blank=True,
    on_delete=models.SET_NULL,
    related_name='+',
    help_text=_("Featured articles for the homepage"),
    verbose_name=_("Article section"),
)

Add the imports MultiFieldPanel and PageChooserPanel and to the content_panels of the HomePage model add:

MultiFieldPanel([
        FieldPanel('article_section_title'),
        FieldPanel('article_section_intro', classname='full'),
        PageChooserPanel('article_section'),
        ], heading=_("Article section"), classname='collapsible'),

This allows us to create an article section with a title, introduction and a link to a TranslatablePage; in admin we will assign the article index page to this, so that we will have access to it in our template. In there, we add a piece of html which very much looks like what we have just created for our article_page.html, only the loop over all articles is different:

{% for childpage in page.article_section.specific.featured_articlepages %}
{% endfor %}

We need to load wagtailimages_tags and static because we use Wagtail's image tag and the image transparent.png in our static directory.

Phew, we're done! Migrate the database to incorporate all the model changes. Then go to Wagtail's admin, to Pages and to our home page and create a child page with the model ArticleIndexPage. Give it a title and type some text in the intro field; publish it. Then create a child page of this index page (it will automatically use the model ArticlePage), add text, upload and insert images, and publish. Create another article page. View the image links on the home page and index page. Obviously feel free to change the styling.

With this the basis for putting content in our site is done. You can read more on how to embed video, add themes and navigation.

Comment on this article (sign in first or confirm by name and email below)