Commit 5399ea63 authored by Christian Kuhn's avatar Christian Kuhn
Browse files

[TASK] Refactor ext:tstemplate controllers

With the refactoring of the '3rd level' backend module
API, we can finally clean up the Template related
backend controllers.

Notable UI changes:
* There was a rather hidden functionality in the template
  module: The "Template record overview" that lists all
  pages that have sys_template records, is only rendered
  when the root page "Page zero" is selected. This can be
  hard to find and is contrary to all other backend modules
  where sub-functionality is selected using the drop down
  in the doc header. The patch changes this: The template
  record overview is now always shown as 5th element in the
  drop down. It currently renders the same output on all
  pages, but could be made page-aware later.
* The TypoScript Object Browser has a detail view to edit
  single properties, which then write to a sys_template
  record. This view also allows to add the edited path to
  an "Object List" if it has sub properties. This is
  remembered for the given user. The user can then select
  this path in the object browser tree view to limit the
  view to this section of the tree. All in all, this is a
  rather hidden and pretty much unknown detail functionality
  of limited use. This functionality has been removed.

Patch overview:
* Implement the "Template record overview" as new
* Resolve "controller extends controller" inheritance and
  introduce an abstract controller with common methods of
  client classes. Methods don't add class state.
* Heavily reduce class state to a minimum.
* Improve separation of controller and view concerns.
* Split some templates to partials.
* Leverage dependency incjection correctly: Abstract uses
  inject* methods, client classes use constructor DI.
* Remove user access checks since the entire Template
  module is 'admin only', which is checked by middlewares.
* Various general clean ups, variable renamings, better
  structured methods.

Change-Id: I64cb7ea6275af491233ff7ee525c9260e5da6627
Resolves: #97591
Related: #97135
Releases: main

