Rendering

This is the second step of the processing chain: The rendering part gets the data array prepared by FormDataCompiler and creates a result array containing HTML, CSS and JavaScript. This is then post-processed by a controller to feed it to the PageRenderer or to create an Ajax response.

The rendering is a tree: The controller initializes this by setting one container as renderType entry point within the data array, then hands over the full data array to the NodeFactory which looks up a class responsible for this renderType, and calls render() on it. A container class creates only a fraction of the full result, and delegates details to another container. The second one does another detail and calls a third one. This continues to happen until a single field should be rendered, at which point an element class is called taking care of one element.

Render tree example

Each container creates some "outer" part of the result, calls some sub-container or element, merges the sub-result with its own content and returns the merged array up again. The data array is given to each sub class along the way, and containers can add further render relevant data to it before giving it "down". The data array can not be given "up" in a changed way again. Inheritance of a data array is always top-bottom. Only HTML, CSS or JavaScript created by a sub-class is returned by the sub-class "up" again in a "result" array of a specified format.

EXT:my_extension/Classes/Containers/SomeContainer.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Containers;

use TYPO3\CMS\Backend\Form\Container\AbstractContainer;

final class SomeContainer extends AbstractContainer
{
    public function render(): array
    {
        $result = $this->initializeResultArray();
        $data = $this->data;
        $data['renderType'] = 'subContainer';
        $childArray = $this->nodeFactory->create($data)->render();
        $resultArray = $this->mergeChildReturnIntoExistingResult($result, $childArray, false);
        $result['html'] = '<h1>A headline</h1>' . $childArray['html'];
        return $result;
    }
}
Copied!

Above example lets NodeFactory find and compile some data from "subContainer", and merges the child result with its own. The helper methods initializeResultArray() and mergeChildReturnIntoExistingResult() help with combining CSS and JavaScript.

An upper container does not directly create an instance of a sub node (element or container) and never calls it directly. Instead, a node that wants to call a sub node only refers to it by a name, sets this name into the data array as $data['renderType'] and then gives the data array to the NodeFactory which determines an appropriate class name, instantiates and initializes the class, gives it the data array, and calls render() on it.

Class Inheritance

Main render class inheritance

All classes must implement NodeInterface to be routed through the NodeFactory. The AbstractNode implements some basic helpers for nodes, the two classes AbstractContainer and AbstractFormElement implement helpers for containers and elements respectively.

The call concept is simple: A first container is called, which either calls a container below or a single element. A single element never calls a container again.

NodeFactory

The NodeFactory plays an important abstraction role within the render chain: Creation of child nodes is always routed through it, and the NodeFactory takes care of finding and validating the according class that should be called for a specific renderType. This is supported by an API that allows registering new renderTypes and overriding existing renderTypes with own implementations. This is true for all classes, including containers, elements, fieldInformation, fieldWizards and fieldControls. This means the child routing can be fully adapted and extended if needed. It is possible to transparently "kick-out" a Core container and to substitute it with an own implementation.

For example, the TemplaVoila implementation needs to add additional render capabilities of the FlexForm rendering to add for instance an own multi-language rendering of flex fields. It does that by overriding the default flex container with own implementation:

EXT:my_extension/ext_localconf.php
<?php

declare(strict_types=1);

use TYPO3\CMS\Compatibility6\Form\Container\FlexFormEntryContainer;

defined('TYPO3') or die();

// Default registration of "flex" in NodeFactory:
// 'flex' => \TYPO3\CMS\Backend\Form\Container\FlexFormEntryContainer::class,

// Register language-aware FlexForm handling in FormEngine
$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1443361297] = [
    'nodeName' => 'flex',
    'priority' => 40,
    'class' => FlexFormEntryContainer::class,
];
Copied!

This re-routes the renderType "flex" to an own class. If multiple registrations for a single renderType exist, the one with highest priority wins.

Adding a new renderType in ext_localconf.php

EXT:my_extension/ext_localconf.php
<?php

declare(strict_types=1);

use MyVendor\CoolTagCloud\Form\Element\SelectTagCloudElement;

defined('TYPO3') or die();

// Add new field type to NodeFactory
$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1487112284] = [
    'nodeName' => 'selectTagCloud',
    'priority' => '70',
    'class' => SelectTagCloudElement::class,
];
Copied!

