May 26, 2017

Quick Tips: Craft CMS

Over the last year, I've built out six mid- to large-size sites in Craft. I've learned a lot of useful lessons during that time, and I thought I'd share a couple of the tricks I've used. I owe a huge thanks to the helpful community over on the Craft Slack #help channel, who've come up with solutions I'd never have thought of.

Note: This article was written using Craft 2. Some of the syntax will have changed in newer versions of Craft.

Building a Language Toggle

As I work at a Canadian agency, a lot of our clients require English & French content. Luckily, Craft is great at handling translations. The official guide for setting up localized sites is pretty good for getting started, but it doesn't cover building out an actual language toggle. Here's how I approach it:

In my main template, _layout.twig, which my other templates extend, I find the URL for the current entry in the other locale. If there isn't one, we just default to the homepage in the other locale, but you could set this default to anything (or not show a language toggle at all in these situations).

{# Get language toggle links #}
{% if craft.locale == 'en_ca' %}
  {% set otherLocale = 'fr_ca' %}
{% else %}
  {% set otherLocale = 'en_ca' %}
{% endif %}

{% if entry is defined %}

  {# Find the current entry in the other locale and make sure that it's actually localized #}
  {% set otherLocaleEntry = %}
  {% if otherLocaleEntry and otherLocaleEntry.locale == otherLocale %}
    {% set otherLocaleLink = otherLocaleEntry.getUrl() %}
  {% else %}
    {# If not, link to home page #}
    {% set otherLocaleLink = craft.config.siteUrl[otherLocale] %}
  {% endif %}

{% else %}

  {# No entry, so lang toggle defaults to home page #}
  {% set otherLocaleLink = craft.config.siteUrl[otherLocale] %}

{% endif %}

{# If the loaded template explicitly sets custom slugs for the locale, override it #}
{% if customLocaleSlugs is defined %}
  {% set otherLocaleLink = craft.config.siteUrl[otherLocale] ~ customLocaleSlugs[otherLocale] %}
{% endif %}

Then, later in your template, you can just set your language toggle link to the otherLocaleLink you've defined above.

Also, on any template that extends _layout.twig, we can manually override the links we've set with the customLocaleSlugs variable. Here's an example where I needed to override the URLs for some custom quiz functionality:

{# Custom Locale Slugs Example - this would go on the template extending "_layout" #}
{% set customLocaleSlugs = {
  'en_ca':'careers/quiz/' ~ pageNum,
  'fr_ca':'carrieres/quiz/' ~ pageNum
} %}

This is really useful, because we can override the default language toggle behaviour on templates that have special requirements.

One more freebie for working with non-English sites: in your general config file, set the limitAutoSlugsToAscii option to true. This will prevent auto-generated URLs on the non-English side from having accented characters in them, which tends to look a little weird and can make it difficult for users to navigate to pages manually.

News and Events Sorting

A few of the sites I've worked on have had joint news and events sections, with multiple entry types within it. Generally these are just News and Events, but sometimes there are others as well.

One site in particular with this setup had some specific requirements that made it a decent challenge. First, the news and events entries needed to be sorted and listed by month. A dropdown would allow the user to switch between specific years, recent months (showing the last six months), and upcoming (showing any future months that contained entries - these entries were events that had future start dates).

However, the events needed to be sorted based on their start date field, while the news posts needed to be sorted by their post date. This proved to be a problem, because there's no obvious way to group entries in a single query by two different fields depending on the entry type. However, using the SuperSort plugin and some arcane Twig statements from some helpful folks on the Craft Slack channel, I was able to make it happen.

{# we're going to use these for sorting months into upcoming(future months), recent(last 6 months), or current(this month) #}
{% set sixMonthsAgo = date(now)|date_modify('-6 months')|date('Y-m') ~ '-01' %}
{% set currentDate = date(now)|date('Y-m-d') %}

{# first, we assemble an array of entries by month, grouped by either postDate or eventStartDate depending on entry type #}
{% set monthlyEntries = [] %}
{% for month, entriesInMonth in craft.entries.section('newsEvents').limit(null)|group('{ object.type == "event" ? object.eventStartDate|date("Y-m") : object.postDate|date("Y-m") }') %}
  {% set thisMonth = { (month|date('Y-m')): {
    'monthName' : month|date('F Y'),
    'entries' : entriesInMonth|supersort('sortAs', '{{ object.type == "event" ? object.eventStartDate|date("Y-m-d") : object.postDate|date("Y-m-d") }}')
  }} %}
  {% set monthlyEntries = monthlyEntries|merge(thisMonth) %}
{% endfor %}

In the group filter above, we specify that events are grouped by their start date, otherwise the post date is used. I was unfamiliar with this "object" variable - the only place I see it referenced in the Craft docs is in the Assets section. I didn't even realize you could write a Twig output statement inside the argument to a filter. But it works!

It's also worth noting that if you compare the two places where I do this above, the group filter wraps the statement in a single brace { }, while the supersort filter takes the more familiar double brace {{ }}. I still have no idea why this is the case, but if you dig into the app/etc/templating/twigextensions/CraftTwigExtension.php file, you'll see that Craft's group filter wraps the input in a single brace, so you end up with the double brace:

public function groupFilter($arr, $item)
  $groups = array();

  $template = '{'.$item.'}';
  $safeMode = $this->environment->isSafeMode();

  foreach ($arr as $key => $object)
    $value = craft()->templates->renderObjectTemplate($template, $object, $safeMode);
    $groups[$value][] = $object;

  return $groups;

This appears to be the only filter that does this. I'm sure there's a reason for it, but for quite some time I just thought what I was trying to do wasn't possible, instead of the syntax error it ended up being.

Anyway! Moving on, we output the entries by month in reverse order (so that the furthest past month shows last).

{# second, we sort and output in reverse order #}
{% for monthNum, month in monthlyEntries|supersort('krsort') %}

  {% set eventType = 'archived' %}
  {% if monthNum|date('Y-m') >= currentDate|date('Y-m') %}
    {% set eventType = 'upcoming' %}
  {% endif %}

  {% if monthNum|date('Y-m') >= sixMonthsAgo|date('Y-m') and monthNum|date('Y-m') <= currentDate|date('Y-m') %}
    {% set eventType = 'recent' %}
  {% endif %}

  {% if monthNum|date('Y-m') == currentDate|date('Y-m') %}
    {% set eventType = 'current' %}
  {% endif %}

  <div class="m-news-month m-news-month--{{ eventType }}" data-year="{{ monthNum|date('Y') }}">

    <h2 class="m-news-month__heading">{{ month.monthName }}</h2>
    <ul class="grid">
    {% spaceless %}
    {% for newsEntry in month.entries %}
      <li class="grid__item" data-item-date="{% if newsEntry.type == 'event' %}{{ newsEntry.eventStartDate|date('Y-m-d') }}{% else %}{{ newsEntry.postDate|date('Y-m-d') }}{% endif %}">
      {% include 'components/_entry-tile' with { entry: newsEntry } %}
    {% endfor %}
    {% endspaceless %}

{% endfor %}

Using JavaScript, I would then show or hide month blocks based on either their event type (e.g. 'recent') or their data-year attribute when the user changed the filter. You could certainly make a more advanced version of this that loads in the data via AJAX rather than doing it all up front, but this site didn't have a particularly high volume of entries, so this solution worked fine.

Searching For Entry Types

Recently, a client asked why searching for "Editorial" returned no results on their site, when there was an Entry Type named Editorial, which had several entries. The word appeared on each of those entries on the front-end, because the template pulled the entry type name to display, but it wasn't actually contained in the title or fields of those entries.

Well it turns out that Craft doesn't index entry type names, nor is there an easy way to do so.

Instead, the solution I found was to install the Preparse plugin. I'd heard of this one, but had avoided using it for fear of overcomplicating things. However, after using it for this, I can see the benefits. If you're unfamiliar, basically what this plugin does is allow you to create a field that runs Twig code when an entry is saved, and saves the output of that Twig code to a (probably hidden) field on the entry.

In this particular example, I created a Preparse field called "Entry Type Name", which just runs this code:

{{ }}

For an entry type called "Editorial", that will save the value "Editorial" to the Entry Type Name field. Pretty simple. From there, you just need to add that field to applicable entry types, and then re-save the entries to get the Preparse field value to generate.

Hot tip: If you don't want to save each entry individually, you can edit a section, and save it without changing anything. This will re-save all the entries in the section automatically, which is a much more efficient use of your time.

That's all well and good, but here's what it allows you to do in your search results template:

{% set results = craft.entries({
  'search' : query ~ ' OR entryTypeName:' ~ query,
  'order' : 'score'
}) %}

This means that you'll get search result hits for "Editorial" on any entry that contains the word "Editorial" (as the default Craft search would) plus any entry that has the entry type of "Editorial" (provided the entryTypeName field has been added to that entry, and it's been saved).

This is an extremely simple example, but if you're having difficulty getting the correct entries to show up in search results, you could use a Preparse field to generate any keywords you want based on Twig markup, where the entry variable is available to you.

That's a wrap!

Hopefully some of that was useful to you. Have questions? Have a better solution to one of the issues above? Tweet me @GregorTerrill.