Mathieu De Keyzer's face

 Twig in an object-oriented approach

HTTP enthusiasts

Goal

Let's assume that we have a json array having a nested structure of objects. Typically something that you manage with a JsonMenuEditorFieldType field in elasticms. Those object can be of any kind with their specifics fields.

The first goal is to recursively generate a HTML DOM out of this structure with a Twig template.

After that, how can we apply a specific Twig template in function of the type of the object.

Think object

You have to consider a Twig template as an object in an object-oriented way. Indeed blockscan be seen as class's functions and with the extends you can inherit from another. In this approach the Twig context is you class's members. In this post I'll define one Twig's class by type of object in my JSON structure. All those classes inherits from an 'abstract' block.html.twig Twig template. That abstract class will implement those functions:

  • render: generates the DOM from a structure
  • row: generates the DOM for the current object
  • title: generates the object title's DOM
  • widget: generates the object content's DOM
  • children: generates the object children's DOM

To call a specific Twig template's block with a specific Twig context we need to combine with and block.

    {% with {
        'structure': source.blocks|default("{}")|ems_json_decode,
        'trans_default_domain': trans_default_domain,
    } %}
        {{ block("children", "@EMSCH/template/blocks/abstract-block.html.twig") }}
    {% endwith %}

Like that, Twig's blocks can be recursively called in a more elegant way that using macros.

Notice that that I also provide the trans_default_domain variable.

Recursively generate DOM

This block receives the array of objects and have to initialize the recursive rendering for each of them. With the right Twig context:

  • item: the object
  • childrenHaveBeenGenerated: a variable set to true if the children block has been called
  • currentBlockLevel: a variable with the object level in the structure (from 0)
  • headerBlockLevel: the header tag (H2, H3, ...) to generate if a label is defined in the current object (starting at 2)
{% block render %}
    {% spaceless %}
        {% set childrenHaveBeenGenerated = false %}
        {% set currentBlockLevel = 1 %}
        {% set headerBlockLevel = 2 %}
        {% for item in structure|default([]) %}
            {% with {
                'item': item,
                'currentBlockLevel': currentBlockLevel,
                'headerBlockLevel': headerBlockLevel,
                'headerBlockLevel': headerBlockLevel,
                'trans_default_domain': trans_default_domain,
            } %}
                {{ block("row") }}
            {% endwith %}
        {%  endfor %}
    {% endspaceless %}
{% endblock render %}

The trans_default_domain variable is still passed to the block sub-templates.

The row block will be called for each object:

{% block row %}
    {% spaceless %}
        <div class="{{ item.object.class|default('pt-4 px-3 px-lg-4') }}" id="row-{{ item.id }}">
            {{ block('title') }}
            {{ block('widget') }}
            {%  if item.children|default([])|length > 0 %}
                {{ block('children') }}
            {% endif %}
        </div>
    {% endspaceless %}
{% endblock row %}

The title block is generating a header tag if a label is defined:

{% block title %}
    {% spaceless %}
        {% if item.object.label is defined %}
            <h{{ headerBlockLevel }} class="h{{ headerBlockLevel+1 }} mb-4 mt-2">{{ item.object.label|default('') }}</h{{ headerBlockLevel }}>
        {% endif %}
    {% endspaceless %}
{% endblock title %}

The widget is the abstract method (it has to be overridden):

{% block widget %}
    <p>{{ ('Block `widget` must be overridden for. `' ~ item.type ~ '`')|ems_markdown }}</p>
{% endblock widget %}

And finally the children block wich initiate the context for all object's children:

{% block children %}
    {% spaceless %}
        {% if not childrenHaveBeenGenerated %}
            {% set currentBlockLevel = currentBlockLevel + 1 %}
            {% set headerBlockLevel = headerBlockLevel + (item.object.label is defined ? 1 : 0) %}
            {% for item in item.children|default([]) %}
                {{ block("row") }}
            {% endfor %}
            {% set childrenHaveBeenGenerated = true %}
        {% endif %}
    {% endspaceless %}
{% endblock %}

The variable childrenHaveBeenGenerated is used in order to avoid that the children are generated twice.

Customize by type

Let's update the children block in order to call a template based on the object's type:

