feat: gridelements grids become container elements

TYPO3 13 does not know gridelements_pi1, so grids rendered nothing: the
inquiry form on /haus-christiler/ was missing. The five grid layouts are
container CTypes now (ep_container_halves, _thirds, _columns_quarters,
_half_and_two_quarters, _two_quarters_and_half; colPos 201-243) with the
TYPO3 10 templates ported. TwoQuartersAndHalf had no setup and output the
wrong columns; it renders its own columns now. As before, no container
inside these columns and the column rules of the old TSconfig apply.

ep:migrate-gridelements converts the data in place (plain SQL, idempotent,
--dry-run and --report): grids get their container CType, children in a
valid column move into it. Children in a column their layout lacks and
leftovers whose parent is no grid any more were invisible in TYPO3 10 and
are soft deleted. TYPO3 10 did not apply defaultHeaderType inside grids,
so text/textmedia/textpic children with header layout "default" get
"hidden" to keep their headers invisible.

Checked against TYPO3 10 on the 101 pages with grids that TYPO3 13 renders:
the container markup is identical; remaining differences are inside not
yet ported child elements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-23 17:41:40 +02:00
1 parent ea2eda5ee1
commit 4172ed978d
12 files changed
+468 -5

No files matched your search

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace EP\EpTheme\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Turns gridelements grids into b13/container elements (see .ddev/plans/GRIDELEMENTS-TO-CONTAINER.md).
*
* Plain SQL instead of DataHandler: no hooks, no history, deterministic. Idempotent: every migrated row
* loses its tx_gridelements_* values, so a second run finds nothing to do.
*/
#[AsCommand(name: 'ep:migrate-gridelements', description: 'Migrates gridelements grids and their children to container elements')]
class MigrateGridelementsCommand extends Command
{
/**
* Gridelements layout => [container CType, first container colPos - 1, valid gridelements columns]
*/
private const LAYOUTS = [
'halves' => ['ep_container_halves', 200, [21, 22]],
'thirds' => ['ep_container_thirds', 210, [31, 32, 33]],
'quarters' => ['ep_container_columns_quarters', 220, [41, 42, 43, 44]],
'halfAndTwoQuarters' => ['ep_container_half_and_two_quarters', 230, [51, 52, 53]],
'twoQuartersAndHalf' => ['ep_container_two_quarters_and_half', 240, [61, 62, 63]],
];
/**
* Content types whose template renders the header with settings.defaultHeaderType for layout 0
*/
private const DEFAULT_HEADER_TYPES = ['text', 'textmedia', 'textpic'];
private const RESET = [
'tx_gridelements_container' => 0,
'tx_gridelements_columns' => 0,
'tx_gridelements_children' => 0,
'tx_gridelements_backend_layout' => '',
];
public function __construct(
private readonly ConnectionPool $connectionPool,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would change')
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Write every row that is not migrated 1:1 to this CSV file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$connection = $this->connectionPool->getConnectionForTable('tt_content');
if (!$this->hasGridelementsColumns($connection)) {
$output->writeln('No tx_gridelements_* columns in tt_content, nothing to do.');
return Command::SUCCESS;
}
// Plain SQL on purpose: Connection::select() would apply the frontend restrictions (deleted, hidden, …)
$grids = [];
foreach ($connection->executeQuery('SELECT * FROM tt_content WHERE CType = ?', ['gridelements_pi1'])->iterateAssociative() as $row) {
$grids[(int)$row['uid']] = $row;
}
$children = $connection->executeQuery(
'SELECT * FROM tt_content WHERE tx_gridelements_container > 0 AND CType <> ? ORDER BY uid',
['gridelements_pi1']
)->fetchAllAssociative();
if ((int)$connection->executeQuery('SELECT COUNT(*) FROM tt_content WHERE CType = ? AND sys_language_uid > 0', ['gridelements_pi1'])->fetchOne() > 0) {
$output->writeln('<error>Translated grids found; translations are not handled by this command.</error>');
return Command::FAILURE;
}
$updates = [];
$counts = [];
$report = [];
$count = static function (string $key) use (&$counts): void {
$counts[$key] = ($counts[$key] ?? 0) + 1;
};
foreach ($grids as $uid => $grid) {
$layout = (string)$grid['tx_gridelements_backend_layout'];
if (isset(self::LAYOUTS[$layout])) {
$updates[$uid] = ['CType' => self::LAYOUTS[$layout][0], 'pi_flexform' => ''] + self::RESET;
$count('grid ' . $layout . ((int)$grid['deleted'] ? ' (deleted)' : ''));
continue;
}
// A grid without layout renders nothing; keep it restorable as an empty container
$updates[$uid] = ['CType' => 'ep_container_halves', 'pi_flexform' => '', 'deleted' => 1] + self::RESET;
$count('grid without layout → deleted');
if (!(int)$grid['deleted']) {
$report[] = [$grid, 'grid', 'grid without layout', 'deleted'];
}
}
foreach ($children as $child) {
$uid = (int)$child['uid'];
$parent = $grids[(int)$child['tx_gridelements_container']] ?? null;
$column = (int)$child['tx_gridelements_columns'];
if ((int)$child['colPos'] >= 0) {
// Rendered in its page column, not by the grid (gridelements only queries colPos -1)
$updates[$uid] = self::RESET;
$count('child outside the grid (colPos ' . $child['colPos'] . '), stays');
if (!(int)$child['deleted']) {
$report[] = [$child, 'page column', 'colPos ' . $child['colPos'] . ' instead of -1', 'unchanged, grid fields cleared'];
}
continue;
}
if ($parent !== null && (int)$child['pid'] !== (int)$parent['pid']) {
// A container only renders children of its own page
$parent = null;
}
$config = self::LAYOUTS[(string)($parent['tx_gridelements_backend_layout'] ?? '')] ?? null;
if ($parent !== null && $config !== null && (int)$child['colPos'] === -1 && in_array($column, $config[2], true)) {
$updates[$uid] = [
'tx_container_parent' => (int)$parent['uid'],
'colPos' => $config[1] + $column % 10,
] + self::RESET;
$count('A: child migrated' . ((int)$child['deleted'] ? ' (deleted)' : ''));
// Inside a grid, TYPO3 10 did not apply settings.defaultHeaderType, so the header of these
// types stayed invisible with layout "default". Keep it invisible, and visible as such to editors.
if ((int)$child['header_layout'] === 0 && trim((string)$child['header']) !== '' && in_array($child['CType'], self::DEFAULT_HEADER_TYPES, true)) {
$updates[$uid]['header_layout'] = 100;
$count('A: header layout default → hidden');
}
continue;
}
// B (column not in the layout) and C (parent is no grid): invisible in TYPO3 10, soft deleted
$group = $parent === null ? 'C' : 'B';
$reason = $parent === null
? 'parent ' . $child['tx_gridelements_container'] . ' is not a grid on the same page'
: 'column ' . $column . ' (colPos ' . $child['colPos'] . ') not in layout ' . $parent['tx_gridelements_backend_layout'];
$updates[$uid] = ['deleted' => 1] + self::RESET;
$count($group . ': child deleted' . ((int)$child['deleted'] ? ' (was deleted already)' : ''));
if (!(int)$child['deleted']) {
$report[] = [$child, $group, $reason, 'deleted'];
}
}
// Other rows still carrying grid values (e.g. a former grid whose CType was changed by hand)
$leftovers = (int)$connection->executeQuery(
'SELECT COUNT(*) FROM tt_content WHERE (tx_gridelements_children > 0 OR tx_gridelements_backend_layout NOT IN (\'\', \'0\')) AND CType <> ? AND tx_gridelements_container = 0',
['gridelements_pi1']
)->fetchOne();
ksort($counts);
foreach ($counts as $label => $number) {
$output->writeln(sprintf('%6d %s', $number, $label));
}
if ($leftovers > 0) {
$output->writeln(sprintf('%6d other rows with grid values, cleared', $leftovers));
}
if ($input->getOption('report')) {
$this->writeReport((string)$input->getOption('report'), $report);
$output->writeln(sprintf('Report: %d rows in %s', count($report), $input->getOption('report')));
}
if ($updates === [] && $leftovers === 0) {
$output->writeln('Nothing to migrate.');
return Command::SUCCESS;
}
if ($input->getOption('dry-run')) {
$output->writeln('<comment>Dry run, nothing written.</comment>');
return Command::SUCCESS;
}
$connection->transactional(function (Connection $connection) use ($updates): void {
foreach ($updates as $uid => $values) {
$connection->update('tt_content', $values, ['uid' => $uid]);
}
$connection->executeStatement(
'UPDATE tt_content SET tx_gridelements_children = 0, tx_gridelements_backend_layout = \'\' WHERE tx_gridelements_children > 0 OR tx_gridelements_backend_layout NOT IN (\'\', \'0\')'
);
});
$output->writeln(sprintf('<info>%d rows migrated.</info> Next: referenceindex:update, container:integrity, cache:flush', count($updates)));
return Command::SUCCESS;
}
private function hasGridelementsColumns(Connection $connection): bool
{
return array_key_exists('tx_gridelements_container', $connection->createSchemaManager()->listTableColumns('tt_content'));
}
/**
* @param list<array{0: array<string, mixed>, 1: string, 2: string, 3: string}> $rows
*/
private function writeReport(string $file, array $rows): void
{
$titles = [];
foreach ($this->connectionPool->getConnectionForTable('pages')->select(['uid', 'title'], 'pages', ['sys_language_uid' => 0])->iterateAssociative() as $page) {
$titles[(int)$page['uid']] = $page['title'];
}
$handle = fopen($file, 'wb');
fputcsv($handle, ['uid', 'pid', 'page', 'CType', 'header', 'hidden', 'group', 'reason', 'action'], escape: '');
foreach ($rows as [$row, $group, $reason, $action]) {
fputcsv($handle, [
$row['uid'], $row['pid'], $titles[(int)$row['pid']] ?? '', $row['CType'], $row['header'], $row['hidden'], $group, $reason, $action,
], escape: '');
}
fclose($handle);
}
}
+21
View File
@@ -1,5 +1,6 @@
<?php
use TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider;
use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider;
return [
@@ -7,4 +8,24 @@ return [
'provider' => SvgIconProvider::class,
'source' => 'EXT:ep_theme/Resources/Public/images/logo.svg',
],
'tx_eptheme_gridelements_halves' => [
'provider' => BitmapIconProvider::class,
'source' => 'EXT:ep_theme/Resources/Public/images/tx_eptheme_gridelements_halves.png',
],
'tx_eptheme_gridelements_thirds' => [
'provider' => BitmapIconProvider::class,
'source' => 'EXT:ep_theme/Resources/Public/images/tx_eptheme_gridelements_thirds.png',
],
'tx_eptheme_gridelements_quarters' => [
'provider' => BitmapIconProvider::class,
'source' => 'EXT:ep_theme/Resources/Public/images/tx_eptheme_gridelements_quarters.png',
],
'tx_eptheme_gridelements_half_and_two_quarters' => [
'provider' => BitmapIconProvider::class,
'source' => 'EXT:ep_theme/Resources/Public/images/tx_eptheme_gridelements_half_and_two_quarters.png',
],
'tx_eptheme_gridelements_two_quarters_and_half' => [
'provider' => BitmapIconProvider::class,
'source' => 'EXT:ep_theme/Resources/Public/images/tx_eptheme_gridelements_two_quarters_and_half.png',
],
];
@@ -31,6 +31,35 @@ call_user_func(function () {
)
);
// Column layouts, migrated from gridelements (ep:migrate-gridelements). colPos = 200 + 10 × layout + column
$menus = 'menu_abstract,menu_categorized_content,menu_categorized_pages,menu_recently_updated,menu_related_pages,menu_section_pages,menu_sitemap,menu_sitemap_pages';
$columnContainers = [
'ep_container_halves' => ['Spalten 50/50', 'Zwei gleich breite Spalten', 'halves', [[201], [202]]],
'ep_container_thirds' => ['Spalten 33/33/33', 'Drei gleich breite Spalten', 'thirds', [[211], [212], [213]]],
'ep_container_columns_quarters' => ['Spalten 25/25/25/25', 'Vier gleich breite Spalten', 'quarters', [[221], [222], [223], [224]]],
'ep_container_half_and_two_quarters' => ['Spalten 50/25/25', 'Eine breite und zwei schmale Spalten', 'half_and_two_quarters', [[231, 'textmedia,teaser,list,form_formframework'], [232], [233]]],
'ep_container_two_quarters_and_half' => ['Spalten 25/25/50', 'Zwei schmale und eine breite Spalte', 'two_quarters_and_half', [[241], [242], [243, 'textmedia,teaser,list,gmap,nlform,form_formframework,menu_pages,menu_subpages,menu_section']]],
];
// No container inside these columns (topLevelLayout of the gridelements configuration)
$containers = implode(',', array_merge(['ep_container_quarters', 'ep_container_teasergroup'], array_keys($columnContainers)));
foreach ($columnContainers as $cType => [$label, $description, $icon, $columns]) {
$row = [];
foreach ($columns as $number => $column) {
$config = ['name' => 'Spalte ' . ($number + 1), 'colPos' => $column[0]];
if (isset($column[1])) {
$config['colspan'] = 2;
$config['allowed'] = ['CType' => $column[1]];
} else {
$config['disallowed'] = ['CType' => $menus . ',' . $containers];
}
$row[] = $config;
}
GeneralUtility::makeInstance(Registry::class)->configureContainer(
(new ContainerConfiguration($cType, $label, $description, [$row]))
->setIcon('tx_eptheme_gridelements_' . $icon)
);
}
$headerConfig = [
'showitem' => '
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:palette.general,
@@ -47,7 +76,7 @@ call_user_func(function () {
'
];
foreach (['ep_container_quarters', 'ep_container_teasergroup'] as $field) {
foreach (array_merge(['ep_container_quarters', 'ep_container_teasergroup'], array_keys($columnContainers)) as $field) {
$GLOBALS['TCA']['tt_content']['types'][$field] = array_replace_recursive(
$GLOBALS['TCA']['tt_content']['types'][$field],
$headerConfig
@@ -1,4 +1,3 @@
@import 'EXT:ep_theme/Configuration/TSconfig/Page/Extensions/GridElements.tsconfig'
@import 'EXT:ep_theme/Configuration/TSconfig/Page/Mod/SHARED.tsconfig'
@import 'EXT:ep_theme/Configuration/TSconfig/Page/Mod/WebLayout/BackendLayouts.tsconfig'
@import 'EXT:ep_theme/Configuration/TSconfig/Page/Mod/Wizards/NewContentElement.tsconfig'
@@ -50,10 +50,14 @@ TCEFORM {
5 = Partnerlogos
}
}
gridelements_pi1 {
ep_container_halves {
disabled = 0
removeItems := addToList(1,2,3)
}
ep_container_thirds < .ep_container_halves
ep_container_columns_quarters < .ep_container_halves
ep_container_half_and_two_quarters < .ep_container_halves
ep_container_two_quarters_and_half < .ep_container_halves
teaser {
disabled = 0
removeItems := addToList(2,3)
@@ -114,7 +118,11 @@ TCEFORM {
space_before_class {
types {
gridelements_pi1.disabled = 1
ep_container_halves.disabled = 1
ep_container_thirds.disabled = 1
ep_container_columns_quarters.disabled = 1
ep_container_half_and_two_quarters.disabled = 1
ep_container_two_quarters_and_half.disabled = 1
form_formframework.disabled = 1
}
removeItems := addToList(extra-small, medium, extra-large)
@@ -1,3 +1,4 @@
# Column layouts (formerly gridelements)
tt_content.ep_container_halves =< lib.contentElement
tt_content.ep_container_halves {
templateName = Halves
@@ -20,6 +21,40 @@ tt_content.ep_container_thirds {
}
}
tt_content.ep_container_columns_quarters =< lib.contentElement
tt_content.ep_container_columns_quarters {
templateName = ColumnsQuarters
templateRootPaths {
10 = EXT:ep_theme/Resources/Private/Templates/Container
}
dataProcessing {
100 = B13\Container\DataProcessing\ContainerProcessor
}
}
tt_content.ep_container_half_and_two_quarters =< lib.contentElement
tt_content.ep_container_half_and_two_quarters {
templateName = HalfAndTwoQuarters
templateRootPaths {
10 = EXT:ep_theme/Resources/Private/Templates/Container
}
dataProcessing {
100 = B13\Container\DataProcessing\ContainerProcessor
}
}
tt_content.ep_container_two_quarters_and_half =< lib.contentElement
tt_content.ep_container_two_quarters_and_half {
templateName = TwoQuartersAndHalf
templateRootPaths {
10 = EXT:ep_theme/Resources/Private/Templates/Container
}
dataProcessing {
100 = B13\Container\DataProcessing\ContainerProcessor
}
}
# Teaser rows
tt_content.ep_container_quarters =< lib.contentElement
tt_content.ep_container_quarters {
templateName = Quarters
@@ -195,7 +195,6 @@ plugin.tx_eptheme {
@import 'EXT:fluid_styled_content/Configuration/TypoScript/constants.typoscript'
@import 'EXT:ep_products/Configuration/TypoScript/constants.typoscript'
@import 'EXT:gridelements/Configuration/TypoScript/constants.typoscript'
@import 'EXT:seo/Configuration/TypoScript/XmlSitemap/constants.typoscript'
@import 'EXT:typo3_encore/Configuration/TypoScript/constants.typoscript'
@import 'EXT:news/Configuration/TypoScript/constants.txt'
@@ -0,0 +1,27 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<ep:container data="{data}" cssClasses="py-8">
<f:render partial="Header/Header"
arguments="{header: data.header, subheader: data.subheader, layout: data.header_layout, link: data.header_link}" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{f:render(section: 'Column', arguments: {children: children_221})}
{f:render(section: 'Column', arguments: {children: children_222})}
{f:render(section: 'Column', arguments: {children: children_223})}
{f:render(section: 'Column', arguments: {children: children_224})}
</div>
</ep:container>
</f:section>
<f:section name="Column">
<f:for each="{children}" as="record">
{record.renderedContent -> f:format.raw()}
</f:for>
</f:section>
</html>
@@ -0,0 +1,36 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<ep:container data="{data}" cssClasses="py-8">
<f:render partial="Header/Header"
arguments="{header: data.header, subheader: data.subheader, layout: data.header_layout, link: data.header_link}" />
<div class="grid grid-cols-2 gap-8">
<div class="col-span-2 md:col-span-1">
{f:render(section: 'Column', arguments: {children: children_231})}
</div>
<div class="col-span-2 md:col-span-1">
<div class="grid grid-cols-2 gap-8">
<div class="col-span-2 md:col-span-1">
{f:render(section: 'Column', arguments: {children: children_232})}
</div>
<div class="col-span-2 md:col-span-1">
{f:render(section: 'Column', arguments: {children: children_233})}
</div>
</div>
</div>
</div>
</ep:container>
</f:section>
<f:section name="Column">
<f:for each="{children}" as="record">
{record.renderedContent -> f:format.raw()}
</f:for>
</f:section>
</html>
@@ -0,0 +1,35 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<ep:container data="{data}" cssClasses="py-8">
<f:render partial="Header/Header"
arguments="{header: data.header, subheader: data.subheader, layout: data.header_layout, link: data.header_link}" />
<f:variable name="column1">{f:render(section: 'Column', arguments: {children: children_201})}</f:variable>
<f:variable name="column2">{f:render(section: 'Column', arguments: {children: children_202})}</f:variable>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<f:if condition="{column1 -> v:format.trim()}">
<div>
{column1 -> f:format.raw()}
</div>
</f:if>
<f:if condition="{column2 -> v:format.trim()}">
<div>
{column2 -> f:format.raw()}
</div>
</f:if>
</div>
</ep:container>
</f:section>
<f:section name="Column">
<f:for each="{children}" as="record">
{record.renderedContent -> f:format.raw()}
</f:for>
</f:section>
</html>
@@ -0,0 +1,26 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<ep:container data="{data}" cssClasses="py-8">
<f:render partial="Header/Header"
arguments="{header: data.header, subheader: data.subheader, layout: data.header_layout, link: data.header_link}" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{f:render(section: 'Column', arguments: {children: children_211})}
{f:render(section: 'Column', arguments: {children: children_212})}
{f:render(section: 'Column', arguments: {children: children_213})}
</div>
</ep:container>
</f:section>
<f:section name="Column">
<f:for each="{children}" as="record">
{record.renderedContent -> f:format.raw()}
</f:for>
</f:section>
</html>
@@ -0,0 +1,36 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<ep:container data="{data}" cssClasses="py-8">
<f:render partial="Header/Header"
arguments="{header: data.header, subheader: data.subheader, layout: data.header_layout, link: data.header_link}" />
<div class="grid grid-cols-2 gap-8">
<div class="col-span-2 md:col-span-1">
<div class="grid grid-cols-2 gap-8">
<div class="col-span-2 md:col-span-1">
{f:render(section: 'Column', arguments: {children: children_241})}
</div>
<div class="col-span-2 md:col-span-1">
{f:render(section: 'Column', arguments: {children: children_242})}
</div>
</div>
</div>
<div class="col-span-2 md:col-span-1">
{f:render(section: 'Column', arguments: {children: children_243})}
</div>
</div>
</ep:container>
</f:section>
<f:section name="Column">
<f:for each="{children}" as="record">
{record.renderedContent -> f:format.raw()}
</f:for>
</f:section>
</html>