diff --git a/typo3/sysext/backend/Classes/View/BackendLayoutView.php b/typo3/sysext/backend/Classes/View/BackendLayoutView.php index 3ede3dfb0f30da4f83f5f908226a19ea899533a0..bcbb46df3741cb474819d1c603d829a6b7040d3e 100644 --- a/typo3/sysext/backend/Classes/View/BackendLayoutView.php +++ b/typo3/sysext/backend/Classes/View/BackendLayoutView.php @@ -302,6 +302,7 @@ class BackendLayoutView implements SingletonInterface 1 { name = LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:colPos.I.1 colPos = 0 + identifier = main } } } diff --git a/typo3/sysext/core/Documentation/Changelog/13.2/Feature-103894-AdditionalPropertiesForColumnsInPageLayouts.rst b/typo3/sysext/core/Documentation/Changelog/13.2/Feature-103894-AdditionalPropertiesForColumnsInPageLayouts.rst new file mode 100644 index 0000000000000000000000000000000000000000..16a294b4b9008e6b26433ea9451c060d7a7cf013 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.2/Feature-103894-AdditionalPropertiesForColumnsInPageLayouts.rst @@ -0,0 +1,101 @@ +.. include:: /Includes.rst.txt + +.. _feature-103894-1716544976: + +==================================================================== +Feature: #103894 - Additional properties for columns in Page Layouts +==================================================================== + +See :issue:`103894` + +Description +=========== + +Backend Layouts were introduced in TYPO3 v6 in order to customize the view of +the Page module in TYPO3 Backend for a page, but has then since grown also in +Frontend rendering to select e.g. Fluid template files via TypoScript for a page, +commonly used via :typoscript:`data:pagelayout`. + +In order to use a single source for Backend and Frontend representation, the +definition of a "Backend Layout" or "Page Layout" is expanded to also include +more information for a specific content area. The Content Area is previously +defined via "name" (for the label in the Page Module) and "colPos", +the numeric database field in which content is grouped in. + +A definition can now optionally also contain a "slideMode" property and an +"identifier" property next to each colPos, in order to simplify the Frontend +rendering. + +Whereas "identifier" is a speaking representation for the colPos, such as +"main", "sidebar" or "footerArea", the "slideMode" can be set to one of the +three options: + +* :typoscript:`slideMode = slide` - if no content is found, check the parent pages for more content +* :typoscript:`slideMode = collect` - use all content from this page, and the parent pages as one collection +* :typoscript:`slideMode = collectReverse`- same as "collect" but in the opposite order + +With this information added, a new DataProcessor :typoscript:"page-content" +(:php:`PageContentFetchingProcessor`) is introduced for the Frontend Rendering, +which fetches all content for a page respecting the settings from the +Page Layout. + + +Impact +====== + +Enriching the Backend Layout information for each colPos enables a TYPO3 +integrator to write less TypoScript in order to render content on a page. + +The DataProcessor fetches all content elements from all defined columns with an +included "identifier" in the selected Backend Layout and makes the resolved +record objects available in the Fluid Template via +:html:`{content."myIdentifier".records}`. + +Example for an enriched Backend Layout definition: + +.. code-block:: typoscript + + mod.web_layout.BackendLayouts { + default { + title = Default + config { + backend_layout { + colCount = 1 + rowCount = 1 + rows { + 1 { + columns { + 1 { + name = Main Content Area + colPos = 0 + identifier = main + slideMode = slide + } + } + } + } + } + } + } + } + +Example for the Frontend output: + +.. code-block:: typoscript + + page = PAGE + page.10 = PAGEVIEW + page.10.paths.10 = EXT:my_site_package/Tests/Resources/Private/Templates/ + page.10.dataProcessing.10 = page-content + page.10.dataProcessing.10.as = myContent + +.. code-block:: html + + <main> + <f:for each="{myContent.main.records}" as="record"> + <h4>{record.header}</h4> + </f:for> + </main> + + +.. index:: Backend, Frontend, ext:frontend diff --git a/typo3/sysext/frontend/Classes/Content/ContentSlideMode.php b/typo3/sysext/frontend/Classes/Content/ContentSlideMode.php new file mode 100644 index 0000000000000000000000000000000000000000..65f5dacfb25ab55de6ac4c8eeae7d1e2b6b21a96 --- /dev/null +++ b/typo3/sysext/frontend/Classes/Content/ContentSlideMode.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * 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\Frontend\Content; + +enum ContentSlideMode +{ + case None; + case Slide; + case Collect; + case CollectReverse; + + public static function tryFrom(?string $slideMode): ContentSlideMode + { + return match ($slideMode) { + 'slide' => self::Slide, + 'collect' => self::Collect, + 'collectReverse' => self::CollectReverse, + default => self::None, + }; + } +} diff --git a/typo3/sysext/frontend/Classes/Content/RecordCollector.php b/typo3/sysext/frontend/Classes/Content/RecordCollector.php new file mode 100644 index 0000000000000000000000000000000000000000..a4d768db2a25544812003ad88d1b0883b529a35a --- /dev/null +++ b/typo3/sysext/frontend/Classes/Content/RecordCollector.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/* + * 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\Frontend\Content; + +use TYPO3\CMS\Core\Domain\RecordFactory; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; + +/** + * Executes a SQL query, and retrieves TCA-based records for Frontend rendering. + */ +class RecordCollector +{ + public function __construct( + protected readonly RecordFactory $recordFactory + ) {} + + public function collect( + string $table, + array $select, + ContentSlideMode $slideMode, + ContentObjectRenderer $contentObjectRenderer + ): array { + $slideCollectReverse = false; + $collect = false; + switch ($slideMode) { + case ContentSlideMode::Slide: + $slide = true; + break; + case ContentSlideMode::Collect: + $slide = true; + $collect = true; + break; + case ContentSlideMode::CollectReverse: + $slide = true; + $collect = true; + $slideCollectReverse = true; + break; + default: + $slide = false; + } + $again = false; + $totalRecords = []; + + do { + $recordsOnPid = $contentObjectRenderer->getRecords($table, $select); + $recordsOnPid = array_map( + function ($record) use ($table) { + return $this->recordFactory->createFromDatabaseRow($table, $record); + }, + $recordsOnPid + ); + + if ($slideCollectReverse) { + $totalRecords = array_merge($totalRecords, $recordsOnPid); + } else { + $totalRecords = array_merge($recordsOnPid, $totalRecords); + } + if ($slide) { + $select['pidInList'] = $contentObjectRenderer->getSlidePids($select['pidInList'] ?? '', $select['pidInList.'] ?? []); + if (isset($select['pidInList.'])) { + unset($select['pidInList.']); + } + $again = $select['pidInList'] !== ''; + } + } while ($again && $slide && ($recordsOnPid === [] || $collect)); + return $totalRecords; + } +} diff --git a/typo3/sysext/frontend/Classes/DataProcessing/PageContentFetchingProcessor.php b/typo3/sysext/frontend/Classes/DataProcessing/PageContentFetchingProcessor.php new file mode 100644 index 0000000000000000000000000000000000000000..1d0978f3d02a5123e5ae51fffa46bcb74de094c7 --- /dev/null +++ b/typo3/sysext/frontend/Classes/DataProcessing/PageContentFetchingProcessor.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/* + * 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\Frontend\DataProcessing; + +use TYPO3\CMS\Core\Page\PageLayoutResolver; +use TYPO3\CMS\Frontend\Content\ContentSlideMode; +use TYPO3\CMS\Frontend\Content\RecordCollector; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; +use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface; + +/** + * All-in-one data processor that loads all tt_content records from the current page layout into + * the template with a given identifier for each colPos, also respecting slideMode or + * collect options based on the page layouts content columns. + */ +readonly class PageContentFetchingProcessor implements DataProcessorInterface +{ + public function __construct( + protected RecordCollector $recordCollector, + protected PageLayoutResolver $pageLayoutResolver, + ) {} + + public function process( + ContentObjectRenderer $cObj, + array $contentObjectConfiguration, + array $processorConfiguration, + array $processedData + ): array { + if (isset($processorConfiguration['if.']) && !$cObj->checkIf($processorConfiguration['if.'])) { + return $processedData; + } + $pageInformation = $cObj->getRequest()->getAttribute('frontend.page.information'); + $pageLayout = $pageInformation->getPageLayout(); + + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, 'content'); + foreach ($pageLayout?->getContentAreas() ?? [] as $contentAreaData) { + if (!isset($contentAreaData['colPos'])) { + continue; + } + if (!isset($contentAreaData['identifier'])) { + continue; + } + $records = $this->recordCollector->collect( + 'tt_content', + [ + 'where' => '{#colPos}=' . (int)$contentAreaData['colPos'], + 'orderBy' => 'sorting', + ], + ContentSlideMode::tryFrom($contentAreaData['slideMode'] ?? null), + $cObj, + ); + $contentAreaData['records'] = $records; + $contentAreaName = $contentAreaData['identifier']; + $processedData[$targetVariableName][$contentAreaName] = $contentAreaData; + } + return $processedData; + } +} diff --git a/typo3/sysext/frontend/Configuration/Services.yaml b/typo3/sysext/frontend/Configuration/Services.yaml index e2285521172cd3b208f078b8dd2cbd00115c698c..ca7c6b0120ffc7f2552079c95643179cae6cd468 100644 --- a/typo3/sysext/frontend/Configuration/Services.yaml +++ b/typo3/sysext/frontend/Configuration/Services.yaml @@ -200,3 +200,7 @@ services: TYPO3\CMS\Frontend\DataProcessing\RecordTransformationProcessor: tags: - { name: 'data.processor', identifier: 'record-transformation' } + + TYPO3\CMS\Frontend\DataProcessing\PageContentFetchingProcessor: + tags: + - { name: 'data.processor', identifier: 'page-content' } diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Default.html b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Default.html new file mode 100644 index 0000000000000000000000000000000000000000..72cae9dc01eb7f7321c9c47b202b41f0b754b04e --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Default.html @@ -0,0 +1,26 @@ +<header> + <h1>{page.pageRecord.title}</h1> + <f:for each="{mainContent.stage.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</header> +<section> + <f:for each="{mainContent.flashInfo.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</section> +<aside> + <f:for each="{mainContent.aside.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</aside> +<main> + <f:for each="{mainContent.main.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</main> +<footer> + <f:for each="{mainContent.footer.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</footer> diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Home.html b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Home.html new file mode 100644 index 0000000000000000000000000000000000000000..9c61d9518c041befb505992be4ea0f8212930958 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Home.html @@ -0,0 +1,16 @@ +<header> + <h1>{page.pageRecord.title}</h1> + <f:for each="{mainContent.stage.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</header> +<main> + <f:for each="{mainContent.main.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</main> +<footer> + <f:for each="{mainContent.footer.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</footer> diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Productdetail.html b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Productdetail.html new file mode 100644 index 0000000000000000000000000000000000000000..19d3f3120cb0227436e56b29d773c694d0a57301 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Pages/Productdetail.html @@ -0,0 +1,26 @@ +<header> + <h1>{page.pageRecord.title}</h1> + <f:for each="{mainContent.stage.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</header> +<section> + <f:for each="{mainContent.flashInfo.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</section> +<aside> + <f:for each="{mainContent.aside.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</aside> +<main> + <f:for each="{mainContent.main.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</main> +<footer><f:spaceless> + <f:for each="{mainContent.footer.records}" as="record"> + <f:render partial="SingleContent" arguments="{record: record}"/> + </f:for> +</f:spaceless></footer> diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Partials/SingleContent.html b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Partials/SingleContent.html new file mode 100644 index 0000000000000000000000000000000000000000..3b93ad1a45014b848d2016be888bf75df388040a --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/Partials/SingleContent.html @@ -0,0 +1,6 @@ +<f:switch expression="{record.recordType}"> + <f:case value="text"> + <h3>{record.header}</h3> + <p>{record.bodytext}</p> + </f:case> +</f:switch> diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/setup.typoscript b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/setup.typoscript new file mode 100644 index 0000000000000000000000000000000000000000..67dc237a779940f03200189b9777d30a2bad6002 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/setup.typoscript @@ -0,0 +1,6 @@ +page = PAGE +page.config.disableAllHeaderCode = 1 +page.10 = PAGEVIEW +page.10.paths.10 = EXT:frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/ +page.10.dataProcessing.10 = page-content +page.10.dataProcessing.10.as = mainContent diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/setup.typoscript b/typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/RecordTransform/setup.typoscript similarity index 100% rename from typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/setup.typoscript rename to typo3/sysext/frontend/Tests/Functional/DataProcessing/Fixtures/RecordTransform/setup.typoscript diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/PageContentFetchingProcessorTest.php b/typo3/sysext/frontend/Tests/Functional/DataProcessing/PageContentFetchingProcessorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c08dbbc7f9a219fdf848ccbc04d4ee6275611a37 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/PageContentFetchingProcessorTest.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/* + * 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\Frontend\Tests\Functional\DataProcessing; + +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Localization\LanguageServiceFactory; +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory; +use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class PageContentFetchingProcessorTest extends FunctionalTestCase +{ + use SiteBasedTestTrait; + + protected const LANGUAGE_PRESETS = [ + 'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'], + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->writeSiteConfiguration( + 'acme-com', + $this->buildSiteConfiguration(1000, 'https://acme.com/'), + [ + $this->buildDefaultLanguageConfiguration('EN', '/'), + ] + ); + + $this->withDatabaseSnapshot(function () { + $this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv'); + $backendUser = $this->setUpBackendUser(1); + $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->createFromUserPreferences($backendUser); + $scenarioFile = __DIR__ . '/Fixtures/ContentScenario.yaml'; + $factory = DataHandlerFactory::fromYamlFile($scenarioFile); + $writer = DataHandlerWriter::withBackendUser($backendUser); + $writer->invokeFactory($factory); + self::failIfArrayIsNotEmpty($writer->getErrors()); + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + + $pageLayoutFileContents[] = file_get_contents(__DIR__ . '/Fixtures/PageLayouts/Default.tsconfig'); + $pageLayoutFileContents[] = file_get_contents(__DIR__ . '/Fixtures/PageLayouts/Home.tsconfig'); + $pageLayoutFileContents[] = file_get_contents(__DIR__ . '/Fixtures/PageLayouts/Productdetail.tsconfig'); + + $connection->update( + 'pages', + ['TSconfig' => implode(chr(10), $pageLayoutFileContents)], + ['uid' => 1000] + ); + $this->setUpFrontendRootPage(1000, ['EXT:frontend/Tests/Functional/DataProcessing/Fixtures/PageContentProcessor/setup.typoscript'], ['title' => 'ACME Guitars']); + }); + } + + #[Test] + public function homeLayoutIsRendered(): void + { + $response = $this->executeFrontendSubRequest((new InternalRequest('https://acme.com/'))->withPageId(1000)); + $body = (string)$response->getBody(); + self::assertStringContainsString('Welcome to ACME guitars', $body); + self::assertStringContainsString('Great to see you here', $body); + self::assertStringContainsString('If you read this you are at the end.', $body); + } + + #[Test] + public function productDetailLayoutIsRendered(): void + { + $response = $this->executeFrontendSubRequest((new InternalRequest('https://acme.com/'))->withPageId(1110)); + $body = (string)$response->getBody(); + self::assertStringContainsString('Hero is our flagship', $body); + self::assertStringContainsString('Get a hero for yourself', $body); + self::assertStringContainsString('Flash Info for all products', $body); + self::assertStringContainsString('If you read this you are at the end.', $body); + } +} diff --git a/typo3/sysext/frontend/Tests/Functional/DataProcessing/RecordTransformationProcessorTest.php b/typo3/sysext/frontend/Tests/Functional/DataProcessing/RecordTransformationProcessorTest.php index a0c9cff1983b7fa0750a472ec295599166169f6b..7992e93b29b9488bd7957c5637b7ba72d82bc913 100644 --- a/typo3/sysext/frontend/Tests/Functional/DataProcessing/RecordTransformationProcessorTest.php +++ b/typo3/sysext/frontend/Tests/Functional/DataProcessing/RecordTransformationProcessorTest.php @@ -67,7 +67,7 @@ final class RecordTransformationProcessorTest extends FunctionalTestCase ['TSconfig' => implode(chr(10), $pageLayoutFileContents)], ['uid' => 1000] ); - $this->setUpFrontendRootPage(1000, ['EXT:frontend/Tests/Functional/DataProcessing/Fixtures/setup.typoscript'], ['title' => 'ACME Guitars']); + $this->setUpFrontendRootPage(1000, ['EXT:frontend/Tests/Functional/DataProcessing/Fixtures/RecordTransform/setup.typoscript'], ['title' => 'ACME Guitars']); }); }