And use it in TCA for a specific field, keeping the full database functionality in DataHandler together with the data preparation of FormDataCompiler, but just routing the rendering of that field to the new element:

EXT:cool_tag_cloud/Configuration/TCA/overrides/tx_cooltagcloud.php
<?php

defined('TYPO3') or die();

$GLOBALS['TCA']['tx_cooltagcloud']['columns']['my_field'] = [
    'label' => 'Cool Tag cloud',
    'config' => [
        'type' => 'select',
        'renderType' => 'selectTagCloud',
        'foreign_table' => 'tx_cooltagcloud_availableTags',
    ],
];
Copied!

The above examples are a static list of nodes that can be changed by settings in ext_localconf.php. If that is not enough, the NodeFactory can be extended with a resolver that is called dynamically for specific renderTypes. This resolver gets the full current data array at runtime and can either return NULL saying "not my job", or return the name of a class that should handle this node.

An example of this are the Core internal rich text editors. Both "ckeditor" and "rtehtmlarea" register a resolver class that are called for node name "text", and if the TCA config enables the editor, and if the user has enabled rich text editing in his user settings, then the resolvers return their own RichTextElement class names to render a given text field:

EXT:my_extension/ext_localconf.php
<?php

declare(strict_types=1);

use TYPO3\CMS\RteCKEditor\Form\Resolver\RichTextNodeResolver;

defined('TYPO3') or die();

// Register FormEngine node type resolver hook to render RTE in FormEngine if enabled
$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeResolver'][1480314091] = [
    'nodeName' => 'text',
    'priority' => 50,
    'class' => RichTextNodeResolver::class,
];
Copied!

The trick here is that CKEditor registers his resolver with a higher priority (50) than "rtehtmlarea" (40), so the "ckeditor" resolver is called first and wins if both extensions are loaded and if both return a valid class name.

Result Array

Each node, no matter if it is a container, an element, or a node expansion, must return an array with specific data keys it wants to add. It is the job of the parent node that calls the sub node to merge child node results into its own result. This typically happens by merging $childResult['html'] into an appropriate position of own HTML, and then calling $this->mergeChildReturnIntoExistingResult() to add other array child demands like stylesheetFiles into its own result.

Container and element nodes should use the helper method $this->initializeResultArray() to have a result array initialized that is understood by a parent node.

Only if extending existing element via node expansion, the result array of a child can be slightly different. For instance, a FieldControl "wizards" must have a iconIdentifier result key key. Using $this->initializeResultArray() is not appropriate in these cases but depends on the specific expansion type. See below for more details on node expansion.

The result array for container and element nodes looks like this. $resultArray = $this->initializeResultArray() takes care of basic keys:

[
    'html' => '',
    'additionalInlineLanguageLabelFiles' => [],
    'stylesheetFiles' => [],
    'javaScriptModules' => $javaScriptModules,
    /** @deprecated requireJsModules will be removed in TYPO3 v13.0 */
    'requireJsModules' => [],
    'inlineData' => [],
    'html' => '',
]
Copied!

CSS and language labels (which can be used in JS) are added with their file names in format EXT:my_extension/path/to/file.

Adding JavaScript modules

JavaScript is added as ES6 modules using the function JavaScriptModuleInstruction::create().

You can for example use it in a container:

EXT:my_extension/Classes/Backend/SomeContainer.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Backend;

use TYPO3\CMS\Backend\Form\Container\AbstractContainer;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;

final class SomeContainer extends AbstractContainer
{
    public function render(): array
    {
        $resultArray = $this->initializeResultArray();
        $resultArray['javaScriptModules'][] =
            JavaScriptModuleInstruction::create('@myvendor/my_extension/my-javascript.js');
        // ...
        return $resultArray;
    }
}
Copied!

Or a controller:

EXT:my_extension/Classes/Backend/Controller/SomeController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Backend\Controller;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;

final class SomeController
{
    public function __construct(private readonly PageRenderer $pageRenderer) {}

    public function mainAction(ServerRequestInterface $request): ResponseInterface
    {
        $javaScriptRenderer = $this->pageRenderer->getJavaScriptRenderer();
        $javaScriptRenderer->addJavaScriptModuleInstruction(
            JavaScriptModuleInstruction::create('@myvendor/my_extension/my-service.js')
                ->invoke('someFunction'),
        );
        // ...
        return $this->pageRenderer->renderResponse();
    }
}
Copied!

