Predefined Content Element for Editors

The MM List is available as a content element or frontend module for displaying records from a MetaModel. Here various selections such as the MetaModel, render setting, filter, etc. need to be made — this may not be desirable for editors.

If editors should simply be able to select one or more records from a fixed MetaModel and have them displayed, this can be done using the following methods.

Selection and Display with the RockSolid Custom Elements Extension

RockSolid Custom Elements (RST-CE) allows you to make all input fields available in the Contao backend freely available as content elements and/or modules. More on this on the RockSolid website or in the CK24 talk by Marcus Lelle.

In the following example, an editor should be able to select a single Point-of-Interest (POI) and have it displayed in the frontend. The name and location should appear in the selection.

Configuration in RST-CE

As is typical with RST-CE, a configuration file for the backend display and a template for the frontend output must be created. The source code is intended to illustrate the approach only, and as noted it is recommended to extract the API queries into separate files. More on the queries at MetaModels Reference and API or in the CK23 talk by Ingolf Steinhardt.

 1<?php
 2// rsce_mm_poi_single_config.php
 3/**
 4 * POI selection in RST-CE.
 5 *
 6 * Note: Option retrieval should be extracted to a helper class — see CK23 talk — or
 7 * fetched via options_callback (https://docs.contao.org/dev/reference/widgets/select/).
 8 */
 9
10use Contao\System;
11
12// POI list.
13$options = [];
14
15// MetaModel table name.
16$modelName = 'mm_poi';
17// ID of the filter "BE POI single view: Published".
18$filterId = 12;
19
20// Retrieve items.
21$container        = System::getContainer();
22$factory          = $container->get('metamodels.factory');
23$model            = $factory->getMetaModel($modelName);
24$filter           = $model->getEmptyFilter();
25$filterFactory    = $container->get('metamodels.filter_setting_factory');
26$filterCollection = $filterFactory->createCollection($filterId);
27$items = $model->findByFilter($filter);
28
29if ($items->getCount()) {
30    foreach ($items as $item) {
31        $options[$item->get('id')] = \sprintf('%s - %s', $item->get('name'), $item->get('city'));
32    }
33}
34
35return [
36    'label'           => ['POI single view', 'POI single view'],
37    'types'           => ['content'],
38    'fields'          => [
39        'poi' => [
40            'label'     => ['POI selection', 'Select a POI to display.'],
41            'inputType' => 'select',
42            'options'   => $options,
43            'eval'      => [
44                'chosen'             => true,
45                'mandatory'          => true,
46                'includeBlankOption' => true,
47                'tl_class'           => 'w50',
48            ],
49        ],
50    ],
51];

Filter in MM

To filter by the selected POI ID, a “Custom SQL” filter rule can be created and filtering can be applied via the passed parameter from $filterUrl.

1-- Filter rule in filter 11
2SELECT id FROM {{table}}
3WHERE id = {{param::filter?name=poi}}

Additionally, you could also filter by publication status in the SQL or in a separate filter rule.

Output template in HTML5

An appropriate template still needs to be created for the output — the RST-CE naming convention must be observed here.

 1<?php
 2// rsce_mm_poi_single.html5
 3
 4/**
 5 * Output of a POI — selection with RST-CE.
 6 *
 7 * Note: Item retrieval should be extracted to a helper class — see CK23 talk.
 8 */
 9
10// Check POI ID.
11if (!$this->poi) {
12    return;
13}
14
15// MetaModel table name.
16$modelName = 'mm_poi';
17// ID of the filter "FE POI single view: POI selection + Published".
18$filterId = 11;
19// Filter value POI ID.
20$filterUrl = ['poi' => (int) $this->poi];
21// ID of the render settings "FE detail view - POI single view".
22$renderId = 20;
23
24// Retrieve item.
25$factory          = $this->getContainer()->get('metamodels.factory');
26$model            = $factory->getMetaModel($modelName);
27$filter           = $model->getEmptyFilter();
28$filterFactory    = $this->getContainer()->get('metamodels.filter_setting_factory');
29$filterCollection = $filterFactory->createCollection($filterId);
30$filterCollection->addRules($filter, $filterUrl);
31$items = $model->findByFilter($filter);
32
33// Render item.
34$renderFactory = $this->getContainer()->get('metamodels.render_setting_factory');
35$arrItems      = $items->parseAll('html5', $renderFactory->createCollection($model, $renderId));
36?>
37<?php if (count($arrItems)): ?>
38    <div class="layout_full">
39        <?php foreach ($arrItems as $arrItem): ?>
40            <div class="poi_item">
41                <h2><?= $arrItem['text']['name'] ?></h2>
42                <p><?= $arrItem['text']['city'] ?></p>
43                <?= $arrItem['html5']['image'] ?>
44                <?php if($arrItem['actions']['jumpTo']['href']): ?>
45                    <p><a href="<?= $arrItem['actions']['jumpTo']['href'] ?>" title="Details">Details</a></p>
46                <?php endif; ?>
47            </div>
48        <?php endforeach; ?>
49    </div>
50<?php else : ?>
51    <p class="info">No POI selected!</p>
52<?php endif; ?>

