From 4172ed978dc9d61bd2c29f4e68b091ee08b7a6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 23 Oct 2026 17:41:40 +0200 Subject: [PATCH] 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 --- .../Command/MigrateGridelementsCommand.php | 212 ++++++++++++++++++ packages/ep_theme/Configuration/Icons.php | 21 ++ .../tt_content_element_container.php | 31 ++- .../Configuration/TSconfig/Page/Main.tsconfig | 1 - .../TSconfig/Page/TCEFORM.tsconfig | 12 +- .../ContentElements/container.typoscript | 35 +++ .../TypoScript/constants.typoscript | 1 - .../Templates/Container/ColumnsQuarters.html | 27 +++ .../Container/HalfAndTwoQuarters.html | 36 +++ .../Private/Templates/Container/Halves.html | 35 +++ .../Private/Templates/Container/Thirds.html | 26 +++ .../Container/TwoQuartersAndHalf.html | 36 +++ 12 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 packages/ep_theme/Classes/Command/MigrateGridelementsCommand.php create mode 100644 packages/ep_theme/Resources/Private/Templates/Container/ColumnsQuarters.html create mode 100644 packages/ep_theme/Resources/Private/Templates/Container/HalfAndTwoQuarters.html create mode 100644 packages/ep_theme/Resources/Private/Templates/Container/Halves.html create mode 100644 packages/ep_theme/Resources/Private/Templates/Container/Thirds.html create mode 100644 packages/ep_theme/Resources/Private/Templates/Container/TwoQuartersAndHalf.html diff --git a/packages/ep_theme/Classes/Command/MigrateGridelementsCommand.php b/packages/ep_theme/Classes/Command/MigrateGridelementsCommand.php new file mode 100644 index 0000000..ee7d9b4 --- /dev/null +++ b/packages/ep_theme/Classes/Command/MigrateGridelementsCommand.php @@ -0,0 +1,212 @@ + [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('Translated grids found; translations are not handled by this command.'); + 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('Dry run, nothing written.'); + 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('%d rows migrated. 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, 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); + } +} diff --git a/packages/ep_theme/Configuration/Icons.php b/packages/ep_theme/Configuration/Icons.php index c7e7025..5cf9b96 100644 --- a/packages/ep_theme/Configuration/Icons.php +++ b/packages/ep_theme/Configuration/Icons.php @@ -1,5 +1,6 @@ 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', + ], ]; diff --git a/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_container.php b/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_container.php index cb46c7b..5b4d438 100644 --- a/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_container.php +++ b/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_container.php @@ -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 diff --git a/packages/ep_theme/Configuration/TSconfig/Page/Main.tsconfig b/packages/ep_theme/Configuration/TSconfig/Page/Main.tsconfig index e8fe637..c9a69d8 100644 --- a/packages/ep_theme/Configuration/TSconfig/Page/Main.tsconfig +++ b/packages/ep_theme/Configuration/TSconfig/Page/Main.tsconfig @@ -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' diff --git a/packages/ep_theme/Configuration/TSconfig/Page/TCEFORM.tsconfig b/packages/ep_theme/Configuration/TSconfig/Page/TCEFORM.tsconfig index bf95d78..4488eef 100644 --- a/packages/ep_theme/Configuration/TSconfig/Page/TCEFORM.tsconfig +++ b/packages/ep_theme/Configuration/TSconfig/Page/TCEFORM.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) diff --git a/packages/ep_theme/Configuration/TypoScript/ContentElements/container.typoscript b/packages/ep_theme/Configuration/TypoScript/ContentElements/container.typoscript index 4a07634..2df465c 100644 --- a/packages/ep_theme/Configuration/TypoScript/ContentElements/container.typoscript +++ b/packages/ep_theme/Configuration/TypoScript/ContentElements/container.typoscript @@ -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 diff --git a/packages/ep_theme/Configuration/TypoScript/constants.typoscript b/packages/ep_theme/Configuration/TypoScript/constants.typoscript index db6d116..6171b9e 100644 --- a/packages/ep_theme/Configuration/TypoScript/constants.typoscript +++ b/packages/ep_theme/Configuration/TypoScript/constants.typoscript @@ -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' diff --git a/packages/ep_theme/Resources/Private/Templates/Container/ColumnsQuarters.html b/packages/ep_theme/Resources/Private/Templates/Container/ColumnsQuarters.html new file mode 100644 index 0000000..2657425 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Container/ColumnsQuarters.html @@ -0,0 +1,27 @@ + + + + + + + +
+ {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})} +
+
+
+ + + + {record.renderedContent -> f:format.raw()} + + + + diff --git a/packages/ep_theme/Resources/Private/Templates/Container/HalfAndTwoQuarters.html b/packages/ep_theme/Resources/Private/Templates/Container/HalfAndTwoQuarters.html new file mode 100644 index 0000000..40bfca8 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Container/HalfAndTwoQuarters.html @@ -0,0 +1,36 @@ + + + + + + + +
+
+ {f:render(section: 'Column', arguments: {children: children_231})} +
+
+
+
+ {f:render(section: 'Column', arguments: {children: children_232})} +
+
+ {f:render(section: 'Column', arguments: {children: children_233})} +
+
+
+
+
+
+ + + + {record.renderedContent -> f:format.raw()} + + + + diff --git a/packages/ep_theme/Resources/Private/Templates/Container/Halves.html b/packages/ep_theme/Resources/Private/Templates/Container/Halves.html new file mode 100644 index 0000000..a78b595 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Container/Halves.html @@ -0,0 +1,35 @@ + + + + + + + + {f:render(section: 'Column', arguments: {children: children_201})} + {f:render(section: 'Column', arguments: {children: children_202})} +
+ +
+ {column1 -> f:format.raw()} +
+
+ +
+ {column2 -> f:format.raw()} +
+
+
+
+
+ + + + {record.renderedContent -> f:format.raw()} + + + + diff --git a/packages/ep_theme/Resources/Private/Templates/Container/Thirds.html b/packages/ep_theme/Resources/Private/Templates/Container/Thirds.html new file mode 100644 index 0000000..a36fc50 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Container/Thirds.html @@ -0,0 +1,26 @@ + + + + + + + +
+ {f:render(section: 'Column', arguments: {children: children_211})} + {f:render(section: 'Column', arguments: {children: children_212})} + {f:render(section: 'Column', arguments: {children: children_213})} +
+
+
+ + + + {record.renderedContent -> f:format.raw()} + + + + diff --git a/packages/ep_theme/Resources/Private/Templates/Container/TwoQuartersAndHalf.html b/packages/ep_theme/Resources/Private/Templates/Container/TwoQuartersAndHalf.html new file mode 100644 index 0000000..0d56f63 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Container/TwoQuartersAndHalf.html @@ -0,0 +1,36 @@ + + + + + + + +
+
+
+
+ {f:render(section: 'Column', arguments: {children: children_241})} +
+
+ {f:render(section: 'Column', arguments: {children: children_242})} +
+
+
+
+ {f:render(section: 'Column', arguments: {children: children_243})} +
+
+
+
+ + + + {record.renderedContent -> f:format.raw()} + + + +