Commit 13c67c45 authored by Claus Due's avatar Claus Due Committed by Georg Ringer
Browse files

[FEATURE] Introduce PreviewRenderer pattern

This introduces a new approach to registering and rendering
previews; for content elements initially but possible to apply
to any record type, and possible to call from other contexts
than the PageLayoutView, e.g. AJAX-based preview rendering.

Basically, this turns the old hook approach into a proper
pattern where preview renderers are registered for a specific
CType and must implement proper interfaces. A Resolver
pattern is also introduced with a standard implementation and
a standard renderer is registered for backwards compatibility.

Resolves: #78450
Releases: master
Change-Id: Ibf85d9b50b7bc6506d72c1ee63078373eaf9e433
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/50389


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
parent 7e342e74
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Backend\Preview;
/*
* 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!
*/
use TYPO3\CMS\Backend\View\BackendLayout\Grid\GridColumnItem;
/**
* Interface PreviewRendererInterface
*
* Conctract for classes capable of rendering previews of a given record
* from a table. Responsible for rendering preview heeader, preview content
* and wrapping of those two values.
*
* Responsibilities are segmented into three methods, one for each responsibility,
* which is done in order to allow overriding classes to change those parts
* individually without having to replace other parts. Rather than relying on
* implementations to be friendly and divide code into smaller pieces and
* give them (at least) protected visibility, the key methods are instead required
* on the interface directly.
*
* Callers are then responsible for calling each method and combining/wrapping
* the output appropriately.
*/
interface PreviewRendererInterface
{
/**
* Dedicated method for rendering preview header HTML for
* the page module only. Receives the the GridColumnItem
* that contains the record for which a preview header
* should be rendered and returned.
*
* @param GridColumnItem $item
* @return string
*/
public function renderPageModulePreviewHeader(GridColumnItem $item): string;
/**
* Dedicated method for rendering preview body HTML for
* the page module only. Receives the the GridColumnItem
* that contains the record for which a preview should be
* rendered and returned.
*
* @param GridColumnItem $item
* @return string
*/
public function renderPageModulePreviewContent(GridColumnItem $item): string;
/**
* Render a footer for the record to display in page module below
* the body of the item's preview.
*
* @param GridColumnItem $item
* @return string
*/
public function renderPageModulePreviewFooter(GridColumnItem $item): string;
/**
* Dedicated method for wrapping a preview header and body
* HTML. Receives $item, an instance of GridColumnItem holding
* among other things the record, which can be used to determine
* appropriate wrapping.
*
* @param string $previewHeader
* @param string $previewContent
* @param GridColumnItem $item
* @return string
*/
public function wrapPageModulePreview(string $previewHeader, string $previewContent, GridColumnItem $item): string;
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Backend\Preview;
/*
* 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!
*/
/**
* Interface PreviewRendererResolverInterface
*
* Contract for classes capable of resolving PreviewRenderInterface
* implementations based on table and record.
*/
interface PreviewRendererResolverInterface
{
/**
* @param string $table The name of the table the returned PreviewRenderer must work with
* @param array $row A record from $table which will be previewed - allows returning a different PreviewRenderer based on record attributes
* @param int $pageUid The UID of the page on which the preview will be rendered - allows returning a different PreviewRenderer based on for example pageTSconfig
* @return PreviewRendererInterface
*/
public function resolveRendererFor(string $table, array $row, int $pageUid): PreviewRendererInterface;
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Backend\Preview;
/*
* 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!
*/
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Class StandardPreviewRendererResolver
*
* Default implementation of PreviewRendererResolverInterface.
* Scans TCA configuration to detect:
*
* - TCA.$table.types.$typeFromTypeField.previewRenderer
* - TCA.$table.ctrl.previewRenderer
*
* Depending on which one is defined and checking the first, type-specific
* variant first.
*/
class StandardPreviewRendererResolver implements PreviewRendererResolverInterface
{
/**
* @param string $table The name of the table the returned PreviewRenderer must work with
* @param array $row A record from $table which will be previewed - allows returning a different PreviewRenderer based on record attributes
* @param int $pageUid The UID of the page on which the preview will be rendered - allows returning a different PreviewRenderer based on for example pageTSconfig
* @return PreviewRendererInterface
* @throws \UnexpectedValueException
* @throws \RuntimeException
*/
public function resolveRendererFor(string $table, array $row, int $pageUid): PreviewRendererInterface
{
$tca = $GLOBALS['TCA'][$table];
$tcaTypeField = $tca['ctrl']['type'] ?? null;
$previewRendererClassName = null;
if ($tcaTypeField) {
$tcaTypeOfRow = $row[$tcaTypeField];
$typeConfiguration = $tca['types'][$tcaTypeOfRow] ?? [];
$subTypeValueField = $typeConfiguration['subtype_value_field'] ?? null;
if (!empty($subTypeValueField) && !empty($typeConfiguration['previewRenderer']) && is_array($typeConfiguration['previewRenderer'])) {
// An array of subtype_value_field indexed preview renderers was defined, look up the right
// class to use for the sub-type defined in this $row.
$previewRendererClassName = $typeConfiguration['previewRenderer'][$row[$subTypeValueField]] ?? null;
}
// If no class was found in the subtype_value_field
if (!$previewRendererClassName && !empty($typeConfiguration['previewRenderer'])) {
// A type-specific preview renderer was configured for the TCA type (and one was not detected
// based on the higher-priority lookups above).
$previewRendererClassName = $typeConfiguration['previewRenderer'];
}
}
if (!$previewRendererClassName) {
// Table either has no type field or no custom preview renderer was defined for the type.
// Use table's standard renderer if any is defined.
$previewRendererClassName = $tca['ctrl']['previewRenderer'] ?? null;
}
if (!empty($previewRendererClassName)) {
if (!is_a($previewRendererClassName, PreviewRendererInterface::class, true)) {
throw new \UnexpectedValueException(
sprintf(
'Class %s must implement %s',
$previewRendererClassName,
PreviewRendererInterface::class
),
1477512798
);
}
return GeneralUtility::makeInstance($previewRendererClassName);
}
throw new \RuntimeException(sprintf('No Preview renderer registered for table %s', $table), 1477520356);
}
}
......@@ -16,6 +16,7 @@ namespace TYPO3\CMS\Backend\View\BackendLayout\Grid;
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayout\BackendLayout;
......@@ -60,124 +61,16 @@ class GridColumnItem extends AbstractGridObject
public function getPreview(): string
{
$item = $this;
$row = $item->getRecord();
$configuration = $this->backendLayout->getDrawingConfiguration();
$out = '';
$outHeader = '';
if ($row['header']) {
$hiddenHeaderNote = '';
// If header layout is set to 'hidden', display an accordant note:
if ($row['header_layout'] == 100) {
$hiddenHeaderNote = ' <em>[' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.hidden')) . ']</em>';
}
$outHeader = $row['date']
? htmlspecialchars($configuration->getItemLabels()['date'] . ' ' . BackendUtility::date($row['date'])) . '<br />'
: '';
$outHeader .= '<strong>' . $this->linkEditContent($this->renderText($row['header']), $row)
. $hiddenHeaderNote . '</strong><br />';
}
$drawItem = true;
// Draw preview of the item depending on its CType (if not disabled by previous hook):
if ($drawItem) {
switch ($row['CType']) {
case 'header':
if ($row['subheader']) {
$out .= $this->linkEditContent($this->renderText($row['subheader']), $row) . '<br />';
}
break;
case 'bullets':
case 'table':
if ($row['bodytext']) {
$out .= $this->linkEditContent($this->renderText($row['bodytext']), $row) . '<br />';
}
break;
case 'uploads':
if ($row['media']) {
$out .= $this->linkEditContent($this->getThumbCodeUnlinked($row, 'tt_content', 'media'), $row) . '<br />';
}
break;
case 'shortcut':
if (!empty($row['records'])) {
$shortcutContent = [];
$recordList = explode(',', $row['records']);
foreach ($recordList as $recordIdentifier) {
$split = BackendUtility::splitTable_Uid($recordIdentifier);
$tableName = empty($split[0]) ? 'tt_content' : $split[0];
$shortcutRecord = BackendUtility::getRecord($tableName, $split[1]);
if (is_array($shortcutRecord)) {
$icon = $this->iconFactory->getIconForRecord($tableName, $shortcutRecord, Icon::SIZE_SMALL)->render();
$icon = BackendUtility::wrapClickMenuOnIcon(
$icon,
$tableName,
$shortcutRecord['uid']
);
$shortcutContent[] = $icon
. htmlspecialchars(BackendUtility::getRecordTitle($tableName, $shortcutRecord));
}
}
$out .= implode('<br />', $shortcutContent) . '<br />';
}
break;
case 'list':
$hookOut = '';
$_params = ['pObj' => &$this, 'row' => $row, 'infoArr' => []];
foreach (
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info'][$row['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, $this);
}
if ((string)$hookOut !== '') {
$out .= $hookOut;
} elseif (!empty($row['list_type'])) {
$label = BackendUtility::getLabelFromItemListMerged($row['pid'], 'tt_content', 'list_type', $row['list_type']);
if (!empty($label)) {
$out .= $this->linkEditContent('<strong>' . htmlspecialchars($this->getLanguageService()->sL($label)) . '</strong>', $row) . '<br />';
} else {
$message = sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue'), $row['list_type']);
$out .= '<span class="label label-warning">' . htmlspecialchars($message) . '</span>';
}
} else {
$out .= '<strong>' . $this->getLanguageService()->getLL('noPluginSelected') . '</strong>';
}
$out .= htmlspecialchars($this->getLanguageService()->sL(
BackendUtility::getLabelFromItemlist('tt_content', 'pages', $row['pages'])
)) . '<br />';
break;
default:
$contentType = $this->backendLayout->getDrawingConfiguration()->getContentTypeLabels()[$row['CType']];
if (!isset($contentType)) {
$contentType = BackendUtility::getLabelFromItemListMerged($row['pid'], 'tt_content', 'CType', $row['CType']);
}
if ($contentType) {
$out .= $this->linkEditContent('<strong>' . htmlspecialchars($contentType) . '</strong>', $row) . '<br />';
if ($row['bodytext']) {
$out .= $this->linkEditContent($this->renderText($row['bodytext']), $row) . '<br />';
}
if ($row['image']) {
$out .= $this->linkEditContent($this->getThumbCodeUnlinked($row, 'tt_content', 'image'), $row) . '<br />';
}
} else {
$message = sprintf(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue'),
$row['CType']
);
$out .= '<span class="label label-warning">' . htmlspecialchars($message) . '</span>';
}
}
}
$out = '<span class="exampleContent">' . $out . '</span>';
$out = $outHeader . $out;
if ($item->isDisabled()) {
return '<span class="text-muted">' . $out . '</span>';
}
return $out;
$record = $this->getRecord();
$previewRenderer = GeneralUtility::makeInstance(StandardPreviewRendererResolver::class)
->resolveRendererFor(
'tt_content',
$record,
$this->backendLayout->getDrawingConfiguration()->getPageId()
);
$previewHeader = $previewRenderer->renderPageModulePreviewHeader($this);
$previewContent = $previewRenderer->renderPageModulePreviewContent($this);
return $previewRenderer->wrapPageModulePreview($previewHeader, $previewContent, $this);
}
public function getWrapperClassName(): string
......@@ -222,16 +115,16 @@ class GridColumnItem extends AbstractGridObject
return $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel');
}
public function getFooterInfo(): iterable
public function getFooterInfo(): string
{
$info = [];
$this->getProcessedValue('starttime,endtime,fe_group,space_before_class,space_after_class', $info);
if (!empty($GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']) && !empty($this->record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']])) {
$info[] = $this->record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']];
}
return $info;
$record = $this->getRecord();
$previewRenderer = GeneralUtility::makeInstance(StandardPreviewRendererResolver::class)
->resolveRendererFor(
'tt_content',
$record,
$this->backendLayout->getDrawingConfiguration()->getPageId()
);
return $previewRenderer->renderPageModulePreviewFooter($this);
}
/**
......@@ -253,70 +146,6 @@ class GridColumnItem extends AbstractGridObject
return $title;
}
/**
* Create thumbnail code for record/field but not linked
*
* @param mixed[] $row Record array
* @param string $table Table (record is from)
* @param string $field Field name for which thumbnail are to be rendered.
* @return string HTML for thumbnails, if any.
*/
protected function getThumbCodeUnlinked($row, $table, $field)
{
return BackendUtility::thumbCode($row, $table, $field, '', '', null, 0, '', '', false);
}
/**
* Will create a link on the input string and possibly a big button after the string which links to editing in the RTE.
* Used for content element content displayed so the user can click the content / "Edit in Rich Text Editor" button
*
* @param string $str String to link. Must be prepared for HTML output.
* @param array $row The row.
* @return string If the whole thing was editable $str is return with link around. Otherwise just $str.
*/
public function linkEditContent($str, $row)
{
if ($this->getBackendUser()->recordEditAccessInternals('tt_content', $row)) {
$urlParameters = [
'edit' => [
'tt_content' => [
$row['uid'] => 'edit'
]
],
'returnUrl' => GeneralUtility::getIndpEnv('REQUEST_URI') . '#element-tt_content-' . $row['uid']
];
$uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$url = (string)$uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
return '<a href="' . htmlspecialchars($url) . '" title="' . htmlspecialchars($this->getLanguageService()->getLL('edit')) . '">' . $str . '</a>';
}
return $str;
}
/**
* Processing of larger amounts of text (usually from RTE/bodytext fields) with word wrapping etc.
*
* @param string $input Input string
* @return string Output string
*/
public function renderText($input): string
{
$input = strip_tags($input);
$input = GeneralUtility::fixed_lgd_cs($input, 1500);
return nl2br(htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8', false));
}
protected function getProcessedValue(string $fieldList, array &$info): void
{
$itemLabels = $this->backendLayout->getDrawingConfiguration()->getItemLabels();
$fieldArr = explode(',', $fieldList);
foreach ($fieldArr as $field) {
if ($this->record[$field]) {
$info[] = '<strong>' . htmlspecialchars($itemLabels[$field]) . '</strong> '
. htmlspecialchars(BackendUtility::getProcessedValue('tt_content', $field, $this->record[$field]));
}
}
}
public function getIcons(): string
{
$table = 'tt_content';
......
<div class="t3-page-ce-footer">
<div class="t3-page-ce-info">
<f:for each="{item.footerInfo}" as="infoLine" iteration="iteration">
{infoLine -> f:format.raw()}<f:if condition="!{iteration.isLast}"><br /></f:if>
</f:for>
<f:if condition="{item.footerInfo}">
<div class="t3-page-ce-footer">
<div class="t3-page-ce-info">
{item.footerInfo -> f:format.raw()}
</div>
</div>
</div>
</f:if>
......@@ -28,6 +28,11 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1460321142] = [
// Register search key shortcuts
$GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch']['page'] = 'pages';
// Register standard preview renderer resolver implementation.
// Resolves PreviewRendererInterface implementations for a given table and record.
// Can be replaced with custom implementation by overriding this value in extensions.
$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['backend']['previewRendererResolver'] = \TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver::class;
// Include base TSconfig setup
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPageTSConfig(
"@import 'EXT:backend/Configuration/TSconfig/Page/Mod/Wizards/NewContentElement.tsconfig'"
......
......@@ -21,8 +21,10 @@ Implementations which depend on :php:`PageLayoutView` should prepare to use the
Affected Installations
======================
Any site which uses PSR-14 events or backend content rendering hooks associated with :php:`PageLayoutView` such as :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter']`.
* Any site which overrides the ``PageLayoutView`` class. The overridden class will still be instanced when rendering previews in BE page module - but no methods will be called on the instance **unless** they are called by a third party hook subscriber.
* Any site which depends on PSR-14 events associated with ``PageLayoutView`` will only have those events dispatched if the ``fluidBasedPageModule`` feature flag is ``false``.
* Affects ``\TYPO3\CMS\Backend\View\Event\AfterSectionMarkupGeneratedEvent``.
* Affects ``\TYPO3\CMS\Backend\View\Event\BeforeSectionMarkupGeneratedEvent``.
Migration
=========
......
.. include:: ../../Includes.txt
===================================================
Feature: #78450 - Introduce PreviewRenderer pattern
===================================================
See :issue:`78450`
Pre-requisites
==============
The PreviewRenderer usage is only active if the "fluid based page layout module" feature is enabled. This feature
is enabled by default in TYPO3 versions 10.3 and later.
The feature toggle can be located in the `Settings` admin module under `Feature Toggles`. Or it can be set in
PHP using :php:``$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['fluidBasedPageModule'] = true;``.
Description
===========
A new pattern has been introduced to facilitate (record) previews in TYPO3. A default implementation has been
added which provides support for the previous methods of generating previews (content previews - using hooks
or by defining a Fluid template to render).
The new pattern creates a strict contract for code which generates such previews and enables switching out the
implementation of both the resolving logic (which finds a preview renderer for a given table and record) as well
as the rendering logic (which now renders both the actual preview and has contract methods for adding wrapping).
The main differences between the old and the new approach are:
* The class used to render previews is now defined in TCA and can be defined per-type or for any type.
* The resolver used to find preview renderers is a global implementation overridable in configuration.
* A single preview renderer will now be used; before, hook subscribers had to toggle passed-by-reference flags.
* Wrapping is no longer forced to be a `<span>` tag so you are not restricted to inline and inline-block display.
* Preview renderers have a public contract which splits up actual preview and wrapping, allowing third party renderers
to subclass the original renderer and for example only change the wrapping tag.
* Preview rendering can now be done ad-hoc through; the pattern can be used from any context where the old pattern
could only be used (was only used) in the PageLayoutView for content previews.
Impact
======
The feature adds two new concepts:
* `PreviewRendererResolver` which is a global implementation to detect which `PreviewRenderer` a given record needs.
* `PreviewRenderer` which is the class responsible for generating the preview and the wrapping.
Configuring the implementation
------------------------------
The PreviewRendererResolver can be overridden by setting:
.. code-block:: php
$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['backend']['previewRendererResolver'] = \TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver::class;
(the class shown is the standard implementation TYPO3 provides, inspect this class for further developer information)
Once overridden the old resolver will no longer be consulted.
And individual preview renderers can be defined using one of the following two approaches:
.. code-block:: php
$GLOBALS['TCA'][$table]['ctrl']['previewRenderer'] = My\PreviewRenderer::class;
Which specifies the PreviewRenderer to use for any record in `$table`
Or if your table has a "type" field/attribute:
.. code-block:: php
$GLOBALS['TCA'][$table]['types'][$type]['previewRenderer'] = My\PreviewRenderer::class;
Which specifies the PreviewRenderer for only records of type `$type` as determined by the type field of your table.