Output in Twig

For output in Twig, the data to be rendered must be passed to the template — querying data inside the template as with HTML5 is not possible in Twig.

The data for Twig is fetched and provided via a TwigFunction:

 1<?php
 2// src/Twig/AppExtension.php
 3namespace App\Twig;
 4
 5use MetaModels\Filter\Setting\FilterSettingFactory;
 6use MetaModels\IFactory;
 7use MetaModels\IMetaModel;
 8use MetaModels\Render\Setting\RenderSettingFactory;
 9use Twig\Extension\AbstractExtension;
10use Twig\TwigFunction;
11
12class AppExtension extends AbstractExtension
13{
14    public function __construct(
15        private readonly IFactory $factory,
16        private readonly FilterSettingFactory $filterFactory,
17        private readonly RenderSettingFactory $renderFactory,
18    ) {
19    }
20
21    public function getFunctions(): array
22    {
23        return [
24            new TwigFunction('getPoiById', [$this, 'getPoiById']),
25        ];
26    }
27
28    public function getPoiById(int $id): array
29    {
30        // MetaModel table name.
31        $modelName = 'mm_poi';
32        // ID of the filter "FE POI single view: POI selection + Published".
33        $filterId = 11;
34        // Filter value POI ID.
35        $filterUrl = ['poi' => $id];
36        // ID of the render settings "FE detail view - POI single view".
37        $renderId = 20;
38
39        // Retrieve item.
40        $model            = $this->factory->getMetaModel($modelName);
41        $filter           = $model->getEmptyFilter();
42        $filterCollection = $this->filterFactory->createCollection($filterId);
43        $filterCollection->addRules($filter, $filterUrl);
44
45        // Render items.
46        return $model->findByFilter($filter)->parseAll(
47            'html5',
48            $this->renderFactory->createCollection($model, $renderId)
49        );
50    }
51}

Registration in service.yml:

 1# config/services.yml
 2services:
 3  _defaults:
 4    autoconfigure: true
 5
 6  App\Twig\AppExtension:
 7    arguments:
 8      $factory: '@metamodels.factory'
 9      $filterFactory: '@metamodels.filter_setting_factory'
10      $renderFactory: '@metamodels.render_setting_factory'

More information on “Registering Services”.

Output in the Twig template:

 1{{ rsce_mm_poi_single.html.twig }}
 2{% set pois = getPoiById(poi) %}
 3<div{% if id %} id={{ id }}{% endif %}{% if class %} class="{{ class }}"{% endif %}>
 4    {% if pois|length > 0 %}
 5        <div class="layout_full">
 6            {% for poi in pois %}
 7                <div class="poi_item">
 8                    <h2>{{ poi.html5.name|raw }}</h2>
 9                    <p>{{ poi.html5.city|raw }}</p>
10                    {{ ... }}
11                </div>
12            {% endfor %}
13        </div>
14    {% else %}
15        <p class="info">No POI selected!</p>
16    {% endif %}
17</div>

Selection and Display with a Custom Content Element

If you want to implement the functionality using Contao’s built-in tools instead of an extension, you can create a custom content element.

In the example, a list of MM records should be selectable as products and displayed on the website. The output order should be individually configurable.

Content Element and Callback

First, a DCA configuration and translations are created. For the custom order, inputType is defined as checkboxWizard. After creating the DCA definition, a database migration must be performed.

 1<?php
 2// contao/dca/tl_content.php
 3use Doctrine\DBAL\Platforms\MySQLPlatform;
 4
 5$GLOBALS['TL_DCA']['tl_content']['palettes']['mm_products'] = '
 6    {type_legend},type,headline;
 7    {mm_products_legend},mm_products;
 8    {protected_legend:hide},protected;
 9    {expert_legend:hide},guests,cssID;