Tested-by: core-ci's avatarcore-ci <>
Tested-by: Stefan Bürk's avatarStefan Bürk <>
Tested-by: Jochen's avatarJochen <>
Tested-by: Christian Kuhn's avatarChristian Kuhn <>
Reviewed-by: Stefan Bürk's avatarStefan Bürk <>
Reviewed-by: Jochen's avatarJochen <>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <>
parent 87feed90
......@@ -72,6 +72,8 @@ class ModuleData
* Cleans a single property by the given allowed list. First fallback
* is the default data list. If this list does also not contain an
* allowed value, the first value from the allowed list is taken.
* @return bool True if something has been cleaned up
public function clean(string $propertyName, array $allowedValues): bool
......@@ -580,7 +580,9 @@ class BackendUtility
* Returns a page record (of page with $id) with an extra field "_thePath" set to the record path IF the WHERE clause, $perms_clause, selects the record. Thus is works as an access check that returns a page record if access was granted, otherwise not.
* Returns a page record (of page with $id) with an extra field "_thePath" set to the record path if
* the WHERE clause $perms_clause selects the record. This works as an access check that returns a page
* record if access was granted, otherwise false.
* If $id is zero a pseudo root-page with "_thePath" set is returned IF the current BE_USER is admin.
* In any case ->isInWebMount must return TRUE for the user (regardless of $perms_clause)
......@@ -153,11 +153,11 @@ class LanguageService
* splitLabel function
* Main and most often used method.
* All translations are based on $LOCAL_LANG variables.
* 'language-splitted' labels can therefore refer to a local-lang file + index.
* Refer to 'Inside TYPO3' for more details
* Resolve strings like these:
* 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
* This looks up the given .xlf file path in the 'core' extension for label 'labels.depth_0'
* @param string $input Label key/reference
* @return string
......@@ -160,6 +160,7 @@ class SiteModuleCest
$pageTree->openPath(['styleguide TCA demo']);
$I->selectOption('div.module-docheader select.t3-js-jumpMenuBox', 'Info/Modify');
$I->waitForText('Create new website');
$I->amGoingTo('Create a new template for the root page');
......@@ -204,6 +205,8 @@ page.10.value = This is a default text for default rendering without dynamic con
$pageTree->openPath(['styleguide TCA demo']);
$I->selectOption('div.module-docheader select.t3-js-jumpMenuBox', 'Info/Modify');
$I->waitForText('Edit the whole template record');
$I->click('Edit the whole template record');
......@@ -258,7 +261,7 @@ page.10.value = This is a default text for default rendering without dynamic con
$I->waitForElement($saveButtonLink, 30);
$I->amGoingTo('Create and delete new site language. Verify "Placehoder" is not added to selector');
$I->amGoingTo('Create and delete new site language. Verify "Placeholder" is not added to selector');
$I->click('Create new language');
......@@ -46,13 +46,14 @@ class TemplateCest
$I->see('This is an overview of the pages in the database containing one or more template records. Click a page title to go to the page.');
$I->see('This is a global overview of all pages in the database containing one or more template records.');
$I->wantTo('show templates overview on website root page (uid = 1 and pid = 0)');
// click on website root page
$I->clickWithLeftButton('//*[text()=\'styleguide TCA demo\']');
$I->selectOption('div.module-docheader select.t3-js-jumpMenuBox', 'Info/Modify');
$I->waitForText('No template');
$I->see('There was no template on this page!');
$I->see('You need to create a template record below in order to edit your configuration.');
......@@ -75,7 +76,6 @@ class TemplateCest
$I->waitForText('Create new website');
$I->waitForText('Edit constants for template');
$I->wantTo('change to Info/Modify and see the template overview table');
$I->selectOption('.t3-js-jumpMenuBox', 'Info/Modify');
......@@ -208,32 +208,4 @@ class TemplateCest
$I->waitForText('CONSTANTS ROOT');
$I->seeInSource('<strong class="text-danger">styles</strong>');
* @depends addANewSiteTemplate
public function useObjectListInObjectBrowser(ApplicationTester $I): void
$I->wantTo('Open the TypoScript Object Browser and use the object list.');
$I->clickWithLeftButton('//*[text()=\'styleguide TCA demo\']');
$I->amGoingTo('Switch to object browser.');
$I->selectOption('.t3-js-jumpMenuBox', 'TypoScript Object Browser');
$I->waitForText('CONSTANTS ROOT');
$I->selectOption('select[name="ts_browser_type"]', 'Setup');
$I->waitForText('SETUP ROOT');
$I->waitForText('Edit object/property value');
$I->amGoingTo('add tt_content to object list');
$I->click('Add key "tt_content" to Object List');
$I->see('Remove key from OL');
$I->amGoingTo('verify "all" can still be selected and shows full setup.');
$I->selectOption('select[name="ts_browser_toplevel_setup"]', 'all');
$I->seeInSource('<i class="text-muted"># Content element rendering</i>');
* This file is part of the TYPO3 CMS project.
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
* The TYPO3 project - inspiring people to share!
namespace TYPO3\CMS\Tstemplate\Controller;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
* Abstract class with helper methods for single 3rd level Template module controllers.
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
abstract class AbstractTemplateModuleController
protected IconFactory $iconFactory;
protected UriBuilder $uriBuilder;
protected ConnectionPool $connectionPool;
public function injectIconFactory(IconFactory $iconFactory): void
$this->iconFactory = $iconFactory;
public function injectUriBuilder(UriBuilder $uriBuilder)
$this->uriBuilder = $uriBuilder;
public function injectConnectionPool(ConnectionPool $connectionPool)
$this->connectionPool = $connectionPool;
protected function addPreviewButtonToDocHeader(ModuleTemplate $view, int $pageId, int $dokType): void
$languageService = $this->getLanguageService();
$buttonBar = $view->getDocHeaderComponent()->getButtonBar();
// Don't add preview button for sysfolders and recycler by default, and look up TS config options
$excludedDokTypes = [
$pagesTsConfig = BackendUtility::getPagesTSconfig($pageId);
if (isset($pagesTsConfig['TCEMAIN.']['preview.']['disableButtonForDokType'])) {
$excludedDokTypes = GeneralUtility::intExplode(
if ($pageId && !in_array($dokType, $excludedDokTypes, true)) {
$previewDataAttributes = PreviewUriBuilder::create($pageId)
$viewButton = $buttonBar->makeLinkButton()
->setDataAttributes($previewDataAttributes ?? [])
->setIcon($this->iconFactory->getIcon('actions-view-page', Icon::SIZE_SMALL));
$buttonBar->addButton($viewButton, ButtonBar::BUTTON_POSITION_LEFT, 99);
protected function addShortcutButtonToDocHeader(ModuleTemplate $view, string $moduleIdentifier, array $pageInfo, int $pageId): void
$languageService = $this->getLanguageService();
$buttonBar = $view->getDocHeaderComponent()->getButtonBar();
$shortcutTitle = sprintf(
'%s: %s [%d]',
BackendUtility::getRecordTitle('pages', $pageInfo),
$shortcutButton = $buttonBar->makeShortcutButton()
->setArguments(['id' => $pageId]);
* Create template if requested.
protected function createTemplateIfRequested(ServerRequestInterface $request, int $pageId, int $afterExistingTemplateId = 0): int
$languageService = $this->getLanguageService();
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$recordData = [];
if ($request->getParsedBody()['createExtension'] ?? $request->getQueryParams()['createExtension'] ?? false) {
$recordData['sys_template']['NEW'] = [
'pid' => $afterExistingTemplateId ? -1 * $afterExistingTemplateId : $pageId,
'title' => '+ext',
$dataHandler->start($recordData, []);
} elseif ($request->getParsedBody()['newWebsite'] ?? $request->getQueryParams()['newWebsite'] ?? false) {
$recordData['sys_template']['NEW'] = [
'pid' => $pageId,
'title' => $languageService->sL('LLL:EXT:tstemplate/Resources/Private/Language/locallang.xlf:titleNewSite'),
'sorting' => 0,
'root' => 1,
'clear' => 3,
'config' => "\n"
. "# Default PAGE object:\n"
. "page = PAGE\n"
. "page.10 = TEXT\n"
. "page.10.value = HELLO WORLD!\n",
$dataHandler->start($recordData, []);
return (int)($dataHandler->substNEWwithIDs['NEW'] ?? 0);
* Get closest page row that has a template up in rootline
protected function getClosestAncestorPageWithTemplateRecord(int $pageId): array
$rootLine = BackendUtility::BEgetRootLine($pageId);
foreach ($rootLine as $rootlineNode) {
if ($this->getFirstTemplateRecordOnPage((int)$rootlineNode['uid'])) {
return $rootlineNode;
return [];
* Get an array of all template records on a page.
protected function getAllTemplateRecordsOnPage(int $pageId): array
if (!$pageId) {
return [];
$result = $this->getTemplateQueryBuilder($pageId)->executeQuery();
$templateRows = [];
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL('sys_template', $row);
if (is_array($row)) {
$templateRows[] = $row;
return $templateRows;
* Get a single sys_template record attached to a single page.
* If multiple template records are on this page, the first (order by sorting)
* record will be returned, unless a specific template uid is specified via $templateUid
* @param int $pageId The pid to select sys_template records from
* @param int $templateUid Optional template uid
* @return array<string,mixed>|false Returns the template record or false if none was found
protected function getFirstTemplateRecordOnPage(int $pageId, int $templateUid = 0): array|false
if (empty($pageId)) {
return false;
$queryBuilder = $this->getTemplateQueryBuilder($pageId)->setMaxResults(1);
if ($templateUid) {
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($templateUid, \PDO::PARAM_INT))
$row = $queryBuilder->executeQuery()->fetchAssociative();
BackendUtility::workspaceOL('sys_template', $row);
return $row;
* Helper method to prepare the query builder for getting sys_template records from a given pid.
protected function getTemplateQueryBuilder(int $pid): QueryBuilder
$backendUser = $this->getBackendUser();
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_template');
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $backendUser->workspace));
return $queryBuilder->select('*')
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT))
protected function getLanguageService(): LanguageService
return $GLOBALS['LANG'];
protected function getBackendUser(): BackendUserAuthentication
return $GLOBALS['BE_USER'];
......@@ -19,8 +19,12 @@ namespace TYPO3\CMS\Tstemplate\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\TypoScript\ExtendedTemplateService;
use TYPO3\CMS\Core\TypoScript\Parser\ConstantConfigurationParser;
......@@ -31,127 +35,135 @@ use TYPO3\CMS\Core\Utility\RootlineUtility;
* TypoScript Constant editor
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
class TypoScriptConstantEditorController extends TypoScriptTemplateModuleController
class ConstantEditorController extends AbstractTemplateModuleController
protected array $categories = [
// Constants of superior importance for the template-layout. This is dimensions, imagefiles and enabling of various features.
//The most basic constants, which you would almost always want to configure.
'basic' => [],
// Constants of superior importance for the template-layout. This is dimensions, imagefiles and enabling of various features. The most basic constants, which you would almost always want to configure.
'menu' => [],
// Menu setup. This includes fontfiles, sizes, background images. Depending on the menutype.
'content' => [],
'menu' => [],
// All constants related to the display of pagecontent elements
'page' => [],
'content' => [],
// General configuration like metatags, link targets
'advanced' => [],
'page' => [],
// Advanced functions, which are used very seldom.
'advanced' => [],
'all' => [],
protected ExtendedTemplateService $templateService;
protected array $constants = [];
protected ConstantConfigurationParser $constantParser;
public function __construct(
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly ConstantConfigurationParser $constantParser,
) {
public function handleRequest(ServerRequestInterface $request): ResponseInterface
$this->constantParser = GeneralUtility::makeInstance(ConstantConfigurationParser::class);
// Fallback to regular module when on root level
if ($this->id === 0) {
return $this->overviewAction();
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUser();
$currentModule = $request->getAttribute('module');
$currentModuleIdentifier = $currentModule->getIdentifier();
$moduleData = $request->getAttribute('moduleData');
if ($moduleData->cleanUp([])) {
$backendUser->pushModuleData($currentModuleIdentifier, $moduleData->toArray());
$pageId = (int)($request->getQueryParams()['id'] ?? 0);
if ($pageId === 0) {
// Redirect to template record overview if on page 0.
return new RedirectResponse($this->uriBuilder->buildUriFromRoute('web_typoscript_recordsoverview'));
// Create extension template
// Checking for more than one template an if, set a menu...
$manyTemplatesMenu = $this->templateMenu();
$selectedTemplateRecord = 0;
if ($manyTemplatesMenu) {
$selectedTemplateRecord = (int)$this->moduleData->get('templatesOnPage');
$pageRecord = BackendUtility::readPageAccess($pageId, '1=1') ?: [];
$this->createTemplateIfRequested($request, $pageId);
$allTemplatesOnPage = $this->getAllTemplateRecordsOnPage($pageId);
if ($moduleData->clean('templatesOnPage', array_column($allTemplatesOnPage, 'uid') ?: [0])) {
$backendUser->pushModuleData($currentModuleIdentifier, $moduleData->toArray());
// initialize
$existTemplate = $this->initialize_editor($selectedTemplateRecord);
if ($existTemplate) {
$assigns['templateRecord'] = $this->templateRow;
$assigns['manyTemplatesMenu'] = $manyTemplatesMenu;
$selectedTemplateRecord = (int)$moduleData->get('templatesOnPage');
$templateRow = $this->parseTemplate($pageId, $selectedTemplateRecord);
$saveId = empty($this->templateRow['_ORIG_uid']) ? $this->templateRow['uid'] : $this->templateRow['_ORIG_uid'];
// Update template ?
if ($this->request->getParsedBody()['_savedok'] ?? false) {
$constantsHaveChanged = $this->templateService->ext_procesInput($this->request->getParsedBody(), $this->constants);
if ($constantsHaveChanged) {
// Set the data to be saved
$recData = [];
$recData['sys_template'][$saveId]['constants'] = implode(LF, $this->templateService->raw);
// Create new tce-object
$tce = GeneralUtility::makeInstance(DataHandler::class);
$tce->start($recData, []);
// re-read the template ...
// re-read the constants as they have changed
// Resetting the menu (stop)
$assigns['title'] = $this->linkWrapTemplateTitle($this->templateRow['title'], 'constants');
// Category and constant editor config
$availableCategories = $this->getCategoryLabels();
$currentCategory = (string)$this->moduleData->get('constant_editor_cat');
if (!empty($availableCategories)) {
$assigns['constantsMenu'] = BackendUtility::getDropdownMenu($this->id, 'constant_editor_cat', $currentCategory, $availableCategories, '', '', ['id' => 'constant_editor_cat']);
if ($request->getParsedBody()['_savedok'] ?? false) {
// Update template with new data on save
$constantsHaveChanged = $this->templateService->ext_procesInput($request->getParsedBody(), $this->constants);
if ($constantsHaveChanged) {
$saveId = empty($templateRow['_ORIG_uid']) ? $templateRow['uid'] : $templateRow['_ORIG_uid'];
$recordData = [];
$recordData['sys_template'][$saveId]['constants'] = implode(LF, $this->templateService->raw);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($recordData, []);
// Re-init template state as constants have changed
$this->parseTemplate($pageId, $selectedTemplateRecord);
$assigns['editorFields'] = $this->printFields($this->constants, $currentCategory);
return $this->view->renderResponse('ConstantEditor');
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle($languageService->sL($currentModule->getTitle()), $pageRecord['title']);
$this->addPreviewButtonToDocHeader($view, $pageId, (int)$pageRecord['doktype']);
$this->addShortcutButtonToDocHeader($view, $currentModuleIdentifier, $pageRecord, $pageId);
$this->addSaveButtonToDocHeader($view, $moduleData, $pageId);
$view->makeDocHeaderModuleMenu(['id' => $pageId]);
$availableCategories = $this->getCategoryLabels($this->categories);
$currentCategory = (string)$moduleData->get('constant_editor_cat');
if (!empty($availableCategories)) {
$view->assign('constantsMenu', BackendUtility::getDropdownMenu($pageId, 'constant_editor_cat', $currentCategory, $availableCategories, '', '', ['id' => 'constant_editor_cat']));
return $this->noTemplateAction();
'pageId' => $pageId,
'previousPage' => $this->getClosestAncestorPageWithTemplateRecord($pageId),
'moduleIdentifier' => $currentModuleIdentifier,
'editorFields' => $this->printFields($this->constants, $this->categories, $currentCategory),
'templateRecord' => $templateRow,
'manyTemplatesMenu' => BackendUtility::getFuncMenu($pageId, 'templatesOnPage', $moduleData->get('templatesOnPage'), array_column($allTemplatesOnPage, 'title', 'uid')),
return $view->renderResponse('ConstantEditor');
* Initialize editor
* Initializes the module. Done in this function because we may need to re-initialize if data is submitted!
* Set $this->templateService with parsed template and set $this->constants.
protected function initialize_editor(int $selectedTemplateRecord): bool
protected function parseTemplate(int $pageId, int $selectedTemplateRecord): ?array
$this->templateService = GeneralUtility::makeInstance(ExtendedTemplateService::class);
// Get the row of the first VISIBLE template of the page. whereclause like the frontend.
$this->templateRow = $this->getFirstTemplateRecordOnPage($this->id, $selectedTemplateRecord);
// IF there was a template...
if (is_array($this->templateRow)) {
// Gets the rootLine
$rootlineUtility = GeneralUtility::makeInstance(RootlineUtility::class, $this->id);
$rootLine = $rootlineUtility->get();
// This generates the constants/config + hierarchy info for the template.
// Get the row of the first *visible* template of the page. where clause like in frontend.
$templateRow = $this->getFirstTemplateRecordOnPage($pageId, $selectedTemplateRecord);
if (is_array($templateRow)) {
// If there is a template
$rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get();
// Generate constants/config + hierarchy info for the template.
$this->templateService->runThroughTemplates($rootLine, $selectedTemplateRecord);
// The editable constants are returned in an array.
// Editable constants are returned in an array.
$this->constants = $this->templateService->generateConfig_constants();
// The returned constants are sorted in categories, that goes into the $tmpl->categories array
// This array will contain key=[expanded constant name], value=line number in template.
return true;
// Returned constants are sorted in categories, that goes into the $tmpl->categories array
$this->categories = $this->categorizeEditableConstants($this->categories, $this->constants);
// This array contains key=[expanded constant name], value=line number in template.
return false;
return $templateRow ?: null;
* This functions returns the HTML-code that creates the editor-layout of the module.