Coen van der Kamp
Coen van der Kamp
27 juli 2018

Filter Wagtail Snippets by Tag 💡

In this tutorial I'll show you how to add tags to Wagtail Snippets. We also create a custom SnippetChooser to filter Snippets by tag.

Wagtail comes with Page, Image and Document content types. Pages have a tree structure. Images and documents do not have any hierarchy. You can group images and documents by applying tags. Tagging helps to describe an item and allows it to be found when using a chooser.

Which tags are used is up to the content creator. Tags are a great way to organise content in a flexible way.

"Wagtail image upload"

Above: Add tags during image upload.
Below: Filter by tag when choosing an image.

"Wagtail ImageChooser"

Create a Snippet

Most websites need additional content types. Imagine a website for a Business Club: It may also need Event, Venue and Organisation types. Let’s create a Django model to store Organisation objects and register it as a Wagtail Snippet. A Snippet makes a Django model editable via the Wagtail admin via the Snippets menu-item.

from django.db import models
from wagtail.snippets.models import register_snippet

@register_snippet
class Organisation(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name

    panels = [
        FieldPanel('name', classname='full'),
    ]

Create an EventPage

Our Business Club organises events. We need an event page. The host of the event will be an organisation. Here we define yet another content type named EventPage.

Wagtail uses Django’s multi-table inheritance feature to allow multiple page models to be used in the same tree.

Our EventPage is a subclass of the Wagtail Page model. The Wagtail Page model provides all the core functionality: page tree, basic fields (title, slug, etc), admin interface, url routes, view, search, revisions, etc.
We define an extra field host with a foreign key to Organisation. The SnippetChooserPanel enables us to choose our Organisation snippets.

from wagtail.core.models import Page
from wagtail.snippets.edit_handlers import SnippetChooserPanel


class EventPage(Page):
    host = models.ForeignKey(
        Organisation,
        on_delete=models.PROTECT,
        help_text='Choose a host organisation for this event.'
     )
    content_panels = Page.content_panels + [
        SnippetChooserPanel('host')
    ]

Great, now we can create organisations, event pages and choose a host organisation!

"Wagtail Organisation Snippet"
"Create an EventPage and choose a Organisation Snippet"

Add Search

Our Business Club has many member organisations. The organisation list will be very long! This will make it hard to select a specific organisation. Let’s add search:

from wagtail.search import index


@register_snippet
class Organisation(index.Indexed, models.Model):
    ...
    search_fields = [
        index.SearchField('name', partial_match=True),
    ]

Add Tags

Wagtail fully supports django-taggit so we recommend using that.

Let’s add tags. By default django-taggit uses a “through model” with a GenericForeignKey. This allows a tag to be linked to any model. We don’t want that. The tags we create should be used on Organisations and not bleed to Images or Documents. TaggedItemBase does wat we want. It allows us to use a Foreign key to link to the Organisation model.

from taggit.managers import TaggableManager
from taggit.models import TaggedItemBase


class OrganisationTag(TaggedItemBase):
    content_object = models.ForeignKey(
        'app.Organisation',
        on_delete=models.CASCADE,
        related_name='tagged_items'
    )


@register_snippet
class Organisation(models.Model):
    ...
    tags = TaggableManager(through=OrganisationTag)

We now can add tags to organisations! Editors already know how tags work. User adoption should be a smooth process.

"Add Tags to Organisation Snippet"
"SnippetChooser does not show tags"

Unfortunately the SnippetChooser does not show tags. Why? Organisation -a snippet- is a custom type. There is no way for Wagtail to know if this type has tags or not. Wagtail does not make assumptions, therefore the SnippetChooser also does not have tags by default.

Override the Organisation SnippetChooser

The Wagtail Documentation does not mention how to customise the SnippetChooser. We are on undocumented terrain! This means future releases of Wagtail might change this code without any warning. Realise that an upgrade of Wagtail might break our customisation!

There are two reasons I’m not too worried. First: removing my custom snippet chooser will re-enable the default SnippetChooser. Second: the concept of choosers is here to stay. We’ll be overriding a view, it is likely we can do the same in the future.

Get on with it!

When ‘Choose an Organisation’ is clicked you can see an Ajax request being fired. This is the view we need to customise. The url is /admin/snippets/choose/website/organisation/.

Let’s create the taggedsnippetchooser app, add it to INSTALLED_APPS, create a urls.py, views.py and template directory. In project main urls.py we include taggedsnippetchooser_urls before wagtailadmin_urls.

from taggedsnippetchooser import urls as taggedsnippetchooser_urls

urlpatterns = [
    url(r'^admin/', include(taggedsnippetchooser_urls)),
    url(r'^admin/', include(wagtailadmin_urls)),
]

In taggedsnippetchooser/urls.py we add the rest of the pattern. Note that we hard code the slugs website and organisation. So that only the organisation SnippetChooser uses the custom code. All other content types use the Wagtail provided SnippetChooser.

from django.conf.urls import url

from .views import choose

kwargs = {'app_label': 'app', 'model_name': 'organisation'}

urlpatterns = [
    url(r'^snippets/choose/website/organisation/$', choose, kwargs, name='choose'),
]

The original choose view lives at wagtail/snippets/views/chooser.py. Just copy/paste the view method and relevant imports into taggedsnippetchooser/views.py. Also copy the original choose.html and choose.js templates from wagtail/snippets/templates/wagtailsnippets/chooser/ to our template directory at taggedsnippetchooser/templates/taggedsnippetchooser/.

Now we have overridden the chooser view for Organisation snippets. A request to /admin/snippets/choose/website/organisation/ isn’t served by Wagtail, but by our code!

This is a good moment to check-in changes. It makes it easier for our future self to see the difference between original Wagtail code and our customisations.

Customise the Organisation SnippetChooser

In the next section we add our customisations. Most code is borrowed from Wagtail’s ImageChooser.

In views.py. Just before the # paginator we add a tag filter. If the querystring parameters contain the tag key, the items will be filterd by tag.

tag_name = request.GET.get('tag')
if tag_name:
    items = items.filter(tags__name=tag_name)

To display tags and make them clickable we have to get the Organisation tags and add them to the context to render in our templates.

  1. Import the Organisation model.
  2. Use our templates.
  3. Add popular_tags to the context.
from app.models import Organisation  # 1.

...

return render_modal_workflow(
    request,
    'taggedsnippetchooser/choose.html', 'taggedsnippetchooser/choose.js',  # 2.
    {
        ...
        'popular_tags': Organisation.tags.most_common(),  # 3.
    }

Render the tags in the template. Edit the taggedsnippetchooser/choose.html templates to display the tags. After <li class="submit">...</li> insert:

{% if popular_tags %}
    <li class="taglist">
        <h3>{% trans 'Popular tags' %}</h3>
        {% for tag in popular_tags %}
            <a class="suggested-tag tag" href="/admin/snippets/choose/website/organisation/?tag={{ tag.name|urlencode }}">{{ tag.name }}</a>
        {% endfor %}
    </li>
{% endif %}

A click on a tag should trigger a Ajax request with the clicked tag as querystring parameter. Edit taggedsnippetchooser/choose.js just above ajaxifyLinks(modal.body); insert:

function fetchResults(requestData) {
    $.ajax({
        url: searchUrl,
        data: requestData,
        success: function(data, status) {
            $('#search-results').html(data);
            ajaxifyLinks($('#search-results'));
        }
    });
}

$('a.suggested-tag').on('click', function() {
    event.preventDefault();
    var currentTag = $(this).text();
    $('#id_q').val('');
    fetchResults({
        tag: currentTag,
        results: 'true',
    });
    return false;
});

Done!

Now we can add tags to organisation snippets, search and filter organisations in the SnippetChooser. Pretty sweet right?

"Filter Organisations by tag in our custom SnippetChooser"
We love code