10    {invisible_legend:hide},invisible,start,stop;';
11
12$GLOBALS['TL_DCA']['tl_content']['fields']['mm_products'] = [
13    'label'            => &$GLOBALS['TL_LANG']['tl_content']['mm_products'],
14    'inputType'        => 'checkboxWizard',
15    //'options_callback' => See attribute config in MmProductsCallbackListener
16    'eval'             => [
17        'mandatory' => true,
18        'multiple'  => true,
19        'tl_class'  => 'w50',
20    ],
21    'sql'              => [
22        'type'    => 'blob',
23        'length'  => MySQLPlatform::LENGTH_LIMIT_BLOB,
24        'notnull' => false,
25    ],
26];
1<?php
2// contao/languages/en/tl_content.php
3
4// CTE
5$GLOBALS['TL_LANG']['CTE']['mm_products'] = ['CE Product selection', 'CE Product selection for MM products'];
6// Legends
7$GLOBALS['TL_LANG']['tl_content']['mm_products_legend'] = 'Product selection';
8// Fields
9$GLOBALS['TL_LANG']['tl_content']['mm_products'] = ['Product selection', 'Select several products.'];

To generate the selection list for the new content element, the records from MM must be read.

 1<?php
 2// src/EventListener/DataContainer/MmProductsCallbackListener.php
 3namespace App\EventListener\DataContainer;
 4
 5use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
 6use Contao\DataContainer;
 7use MetaModels\Filter\Setting\FilterSettingFactory;
 8use MetaModels\IFactory;
 9use MetaModels\IMetaModel;
10
11use function sprintf;
12
13#[AsCallback(table: 'tl_content', target: 'fields.mm_products.options')]
14class MmProductsCallbackListener
15{
16    public function __construct(
17        private readonly IFactory $factory,
18        private readonly FilterSettingFactory $filterFactory,
19    ) {
20    }
21
22    public function __invoke(DataContainer|null $dc = null): array
23    {
24        // Product list.
25        $options = [];
26
27        // MetaModel table name.
28        $modelName = 'mm_products';
29        // ID of the filter "List published".
30        $filterId = 4;
31
32        // Retrieve items - sorted by name.
33        $model = $this->factory->getMetaModel($modelName);
34        assert($model instanceof IMetaModel);
35        $filter           = $model->getEmptyFilter();
36        $filterCollection = $this->filterFactory->createCollection($filterId);
37        $filterCollection->addRules($filter, []);
38        $items = $model->findByFilter($filter, 'name');
39
40        if ($items->getCount()) {
41            foreach ($items as $item) {
42                $options[$item->get('id')] =
43                    \sprintf('%s - %s [%s]', $item->get('name'), $item->get('measures'), $item->get('articleno'));
44            }
45        }
46
47        return $options;
48    }
49}

Output in Twig with a Custom Controller

The next step is to create the product output. A controller and a Twig output template are needed.

  1<?php
  2// src/Controller/ContentElement/MmProductsElement.php
  3namespace App\Controller\ContentElement;
  4
  5use Contao\BackendTemplate;
  6use Contao\ContentModel;
  7use Contao\CoreBundle\Controller\ContentElement\AbstractContentElementController;
  8use Contao\CoreBundle\Routing\ScopeMatcher;
  9use Contao\CoreBundle\ServiceAnnotation\ContentElement;
 10use Contao\CoreBundle\Twig\FragmentTemplate;
 11use Contao\PageModel;
 12use Contao\StringUtil;
 13use MetaModels\Filter\Setting\FilterSettingFactory;
 14use MetaModels\IFactory;
 15use MetaModels\IMetaModel;
 16use MetaModels\Render\Setting\RenderSettingFactory;
 17use Symfony\Component\HttpFoundation\Request;
 18use Symfony\Component\HttpFoundation\RequestStack;
 19use Symfony\Component\HttpFoundation\Response;
 20
 21use function implode;
 22use function is_array;
 23
 24/**
 25 * @ContentElement("mm_products",
 26 *   category="texts",
 27 *   template="ce_mm_products",
 28 * )
 29 */
 30class MmProductsElement extends AbstractContentElementController
 31{
 32    public function __construct(
 33        private readonly IFactory $factory,
 34        private readonly FilterSettingFactory $filterFactory,
 35        private readonly RenderSettingFactory $renderFactory,
 36        private readonly ScopeMatcher $scopeMatcher,
 37        private readonly RequestStack $requestStack,
 38    ) {
 39    }
 40
 41    protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response
 42    {
 43        $arrHeadline = StringUtil::deserialize($model->headline, true);
 44        $headline    = is_array($arrHeadline) ? $arrHeadline['value'] ?? '' : $arrHeadline;
 45        $template->set('headline', $headline);
 46        $template->set('hl', $arrHeadline['unit'] ?? 'h2');
 47
 48        $productsList = StringUtil::deserialize($model->mm_products, true);
 49
 50        if ($this->isBackend()) {
 51            $template = new BackendTemplate('be_wildcard');
 52            $template->title    = $headline;
 53            $template->wildcard = 'Products: ' . implode(', ', $productsList);
 54
 55            return $template->getResponse();
 56        }
 57
 58        $arrCssId = StringUtil::deserialize($model->cssID, true);
 59        $template->set('id', $arrCssId[0] ?? '');
 60        $template->set('class', $arrCssId[1] ?? '');
 61
 62        $template->set('products', $this->getProductsByIds($productsList));
 63
 64        // ID of the inquiry form page.
 65        $template->set('pageAlias', PageModel::findById(5)->alias);
 66
 67        return $template->getResponse();
 68    }
 69
 70    protected function getProductsByIds(array $ids): array
 71    {
 72        // MetaModel table name.
 73        $modelName = 'mm_products';
 74        // ID of the filter "List published + product IDs".
 75        $filterId = 5;
 76        // Filter value products.
 77        $filterUrl = ['products' => $ids];
 78        // ID of the render settings "Product list".
 79        $renderId = 4;
 80
 81        // Retrieve items.
 82        $model = $this->factory->getMetaModel($modelName);
 83        assert($model instanceof IMetaModel);
 84        $filter           = $model->getEmptyFilter();
 85        $filterCollection = $this->filterFactory->createCollection($filterId);
 86        $filterCollection->addRules($filter, $filterUrl);
 87
 88        // Render items.
 89        return $model->findByFilter($filter)->parseAll(
 90            'html5',
 91            $this->renderFactory->createCollection($model, $renderId)
 92        );
 93    }
 94
 95    public function isBackend(): bool
 96    {
 97        if ($request = $this->requestStack->getCurrentRequest()) {
 98            return $this->scopeMatcher->isBackendRequest($request);
 99        }
100
101        return false;
102    }
103}