Node Expansion

The "node expansion" classes FieldControl, FieldInformation and FieldWizard are called by containers and elements and allow "enriching" containers and elements. Which enrichments are called can be configured via TCA.

FieldInformation
Additional information. In elements, their output is shown between the field label and the element itself. They can not add functionality, but only simple and restricted HTML strings. No buttons, no images. An example usage could be an extension that auto-translates a field content and outputs an information like "Hey, this field was auto-filled for you by an automatic translation wizard. Maybe you want to check the content".
FieldWizard
Wizards shown below the element. "enrich" an element with additional functionality. The localization wizard and the file upload wizard of type=group fields are examples of that.
FieldControl
"Buttons", usually shown next to the element. For type=group the "list" button and the "element browser" button are examples. A field control must return an icon identifier.

Currently, all elements usually implement all three of these, except in cases where it does not make sense. This API allows adding functionality to single nodes, without overriding the whole node. Containers and elements can come with default expansions (and usually do). TCA configuration can be used to add own stuff. On container side the implementation is still basic, only OuterWrapContainer and InlineControlContainer currently implement FieldInformation and FieldWizard.

See the TCA reference ctrl section for more information on how to configure these for containers in TCA.

Example. The InputTextElement (standard input element) defines a couple of default wizards and embeds them in its main result HTML:

EXT:my_extension/Classes/Backend/Form/InputTextElement.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Backend\Form;

use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;

final class InputTextElement extends AbstractFormElement
{
    protected $defaultFieldWizard = [
        'localizationStateSelector' => [
            'renderType' => 'localizationStateSelector',
        ],
        'otherLanguageContent' => [
            'renderType' => 'otherLanguageContent',
            'after' => [
                'localizationStateSelector',
            ],
        ],
        'defaultLanguageDifferences' => [
            'renderType' => 'defaultLanguageDifferences',
            'after' => [
                'otherLanguageContent',
            ],
        ],
    ];

    public function render(): array
    {
        $resultArray = $this->initializeResultArray();

        $fieldWizardResult = $this->renderFieldWizard();
        $fieldWizardHtml = $fieldWizardResult['html'];
        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);

        $mainFieldHtml = [];
        $mainFieldHtml[] = '<div class="form-control-wrap">';
        $mainFieldHtml[] =  '<div class="form-wizards-wrap">';
        $mainFieldHtml[] =      '<div class="form-wizards-element">';
        // Main HTML of element done here ...
        $mainFieldHtml[] =      '</div>';
        $mainFieldHtml[] =      '<div class="form-wizards-items-bottom">';
        $mainFieldHtml[] =          $fieldWizardHtml;
        $mainFieldHtml[] =      '</div>';
        $mainFieldHtml[] =  '</div>';
        $mainFieldHtml[] = '</div>';

        $resultArray['html'] = implode(LF, $mainFieldHtml);
        return $resultArray;
    }
}
Copied!

This element defines three wizards to be called by default. The renderType concept is re-used, the values localizationStateSelector are registered within the NodeFactory and resolve to class names. They can be overridden and extended like all other nodes. The $defaultFieldWizards are merged with TCA settings by the helper method renderFieldWizards(), which uses the DependencyOrderingService again.

It is possible to:

  • Override existing expansion nodes with own ones from extensions, even using the resolver mechanics is possible.
  • It is possible to disable single wizards via TCA
  • It is possible to add own expansion nodes at any position relative to the other nodes by specifying "before" and "after" in TCA.

Add fieldControl Example

To illustrate the principals discussed in this chapter see the following example which registers a fieldControl (button) next to a field in the pages table to trigger a data import via Ajax.

Add a new renderType in ext_localconf.php:

EXT:my_extension/ext_localconf.php
<?php

declare(strict_types=1);

use MyVendor\MyExtension\FormEngine\FieldControl\ImportDataControl;

defined('TYPO3') or die();

$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1485351217] = [
    'nodeName' => 'importDataControl',
    'priority' => 30,
    'class' => ImportDataControl::class,
];
Copied!

Register the control in Configuration/TCA/Overrides/pages.php:

