Commit 9202450c authored by Benni Mack's avatar Benni Mack Committed by Christian Kuhn
Browse files

[!!!][FEATURE] Add PSR-14 Events for customized Page Module rendering

The three legacy hooks
* $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['record_is_used']
* $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][PageLayoutView::class]['modifyQuery']
* $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem']

are replaced by new PSR-14 Events:

* TYPO3\CMS\Backend\View\Event\IsContentUsedOnPageLayoutEvent
* TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForContentEvent
* TYPO3\CMS\Backend\View\Event\PageContentPreviewRenderingEvent

Additionally, the hooks
* $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info']
* $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter']
are removed as the same functionality can be achieved
with the existing PreviewRenderer functionality since TYPO3 v10.

The previous "main class" PageLayoutView is now removed (was marked
as internal) along with the interfaces for the removed hooks

* TYPO3\CMS\Backend\View\PageLayoutViewDrawFooterHookInterface
* TYPO3\CMS\Backend\View\PageLayoutViewDrawItemHookInterface

Resolves: #98375
Releases: main
Change-Id: Iac4a76dce934de31c9749076d8054ae83ac45edb
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/75778


Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
parent 08625d36
......@@ -165,11 +165,6 @@ parameters:
count: 1
path: ../../typo3/sysext/backend/Classes/View/BackendLayout/ContentFetcher.php
-
message: "#^Variable \\$localizationButtons in empty\\(\\) always exists and is not falsy\\.$#"
count: 1
path: ../../typo3/sysext/backend/Classes/View/PageLayoutView.php
-
message: "#^Call to an undefined method TYPO3Fluid\\\\Fluid\\\\Core\\\\Rendering\\\\RenderingContextInterface\\:\\:getRequest\\(\\)\\.$#"
count: 1
......
......@@ -22,8 +22,6 @@ use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayout\Grid\GridColumnItem;
use TYPO3\CMS\Backend\View\PageLayoutView;
use TYPO3\CMS\Backend\View\PageLayoutViewDrawFooterHookInterface;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Imaging\Icon;
......@@ -145,22 +143,7 @@ class StandardContentPreviewRenderer implements PreviewRendererInterface, Logger
}
break;
case 'list':
$hookOut = '';
if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info'])) {
$pageLayoutView = PageLayoutView::createFromPageLayoutContext($item->getContext());
$_params = ['pObj' => &$pageLayoutView, 'row' => $record];
foreach (
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info'][$record['list_type']] ??
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info']['_DEFAULT'] ??
[] as $_funcRef
) {
$hookOut .= GeneralUtility::callUserFunction($_funcRef, $_params, $pageLayoutView);
}
}
if ((string)$hookOut !== '') {
$out .= $hookOut;
} elseif (!empty($record['list_type'])) {
if (!empty($record['list_type'])) {
$label = BackendUtility::getLabelFromItemListMerged($record['pid'], 'tt_content', 'list_type', $record['list_type']);
if (!empty($label)) {
$out .= $this->linkEditContent('<strong>' . htmlspecialchars($languageService->sL($label)) . '</strong>', $record);
......@@ -209,7 +192,6 @@ class StandardContentPreviewRenderer implements PreviewRendererInterface, Logger
*/
public function renderPageModulePreviewFooter(GridColumnItem $item): string
{
$content = '';
$info = [];
$record = $item->getRecord();
$this->getProcessedValue($item, 'starttime,endtime,fe_group,space_before_class,space_after_class', $info);
......@@ -218,24 +200,10 @@ class StandardContentPreviewRenderer implements PreviewRendererInterface, Logger
$info[] = htmlspecialchars($record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']]);
}
// Call drawFooter hooks
if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter'])) {
$pageLayoutView = PageLayoutView::createFromPageLayoutContext($item->getContext());
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter'] ?? [] as $className) {
$hookObject = GeneralUtility::makeInstance($className);
if (!$hookObject instanceof PageLayoutViewDrawFooterHookInterface) {
throw new \UnexpectedValueException($className . ' must implement interface ' . PageLayoutViewDrawFooterHookInterface::class, 1582574541);
}
$hookObject->preProcess($pageLayoutView, $info, $record);
}
$item->setRecord($record);
}
if (!empty($info)) {
$content = implode('<br>', $info);
return implode('<br>', $info);
}
return $content;
return '';
}
public function wrapPageModulePreview(string $previewHeader, string $previewContent, GridColumnItem $item): string
......
......@@ -17,9 +17,11 @@ declare(strict_types=1);
namespace TYPO3\CMS\Backend\View\BackendLayout;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\Event\IsContentUsedOnPageLayoutEvent;
use TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForContentEvent;
use TYPO3\CMS\Backend\View\PageLayoutContext;
use TYPO3\CMS\Backend\View\PageLayoutView;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
use TYPO3\CMS\Core\Database\ConnectionPool;
......@@ -57,10 +59,13 @@ class ContentFetcher
*/
protected $fetchedContentRecords = [];
protected EventDispatcherInterface $eventDispatcher;
public function __construct(PageLayoutContext $pageLayoutContext)
{
$this->context = $pageLayoutContext;
$this->fetchedContentRecords = $this->getRuntimeCache()->get('ContentFetcher_fetchedContentRecords') ?: [];
$this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
}
/**
......@@ -114,28 +119,22 @@ class ContentFetcher
}
/**
* A hook allows to decide whether a custom type has children which were rendered or should not be rendered.
* Allows to decide via an Event whether a custom type has children which were rendered or should not be rendered.
*
* @return iterable
*/
public function getUnusedRecords(): iterable
{
$unrendered = [];
$hookArray = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['record_is_used'] ?? [];
$pageLayoutView = PageLayoutView::createFromPageLayoutContext($this->context);
$knownColumnPositionNumbers = $this->context->getBackendLayout()->getColumnPositionNumbers();
$rememberer = GeneralUtility::makeInstance(RecordRememberer::class);
$languageId = $this->context->getDrawingConfiguration()->getSelectedLanguageId();
foreach ($this->getContentRecordsPerColumn(null, $languageId) as $contentRecordsInColumn) {
foreach ($contentRecordsInColumn as $contentRecord) {
$used = $rememberer->isRemembered((int)$contentRecord['uid']);
// A hook mentioned that this record is used somewhere, so this is in fact "rendered" already
foreach ($hookArray as $hookFunction) {
$_params = ['columns' => $knownColumnPositionNumbers, 'record' => $contentRecord, 'used' => $used];
$used = GeneralUtility::callUserFunction($hookFunction, $_params, $pageLayoutView);
}
if (!$used) {
$event = new IsContentUsedOnPageLayoutEvent($contentRecord, $used, $this->context);
$event = $this->eventDispatcher->dispatch($event);
if (!$event->isRecordUsed()) {
$unrendered[] = $contentRecord;
}
}
......@@ -211,7 +210,6 @@ class ContentFetcher
protected function getQueryBuilder(): QueryBuilder
{
$fields = ['*'];
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()
......@@ -219,7 +217,7 @@ class ContentFetcher
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$GLOBALS['BE_USER']->workspace));
$queryBuilder
->select(...$fields)
->select('*')
->from('tt_content');
$queryBuilder->andWhere(
......@@ -229,34 +227,14 @@ class ContentFetcher
)
);
$additionalConstraints = [];
$parameters = [
'table' => 'tt_content',
'fields' => $fields,
'groupBy' => null,
'orderBy' => null,
];
$sortBy = (string)($GLOBALS['TCA']['tt_content']['ctrl']['sortby'] ?: $GLOBALS['TCA']['tt_content']['ctrl']['default_sortby']);
foreach (QueryHelper::parseOrderBy($sortBy) as $orderBy) {
$queryBuilder->addOrderBy($orderBy[0], $orderBy[1]);
}
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][PageLayoutView::class]['modifyQuery'] ?? [] as $className) {
$hookObject = GeneralUtility::makeInstance($className);
if (method_exists($hookObject, 'modifyQuery')) {
$hookObject->modifyQuery(
$parameters,
'tt_content',
$this->context->getPageId(),
$additionalConstraints,
$fields,
$queryBuilder
);
}
}
return $queryBuilder;
$event = new ModifyDatabaseQueryForContentEvent($queryBuilder, 'tt_content', $this->context->getPageId());
$event = $this->eventDispatcher->dispatch($event);
return $event->getQueryBuilder();
}
protected function getResult($result): array
......
......@@ -17,12 +17,12 @@ declare(strict_types=1);
namespace TYPO3\CMS\Backend\View\BackendLayout\Grid;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\Event\PageContentPreviewRenderingEvent;
use TYPO3\CMS\Backend\View\PageLayoutContext;
use TYPO3\CMS\Backend\View\PageLayoutView;
use TYPO3\CMS\Backend\View\PageLayoutViewDrawItemHookInterface;
use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
......@@ -81,22 +81,11 @@ class GridColumnItem extends AbstractGridObject
);
$previewHeader = $previewRenderer->renderPageModulePreviewHeader($this);
$drawItem = true;
$previewContent = '';
// Hook: Render an own preview of a record
if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem'])) {
$pageLayoutView = PageLayoutView::createFromPageLayoutContext($this->getContext());
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem'] ?? [] as $className) {
$hookObject = GeneralUtility::makeInstance($className);
if (!$hookObject instanceof PageLayoutViewDrawItemHookInterface) {
throw new \UnexpectedValueException($className . ' must implement interface ' . PageLayoutViewDrawItemHookInterface::class, 1582574553);
}
$hookObject->preProcess($pageLayoutView, $drawItem, $previewHeader, $previewContent, $record);
}
$this->setRecord($record);
}
$event = new PageContentPreviewRenderingEvent('tt_content', $this->record, $this->context);
GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch($event);
if ($drawItem) {
$previewContent = $event->getPreviewContent();
if ($previewContent === null) {
$previewContent = $previewRenderer->renderPageModulePreviewContent($this);
}
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
......@@ -13,22 +15,44 @@
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\View;
namespace TYPO3\CMS\Backend\View\Event;
use TYPO3\CMS\Backend\View\PageLayoutContext;
/**
* Interface for classes which hook into PageLayoutView and do additional
* tt_content_drawItem processing.
* Use this Event to identify whether a content element is used.
*/
interface PageLayoutViewDrawItemHookInterface
final class IsContentUsedOnPageLayoutEvent
{
/**
* Preprocesses the preview rendering of a content element.
*
* @param \TYPO3\CMS\Backend\View\PageLayoutView $parentObject Calling parent object
* @param bool $drawItem Whether to draw the item using the default functionalities
* @param string $headerContent Header content
* @param string $itemContent Item content
* @param array $row Record row of tt_content
*/
public function preProcess(PageLayoutView &$parentObject, &$drawItem, &$headerContent, &$itemContent, array &$row);
public function __construct(
private readonly array $record,
private bool $used,
private PageLayoutContext $context
) {
}
public function getRecord(): array
{
return $this->record;
}
public function isRecordUsed(): bool
{
return $this->used;
}
public function setUsed(bool $isUsed): void
{
$this->used = $isUsed;
}
public function getKnownColumnPositionNumbers(): array
{
return $this->context->getBackendLayout()->getColumnPositionNumbers();
}
public function getPageLayoutContext(): PageLayoutContext
{
return $this->context;
}
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
......@@ -13,20 +15,39 @@
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\View;
namespace TYPO3\CMS\Backend\View\Event;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Interface for classes which hook into PageLayoutView and do additional
* tt_content_drawFooter processing.
* Use this Event to alter the database query when loading content for a page.
*/
interface PageLayoutViewDrawFooterHookInterface
final class ModifyDatabaseQueryForContentEvent
{
/**
* Preprocesses the preview footer rendering of a content element.
*
* @param \TYPO3\CMS\Backend\View\PageLayoutView $parentObject Calling parent object
* @param array $info Processed values
* @param array $row Record row of tt_content
*/
public function preProcess(PageLayoutView &$parentObject, &$info, array &$row);
public function __construct(
private QueryBuilder $queryBuilder,
private string $table,
private int $pageId,
) {
}
public function getQueryBuilder(): QueryBuilder
{
return $this->queryBuilder;
}
public function setQueryBuilder(QueryBuilder $queryBuilder): void
{
$this->queryBuilder = $queryBuilder;
}
public function getTable(): string
{
return $this->table;
}
public function getPageId(): int
{
return $this->pageId;
}
}
<?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\Backend\View\Event;
use Psr\EventDispatcher\StoppableEventInterface;
use TYPO3\CMS\Backend\View\PageLayoutContext;
/**
* Use this Event to have a custom preview for a content type in the Page Module
*/
final class PageContentPreviewRenderingEvent implements StoppableEventInterface
{
private ?string $content = null;
public function __construct(
private string $table,
private array $record,
private PageLayoutContext $context
) {
}
public function getTable(): string
{
return $this->table;
}
public function getRecord(): array
{
return $this->record;
}
public function getPageLayoutContext(): PageLayoutContext
{
return $this->context;
}
public function getPreviewContent(): ?string
{
return $this->content;
}
public function setPreviewContent(string $content): void
{
$this->content = $content;
}
public function isPropagationStopped(): bool
{
return $this->content !== null;
}
}
This diff is collapsed.
"pages",,,,,,,,,
,uid,pid,sys_language_uid,deleted,l10n_parent,t3ver_state,t3ver_wsid,title,l10n_source
,17,0,0,0,0,0,0,Home,0
"pages",,,,,,,,,
,uid,pid,sys_language_uid,deleted,l10n_parent,t3ver_state,t3ver_wsid,title,l10n_source
,17,0,0,0,0,0,0,Home,0
,2,0,3,0,17,0,0,"[Translate to polish] Home",17
<?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\Backend\Tests\Functional\View;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\View\PageLayoutView;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\AccessibleObjectInterface;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
class PageLayoutViewTest extends FunctionalTestCase
{
use ProphecyTrait;
/**
* @var PageLayoutView|AccessibleObjectInterface
*/
private $subject;
/**
* Sets up this test case.
*/
protected function setUp(): void
{
parent::setUp();
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(Argument::cetera())->willReturnArgument(0);
$this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv');
$this->setUpBackendUser(1);
Bootstrap::initializeLanguageObject();
$site = new Site('test', 1, [
'identifier' => 'test',
'rootPageId' => 1,
'base' => '/',
'languages' => [
[
'languageId' => 0,
'locale' => '',
'base' => '/',
'title' => 'default',
],
[
'languageId' => 1,
'locale' => '',
'base' => '/',
'title' => 'german',
],
[
'languageId' => 2,
'locale' => '',
'base' => '/',
'title' => 'french',
],
[
'languageId' => 3,
'locale' => '',
'base' => '/',
'title' => 'polish',
],
],
]);
$this->subject = $this->getAccessibleMock(PageLayoutView::class, ['dummy'], [$eventDispatcher->reveal()]);
$this->subject->_set('siteLanguages', $site->getLanguages());
$GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://www.example.com/'))
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE)
->withAttribute('normalizedParams', new NormalizedParams([], [], '', ''));
}
/**
* @test
*/
public function languageSelectorShowsAllAvailableLanguagesForTranslation(): void
{
$this->importCSVDataSet(__DIR__ . '/Fixtures/LanguageSelectorScenarioDefault.csv');
$result = $this->subject->languageSelector(17);
$matches = [];
preg_match_all('/<option value=.+<\/option>/', $result, $matches);
$resultingOptions = GeneralUtility::trimExplode('</option>', $matches[0][0], true);
self::assertCount(4, $resultingOptions);
// first entry is the empty option
self::assertStringEndsWith('german', $resultingOptions[1]);
self::assertStringEndsWith('french', $resultingOptions[2]);
self::assertStringEndsWith('polish', $resultingOptions[3]);
}
/**
* @test
*/
public function languageSelectorDoesNotOfferLanguageIfTranslationHasBeenDoneAlready(): void
{
$this->importCSVDataSet(__DIR__ . '/Fixtures/LanguageSelectorScenarioTranslationDone.csv');
$result = $this->subject->languageSelector(17);
$matches = [];
preg_match_all('/<option value=.+<\/option>/', $result, $matches);
$resultingOptions = GeneralUtility::trimExplode('</option>', $matches[0][0], true);
self::assertCount(3, $resultingOptions);
// first entry is the empty option
self::assertStringEndsWith('german', $resultingOptions[1]);
self::assertStringEndsWith('french', $resultingOptions[2]);
}
}
.. include:: /Includes.rst.txt
.. _breaking-98375-1663598608:
===============================================
Breaking: #98375 - Removed hooks in Page Module
<