To filter by the selected product IDs, a “Custom SQL” filter rule can be created and filtering applied via the passed parameter from $filterUrl — here a series of IDs is passed and their order should remain unchanged.

1-- Filter rule in filter 11
2SELECT id FROM {{table}}
3WHERE id IN({{param::filter?name=products&aggregate=set&default=0}})
4ORDER BY FIELD(id, {{param::filter?name=products&aggregate=set&default=0}})

Additionally, you could filter by publication status in the SQL or in a separate filter rule.

 1{# templates/ce_mm_products.html.twig #}
 2{% if headline %}
 3    <{{ hl }}>{{ headline }}</{{ hl }}>
 4{% endif %}
 5<div{% if id %} id={{ id }}{% endif %}{% if class %} class="{{ class }}"{% endif %}>
 6    {% if products|length > 0 %}
 7    <div class="product__list">
 8        {% for product in products %}
 9            <div class="product">
10                <div class="product__image">
11                    {{ product.html5.list_image|raw }}
12                </div>
13                <div class="product__features">
14                    <div class="product__name"><a href="{{ product.actions.jumpTo.href }}">{{ product.text.name }}</a></div>
15                    {% if product.text.sub_headline %}
16                        <div class="product__subheadline">({{ product.text.sub_headline }})</div>
17                    {% endif %}
18                    {% if product.text.measures %}
19                        <div class="product__measures">{{ product.text.measures }}</div>
20                    {% endif %}
21                </div>
22                {% if product.text.inquiry %}
23                    <div class="inquiry">
24                        <a href="{{ pageAlias }}?articlno={{ product.text.articleno }}&name={{ product.text.name }}" class="inquiry__button">Enquire</a>
25                    </div>
26                {% endif %}
27            </div>
28        {% endfor %}
29    </div>
30    {% else %}
31        <p class="info">No product selected!</p>
32    {% endif %}
33</div>

Loading Services

There are several ways to load all classes — see “Registering Services”. With a custom services.yml, it looks like this:

 1# config/services.yml
 2services:
 3  _defaults:
 4    autoconfigure: true
 5
 6  App\Controller\ContentElement\MmProductsElement:
 7    arguments:
 8      $factory: '@metamodels.factory'
 9      $filterFactory: '@metamodels.filter_setting_factory'
10      $renderFactory: '@metamodels.render_setting_factory'
11      $scopeMatcher: '@contao.routing.scope_matcher'
12      $requestStack: '@request_stack'
13
14  App\EventListener\DataContainer\MmProductsCallbackListener:
15    arguments:
16      $factory: '@metamodels.factory'
17      $filterFactory: '@metamodels.filter_setting_factory'

Whether everything is loaded can be tested via a console call — clear the cache and run “composer install” if necessary.