{% block children %}
    {% spaceless %}
        {% if not childrenHaveBeenGenerated %}
            {% set currentBlockLevel = currentBlockLevel + 1 %}
            {% set headerBlockLevel = headerBlockLevel + (item.object.label is defined ? 1 : 0) %}
            {% for item in item.children|default([]) %}
                {{ block("row", "@EMSCH/template/blocks/"~item.type~".html.twig") }}
            {% endfor %}
            {% set childrenHaveBeenGenerated = true %}
        {% endif %}
    {% endspaceless %}
{% endblock %}

And the render and the row block:

{% block render %}
    {% spaceless %}
        {% set childrenHaveBeenGenerated = false %}
        {% set currentBlockLevel = 1 %}
        {% set headerBlockLevel = 2 %}
        {% for item in structure|default([]) %}
            {% with {
                'item': item,
                'currentBlockLevel': currentBlockLevel,
                'headerBlockLevel': headerBlockLevel,
                'headerBlockLevel': headerBlockLevel,
                'trans_default_domain': trans_default_domain,
            } %}
                {{ block("row", "@EMSCH/template/blocks/"~item.type~".html.twig") }}
            {% endwith %}
        {%  endfor %}
    {% endspaceless %}
{% endblock render %}
{% block row %}
    {% spaceless %}
        <div class="{{ item.object.class|default('pt-4 px-3 px-lg-4') }}" id="row-{{ item.id }}">
            {{ block('title', "@EMSCH/template/blocks/"~item.type~".html.twig") }}
            {{ block('widget', "@EMSCH/template/blocks/"~item.type~".html.twig") }}
            {%  if item.children|default([])|length > 0 %}
                {{ block('children', "@EMSCH/template/blocks/"~item.type~".html.twig") }}
            {% endif %}
        </div>
    {% endspaceless %}
{% endblock row %}

If our object item has a markdown field, it's Twig may looks like this:

{% extends '@EMSCH/template/blocks/abstract-block.html.twig' %}

{% block widget %}
    {{ item.object.body|default('')|ems_markdown|emsch_routing }}
{% endblock %}

So you can also call the parent function:

{% block row %}
    {% spaceless %}
        {% if item.object.hr|default(false) %}
            <hr class="d-print-none">
        {% endif %}
        {{ parent() }}
    {% endspaceless %}
{% endblock row %}

{% block widget %}{% endblock widget %}

Reuse a block

Hopefully you can reuse a block outside a structure context:

{% with {
    'item': {
        id: 'blog-template',
        type: 'last_updates',
        object: {
            title: 'blog.last_update',
            limit: 10,
        }
    }
} %}
    {{ block("row", "@EMSCH/template/blocks/last_updates.html.twig") }}
{% endwith %}

Define a translation domain

If you want to use a skeleton translation inside your blocks you have to specify, inside your blocks, the skeleton translation domain (which is in fact your environment alias name):

{% extends '@EMSCH/template/blocks/abstract-block.html.twig' %}
{% trans_default_domain trans_default_domain %}

{% block widget %}
    {% set result = emsch_search('page', {
        "query": {
            "term": {
                "template": "blog"
            }
        },
        "sort": {
            "published_date": {
                "order": "desc",
                "missing": "_last",
                "unmapped_type": "long"
            }
        }
    }, 0 , item.object.limit|default(10)) %}

    <ul>
        {% for item in result.hits.hits %}
            <li><a lang="{{ item._source.locale }}" href="{{ path('match_all', {
                    path:item._source.path,
                    _locale:item._source.locale,
                }) }}">{{ item._source.title }}</a>
            {% if app.request.locale != item._source.locale %}
                &nbsp;[{{ ('website.content_in.'~item._source.locale)|trans }}]
            {% endif %}
            </li>
        {% endfor %}
    </ul>
{% endblock %}

As those blocks are loaded outside of the request; the trans_default_domain variable is unknowed outside of the twig blocks. So some block template, like this one, will trigged an error:

{% extends '@EMSCH/template/blocks/abstract-block.html.twig' %}
{% trans_default_domain trans_default_domain %}

{% block widget %}
    {{ 'foobar'| trans }}
{% endblock %}