EXT:my_extension/Configuration/TCA/Overrides/pages.php
<?php

defined('TYPO3') or die();

(static function (): void {
    $langFile = 'LLL:EXT:my_extension/Ressources/Private/Language/locallang.xlf';

    $GLOBALS['TCA']['pages']['columns']['somefield'] = [
        'label' => $langFile . ':pages.somefield',
        'config' => [
            'type' => 'input',
            'eval' => 'int, unique',
            'fieldControl' => [
                'importControl' => [
                    'renderType' => 'importDataControl',
                ],
            ],
        ],
    ];
})();
Copied!

Add the php class for rendering the control in Classes/FormEngine/FieldControl/ImportDataControl.php:

EXT:my_extension/Classes/FormEngine/FieldControl/ImportDataControl.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\FormEngine\FieldControl;

use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Core\Information\Typo3Version;

final class ImportDataControl extends AbstractNode
{
    private string $langFile = 'LLL:EXT:my_extension/Ressources/Private/Language/locallang_db.xlf';

    public function __construct(private readonly Typo3Version $typo3Version) {}

    public function render(): array
    {
        $result = [
            'iconIdentifier' => 'import-data',
            'title' => $GLOBALS['LANG']->sL($this->langFile . ':pages.importData'),
            'linkAttributes' => [
                'class' => 'importData ',
                'data-id' => $this->data['databaseRow']['somefield'],
            ],
            'javaScriptModules' => ['@my_vendor/my_extension/import-data.js'],
        ];

        /** @deprecated remove on dropping TYPO3 v11 support */
        if ($this->typo3Version->getMajorVersion() < 12) {
            unset($result['javaScriptModules']);
            $result['requireJsModules'] = ['TYPO3/CMS/Something/ImportData'];
        }

        return $result;
    }
}
Copied!

Add the JavaScript for defining the behavior of the control in Resources/Public/JavaScript/ImportData.js:

EXT:my_extension/Resources/Public/JavaScript/ImportData.js
/**
 * Module: TYPO3/CMS/Something/ImportData
 *
 * JavaScript to handle data import
 * @exports TYPO3/CMS/Something/ImportData
 */
define(function () {
  'use strict';

  /**
   * @exports TYPO3/CMS/Something/ImportData
   */
  var ImportData = {};

  /**
   * @param {int} id
   */
  ImportData.import = function (id) {
    $.ajax({
      type: 'POST',
      url: TYPO3.settings.ajaxUrls['something-import-data'],
      data: {
        'id': id
      }
    }).done(function (response) {
      if (response.success) {
        top.TYPO3.Notification.success('Import Done', response.output);
      } else {
        top.TYPO3.Notification.error('Import Error!');
      }
    });
  };

  /**
   * initializes events using deferred bound to document
   * so Ajax reloads are no problem
   */
  ImportData.initializeEvents = function () {

    $('.importData').on('click', function (evt) {
      evt.preventDefault();
      ImportData.import($(this).attr('data-id'));
    });
  };

  $(ImportData.initializeEvents);

  return ImportData;
});
Copied!

Add an Ajax route for the request in Configuration/Backend/AjaxRoutes.php:

EXT:my_extension/Configuration/Backend/AjaxRoutes.php
<?php

use MyVendor\MyExtension\Controller\Ajax\ImportDataController;

return [
    'something-import-data' => [
        'path' => '/something/import-data',
        'target' => ImportDataController::class . '::importDataAction',
    ],
];
Copied!

Add the Ajax controller class in Classes/Controller/Ajax/ImportDataController.php:

EXT:my_extension/Classes/Controller/Ajax/ImportDataController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller\Ajax;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\JsonResponse;

final class ImportDataController
{
    public function importDataAction(ServerRequestInterface $request): ResponseInterface
    {
        $queryParameters = $request->getParsedBody();
        $id = (int)($queryParameters['id'] ?? 0);

        if ($id === 0) {
            return new JsonResponse(['success' => false]);
        }
        $param = ' -id=' . $id;

        // trigger data import (simplified as example)
        $output = shell_exec('.' . DIRECTORY_SEPARATOR . 'import.sh' . $param);

        return new JsonResponse(['success' => true, 'output' => $output]);
    }
}
Copied!