TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,174 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Abstract container has various methods used by the container classes
*/
abstract class AbstractContainer extends AbstractNode
{
protected NodeFactory $nodeFactory;
protected BackendViewFactory $backendViewFactory;
/**
* Injection of NodeFactory, which is used in this abstract already.
* Using inject* method to not pollute __construct() for inheriting classes.
*/
public function injectNodeFactory(NodeFactory $nodeFactory): void
{
$this->nodeFactory = $nodeFactory;
}
public function injectBackendViewFactory(BackendViewFactory $backendViewFactory)
{
$this->backendViewFactory = $backendViewFactory;
}
/**
* Merge field information configuration with default and render them.
*
* @return array Result array
*/
protected function renderFieldInformation(): array
{
$options = $this->data;
$fieldInformation = $this->defaultFieldInformation;
$currentRenderType = $this->data['renderType'];
$fieldInformationFromTca = $options['processedTca']['ctrl']['container'][$currentRenderType]['fieldInformation'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($fieldInformation, $fieldInformationFromTca);
$options['renderType'] = 'fieldInformation';
$options['renderData']['fieldInformation'] = $fieldInformation;
return $this->nodeFactory->create($options)->render();
}
/**
* Merge field control configuration with default controls and render them.
*
* @return array Result array
*/
protected function renderFieldControl(): array
{
$options = $this->data;
$fieldControl = $this->defaultFieldControl;
$currentRenderType = $this->data['renderType'];
$fieldControlFromTca = $options['processedTca']['ctrl']['container'][$currentRenderType]['fieldControl'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($fieldControl, $fieldControlFromTca);
$options['renderType'] = 'fieldControl';
$options['renderData']['fieldControl'] = $fieldControl;
return $this->nodeFactory->create($options)->render();
}
/**
* Merge field wizard configuration with default wizards and render them.
*
* @return array Result array
*/
protected function renderFieldWizard(): array
{
$options = $this->data;
$fieldWizard = $this->defaultFieldWizard;
$currentRenderType = $this->data['renderType'];
$fieldWizardFromTca = $options['processedTca']['ctrl']['container'][$currentRenderType]['fieldWizard'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($fieldWizard, $fieldWizardFromTca);
$options['renderType'] = 'fieldWizard';
$options['renderData']['fieldWizard'] = $fieldWizard;
return $this->nodeFactory->create($options)->render();
}
/**
* A single field of TCA 'types' 'showitem' can have three semicolon separated configuration options:
* fieldName: Name of the field to be found in TCA 'columns' section
* fieldLabel: An alternative field label
* paletteName: Name of a palette to be found in TCA 'palettes' section that is rendered after this field
*
* @param string $field Semicolon separated field configuration
* @throws \RuntimeException
*/
protected function explodeSingleFieldShowItemConfiguration(string $field): array
{
$fieldArray = GeneralUtility::trimExplode(';', $field);
if (empty($fieldArray[0])) {
throw new \RuntimeException('Field must not be empty', 1426448465);
}
return [
'fieldName' => $fieldArray[0],
'fieldLabel' => !empty($fieldArray[1]) ? $fieldArray[1] : null,
'paletteName' => !empty($fieldArray[2]) ? $fieldArray[2] : null,
];
}
/**
* Render tabs with label and content. Used by TabsContainer and FlexFormTabsContainer.
* Re-uses the template Tabs.fluid.html which is also used by ModuleTemplate.php.
*
* @param array $menuItems Tab elements, each element is an array with "label" and "content"
* @param string $domId DOM id attribute, will be appended with an iteration number per tab.
*/
protected function renderTabMenu(array $menuItems, string $domId): string
{
$view = $this->backendViewFactory->create($this->data['request']);
$view->assignMultiple([
'id' => $domId,
'items' => $menuItems,
'defaultTabIndex' => 1,
'wrapContent' => false,
'storeLastActiveTab' => true,
]);
return $view->render('Form/Tabs');
}
protected function wrapWithFieldsetAndLegend(string $fieldContent): string
{
$legend = htmlspecialchars($this->data['parameterArray']['fieldConf']['label']);
if ($this->getBackendUserAuthentication()->shallDisplayDebugInformation()) {
$fieldName = $this->data['flexFormContainerFieldName'] ?? $this->data['flexFormFieldName'] ?? $this->data['fieldName'];
$legend .= ' <code>[' . htmlspecialchars($fieldName) . ']</code>';
}
$description = $this->renderDescription();
return '<fieldset><legend class="form-label t3js-formengine-label">' . $legend . '</legend>' . $description . $fieldContent . '</fieldset>';
}
protected function renderDescription(): string
{
$description = (string)($this->data['parameterArray']['fieldConf']['description'] ?? '');
if ($description === '') {
return '';
}
$description = $this->getLanguageService()->sL($description);
if ($description === '') {
return '';
}
return '<div class="form-description">' . nl2br(htmlspecialchars($description)) . '</div>';
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,515 @@
<?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\Form\Container;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Form\Event\ModifyFileReferenceControlsEvent;
use TYPO3\CMS\Backend\Form\Event\ModifyFileReferenceEnabledControlsEvent;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Render a single file reference.
*
* This container is called by FilesControlContainer to render a single file (reference). The container is also
* called by FormEngine for an incoming ajax request to expand an existing file (reference) or to create a new one.
*
* This container creates the outer HTML of single file (references) - e.g. drag and drop and delete buttons.
* For rendering of the record itself processing is handed over to FullRecordContainer.
*/
class FileReferenceContainer extends AbstractContainer
{
private const string FILE_REFERENCE_TABLE = 'sys_file_reference';
private const string FOREIGN_SELECTOR = 'uid_local';
/**
* File reference data used for JSON output
*/
protected array $fileReferenceData = [];
public function __construct(
private readonly IconFactory $iconFactory,
private readonly InlineStackProcessor $inlineStackProcessor,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly ResourceFactory $resourceFactory,
private readonly ConnectionPool $connectionPool,
private readonly UriBuilder $uriBuilder,
) {}
public function render(): array
{
// Send a mapping information to the browser via JSON:
// e.g. data[<curTable>][<curId>][<curField>] => data-<pid>-<parentTable>-<parentId>-<parentField>-<curTable>-<curId>-<curField>
$formPrefix = $this->inlineStackProcessor->getFormPrefixFromStructure($this->data['inlineStructure']);
$domObjectId = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($this->data['inlineStructure'], $this->data['inlineFirstPid']);
$this->fileReferenceData = $this->data['inlineData'];
$this->fileReferenceData['map'][$formPrefix] = $domObjectId;
$resultArray = $this->initializeResultArray();
$resultArray['inlineData'] = $this->fileReferenceData;
$html = '';
$classes = [];
$combinationHtml = '';
$record = $this->data['databaseRow'];
$uid = $record['uid'] ?? 0;
$appendFormFieldNames = '[' . self::FILE_REFERENCE_TABLE . '][' . $uid . ']';
$objectId = $domObjectId . '-' . self::FILE_REFERENCE_TABLE . '-' . $uid;
$isNewRecord = $this->data['command'] === 'new';
$hiddenFieldName = (string)($this->data['processedTca']['ctrl']['enablecolumns']['disabled'] ?? '');
if (!$this->data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
if ($isNewRecord || $this->data['isInlineChildExpanded']) {
$fileReferenceData = $this->renderFileReference($this->data);
$html = $fileReferenceData['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fileReferenceData, false);
} else {
// This class is the marker for the JS-function to check if the full content has already been loaded
$classes[] = 't3js-not-loaded';
}
if ($isNewRecord) {
// Add pid of file reference as hidden field
$html .= '<input type="hidden" name="data' . htmlspecialchars($appendFormFieldNames)
. '[pid]" value="' . htmlspecialchars((string)$record['pid']) . '"/>';
// Tell DataHandler this file reference is expanded
$ucFieldName = 'uc[inlineView]'
. '[' . $this->data['inlineTopMostParentTableName'] . ']'
. '[' . $this->data['inlineTopMostParentUid'] . ']'
. htmlspecialchars($appendFormFieldNames);
$html .= '<input type="hidden" name="' . htmlspecialchars($ucFieldName)
. '" value="' . (int)$this->data['isInlineChildExpanded'] . '" />';
} else {
// Set additional field for processing for saving
$html .= '<input type="hidden" name="cmd' . htmlspecialchars($appendFormFieldNames)
. '[delete]" value="1" disabled="disabled" />';
if ($hiddenFieldName !== ''
&& (!$this->data['isInlineChildExpanded']
|| !in_array($hiddenFieldName, $this->data['columnsToProcess'], true))
) {
$isHidden = (bool)($record[$hiddenFieldName] ?? false);
$html .= '<input type="checkbox" class="d-none" data-formengine-input-name="data'
. htmlspecialchars($appendFormFieldNames)
. '[' . htmlspecialchars($hiddenFieldName) . ']" value="1"'
. ($isHidden ? ' checked="checked"' : '') . ' />';
$html .= '<input type="input" class="d-none" name="data' . htmlspecialchars($appendFormFieldNames)
. '[' . htmlspecialchars($hiddenFieldName) . ']" value="' . (int)$isHidden . '" />';
}
}
}
if ($this->data['inlineParentConfig']['renderFieldsOnly'] ?? false) {
// Render "body" part only
$resultArray['html'] = $html . $combinationHtml;
return $resultArray;
}
// Render header row and content (if expanded)
if ($this->data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$classes[] = 'panel-placeholder';
}
if ($record[$hiddenFieldName] ?? false) {
$classes[] = 'panel-hidden';
}
if ($isNewRecord) {
$classes[] = 'inlineIsNewRecord';
}
// The hashed object id needs a non-numeric prefix, the value is used as ID selector in JavaScript
$hashedObjectId = 'hash-' . md5($objectId);
$containerAttributes = [
'id' => $objectId . '_div',
'class' => 'form-irre-object panel panel-default ' . trim(implode(' ', $classes)),
'data-object-uid' => $record['uid'] ?? 0,
'data-object-id' => $objectId,
'data-object-id-hash' => $hashedObjectId,
'data-object-parent-group' => $domObjectId . '-' . self::FILE_REFERENCE_TABLE,
'data-field-name' => $appendFormFieldNames,
'data-topmost-parent-table' => $this->data['inlineTopMostParentTableName'],
'data-topmost-parent-uid' => $this->data['inlineTopMostParentUid'],
'data-placeholder-record' => $this->data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ? '1' : '0',
];
$isExpanded = $this->data['isInlineChildExpanded'] ?? false;
$ariaControls = htmlspecialchars($objectId . '_fields', ENT_QUOTES | ENT_HTML5);
$resultArray['html'] = '
<div ' . GeneralUtility::implodeAttributes($containerAttributes, true) . '>
<div class="panel-heading">
<div class="panel-heading-row">
' . $this->renderFileHeader($isExpanded, $ariaControls) . '
</div>
</div>
<div class="panel-collapse collapse' . ($isExpanded ? ' show' : '') . '" id="' . $ariaControls . '">' . $html . $combinationHtml . '</div>
</div>';
return $resultArray;
}
protected function renderFileReference(array $data): array
{
$data['tabAndInlineStack'][] = [
'inline',
$this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($this->data['inlineStructure'], $data['inlineFirstPid'])
. '-'
. $data['tableName']
. '-'
. $data['databaseRow']['uid'],
];
return $this->nodeFactory->create(array_replace_recursive($data, [
'inlineData' => $this->fileReferenceData,
'renderType' => 'fullRecordContainer',
]))->render();
}
/**
* Renders the HTML header for the file, such as the title, toggle-function, drag'n'drop, etc.
* Later on the command-icons are inserted here, too.
*/
protected function renderFileHeader(bool $isExpanded, string $ariaControls): string
{
$languageService = $this->getLanguageService();
$databaseRow = $this->data['databaseRow'];
$recordTitle = $this->getRecordTitle();
if (empty($recordTitle)) {
$recordTitle = '<em>[' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title')) . ']</em>';
}
$objectId = htmlspecialchars($this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($this->data['inlineStructure'], $this->data['inlineFirstPid'])
. '-' . self::FILE_REFERENCE_TABLE
. '-' . ($databaseRow['uid'] ?? 0));
$altText = BackendUtility::getRecordIconAltText($databaseRow, self::FILE_REFERENCE_TABLE, false);
// Renders the header image (thumbnail, icon, or missing file indicator)
$headerImage = '';
$headerBadge = '';
$isMissing = false;
if ($GLOBALS['TYPO3_CONF_VARS']['GFX']['thumbnails'] ?? false) {
$fileUid = $databaseRow[self::FOREIGN_SELECTOR][0]['uid'] ?? null;
if (!empty($fileUid)) {
try {
$fileObject = $this->resourceFactory->getFileObject($fileUid);
if ($fileObject->isMissing()) {
$isMissing = true;
$recordTitle = htmlspecialchars($fileObject->getName());
$headerImage = '
<div class="panel-icon" id="' . $objectId . '_iconcontainer">
' . $this->iconFactory->getIcon('default-not-found', IconSize::SMALL)->render() . '
</div>';
$headerBadge = '
<div class="panel-badge">
<span class="badge badge-danger">'
. htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:warning.file_missing')) . '
</span>
</div>';
} elseif ($fileObject->isImage() || $fileObject->isMediaFile()) {
$imageSetup = $this->data['inlineParentConfig']['appearance']['headerThumbnail'] ?? [];
$cropVariantCollection = CropVariantCollection::create($databaseRow['crop'] ?? '');
if (!$cropVariantCollection->getCropArea()->isEmpty()) {
$imageSetup['crop'] = $cropVariantCollection->getCropArea()->makeAbsoluteBasedOnFile($fileObject);
}
$processedImage = $fileObject->process(
ProcessedFile::CONTEXT_IMAGECROPSCALEMASK,
array_merge(['maxWidth' => 60, 'maxHeight' => 45], $imageSetup)
);
// Only use a thumbnail if the processing process was successful by checking if image width is set
if ($processedImage->getProperty('width')) {
$imageUrl = $processedImage->getPublicUrl() ?? '';
$headerImage = '
<div class="panel-thumbnail" id="' . $objectId . '_thumbnailcontainer">
<img src="' . htmlspecialchars($imageUrl) . '" '
. 'width="' . $processedImage->getProperty('width') . '" '
. 'height="' . $processedImage->getProperty('height') . '" '
. 'alt="" '
. 'title="' . htmlspecialchars($altText) . '" '
. 'loading="lazy">
</div>';
}
}
} catch (\InvalidArgumentException $e) {
$fileObject = null;
}
}
}
if ($headerImage === '' && !$isMissing) {
$headerImage = '
<div class="panel-icon" id="' . $objectId . '_iconcontainer">
' . $this->iconFactory
->getIconForRecord(self::FILE_REFERENCE_TABLE, $databaseRow, IconSize::SMALL)
->setTitle($altText)
->render() . '
</div>';
}
return '
<button class="panel-button' . ($isExpanded ? '' : ' collapsed') . '" type="button"
data-bs-toggle="collapse" data-bs-target="#' . $ariaControls . '"
aria-expanded="' . ($isExpanded ? 'true' : 'false') . '" aria-controls="' . $ariaControls . '">
<span class="caret"></span>
' . $headerImage . '
<div class="panel-title"><span id="' . $objectId . '_label">' . $recordTitle . '</span></div>
' . $headerBadge . '
</button>
<div class="panel-actions t3js-formengine-irre-control">
' . $this->renderFileReferenceHeaderControl() . '
</div>';
}
/**
* Render the control-icons for a file reference (e.g. create new, sorting, delete, disable/enable).
*/
protected function renderFileReferenceHeaderControl(): string
{
$controls = [];
$databaseRow = $this->data['databaseRow'];
$databaseRow += [
'uid' => 0,
];
$parentConfig = $this->data['inlineParentConfig'];
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUserAuthentication();
$isNewItem = str_starts_with((string)$databaseRow['uid'], 'NEW');
$fileReferenceTableTca = $this->data['tcaSchemata']->get(self::FILE_REFERENCE_TABLE);
$calcPerms = new Permission(
$backendUser->calcPerms(BackendUtility::readPageAccess(
(int)($this->data['parentPageRow']['uid'] ?? 0),
$backendUser->getPagePermsClause(Permission::PAGE_SHOW)
))
);
$event = $this->eventDispatcher->dispatch(
new ModifyFileReferenceEnabledControlsEvent($this->data, $databaseRow)
);
if ($this->data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$controls['localize'] = $this->iconFactory
->getIcon('actions-edit-localize-status-low', IconSize::SMALL)
->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:localize.isLocalizable'))
->render();
}
if ($event->isControlEnabled('info')) {
if ($isNewItem) {
$controls['info'] = '
<span class="btn btn-default disabled">
' . $this->iconFactory->getIcon('empty-empty', IconSize::SMALL)->render() . '
</span>';
} else {
$controls['info'] = '
<button type="button" class="btn btn-default" data-action="infowindow" data-info-table="' . htmlspecialchars('_FILE') . '" data-info-uid="' . (int)$databaseRow['uid_local'][0]['uid'] . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:showInfo')) . '">
' . $this->iconFactory->getIcon('actions-document-info', IconSize::SMALL)->render() . '
</button>';
}
}
// If the table is NOT a read-only table, then show these links:
if (!($parentConfig['readOnly'] ?? false)
&& !($fileReferenceTableTca->getCapability(TcaSchemaCapability::AccessReadOnly)->getValue() ?? false)
&& !($this->data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ?? false)
) {
if ($event->isControlEnabled('sort')) {
$icon = 'actions-move-up';
$class = '';
if ((int)$parentConfig['inline']['first'] === (int)$databaseRow['uid']) {
$class = ' disabled';
$icon = 'empty-empty';
}
$controls['sort.up'] = '
<button type="button" class="btn btn-default' . $class . '" data-action="sort" data-direction="up" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:moveUp')) . '">
' . $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() . '
</button>';
$icon = 'actions-move-down';
$class = '';
if ((int)$parentConfig['inline']['last'] === (int)$databaseRow['uid']) {
$class = ' disabled';
$icon = 'empty-empty';
}
$controls['sort.down'] = '
<button type="button" class="btn btn-default' . $class . '" data-action="sort" data-direction="down" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:moveDown')) . '">
' . $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() . '
</button>';
}
$sysFileMetadataTableTca = $this->data['tcaSchemata']->has('sys_file_metadata') ? $this->data['tcaSchemata']->get('sys_file_metadata') : null;
if (!$isNewItem
&& ($languageField = ($sysFileMetadataTableTca?->getRawConfiguration()['languageField'] ?? false))
&& $backendUser->check('tables_modify', 'sys_file_metadata')
&& $event->isControlEnabled('edit')
) {
$languageId = (int)(is_array($databaseRow[$languageField] ?? null)
? ($databaseRow[$languageField][0] ?? 0)
: ($databaseRow[$languageField] ?? 0));
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_metadata');
$metadataRecord = $queryBuilder
->select('uid')
->from('sys_file_metadata')
->where(
$queryBuilder->expr()->eq(
'file',
$queryBuilder->createNamedParameter((int)$databaseRow['uid_local'][0]['uid'], Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
$languageField,
$queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT)
)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!empty($metadataRecord)) {
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit[sys_file_metadata][' . (int)$metadataRecord['uid'] . ']' => 'edit',
'module' => (string)($this->data['request']->getQueryParams()['module'] ?? ''),
'returnUrl' => $this->data['returnUrl'],
]);
$controls['edit'] = '
<a class="btn btn-default" href="' . htmlspecialchars($url) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.editMetadata')) . '">
' . $this->iconFactory->getIcon('actions-open', IconSize::SMALL)->render() . '
</a>';
}
}
if ($event->isControlEnabled('delete') && $calcPerms->editContentPermissionIsGranted()) {
$recordInfo = $this->data['databaseRow']['uid_local'][0]['title'] ?? $this->data['recordTitle'] ?? '';
if ($this->getBackendUserAuthentication()->shallDisplayDebugInformation()) {
$recordInfo .= ' [' . $this->data['tableName'] . ':' . $this->data['vanillaUid'] . ']';
}
$controls['delete'] = '
<button type="button" class="btn btn-default t3js-editform-delete-inline-record" data-record-info="' . htmlspecialchars(trim($recordInfo)) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')) . '">
' . $this->iconFactory->getIcon('actions-edit-delete', IconSize::SMALL)->render() . '
</button>';
}
if (($hiddenField = ($fileReferenceTableTca->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName())) !== ''
&& ($fileReferenceTableTca->hasField($hiddenField))
&& $event->isControlEnabled('hide')
&& (
!($fileReferenceTableTca->getField($hiddenField)->getConfiguration()['exclude'] ?? false)
|| $backendUser->check('non_exclude_fields', self::FILE_REFERENCE_TABLE . ':' . $hiddenField)
)
) {
if ($databaseRow[$hiddenField] ?? false) {
$controls['hide'] = '
<button type="button" class="btn btn-default t3js-toggle-visibility-button" data-hidden-field="' . htmlspecialchars($hiddenField) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')) . '">
' . $this->iconFactory->getIcon('actions-edit-unhide', IconSize::SMALL)->render() . '
</button>';
} else {
$controls['hide'] = '
<button type="button" class="btn btn-default t3js-toggle-visibility-button" data-hidden-field="' . htmlspecialchars($hiddenField) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')) . '">
' . $this->iconFactory->getIcon('actions-edit-hide', IconSize::SMALL)->render() . '
</button>';
}
}
if (($parentConfig['appearance']['useSortable'] ?? false) && $event->isControlEnabled('dragdrop')) {
$controls['dragdrop'] = '
<span class="btn btn-default sortableHandle" data-id="' . (int)$databaseRow['uid'] . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.move')) . '">
' . $this->iconFactory->getIcon('actions-move-move', IconSize::SMALL)->render() . '
</span>';
}
} elseif (($this->data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ?? false)
&& MathUtility::canBeInterpretedAsInteger($this->data['inlineParentUid'])
&& $event->isControlEnabled('localize')
) {
$controls['localize'] = '
<button type="button" class="btn btn-default t3js-synchronizelocalize-button" data-type="' . htmlspecialchars((string)$databaseRow['uid']) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:localize')) . '">
' . $this->iconFactory->getIcon('actions-document-localize', IconSize::SMALL)->render() . '
</button>';
}
if ($lockInfo = BackendUtility::isRecordLocked(self::FILE_REFERENCE_TABLE, $databaseRow['uid'])) {
$controls['locked'] = '
<button type="button" class="btn btn-default" title="' . htmlspecialchars($lockInfo['msg']) . '">
' . $this->iconFactory->getIcon('status-user-backend', IconSize::SMALL, 'overlay-edit')->render() . '
</button>';
}
// Get modified controls. This means their markup was modified, new controls were added or controls got removed.
$controls = $this->eventDispatcher->dispatch(
new ModifyFileReferenceControlsEvent($controls, $this->data, $databaseRow)
)->getControls();
$out = '';
if (($controls['edit'] ?? false) || ($controls['hide'] ?? false) || ($controls['delete'] ?? false)) {
$out .= '
<div class="btn-group btn-group-sm" role="group">
' . ($controls['edit'] ?? '') . ($controls['hide'] ?? '') . ($controls['delete'] ?? '') . '
</div>';
unset($controls['edit'], $controls['hide'], $controls['delete']);
}
if (($controls['info'] ?? false) || ($controls['new'] ?? false) || ($controls['sort.up'] ?? false) || ($controls['sort.down'] ?? false) || ($controls['dragdrop'] ?? false)) {
$out .= '
<div class="btn-group btn-group-sm" role="group">
' . ($controls['info'] ?? '') . ($controls['new'] ?? '') . ($controls['sort.up'] ?? '') . ($controls['sort.down'] ?? '') . ($controls['dragdrop'] ?? '') . '
</div>';
unset($controls['info'], $controls['new'], $controls['sort.up'], $controls['sort.down'], $controls['dragdrop']);
}
if ($controls['localize'] ?? false) {
$out .= '<div class="btn-group btn-group-sm" role="group">' . $controls['localize'] . '</div>';
unset($controls['localize']);
}
if ($controls !== [] && ($remainingControls = trim(implode('', $controls))) !== '') {
$out .= '<div class="btn-group btn-group-sm" role="group">' . $remainingControls . '</div>';
}
return $out;
}
protected function getRecordTitle(): string
{
$databaseRow = $this->data['databaseRow'];
$fileRecord = $databaseRow['uid_local'][0]['row'] ?? null;
if ($fileRecord === null) {
return $this->data['recordTitle'] ?: (string)$databaseRow['uid'];
}
$title = '<span>' . $this->getLabelFieldForRecord($databaseRow, $fileRecord, 'name') . '</span>';
// In debug mode, add the table name to the record title
if ($this->getBackendUserAuthentication()->shallDisplayDebugInformation()) {
$title .= ' <span class="panel-meta"><code>[' . self::FILE_REFERENCE_TABLE . ']</code></span>';
}
return $title;
}
protected function getLabelFieldForRecord(array $databaseRow, array $fileRecord, string $field): string
{
$value = '';
if (isset($databaseRow[$field])) {
$value = htmlspecialchars((string)$databaseRow[$field]);
} elseif (isset($fileRecord[$field])) {
$value = htmlspecialchars((string)$fileRecord[$field]);
}
return $value;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,448 @@
<?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\Form\Container;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Form\Event\CustomFileControlsEvent;
use TYPO3\CMS\Backend\Form\Event\CustomFileSelectorsEvent;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Resource\DefaultUploadFolderResolver;
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Files entry container.
*
* This container is the entry step to rendering a file reference. It is created by SingleFieldContainer.
*
* The code creates the main structure for the single file reference, initializes the inlineData array,
* that is manipulated and also returned in its manipulated state. The "control" stuff of file
* references is rendered here, for example the "create new" button.
*
* For each existing file reference, a FileReferenceContainer is called for further processing.
*/
class FilesControlContainer extends AbstractContainer
{
private const string FILE_REFERENCE_TABLE = 'sys_file_reference';
/**
* Inline data array used in JS, returned as JSON object to frontend
*/
protected array $fileReferenceData = [];
/**
* @var array<int,JavaScriptModuleInstruction|string|array<string,string>>
*/
protected array $javaScriptModules = [];
protected $defaultFieldWizard = [
'localizationStateSelector' => [
'renderType' => 'localizationStateSelector',
],
];
public function __construct(
private readonly IconFactory $iconFactory,
private readonly InlineStackProcessor $inlineStackProcessor,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly OnlineMediaHelperRegistry $onlineMediaHelperRegistry,
private readonly DefaultUploadFolderResolver $defaultUploadFolderResolver,
private readonly HashService $hashService,
) {}
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
$this->fileReferenceData = $this->data['inlineData'];
$inlineStructure = $this->data['inlineStructure'];
$table = $this->data['tableName'];
$row = $this->data['databaseRow'];
$field = $this->data['fieldName'];
$parameterArray = $this->data['parameterArray'];
$resultArray = $this->initializeResultArray();
$config = $parameterArray['fieldConf']['config'];
$isReadOnly = (bool)($config['readOnly'] ?? false);
$language = 0;
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
$languageFieldName = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$language = isset($row[$languageFieldName][0]) ? (int)$row[$languageFieldName][0] : (int)$row[$languageFieldName];
}
// Add the current inline job to the structure stack
$newStructureItem = [
'table' => $table,
'uid' => $row['uid'],
'field' => $field,
'config' => $config,
];
// Extract FlexForm parts (if any) from element name, e.g. array('vDEF', 'lDEF', 'FlexField', 'vDEF')
$itemName = (string)$parameterArray['itemFormElName'];
if ($itemName !== '') {
$flexFormParts = $this->extractFlexFormParts($itemName);
if ($flexFormParts !== null) {
$newStructureItem['flexform'] = $flexFormParts;
if ($flexFormParts !== []
&& isset($this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'])
) {
// Transport the flexform DS identifier fields to the FormFilesAjaxController
$config['dataStructureIdentifier'] = $this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'];
}
}
}
$inlineStructure['stable'][] = $newStructureItem;
// Hand over original returnUrl to FormFilesAjaxController. Needed if opening for instance a
// nested element in a new view to then go back to the original returnUrl and not the url of
// the inline ajax controller
$config['originalReturnUrl'] = $this->data['returnUrl'];
// e.g. data[<table>][<uid>][<field>]
$formFieldName = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure);
// e.g. data-<pid>-<table1>-<uid1>-<field1>-<table2>-<uid2>-<field2>
$formFieldIdentifier = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
$inlineChildren = $parameterArray['fieldConf']['children'] ?? [];
$config['inline']['first'] = $config['inline']['last'] = false;
if (is_array($inlineChildren) && $inlineChildren !== []) {
$firstChild = array_first($inlineChildren);
if (isset($firstChild['databaseRow']['uid'])) {
$config['inline']['first'] = $firstChild['databaseRow']['uid'];
}
$lastChild = array_last($inlineChildren);
if (isset($lastChild['databaseRow']['uid'])) {
$config['inline']['last'] = $lastChild['databaseRow']['uid'];
}
}
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$this->fileReferenceData['config'][$formFieldIdentifier] = [
'table' => self::FILE_REFERENCE_TABLE,
];
$configJson = (string)json_encode($config);
$this->fileReferenceData['config'][$formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE] = [
'min' => $config['minitems'] ?? null,
'max' => $config['maxitems'] ?? null,
'sortable' => $config['appearance']['useSortable'] ?? false,
'top' => [
'table' => $top['table'],
'uid' => $top['uid'],
],
'context' => [
'config' => $configJson,
'hmac' => $this->hashService->hmac($configJson, 'FilesContext'),
],
];
$this->fileReferenceData['nested'][$formFieldIdentifier] = $this->data['tabAndInlineStack'];
$resultArray['inlineData'] = $this->fileReferenceData;
// @todo: It might be a good idea to have something like "isLocalizedRecord" or similar set by a data provider
$uidOfDefaultRecord = 0;
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
$originPointerField = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
$uidOfDefaultRecord = $row[$originPointerField] ?? 0;
}
$isLocalizedParent = $language > 0
&& ($uidOfDefaultRecord[0] ?? $uidOfDefaultRecord) > 0
&& MathUtility::canBeInterpretedAsInteger($row['uid']);
$numberOfFullLocalizedChildren = 0;
$numberOfNotYetLocalizedChildren = 0;
foreach ($inlineChildren as $child) {
if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$numberOfFullLocalizedChildren++;
}
if ($isLocalizedParent && $child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$numberOfNotYetLocalizedChildren++;
}
}
if ($isReadOnly || $numberOfFullLocalizedChildren >= ($config['maxitems'] ?? 0)) {
$config['inline']['showNewFileReferenceButton'] = false;
$config['inline']['showCreateNewRelationButton'] = false;
$config['inline']['showOnlineMediaAddButtonStyle'] = false;
}
$fieldInformationResult = $this->renderFieldInformation();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$fieldWizardResult = $this->renderFieldWizard();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
$sortableRecordUids = $fileReferencesHtml = [];
foreach ($inlineChildren as $options) {
$options['inlineParentUid'] = $row['uid'];
$options['inlineFirstPid'] = $this->data['inlineFirstPid'];
$options['inlineParentConfig'] = $config;
$options['inlineData'] = $this->fileReferenceData;
$options['inlineStructure'] = $inlineStructure;
$options['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray'];
$options['renderType'] = 'fileReferenceContainer';
$fileReference = $this->nodeFactory->create($options)->render();
$fileReferencesHtml[] = $fileReference['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fileReference, false);
if (!$options['isInlineDefaultLanguageRecordInLocalizedParentContext'] && isset($options['databaseRow']['uid'])) {
// Don't add record to list of "valid" uids if it is only the default
// language record of a not yet localized child
$sortableRecordUids[] = $options['databaseRow']['uid'];
}
}
$view = $this->backendViewFactory->create($this->data['request']);
$view->assignMultiple([
'formFieldIdentifier' => $formFieldIdentifier,
'formFieldName' => $formFieldName,
'webComponentAttributes' => GeneralUtility::implodeAttributes([
'id' => $formFieldIdentifier,
'data-type' => 'file',
'data-object-group' => $formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE,
'data-form-field' => $formFieldName,
'data-expand-single' => (bool)($config['appearance']['expandSingle'] ?? false) ? 'true' : 'false',
'data-sortable' => (bool)($config['appearance']['useSortable'] ?? false) ? 'true' : 'false',
'data-min' => (int)($config['minitems'] ?? 0),
'data-max' => (int)($config['maxitems'] ?? 0),
], true),
'fieldInformation' => $fieldInformationResult['html'],
'fieldWizard' => $fieldWizardResult['html'],
'fileReferences' => [
'id' => $formFieldIdentifier . '_records',
'title' => $languageService->sL(trim($parameterArray['fieldConf']['label'] ?? '')),
'records' => implode(LF, $fileReferencesHtml),
],
'sortableRecordUids' => implode(',', $sortableRecordUids),
'validationRules' => $this->getValidationDataAsJsonString([
'type' => 'inline',
'minitems' => $config['minitems'] ?? null,
'maxitems' => $config['maxitems'] ?? null,
]),
]);
if (!$isReadOnly && ($config['appearance']['showFileSelectors'] ?? true) !== false) {
/** @var FileExtensionFilter $fileExtensionFilter */
$fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
$fileExtensionFilter->setAllowedFileExtensions($config['allowed'] ?? null);
$fileExtensionFilter->setDisallowedFileExtensions($config['disallowed'] ?? null);
$view->assign('fileSelectors', $this->getFileSelectors($inlineStructure, $config, $fileExtensionFilter));
$filteredFileExtensions = $fileExtensionFilter->getFilteredFileExtensions();
// Do not display "allowed file extensions" if all extensions are allowed (indicated by ['*'])
if (($filteredFileExtensions['allowedFileExtensions'] ?? null) === ['*']) {
$filteredFileExtensions = [];
}
$view->assignMultiple($filteredFileExtensions);
// Render the localization buttons if needed
if ($numberOfNotYetLocalizedChildren) {
$view->assignMultiple([
'showAllLocalizationLink' => !empty($config['appearance']['showAllLocalizationLink']),
'showSynchronizationLink' => !empty($config['appearance']['showSynchronizationLink']),
]);
}
}
$event = $this->eventDispatcher->dispatch(
new CustomFileControlsEvent($resultArray, $table, $field, $row, $config, $formFieldIdentifier, $formFieldName)
);
$resultArray = $event->getResultArray();
$controls = $event->getControls();
if ($controls !== []) {
$view->assign('customControls', [
'id' => $formFieldIdentifier . '_customControls',
'controls' => implode("\n", $controls),
]);
}
$resultArray['javaScriptModules'] = array_merge(
$resultArray['javaScriptModules'],
$this->javaScriptModules,
[JavaScriptModuleInstruction::create('@typo3/backend/form-engine/container/inline-control-container.js')]
);
$resultArray['html'] = $this->wrapWithFieldsetAndLegend($view->render('Form/FilesControlContainer'));
return $resultArray;
}
/**
* Generate buttons to select, reference and upload files.
*/
protected function getFileSelectors(array $inlineStructure, array $inlineConfiguration, FileExtensionFilter $fileExtensionFilter): array
{
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUserAuthentication();
$currentStructureDomObjectIdPrefix = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
$objectPrefix = $currentStructureDomObjectIdPrefix . '-' . self::FILE_REFERENCE_TABLE;
$controls = [];
if ($inlineConfiguration['appearance']['elementBrowserEnabled'] ?? true) {
if ($inlineConfiguration['appearance']['createNewRelationLinkTitle'] ?? false) {
$buttonText = $inlineConfiguration['appearance']['createNewRelationLinkTitle'];
} else {
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.createNewRelation';
}
$buttonText = $languageService->sL($buttonText);
$attributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-element-browser',
'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null,
'title' => $buttonText,
'data-mode' => 'file',
'data-allowed-types' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []),
'data-disallowed-types' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []),
'data-irre-object-id' => $objectPrefix,
];
$controls[] = '
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-insert-record', IconSize::SMALL)->render() . '
' . htmlspecialchars($buttonText) . '
</button>';
}
$onlineMediaAllowed = [];
foreach ($this->onlineMediaHelperRegistry->getSupportedFileExtensions() as $supportedFileExtension) {
if ($fileExtensionFilter->isAllowed($supportedFileExtension)) {
$onlineMediaAllowed[] = $supportedFileExtension;
}
}
$showUpload = (bool)($inlineConfiguration['appearance']['fileUploadAllowed'] ?? true);
$showByUrl = ($inlineConfiguration['appearance']['fileByUrlAllowed'] ?? true) && $onlineMediaAllowed !== [];
if (($showUpload || $showByUrl) && $backendUser->getUserSettings()->isUploadFieldsInTopOfEBEnabled()) {
$folder = $this->defaultUploadFolderResolver->resolve(
$backendUser,
$this->data['tableName'] === 'pages' ? $this->data['vanillaUid'] : ($this->data['parentPageRow']['uid'] ?? 0),
$this->data['tableName'],
$this->data['fieldName']
);
if (
$folder instanceof Folder
&& $folder->getStorage()->checkUserActionPermission('add', 'File')
) {
if ($showUpload) {
if ($inlineConfiguration['appearance']['uploadFilesLinkTitle'] ?? false) {
$buttonText = $inlineConfiguration['appearance']['uploadFilesLinkTitle'];
} else {
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:file_upload.select-and-submit';
}
$buttonText = $languageService->sL($buttonText);
$attributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-drag-uploader',
'title' => $buttonText,
'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null,
'data-dropzone-target' => '#' . StringUtility::escapeCssSelector($currentStructureDomObjectIdPrefix),
'data-insert-dropzone-before' => '1',
'data-file-irre-object' => $objectPrefix,
'data-file-allowed' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []),
'data-file-disallowed' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []),
'data-target-folder' => $folder->getCombinedIdentifier(),
'data-max-file-size' => (string)(GeneralUtility::getMaxUploadFileSize() * 1024),
];
$controls[] = '
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-upload', IconSize::SMALL)->render() . '
' . htmlspecialchars($buttonText) . '
</button>';
$this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/drag-uploader.js');
}
if ($showByUrl) {
if ($inlineConfiguration['appearance']['addMediaLinkTitle'] ?? false) {
$buttonText = $inlineConfiguration['appearance']['addMediaLinkTitle'];
} else {
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.button';
}
$buttonText = $languageService->sL($buttonText);
$attributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-online-media-add-btn',
'title' => $buttonText,
'hidden' => !($inlineConfiguration['inline']['showOnlineMediaAddButtonStyle'] ?? true) ? 'hidden' : null,
'data-target-folder' => $folder->getCombinedIdentifier(),
'data-file-irre-object' => $objectPrefix,
'data-online-media-allowed' => implode(',', $onlineMediaAllowed),
'data-btn-submit' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'),
'data-placeholder' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'),
'data-online-media-allowed-help-text' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.allowEmbedSources'),
];
// @todo Should be implemented as web component
$controls[] = '
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-online-media-add', IconSize::SMALL)->render() . '
' . htmlspecialchars($buttonText) . '
</button>';
$this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/online-media.js');
}
}
}
$event = $this->eventDispatcher->dispatch(
new CustomFileSelectorsEvent($controls, $this->javaScriptModules, $this->data['tableName'], $this->data['fieldName'], $this->data['databaseRow'], $inlineConfiguration, $fileExtensionFilter, $objectPrefix)
);
$this->javaScriptModules = $event->getJavaScriptModules();
return $event->getSelectors();
}
/**
* Extracts FlexForm parts of a form element name like
* data[table][uid][field][sDEF][lDEF][FlexForm][vDEF]
*/
protected function extractFlexFormParts(string $formElementName): ?array
{
$flexFormParts = null;
$matches = [];
if (preg_match('#^data(?:\[[^]]+\]){3}(\[data\](?:\[[^]]+\]){4,})$#', $formElementName, $matches)) {
$flexFormParts = GeneralUtility::trimExplode(
'][',
trim($matches[1], '[]')
);
}
return $flexFormParts;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,137 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Flex form container implementation
* This one is called by FlexFormSectionContainer and renders HTML for a single container.
* For processing of single elements FlexFormElementContainer is called
*/
class FlexFormContainerContainer extends AbstractContainer
{
public function __construct(
private readonly IconFactory $iconFactory,
) {}
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
$table = $this->data['tableName'];
$row = $this->data['databaseRow'];
$fieldName = $this->data['fieldName'];
$flexFormFormPrefix = $this->data['flexFormFormPrefix'];
$flexFormDataStructureArray = $this->data['flexFormDataStructureArray'];
$flexFormContainerIdentifier = $this->data['flexFormContainerIdentifier'];
$actionFieldName = 'data[' . $table . '][' . $row['uid'] . '][' . $fieldName . ']'
. $flexFormFormPrefix
. '[' . $flexFormContainerIdentifier . ']'
. '[_ACTION]';
$moveAndDeleteContent = [];
$userHasAccessToDefaultLanguage = $this->getBackendUserAuthentication()->checkLanguageAccess(0);
if ($userHasAccessToDefaultLanguage) {
$moveAndDeleteContent[] = ''
. '<button type="button" class="btn btn-default t3js-delete">'
. $this->iconFactory->getIcon('actions-edit-delete', IconSize::SMALL)->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:delete'))->render()
. '</button>';
$moveAndDeleteContent[] = ''
. '<button type="button" class="btn btn-default t3js-sortable-handle sortableHandle">'
. $this->iconFactory->getIcon('actions-move-move', IconSize::SMALL)->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:sortable.dragmove'))->render()
. '</button>';
}
$options = $this->data;
// Append container specific stuff to field prefix
$options['flexFormFormPrefix'] = $flexFormFormPrefix . '[' . $flexFormContainerIdentifier . '][' . $this->data['flexFormContainerName'] . '][el]';
$options['flexFormDataStructureArray'] = $flexFormDataStructureArray['el'];
$options['renderType'] = 'flexFormElementContainer';
$containerContentResult = $this->nodeFactory->create($options)->render();
$containerTitle = '';
if (!empty(trim($flexFormDataStructureArray['title']))) {
$containerTitle = $languageService->sL(trim($flexFormDataStructureArray['title']));
}
$resultArray = $this->initializeResultArray();
$parentSectionContainer = sprintf('flexform-section-container-%s-%s-%s-%s', $this->data['flexFormSheetName'], $this->data['fieldName'], md5($this->data['flexFormFieldName']), md5($this->data['elementBaseName']));
$flexFormDomContainerId = sprintf('%s-%s', $parentSectionContainer, $flexFormContainerIdentifier);
$containerAttributes = [
'class' => 'panel panel-default t3js-flex-section',
'data-parent' => $parentSectionContainer,
'data-flexform-container-id' => $flexFormContainerIdentifier,
];
$panelHeaderAttributes = [
'class' => 'panel-heading',
];
$toggleAttributes = [
'class' => 'panel-button collapsed',
'type' => 'button',
'data-bs-toggle' => 'collapse',
'data-bs-target' => '#' . $flexFormDomContainerId,
'aria-controls' => $flexFormDomContainerId,
'aria-expanded' => 'false',
];
$html = [];
$html[] = '<div ' . GeneralUtility::implodeAttributes($containerAttributes, true) . '>';
$html[] = '<div ' . GeneralUtility::implodeAttributes($panelHeaderAttributes, true) . '>';
$html[] = '<div class="panel-heading-row">';
$html[] = '<button ' . GeneralUtility::implodeAttributes($toggleAttributes, true) . '>';
$html[] = '<span class="caret"></span>';
$html[] = '<div class="panel-title">';
$html[] = '<output class="content-preview"></output>';
$html[] = '<span class="panel-meta">' . htmlspecialchars($containerTitle) . '</span>';
$html[] = '</div>';
$html[] = '</button>';
$html[] = '<div class="panel-actions t3js-formengine-irre-control">';
$html[] = '<div class="btn-group btn-group-sm">';
$html[] = implode(LF, $moveAndDeleteContent);
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '<div id="' . htmlspecialchars($flexFormDomContainerId) . '" class="panel-collapse collapse t3js-flex-section-content">';
$html[] = $containerContentResult['html'];
$html[] = '</div>';
$html[] = '<input class="t3js-flex-control-action" type="hidden" name="' . htmlspecialchars($actionFieldName) . '" value="" />';
$html[] = '</div>';
$resultArray['html'] = implode(LF, $html);
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $containerContentResult, false);
return $resultArray;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,153 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Backend\Form\Behavior\ReloadOnFieldChange;
use TYPO3\CMS\Backend\Form\Behavior\UpdateValueOnFieldChange;
use TYPO3\CMS\Core\Authentication\JsConfirmation;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* The container handles single elements.
*
* This one is called by FlexFormTabsContainer, FlexFormNoTabsContainer or FlexFormContainerContainer.
* For single fields, the code is similar to SingleFieldContainer, processing will end up in single
* element classes depending on specific renderType of an element. Additionally, it determines if a
* section is handled and hands over to FlexFormSectionContainer in this case.
*/
class FlexFormElementContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$flexFormDataStructureArray = $this->data['flexFormDataStructureArray'];
$flexFormRowData = $this->data['flexFormRowData'];
$flexFormFormPrefix = $this->data['flexFormFormPrefix'];
$parameterArray = $this->data['parameterArray'];
$languageService = $this->getLanguageService();
$resultArray = $this->initializeResultArray();
foreach ($flexFormDataStructureArray as $flexFormFieldName => $flexFormFieldArray) {
if (
// No item array found at all
!is_array($flexFormFieldArray)
// Not a section or container and not a list of single items
|| (!isset($flexFormFieldArray['type']) && !is_array($flexFormFieldArray['config']))
) {
continue;
}
if (($flexFormFieldArray['type'] ?? null) === 'array') {
// Section
if (empty($flexFormFieldArray['section'])) {
$resultArray['html'] = LF . 'Section expected at ' . $flexFormFieldName . ' but not found';
continue;
}
$options = $this->data;
$options['flexFormDataStructureArray'] = $flexFormFieldArray;
$options['flexFormRowData'] = $flexFormRowData[$flexFormFieldName]['el'] ?? [];
$options['flexFormFieldName'] = $flexFormFieldName;
$options['renderType'] = 'flexFormSectionContainer';
$sectionContainerResult = $this->nodeFactory->create($options)->render();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $sectionContainerResult);
} else {
// Set up options for single element
$fakeParameterArray = [
'fieldConf' => [
'label' => $languageService->sL(trim($flexFormFieldArray['label'] ?? '')),
'config' => $flexFormFieldArray['config'] ?? [],
'children' => $flexFormFieldArray['children'] ?? [],
// https://docs.typo3.org/m/typo3/reference-tca/main/en-us/Columns/Properties/OnChange.html
'onChange' => $flexFormFieldArray['onChange'] ?? '',
],
'fieldChangeFunc' => $parameterArray['fieldChangeFunc'],
'label' => $parameterArray['label'] ?? '',
];
if (isset($flexFormFieldArray['description']) && !empty($flexFormFieldArray['description'])) {
$fakeParameterArray['fieldConf']['description'] = $flexFormFieldArray['description'];
}
if ($fakeParameterArray['fieldConf']['onChange'] === 'reload') {
$confirmation = $this->getBackendUserAuthentication()->jsConfirmation(JsConfirmation::TYPE_CHANGE);
$fakeParameterArray['fieldChangeFunc']['alert'] = new ReloadOnFieldChange($confirmation);
}
$originalFieldName = $parameterArray['itemFormElName'];
$fakeParameterArray['itemFormElName'] = $parameterArray['itemFormElName'] . $flexFormFormPrefix . '[' . $flexFormFieldName . '][vDEF]';
if ($fakeParameterArray['itemFormElName'] !== $originalFieldName) {
// If calculated itemFormElName is different from originalFieldName
// change the originalFieldName in TBE_EDITOR_fieldChanged. This is
// especially relevant for wizards writing their content back to hidden fields
$onFieldChange = $fakeParameterArray['fieldChangeFunc']['TBE_EDITOR_fieldChanged'] ?? null;
if ($onFieldChange instanceof UpdateValueOnFieldChange) {
$fakeParameterArray['fieldChangeFunc']['TBE_EDITOR_fieldChanged'] = $onFieldChange->withElementName($fakeParameterArray['itemFormElName']);
}
}
if (array_key_exists('vDEF', $flexFormRowData[$flexFormFieldName] ?? [])) {
$fakeParameterArray['itemFormElValue'] = $flexFormRowData[$flexFormFieldName]['vDEF'];
} else {
$fakeParameterArray['itemFormElValue'] = $fakeParameterArray['fieldConf']['config']['default'] ?? '';
}
$options = $this->data;
// Set either flexFormFieldName or flexFormContainerFieldName, depending on if we are a "regular" field or a flex container section field
if (empty($options['flexFormFieldName'])) {
$options['flexFormFieldName'] = $flexFormFieldName;
} else {
$options['flexFormContainerFieldName'] = $flexFormFieldName;
}
$options['parameterArray'] = $fakeParameterArray;
$options['elementBaseName'] = $this->data['elementBaseName'] . $flexFormFormPrefix . '[' . $flexFormFieldName . '][vDEF]';
if (!empty($flexFormFieldArray['config']['renderType'])) {
$options['renderType'] = $flexFormFieldArray['config']['renderType'];
} else {
// Fallback to type if no renderType is given
$options['renderType'] = $flexFormFieldArray['config']['type'];
}
$childResult = $this->nodeFactory->create($options)->render();
if (!empty($childResult['html'])) {
$html = [];
$html[] = '<div class="form-section" data-id="' . htmlspecialchars($flexFormFieldName) . '">';
$html[] = '<div class="form-group t3js-formengine-palette-field t3js-formengine-validation-marker">';
$html[] = '<div class="formengine-field-item t3js-formengine-field-item">';
$html[] = $childResult['html'];
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$resultArray['html'] .= implode(LF, $html);
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childResult, false);
}
}
}
return $resultArray;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,52 @@
<?php
/*
* 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\Form\Container;
/**
* Entry container to a flex form element. This container is created by
* SingleFieldContainer if a type='flex' field is rendered.
*
* It either forks a FlexFormTabsContainer or a FlexFormNoTabsContainer.
*/
class FlexFormEntryContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$flexFormDataStructureIdentifier = $this->data['parameterArray']['fieldConf']['config']['dataStructureIdentifier'];
$flexFormDataStructureArray = $this->data['parameterArray']['fieldConf']['config']['ds'];
$options = $this->data;
$options['flexFormDataStructureIdentifier'] = $flexFormDataStructureIdentifier;
$options['flexFormDataStructureArray'] = $flexFormDataStructureArray;
$options['flexFormRowData'] = $this->data['parameterArray']['itemFormElValue'];
$options['renderType'] = 'flexFormNoTabsContainer';
// Enable tabs if there is more than one sheet
if (count($flexFormDataStructureArray['sheets']) > 1) {
$options['renderType'] = 'flexFormTabsContainer';
}
$resultArray = $this->nodeFactory->create($options)->render();
$resultArray['html'] = '<div class="panel">' . $resultArray['html'] . '</div>';
$resultArray['html'] = $this->wrapWithFieldsetAndLegend($resultArray['html']);
return $resultArray;
}
}
@@ -0,0 +1,68 @@
<?php
/*
* 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\Form\Container;
/**
* Handle a flex form that has no tabs.
*
* This container is called by FlexFormEntryContainer if only a default sheet
* exists. It evaluates the display condition and hands over rendering of single
* fields to FlexFormElementContainer.
*/
class FlexFormNoTabsContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$parameterArray = $this->data['parameterArray'];
$flexFormDataStructureArray = $this->data['flexFormDataStructureArray'];
$flexFormRowData = $this->data['flexFormRowData'];
$resultArray = $this->initializeResultArray();
// Determine this single sheet name, most often it ends up with sDEF, except if only one sheet was defined
$flexFormSheetNames = array_keys($flexFormDataStructureArray['sheets']);
$sheetName = array_pop($flexFormSheetNames);
$flexFormRowDataSubPart = $flexFormRowData['data'][$sheetName]['lDEF'] ?? [];
unset($flexFormDataStructureArray['meta']);
if (!is_array($flexFormDataStructureArray['sheets'][$sheetName]['ROOT']['el'])) {
$resultArray['html'] = 'Data Structure ERROR: No [\'ROOT\'][\'el\'] element found in flex form definition.';
return $resultArray;
}
$options = $this->data;
$options['flexFormDataStructureArray'] = $flexFormDataStructureArray['sheets'][$sheetName]['ROOT']['el'];
$options['flexFormRowData'] = $flexFormRowDataSubPart;
$options['flexFormSheetName'] = $sheetName;
$options['flexFormFormPrefix'] = '[data][' . $sheetName . '][lDEF]';
$options['parameterArray'] = $parameterArray;
$resultArray = $this->initializeResultArray();
$fieldInformationResult = $this->renderFieldInformation();
$resultArray['html'] = $fieldInformationResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$options['renderType'] = 'flexFormElementContainer';
$childResult = $this->nodeFactory->create($options)->render();
return $this->mergeChildReturnIntoExistingResult($resultArray, $childResult, true);
}
}
@@ -0,0 +1,158 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handle flex form sections.
*
* This container is created by FlexFormElementContainer if a "single" element is in
* fact a section. For each existing section container it creates as FlexFormContainerContainer
* to render its inner fields.
* Additionally, a button for each possible container is rendered with information for the
* ajax controller that fetches one on click.
*/
class FlexFormSectionContainer extends AbstractContainer
{
public function __construct(
private readonly IconFactory $iconFactory,
) {}
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
$flexFormDataStructureArray = $this->data['flexFormDataStructureArray'];
$flexFormRowData = $this->data['flexFormRowData'];
$flexFormFieldName = $this->data['flexFormFieldName'];
$flexFormSheetName = $this->data['flexFormSheetName'];
$userHasAccessToDefaultLanguage = $this->getBackendUserAuthentication()->checkLanguageAccess(0);
$resultArray = $this->initializeResultArray();
// Render each existing container
foreach ($flexFormDataStructureArray['children'] as $flexFormContainerIdentifier => $containerDataStructure) {
$existingContainerData = $flexFormRowData[$flexFormContainerIdentifier];
$existingSectionContainerDataStructureType = key($existingContainerData);
$existingContainerData = $existingContainerData[$existingSectionContainerDataStructureType];
$options = $this->data;
$options['flexFormRowData'] = $existingContainerData['el'];
$options['flexFormDataStructureArray'] = $containerDataStructure;
$options['flexFormFormPrefix'] = $this->data['flexFormFormPrefix'] . '[' . $flexFormFieldName . '][el]';
$options['flexFormContainerName'] = $existingSectionContainerDataStructureType;
$options['flexFormContainerIdentifier'] = $flexFormContainerIdentifier;
$options['renderType'] = 'flexFormContainerContainer';
$flexFormContainerContainerResult = $this->nodeFactory->create($options)->render();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $flexFormContainerContainerResult);
}
$containerId = sprintf('flexform-section-container-%s-%s-%s-%s', $flexFormSheetName, $this->data['fieldName'], md5($flexFormFieldName), md5($this->data['elementBaseName']));
$sectionContainerId = sprintf('flexform-section-%s-%s-%s-%s', $flexFormSheetName, $this->data['fieldName'], md5($flexFormFieldName), md5($this->data['elementBaseName']));
$hashedSectionContainerId = 'section-' . md5($sectionContainerId);
// "New container" handling: Creates buttons for each possible container with all relevant information for the ajax call.
$containerTemplatesHtml = [];
foreach ($flexFormDataStructureArray['el'] as $flexFormContainerName => $flexFormFieldDefinition) {
$containerTitle = '';
if (!empty(trim($flexFormFieldDefinition['title']))) {
$containerTitle = $languageService->sL(trim($flexFormFieldDefinition['title']));
}
$containerTemplateHtml = [];
$containerTemplateHtml[] = '<a';
$containerTemplateHtml[] = 'href="#"';
$containerTemplateHtml[] = 'class="btn btn-default t3js-flex-container-add"';
$containerTemplateHtml[] = 'data-vanillauid="' . (int)$this->data['vanillaUid'] . '"';
// no int cast for databaseRow uid, this can be "NEW1234..."
$containerTemplateHtml[] = 'data-databaserowuid="' . htmlspecialchars($this->data['databaseRow']['uid']) . '"';
$containerTemplateHtml[] = 'data-command="' . htmlspecialchars($this->data['command']) . '"';
$containerTemplateHtml[] = 'data-tablename="' . htmlspecialchars($this->data['tableName']) . '"';
$containerTemplateHtml[] = 'data-fieldname="' . htmlspecialchars($this->data['fieldName']) . '"';
$containerTemplateHtml[] = 'data-recordtypevalue="' . $this->data['recordTypeValue'] . '"';
$containerTemplateHtml[] = 'data-flexformsheetname="' . htmlspecialchars($flexFormSheetName) . '"';
$containerTemplateHtml[] = 'data-flexformfieldname="' . htmlspecialchars($flexFormFieldName) . '"';
$containerTemplateHtml[] = 'data-flexformcontainername="' . htmlspecialchars($flexFormContainerName) . '"';
$containerTemplateHtml[] = 'data-target="#' . htmlspecialchars($hashedSectionContainerId) . '"';
$containerTemplateHtml[] = '>';
$containerTemplateHtml[] = $this->iconFactory->getIcon('actions-document-new', IconSize::SMALL)->render();
$containerTemplateHtml[] = htmlspecialchars(GeneralUtility::fixed_lgd_cs($containerTitle, 30));
$containerTemplateHtml[] = '</a>';
$containerTemplatesHtml[] = implode(LF, $containerTemplateHtml);
}
// Create new elements links
$createElementsHtml = [];
if ($userHasAccessToDefaultLanguage) {
$createElementsHtml[] = '<div class="t3-form-field-add-flexsection">';
$createElementsHtml[] = '<div class="btn-group">';
$createElementsHtml[] = implode('', $containerTemplatesHtml);
$createElementsHtml[] = '</div>';
$createElementsHtml[] = '</div>';
}
$sectionTitle = '';
if (!empty(trim($flexFormDataStructureArray['title'] ?? ''))) {
$sectionTitle = $languageService->sL(trim($flexFormDataStructureArray['title']));
}
// Wrap child stuff
$toggleAll = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.toggleall'));
$html = [];
$html[] = '<div class="form-section">';
$html[] = '<div class="t3-form-field-container t3-form-flex" id="' . htmlspecialchars($containerId) . '" data-section="#' . htmlspecialchars($hashedSectionContainerId) . '">';
$html[] = '<fieldset>';
$html[] = '<legend class="form-label t3js-formengine-label">';
$html[] = htmlspecialchars($sectionTitle);
$html[] = '</legend>';
$html[] = '<div class="form-group">';
$html[] = '<button class="btn btn-default t3-form-flexsection-toggle" type="button" title="' . $toggleAll . '">';
$html[] = $this->iconFactory->getIcon('actions-move-right', IconSize::SMALL)->render() . $toggleAll;
$html[] = '</button>';
$html[] = '</div>';
$html[] = '<div';
$html[] = 'id="' . htmlspecialchars($hashedSectionContainerId) . '"';
$html[] = 'class="panel-group panel-hover t3-form-field-container-flexsection t3-flex-container"';
$html[] = 'data-t3-flex-allow-restructure="' . ($userHasAccessToDefaultLanguage ? '1' : '0') . '"';
$html[] = '>';
$html[] = $resultArray['html'];
$html[] = '</div>';
$html[] = implode(LF, $createElementsHtml);
$html[] = '</fieldset>';
$html[] = '</div>';
$html[] = '</div>';
$resultArray['html'] = implode(LF, $html);
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create(
'@typo3/backend/form-engine/container/flex-form-section-container.js'
)->instance($containerId);
return $resultArray;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,96 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
/**
* Handle flex forms that have tabs (multiple "sheets").
*
* This container is called by FlexFormEntryContainer. It resolves each
* sheet and hands rendering of single sheet content over to FlexFormElementContainer.
*/
class FlexFormTabsContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
$parameterArray = $this->data['parameterArray'];
$flexFormDataStructureArray = $this->data['flexFormDataStructureArray'];
$flexFormRowData = $this->data['flexFormRowData'];
$resultArray = $this->initializeResultArray();
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/tab.js');
$domIdPrefix = 'DTM-' . md5($this->data['parameterArray']['itemFormElName']);
$tabCounter = 0;
$tabElements = [];
foreach ($flexFormDataStructureArray['sheets'] as $sheetName => $sheetDataStructure) {
$flexFormRowSheetDataSubPart = $flexFormRowData['data'][$sheetName]['lDEF'] ?? [];
if (!is_array($sheetDataStructure['ROOT']['el'])) {
$resultArray['html'] .= LF . 'No Data Structure ERROR: No [\'ROOT\'][\'el\'] found for sheet "' . $sheetName . '".';
continue;
}
$tabCounter++;
$options = $this->data;
$options['flexFormDataStructureArray'] = $sheetDataStructure['ROOT']['el'];
$options['flexFormRowData'] = $flexFormRowSheetDataSubPart;
$options['flexFormSheetName'] = $sheetName;
$options['flexFormFormPrefix'] = '[data][' . $sheetName . '][lDEF]';
$options['parameterArray'] = $parameterArray;
// Merge elements of this tab into a single list again and hand over to
// palette and single field container to render this group
$options['tabAndInlineStack'][] = [
'tab',
$domIdPrefix . '-' . $tabCounter,
];
$options['renderType'] = 'flexFormElementContainer';
$childReturn = $this->nodeFactory->create($options)->render();
if ($childReturn['html'] !== '') {
$tabElements[] = [
'label' => !empty(trim($sheetDataStructure['ROOT']['sheetTitle'] ?? '')) ? $languageService->sL(trim($sheetDataStructure['ROOT']['sheetTitle'])) : $sheetName,
'content' => $childReturn['html'],
'description' => trim($sheetDataStructure['ROOT']['sheetDescription'] ?? '') ? $languageService->sL(trim($sheetDataStructure['ROOT']['sheetDescription'])) : '',
'linkTitle' => trim($sheetDataStructure['ROOT']['sheetShortDescr'] ?? '') ? $languageService->sL(trim($sheetDataStructure['ROOT']['sheetShortDescr'])) : '',
];
}
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childReturn, false);
}
$fieldInformationResult = $this->renderFieldInformation();
$resultArray['html'] = $fieldInformationResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$resultArray['html'] .= $this->renderTabMenu($tabElements, $domIdPrefix);
return $resultArray;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,71 @@
<?php
/*
* 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\Form\Container;
/**
* Entry container called from controllers.
* It either calls a FullRecordContainer or ListOfFieldsContainer to render
* a full record or only some fields from a full record.
*/
class FormWrapContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$options = $this->data;
if (empty($this->data['fieldListToRender'])) {
$options['renderType'] = 'fullRecordContainer';
} else {
$options['renderType'] = 'listOfFieldsContainer';
}
$result = $this->nodeFactory->create($options)->render();
$childHtml = $result['html'];
$view = $this->backendViewFactory->create($this->data['request']);
$descriptionColumn = !empty($this->data['processedTca']['ctrl']['descriptionColumn'])
? $this->data['processedTca']['ctrl']['descriptionColumn'] : null;
if ($descriptionColumn !== null && isset($this->data['databaseRow'][$descriptionColumn])) {
$view->assign('recordDescription', $this->data['databaseRow'][$descriptionColumn]);
}
$readOnlyRecord = !empty($this->data['processedTca']['ctrl']['readOnly'])
? (bool)$this->data['processedTca']['ctrl']['readOnly'] : null;
if ($readOnlyRecord === true) {
$view->assign('recordReadonly', true);
}
$fieldInformationResult = $this->renderFieldInformation();
$fieldInformationHtml = $fieldInformationResult['html'];
$result = $this->mergeChildReturnIntoExistingResult($result, $fieldInformationResult, false);
$fieldWizardResult = $this->renderFieldWizard();
$fieldWizardHtml = $fieldWizardResult['html'];
$result = $this->mergeChildReturnIntoExistingResult($result, $fieldWizardResult, false);
$view->assignMultiple([
'fieldInformationHtml' => $fieldInformationHtml,
'fieldWizardHtml' => $fieldWizardHtml,
'childHtml' => $childHtml,
'isNewRecord' => $this->data['command'] === 'new',
]);
$result['html'] = $view->render('Form/FormWrapContainer');
return $result;
}
}
@@ -0,0 +1,89 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Backend\Form\Exception\NoFieldsToRenderException;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A container rendering a "full record". This is an entry container used as first
* step into the rendering tree..
*
* This container determines the to be rendered fields depending on the record type,
* initializes possible language base data, finds out if tabs should be rendered and
* then calls either TabsContainer or a NoTabsContainer for further processing.
*/
class FullRecordContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$recordTypeValue = $this->data['recordTypeValue'];
// List of items to be rendered
$itemList = $this->data['processedTca']['types'][$recordTypeValue]['showitem'];
$fieldsArray = GeneralUtility::trimExplode(',', $itemList, true);
if ($fieldsArray === []) {
throw new NoFieldsToRenderException('No fields defined for record type "' . $recordTypeValue . '" of table "' . $this->data['tableName'] . '"', 1730106227);
}
// Streamline the fields array
// First, make sure there is always a --div-- definition for the first element
if (!str_starts_with($fieldsArray[0], '--div--')) {
array_unshift($fieldsArray, '--div--;core.form.tabs:general');
}
// If first tab has no label definition, add "general" label
$firstTabHasLabel = count(GeneralUtility::trimExplode(';', $fieldsArray[0])) > 1;
if (!$firstTabHasLabel) {
$fieldsArray[0] = '--div--;core.form.tabs:general';
}
// If there are at least two --div-- definitions, inner container will be a TabContainer, else a NoTabContainer
$tabCount = 0;
foreach ($fieldsArray as $field) {
if (str_starts_with($field, '--div--')) {
$tabCount++;
}
}
$hasTabs = true;
if ($tabCount < 2) {
// Remove first tab definition again if there is only one tab defined
array_shift($fieldsArray);
$hasTabs = false;
}
$data = $this->data;
$data['fieldsArray'] = $fieldsArray;
if ($hasTabs) {
$data['renderType'] = 'tabsContainer';
} else {
$data['renderType'] = 'noTabsContainer';
}
return $this->nodeFactory->create($data)->render();
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,553 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Inline element entry container.
*
* This container is the entry step to rendering an inline element. It is created by SingleFieldContainer.
*
* The code creates the main structure for the single inline elements, initializes
* the inlineData array, that is manipulated and also returned back in its manipulated state.
* The "control" stuff of inline elements is rendered here, for example the "create new" button.
*
* For each existing inline relation an InlineRecordContainer is called for further processing.
*/
class InlineControlContainer extends AbstractContainer
{
/**
* Inline data array used in JS, returned as JSON object to frontend
*
* @var array
*/
protected $inlineData = [];
/**
* @var array<int,JavaScriptModuleInstruction>
*/
protected $javaScriptModules = [];
/**
* @var array Default wizards
*/
protected $defaultFieldWizard = [
'localizationStateSelector' => [
'renderType' => 'localizationStateSelector',
],
];
public function __construct(
private readonly IconFactory $iconFactory,
private readonly InlineStackProcessor $inlineStackProcessor,
private readonly HashService $hashService,
) {}
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
$this->inlineData = $this->data['inlineData'];
$inlineStructure = $this->data['inlineStructure'];
$table = $this->data['tableName'];
$row = $this->data['databaseRow'];
$field = $this->data['fieldName'];
$parameterArray = $this->data['parameterArray'];
$resultArray = $this->initializeResultArray();
$config = $parameterArray['fieldConf']['config'];
$foreign_table = $config['foreign_table'];
$isReadOnly = isset($config['readOnly']) && $config['readOnly'];
$language = 0;
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
$languageFieldName = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$language = isset($row[$languageFieldName][0]) ? (int)$row[$languageFieldName][0] : (int)($row[$languageFieldName] ?? 0);
}
// Add the current inline job to the structure stack
$newStructureItem = [
'table' => $table,
'uid' => $row['uid'],
'field' => $field,
'config' => $config,
];
// Extract FlexForm parts (if any) from element name, e.g. array('vDEF', 'lDEF', 'FlexField', 'vDEF')
if (!empty($parameterArray['itemFormElName'])) {
$flexFormParts = $this->extractFlexFormParts($parameterArray['itemFormElName']);
if ($flexFormParts !== null) {
$newStructureItem['flexform'] = $flexFormParts;
}
}
$inlineStructure['stable'][] = $newStructureItem;
// Transport the flexform DS identifier fields to the FormInlineAjaxController
if (!empty($newStructureItem['flexform'])
&& isset($this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'])
) {
$config['dataStructureIdentifier'] = $this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'];
}
// Hand over original returnUrl to FormInlineAjaxController. Needed if opening for instance a
// nested element in a new view to then go back to the original returnUrl and not the url of
// the inline ajax controller
$config['originalReturnUrl'] = $this->data['returnUrl'];
// e.g. data[<table>][<uid>][<field>]
$nameForm = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure);
// e.g. data-<pid>-<table1>-<uid1>-<field1>-<table2>-<uid2>-<field2>
$nameObject = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
$inlineChildren = $parameterArray['fieldConf']['children'] ?? [];
$config['inline']['first'] = $config['inline']['last'] = false;
if (is_array($inlineChildren) && $inlineChildren !== []) {
$firstChild = array_first($inlineChildren);
if (isset($firstChild['databaseRow']['uid'])) {
$config['inline']['first'] = $firstChild['databaseRow']['uid'];
}
$lastChild = array_last($inlineChildren);
if (isset($lastChild['databaseRow']['uid'])) {
$config['inline']['last'] = $lastChild['databaseRow']['uid'];
}
}
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$this->inlineData['config'][$nameObject] = [
'table' => $foreign_table,
];
$configJson = (string)json_encode($config);
$this->inlineData['config'][$nameObject . '-' . $foreign_table] = [
'top' => [
'table' => $top['table'],
'uid' => $top['uid'],
],
'context' => [
'config' => $configJson,
'hmac' => $this->hashService->hmac($configJson, 'InlineContext'),
],
];
$this->inlineData['nested'][$nameObject] = $this->data['tabAndInlineStack'];
$uniqueMax = 0;
$uniqueIds = [];
if ($config['foreign_unique'] ?? false) {
// Add inlineData['unique'] with JS unique configuration
// @todo: Improve validation and throw an exception if type is neither select nor group here
$type = ($config['selectorOrUniqueConfiguration']['config']['type'] ?? '') === 'select' ? 'select' : 'groupdb';
foreach ($inlineChildren as $child) {
// Determine used unique ids, skip not localized records
if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$value = $child['databaseRow'][$config['foreign_unique']];
// We're assuming there is only one connected value here for both select and group
if ($type === 'select') {
// A select field is an array of uids. See TcaSelectItems data provider for details.
// Pick first entry, ends up as eg. $value = 42.
$value = $value['0'] ?? [];
} else {
// A group field is an array of arrays containing uid + table + title + row.
// See TcaGroup data provider for details.
// Pick the first one (always on 0), and use uid + table only. Exclude title + row
// since the entire inlineData['unique'] array ends up in JavaScript in the end
// and we don't need and want the title and the entire row data in the frontend.
// Ends up as $value = [ 'uid' => '42', 'table' => 'tx_my_table' ]
$value = [
'uid' => $value[0]['uid'],
'table' => $value[0]['table'],
];
}
// Note structure of $value is different in select vs. group: It's a uid for select, but an
// array with uid + table for group.
if (isset($child['databaseRow']['uid'])) {
$uniqueIds[$child['databaseRow']['uid']] = $value;
}
}
}
$possibleRecords = $config['selectorOrUniquePossibleRecords'] ?? [];
$possibleRecordsUidToTitle = [];
foreach ($possibleRecords as $possibleRecord) {
$possibleRecordsUidToTitle[$possibleRecord['value']] = $possibleRecord['label'];
}
$uniqueMax = ($config['appearance']['useCombination'] ?? false) || empty($possibleRecords) ? -1 : count($possibleRecords);
$this->inlineData['unique'][$nameObject . '-' . $foreign_table] = [
'max' => $uniqueMax,
'used' => $uniqueIds,
'type' => $type,
'table' => $foreign_table,
'elTable' => $config['selectorOrUniqueConfiguration']['foreignTable'] ?? '',
'field' => $config['foreign_unique'] ?? '',
'selector' => ($config['selectorOrUniqueConfiguration']['isSelector'] ?? false) ? $type : false,
'possible' => $possibleRecordsUidToTitle,
];
}
$resultArray['inlineData'] = $this->inlineData;
// @todo: It might be a good idea to have something like "isLocalizedRecord" or similar set by a data provider
$uidOfDefaultRecord = 0;
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
$originPointerField = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
$uidOfDefaultRecord = $row[$originPointerField] ?? 0;
}
$isLocalizedParent = $language > 0
&& ($uidOfDefaultRecord[0] ?? $uidOfDefaultRecord) > 0
&& MathUtility::canBeInterpretedAsInteger($row['uid']);
$numberOfFullLocalizedChildren = 0;
$numberOfNotYetLocalizedChildren = 0;
foreach ($inlineChildren as $child) {
if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$numberOfFullLocalizedChildren++;
}
if ($isLocalizedParent && $child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$numberOfNotYetLocalizedChildren++;
}
}
// Render the localization buttons if needed
$localizationButtons = '';
if ($numberOfNotYetLocalizedChildren) {
// Add the "Localize all records" button before all child records:
if (!empty($config['appearance']['showAllLocalizationLink'])) {
$localizationButtons = ' ' . $this->getLevelInteractionButton('localize', $config);
}
// Add the "Synchronize with default language" button before all child records:
if (!empty($config['appearance']['showSynchronizationLink'])) {
$localizationButtons .= ' ' . $this->getLevelInteractionButton('synchronize', $config);
}
}
// Hide the "Create new record" button if there are more than maxitems or the field is read-only
if ($isReadOnly || $numberOfFullLocalizedChildren >= ($config['maxitems'] ?? 0) || ($uniqueMax > 0 && $numberOfFullLocalizedChildren >= $uniqueMax)) {
$config['inline']['hideNewButton'] = true;
}
// Render the "new record" level button:
$newRecordButton = '';
// For b/w compatibility, "showNewRecordLink" - in contrast to the other show* options - defaults to TRUE
if (!isset($config['appearance']['showNewRecordLink']) || $config['appearance']['showNewRecordLink']) {
$newRecordButton = $this->getLevelInteractionButton('newRecord', $config);
}
$fieldInformationResult = $this->renderFieldInformation();
$html = $fieldInformationResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
// Wrap all inline fields of a record with a custom element (container)
$formGroupAttributes = [
'id' => $nameObject,
'data-type' => 'record',
'data-object-group' => $nameObject . '-' . $foreign_table,
'data-form-field' => $nameForm,
'data-expand-single' => (bool)($config['appearance']['expandSingle'] ?? false) ? 'true' : 'false',
'data-sortable' => (bool)($config['appearance']['useSortable'] ?? false) ? 'true' : 'false',
'data-min' => (int)($config['minitems'] ?? 0),
'data-max' => (int)($config['maxitems'] ?? 0),
];
$html .= '<typo3-formengine-container-inline ' . GeneralUtility::implodeAttributes($formGroupAttributes, true) . '>';
// Add the level buttons before all child records:
if (in_array($config['appearance']['levelLinksPosition'], ['both', 'top'], true)) {
$html .= '<div class="form-group t3js-formengine-validation-marker t3js-inline-controls">' . $newRecordButton . $localizationButtons . '</div>';
}
// If it's required to select from possible child records (reusable children), add a selector box
if (!$isReadOnly && ($config['foreign_selector'] ?? false) && ($config['appearance']['showPossibleRecordsSelector'] ?? true) !== false) {
if (($config['selectorOrUniqueConfiguration']['config']['type'] ?? false) === 'select') {
$selectorBox = $this->renderPossibleRecordsSelectorTypeSelect($inlineStructure, $config, $uniqueIds);
} else {
$selectorBox = $this->renderPossibleRecordsSelectorTypeGroupDB($inlineStructure, $config);
}
$html .= $selectorBox . $localizationButtons;
}
$title = $languageService->sL(trim($parameterArray['fieldConf']['label'] ?? ''));
$html .= '<div class="panel-group panel-hover" data-title="' . htmlspecialchars($title) . '" id="' . $nameObject . '_records">';
$sortableRecordUids = [];
foreach ($inlineChildren as $options) {
$options['inlineParentUid'] = $row['uid'];
$options['inlineFirstPid'] = $this->data['inlineFirstPid'];
// @todo: this can be removed if this container no longer sets additional info to $config
$options['inlineParentConfig'] = $config;
$options['inlineData'] = $this->inlineData;
$options['inlineStructure'] = $inlineStructure;
$options['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray'];
$options['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($options)->render();
$html .= $childResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childResult, false);
if (!$options['isInlineDefaultLanguageRecordInLocalizedParentContext'] && isset($options['databaseRow']['uid'])) {
// Don't add record to list of "valid" uids if it is only the default
// language record of a not yet localized child
$sortableRecordUids[] = $options['databaseRow']['uid'];
}
}
$html .= '</div>';
$fieldWizardResult = $this->renderFieldWizard();
$fieldWizardHtml = $fieldWizardResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
$html .= $fieldWizardHtml;
// Add the level buttons after all child records:
if (in_array($config['appearance']['levelLinksPosition'], ['both', 'bottom'], true)) {
$html .= '<div class="form-group t3js-formengine-validation-marker t3js-inline-controls">' . $newRecordButton . $localizationButtons . '</div>';
}
if (is_array($config['customControls'] ?? false)) {
$html .= '<div id="' . $nameObject . '_customControls">';
foreach ($config['customControls'] as $customControlConfig) {
if (!isset($customControlConfig['userFunc'])) {
throw new \RuntimeException('Support for customControl without a userFunc key in TCA type inline is not supported.', 1548052629);
}
$parameters = [
'table' => $table,
'field' => $field,
'row' => $row,
'nameObject' => $nameObject,
'nameForm' => $nameForm,
'config' => $config,
'customControlConfig' => $customControlConfig,
// Warning: By reference should be used with care here and exists mostly to allow additional $resultArray['javaScriptModules']
'resultArray' => &$resultArray,
];
$html .= GeneralUtility::callUserFunction($customControlConfig['userFunc'], $parameters, $this);
}
$html .= '</div>';
}
$resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $this->javaScriptModules);
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create(
'@typo3/backend/form-engine/container/inline-control-container.js'
);
// Publish the uids of the child records in the given order to the browser
$html .= '<input type="hidden" name="' . $nameForm . '" value="' . implode(',', $sortableRecordUids) . '" '
. ' data-formengine-validation-rules="'
. htmlspecialchars($this->getValidationDataAsJsonString([
'type' => 'inline',
'minitems' => $config['minitems'] ?? null,
'maxitems' => $config['maxitems'] ?? null,
]))
. '"'
. ' class="inlineRecord" />';
// Close the wrap for all inline fields (container)
$html .= '</typo3-formengine-container-inline>';
$resultArray['html'] = $this->wrapWithFieldsetAndLegend($html);
return $resultArray;
}
/**
* Creates the HTML code of a general button to be used on a level of inline children.
* The possible keys for the parameter $type are 'newRecord', 'localize' and 'synchronize'.
*
* @param string $type The button type, values are 'newRecord', 'localize' and 'synchronize'.
* @param array $conf TCA configuration of the parent(!) field
* @return string The HTML code of the new button, wrapped in a div
*/
protected function getLevelInteractionButton(string $type, array $conf = []): string
{
$languageService = $this->getLanguageService();
$attributes = [];
switch ($type) {
case 'newRecord':
$title = htmlspecialchars($languageService->sL('core.core:cm.createnew'));
$icon = 'actions-plus';
$attributes['class'] = 'btn btn-default t3js-create-new-button';
$attributes['data-type'] = 'newRecord';
if (!empty($conf['inline']['hideNewButton'])) {
$attributes['hidden'] = 'hidden';
}
if (!empty($conf['appearance']['newRecordLinkAddTitle'])) {
$title = htmlspecialchars(sprintf(
$languageService->sL('core.core:cm.createnew.link'),
$languageService->sL($this->data['tcaSchemata']->get($conf['foreign_table'])->getTitle()),
));
} elseif (isset($conf['appearance']['newRecordLinkTitle']) && $conf['appearance']['newRecordLinkTitle'] !== '') {
$title = htmlspecialchars($languageService->sL($conf['appearance']['newRecordLinkTitle']));
}
break;
case 'localize':
$title = htmlspecialchars($languageService->sL('core.misc:localizeAllRecords'));
$icon = 'actions-document-localize';
$attributes['class'] = 'btn btn-default t3js-synchronizelocalize-button';
$attributes['data-type'] = 'localize';
break;
case 'synchronize':
$title = htmlspecialchars($languageService->sL('core.misc:synchronizeWithOriginalLanguage'));
$icon = 'actions-document-synchronize';
$attributes['class'] = 'btn btn-default t3js-synchronizelocalize-button';
$attributes['data-type'] = 'synchronize';
break;
default:
$title = '';
$icon = '';
}
// Create the button:
$icon = $icon ? $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() : '';
$attributes['title'] = $title;
return '
<button type="button" ' . GeneralUtility::implodeAttributes($attributes, true, true) . '>
' . $icon . ' ' . $title . '
</button>
';
}
/**
* Generate a button that opens an element browser in a new window.
* For group/db there is no way to use a "selector" like a <select>|</select>-box.
*
* @param array $inlineConfiguration TCA inline configuration of the parent(!) field
* @return string A HTML button that opens an element browser in a new window
*/
protected function renderPossibleRecordsSelectorTypeGroupDB(array $inlineStructure, array $inlineConfiguration): string
{
$languageService = $this->getLanguageService();
$groupFieldConfiguration = $inlineConfiguration['selectorOrUniqueConfiguration']['config'];
$objectPrefix = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']) . '-' . $inlineConfiguration['foreign_table'];
$elementBrowserEnabled = (bool)($inlineConfiguration['appearance']['elementBrowserEnabled'] ?? true);
// Remove any white-spaces from the allowed extension lists
$allowed = GeneralUtility::trimExplode(',', (string)($groupFieldConfiguration['allowed'] ?? ''), true);
$item = '';
if ($elementBrowserEnabled) {
if (!empty($inlineConfiguration['appearance']['createNewRelationLinkTitle'])) {
$createNewRelationText = htmlspecialchars($languageService->sL($inlineConfiguration['appearance']['createNewRelationLinkTitle']));
} else {
$createNewRelationText = htmlspecialchars($languageService->sL('core.core:cm.createNewRelation'));
}
$item .= '
<button type="button" class="btn btn-default t3js-element-browser" data-mode="db"
data-allowed-types="' . htmlspecialchars(implode(',', $allowed)) . '"
data-irre-object-id="' . htmlspecialchars($objectPrefix) . '"
title="' . $createNewRelationText . '">
' . $this->iconFactory->getIcon('actions-insert-record', IconSize::SMALL)->render() . '
' . $createNewRelationText . '
</button>';
}
$item = '<div class="form-control-wrap t3js-inline-controls">' . $item . '</div>';
if (!empty($allowed)) {
$item .= '
<div class="form-text mt-2">
' . htmlspecialchars($languageService->sL('core.core:cm.allowedRelations')) . '
<ul class="badge-list">
' . implode(' ', array_map(static fn(string $item): string => '<li><span class="badge badge-secondary">' . strtoupper($item) . '</span></li>', $allowed)) . '
</ul>
</div>';
}
return '<div class="form-group t3js-formengine-validation-marker">' . $item . '</div>';
}
/**
* Get a selector as used for the select type, to select from all available
* records and to create a relation to the embedding record (e.g. like MM).
*
* @param array $config TCA inline configuration of the parent(!) field
* @param array $uniqueIds The uids that have already been used and should be unique
* @return string A HTML <select> box with all possible records
*/
protected function renderPossibleRecordsSelectorTypeSelect(array $inlineStructure, array $config, array $uniqueIds)
{
$config += [
'autoSizeMax' => 0,
'foreign_table' => '',
];
$possibleRecords = $config['selectorOrUniquePossibleRecords'];
$nameObject = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
// Create option tags:
$opt = [];
foreach ($possibleRecords as $possibleRecord) {
if (!in_array($possibleRecord['value'], $uniqueIds)) {
$opt[] = '<option value="' . htmlspecialchars($possibleRecord['value']) . '">' . htmlspecialchars($possibleRecord['label']) . '</option>';
}
}
// Put together the selector box:
$size = (int)($config['size'] ?? 0);
$autoSizeMax = (int)($config['autoSizeMax'] ?? 0);
if ($autoSizeMax > 0) {
$size = MathUtility::forceIntegerInRange($size, 1);
$size = MathUtility::forceIntegerInRange(count($possibleRecords) + 1, $size, $autoSizeMax);
}
$item = '
<select id="' . $nameObject . '-' . $config['foreign_table'] . '_selector" class="form-select t3js-create-new-selector"' . ($size ? ' size="' . $size . '"' : '') . '>
' . implode('', $opt) . '
</select>';
if ($size <= 1) {
// Add a "Create new relation" button for adding new relations
// This is necessary, if the size of the selector is "1" or if
// there is only one record item in the select-box, that is selected by default
// The selector-box creates a new relation on using an onChange event (see some line above)
if (!empty($config['appearance']['createNewRelationLinkTitle'])) {
$createNewRelationText = htmlspecialchars($this->getLanguageService()->sL($config['appearance']['createNewRelationLinkTitle']));
} else {
$createNewRelationText = htmlspecialchars($this->getLanguageService()->sL('core.core:cm.createNewRelation'));
}
$item .= '
<button type="button" class="btn btn-default t3js-create-new-button" title="' . $createNewRelationText . '">
' . $this->iconFactory->getIcon('actions-plus', IconSize::SMALL)->render() . $createNewRelationText . '
</button>';
}
// Wrap the selector and add a spacer to the bottom
$item = '<div class="input-group form-group t3js-formengine-validation-marker t3js-inline-controls">' . $item . '</div>';
return $item;
}
/**
* Extracts FlexForm parts of a form element name like
* data[table][uid][field][sDEF][lDEF][FlexForm][vDEF]
* Helper method used in inline
*
* @param string $formElementName The form element name
* @return array|null
*/
protected function extractFlexFormParts($formElementName)
{
$flexFormParts = null;
$matches = [];
if (preg_match('#^data(?:\[[^]]+\]){3}(\[data\](?:\[[^]]+\]){4,})$#', $formElementName, $matches)) {
$flexFormParts = GeneralUtility::trimExplode(
'][',
trim($matches[1], '[]')
);
}
return $flexFormParts;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,523 @@
<?php
/*
* 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\Form\Container;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Form\Event\ModifyInlineElementControlsEvent;
use TYPO3\CMS\Backend\Form\Event\ModifyInlineElementEnabledControlsEvent;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Render a single inline record relation.
*
* This container is called by InlineControlContainer to render single existing records.
* Furthermore, it is called by FormEngine for an incoming ajax request to expand an existing record
* or to create a new one.
*
* This container creates the outer HTML of single inline records - eg. drag and drop and delete buttons.
* For rendering of the record itself processing is handed over to FullRecordContainer.
*/
class InlineRecordContainer extends AbstractContainer
{
/**
* Inline data array used for JSON output
*
* @var array
*/
protected $inlineData = [];
public function __construct(
private readonly IconFactory $iconFactory,
private readonly InlineStackProcessor $inlineStackProcessor,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$data = $this->data;
$this->inlineData = $data['inlineData'];
$record = $data['databaseRow'];
$inlineConfig = $data['inlineParentConfig'];
$foreignTable = $inlineConfig['foreign_table'];
$resultArray = $this->initializeResultArray();
// Send a mapping information to the browser via JSON:
// e.g. data[<curTable>][<curId>][<curField>] => data-<pid>-<parentTable>-<parentId>-<parentField>-<curTable>-<curId>-<curField>
$formPrefix = $this->inlineStackProcessor->getFormPrefixFromStructure($data['inlineStructure']);
$domObjectId = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($data['inlineStructure'], $data['inlineFirstPid']);
$this->inlineData['map'][$formPrefix] = $domObjectId;
$resultArray['inlineData'] = $this->inlineData;
// Get the current naming scheme for DOM name/id attributes:
$appendFormFieldNames = '[' . $foreignTable . '][' . ($record['uid'] ?? 0) . ']';
$objectId = $domObjectId . '-' . $foreignTable . '-' . ($record['uid'] ?? 0);
$classes = [];
$html = '';
$combinationHtml = '';
$isNewRecord = $data['command'] === 'new';
$hiddenField = '';
if (isset($data['processedTca']['ctrl']['enablecolumns']['disabled'])) {
$hiddenField = $data['processedTca']['ctrl']['enablecolumns']['disabled'];
}
if (!$data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
if ($isNewRecord || $data['isInlineChildExpanded']) {
// Render full content ONLY IF this is an AJAX request, a new record, or the record is not collapsed
if (isset($data['combinationChild'])) {
$combinationChild = $this->renderCombinationChild($data, $appendFormFieldNames);
$combinationHtml = $combinationChild['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $combinationChild, false);
}
$childArray = $this->renderChild($data);
$html = $childArray['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray, false);
} else {
// This class is the marker for the JS-function to check if the full content has already been loaded
$classes[] = 't3js-not-loaded';
}
if ($isNewRecord) {
// Add pid of record as hidden field
$html .= '<input type="hidden" name="data' . htmlspecialchars($appendFormFieldNames)
. '[pid]" value="' . htmlspecialchars($record['pid']) . '"/>';
// Tell DataHandler this record is expanded
$ucFieldName = 'uc[inlineView]'
. '[' . $data['inlineTopMostParentTableName'] . ']'
. '[' . $data['inlineTopMostParentUid'] . ']'
. $appendFormFieldNames;
$html .= '<input type="hidden" name="' . htmlspecialchars($ucFieldName)
. '" value="' . (int)$data['isInlineChildExpanded'] . '" />';
} else {
// Set additional field for processing for saving
$html .= '<input type="hidden" name="cmd' . htmlspecialchars($appendFormFieldNames)
. '[delete]" value="1" disabled="disabled" />';
if (!empty($hiddenField) && (!$data['isInlineChildExpanded'] || !in_array($hiddenField, $data['columnsToProcess'], true))) {
$checked = !empty($record[$hiddenField]) ? ' checked="checked"' : '';
$html .= '<input type="checkbox" class="d-none" data-formengine-input-name="data'
. htmlspecialchars($appendFormFieldNames)
. '[' . htmlspecialchars($hiddenField) . ']" value="1"' . $checked . ' />';
$html .= '<input type="input" class="d-none" name="data' . htmlspecialchars($appendFormFieldNames)
. '[' . htmlspecialchars($hiddenField) . ']" value="' . htmlspecialchars($record[$hiddenField]) . '" />';
}
}
}
if ($inlineConfig['renderFieldsOnly'] ?? false) {
// Render "body" part only
$html .= $combinationHtml;
} else {
// Render header row and content (if expanded)
if ($data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$classes[] = 'panel-placeholder';
}
if (!empty($hiddenField) && isset($record[$hiddenField]) && (int)$record[$hiddenField]) {
$classes[] = 'panel-hidden';
}
if ($isNewRecord) {
$classes[] = 'inlineIsNewRecord';
}
$originalUniqueValue = '';
if (isset($record['uid'], $data['inlineData']['unique'][$domObjectId . '-' . $foreignTable]['used'][$record['uid']])) {
$uniqueValueValues = $data['inlineData']['unique'][$domObjectId . '-' . $foreignTable]['used'][$record['uid']];
// in case of site_language we don't have the full form engine options, so fallbacks need to be taken into account
$originalUniqueValue = ($uniqueValueValues['table'] ?? $foreignTable) . '_';
// @todo In what circumstance would $uniqueValueValues be an array that lacks a 'uid' key? Unclear, but
// it breaks the string concatenation. This is a hacky workaround for type safety only.
$uVV = ($uniqueValueValues['uid'] ?? $uniqueValueValues);
if (is_array($uVV)) {
$uVV = implode(',', $uVV);
}
$originalUniqueValue .= $uVV;
}
// The hashed object id needs a non-numeric prefix, the value is used as ID selector in JavaScript
$hashedObjectId = 'hash-' . md5($objectId);
$containerAttributes = [
'id' => $objectId . '_div',
'class' => 'form-irre-object panel panel-default ' . trim(implode(' ', $classes)),
'data-object-uid' => $record['uid'] ?? 0,
'data-object-id' => $objectId,
'data-object-id-hash' => $hashedObjectId,
'data-object-parent-group' => $domObjectId . '-' . $foreignTable,
'data-field-name' => $appendFormFieldNames,
'data-topmost-parent-table' => $data['inlineTopMostParentTableName'],
'data-topmost-parent-uid' => $data['inlineTopMostParentUid'],
'data-table-unique-original-value' => $originalUniqueValue,
'data-placeholder-record' => $data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ? '1' : '0',
];
$isExpanded = $data['isInlineChildExpanded'] ?? false;
$ariaControls = htmlspecialchars($objectId . '_fields', ENT_QUOTES | ENT_HTML5);
$html = '
<div ' . GeneralUtility::implodeAttributes($containerAttributes, true) . '>
<div class="panel-heading">
<div class="panel-heading-row">
' . $this->renderForeignRecordHeader($data, $isExpanded, $ariaControls) . '
</div>
</div>
<div class="panel-collapse collapse' . ($isExpanded ? ' show' : '') . '" id="' . $ariaControls . '">' . $html . $combinationHtml . '</div>
</div>';
}
$resultArray['html'] = $html;
return $resultArray;
}
/**
* Render inner child
*
* @return array Result array
*/
protected function renderChild(array $data)
{
$domObjectId = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($this->data['inlineStructure'], $data['inlineFirstPid']);
$data['tabAndInlineStack'][] = [
'inline',
$domObjectId . '-' . $data['tableName'] . '-' . $data['databaseRow']['uid'],
];
// @todo: ugly construct ...
$data['inlineData'] = $this->inlineData;
$data['renderType'] = 'fullRecordContainer';
return $this->nodeFactory->create($data)->render();
}
/**
* Render child child
*
* Render a table with FormEngine, that occurs on an intermediate table but should be editable directly,
* so two tables are combined (the intermediate table with attributes and the sub-embedded table).
* -> This is a direct embedding over two levels!
*
* @param array $data
* @param string $appendFormFieldNames The [<table>][<uid>] of the parent record (the intermediate table)
* @return array Result array
*/
protected function renderCombinationChild(array $data, $appendFormFieldNames)
{
$childData = $data['combinationChild'];
$parentConfig = $data['inlineParentConfig'];
// If field is set to readOnly, set all fields of the relation to readOnly as well
if (isset($parentConfig['readOnly']) && $parentConfig['readOnly']) {
foreach ($childData['processedTca']['columns'] as $columnName => $columnConfiguration) {
$childData['processedTca']['columns'][$columnName]['config']['readOnly'] = true;
}
}
$resultArray = $this->initializeResultArray();
// Display Warning FlashMessage if it is not suppressed
if (!isset($parentConfig['appearance']['suppressCombinationWarning']) || empty($parentConfig['appearance']['suppressCombinationWarning'])) {
$combinationWarningMessage = 'core.core:warning.inline_use_combination';
if (!empty($parentConfig['appearance']['overwriteCombinationWarningMessage'])) {
$combinationWarningMessage = $parentConfig['appearance']['overwriteCombinationWarningMessage'];
}
$message = $this->getLanguageService()->sL($combinationWarningMessage);
$markup = [];
// @TODO: This is not a FlashMessage! The markup must be changed and special CSS
// @TODO: should be created, in order to prevent confusion.
$markup[] = '<div class="alert alert-warning">';
$markup[] = ' <div class="alert-inner">';
$markup[] = ' <div class="alert-icon">';
$markup[] = ' <span class="icon-emphasized">';
$markup[] = ' ' . $this->iconFactory->getIcon('actions-exclamation', IconSize::SMALL)->render();
$markup[] = ' </span>';
$markup[] = ' </div>';
$markup[] = ' <div class="alert-content">';
$markup[] = ' <div class="alert-message">' . htmlspecialchars($message) . '</div>';
$markup[] = ' </div>';
$markup[] = ' </div>';
$markup[] = '</div>';
$resultArray['html'] = implode(LF, $markup);
}
$childArray = $this->renderChild($childData);
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray);
// If this is a new record, add a pid value to store this record and the pointer value for the intermediate table
if ($childData['command'] === 'new') {
$comboFormFieldName = 'data[' . $childData['tableName'] . '][' . $childData['databaseRow']['uid'] . '][pid]';
$resultArray['html'] .= '<input type="hidden" name="' . htmlspecialchars($comboFormFieldName) . '" value="' . htmlspecialchars($childData['databaseRow']['pid']) . '" />';
}
// If the foreign_selector field is also responsible for uniqueness, tell the browser the uid of the "other" side of the relation
if ($childData['command'] === 'new' || $parentConfig['foreign_unique'] === $parentConfig['foreign_selector']) {
$parentFormFieldName = 'data' . $appendFormFieldNames . '[' . $parentConfig['foreign_selector'] . ']';
$resultArray['html'] .= '<input type="hidden" name="' . htmlspecialchars($parentFormFieldName) . '" value="' . htmlspecialchars($childData['databaseRow']['uid']) . '" />';
}
return $resultArray;
}
/**
* Renders the HTML header for a foreign record, such as the title, toggle-function, drag'n'drop, etc.
* Later on the command-icons are inserted here.
*
* @param array $data Current data
* @param bool $isExpanded Whether the record is currently expanded
* @param string $ariaControls The ID of the collapse target element
* @return string The HTML code of the header
*/
protected function renderForeignRecordHeader(array $data, bool $isExpanded, string $ariaControls): string
{
$record = $data['databaseRow'];
$recordTitle = $data['recordTitle'];
$foreignTable = $data['inlineParentConfig']['foreign_table'];
$domObjectId = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($this->data['inlineStructure'], $data['inlineFirstPid']);
if (!empty($recordTitle)) {
// The user function may return HTML, therefore we can't escape it
if (empty($data['processedTca']['ctrl']['formattedLabel_userFunc'])) {
$recordTitle = htmlspecialchars($recordTitle);
}
} else {
$recordTitle = '<em>[' . htmlspecialchars($this->getLanguageService()->sL('core.core:labels.no_title')) . ']</em>';
}
// In case the record title is not generated by a formattedLabel_userFunc, which already
// contains custom markup, and we are in debug mode, add the inline record table name.
if (empty($data['processedTca']['ctrl']['formattedLabel_userFunc'])
&& $this->getBackendUserAuthentication()->shallDisplayDebugInformation()
) {
$recordTitle .= ' <span class="panel-meta"><code>[' . htmlspecialchars($foreignTable) . ']</code></span>';
}
$objectId = htmlspecialchars($domObjectId . '-' . $foreignTable . '-' . ($record['uid'] ?? 0));
return '
<button class="panel-button' . ($isExpanded ? '' : ' collapsed') . '" type="button"
data-bs-toggle="collapse" data-bs-target="#' . $ariaControls . '"
aria-expanded="' . ($isExpanded ? 'true' : 'false') . '" aria-controls="' . $ariaControls . '">
<span class="caret"></span>
<div class="panel-icon" id="' . $objectId . '_iconcontainer">
' . $this->iconFactory->getIconForRecord($foreignTable, $record, IconSize::SMALL, $this->data['tcaSchemata']->get($foreignTable))->setTitle(BackendUtility::getRecordIconAltText($record, $foreignTable, false))->render() . '
</div>
<div class="panel-title"><span id="' . $objectId . '_label">' . $recordTitle . '</span></div>
</button>
<div class="panel-actions t3js-formengine-irre-control">
' . $this->renderForeignRecordHeaderControl($data) . '
</div>';
}
/**
* Render the control-icons for a record header (create new, sorting, delete, disable/enable).
* Most of the parts are copy&paste from TYPO3\CMS\Backend\RecordList\DatabaseRecordList and
* modified for the JavaScript calls here
*
* @param array $data Current data
* @return string The HTML code with the control-icons
*/
protected function renderForeignRecordHeaderControl(array $data)
{
$rec = $data['databaseRow'];
$rec += [
'uid' => 0,
];
$inlineConfig = $data['inlineParentConfig'];
$foreignTable = $inlineConfig['foreign_table'];
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUserAuthentication();
// Initialize:
$cells = [
'hide' => '',
'delete' => '',
'info' => '',
'new' => '',
'sort.up' => '',
'sort.down' => '',
'dragdrop' => '',
'localize' => '',
'locked' => '',
];
$isNewItem = str_starts_with($rec['uid'], 'NEW');
$isParentReadOnly = isset($inlineConfig['readOnly']) && $inlineConfig['readOnly'];
$isParentExisting = MathUtility::canBeInterpretedAsInteger($data['inlineParentUid']);
$tableSchema = $this->data['tcaSchemata']->get($foreignTable);
$isPagesTable = $foreignTable === 'pages';
$enableManualSorting = ($tableSchema->hasCapability(TcaSchemaCapability::SortByField))
|| ($inlineConfig['MM'] ?? false)
|| (!($data['isOnSymmetricSide'] ?? false) && ($inlineConfig['foreign_sortby'] ?? false))
|| (($data['isOnSymmetricSide'] ?? false) && ($inlineConfig['symmetric_sortby'] ?? false));
$calcPerms = new Permission($backendUser->calcPerms(BackendUtility::readPageAccess((int)($data['parentPageRow']['uid'] ?? 0), $backendUser->getPagePermsClause(Permission::PAGE_SHOW))));
// If the listed table is 'pages' we have to request the permission settings for each page:
$localCalcPerms = new Permission(Permission::NOTHING);
if ($isPagesTable) {
$localCalcPerms = new Permission($backendUser->calcPerms(BackendUtility::getRecord('pages', $rec['uid'])));
}
// This expresses the edit permissions for this particular element:
$permsEdit = ($isPagesTable && $localCalcPerms->editPagePermissionIsGranted()) || (!$isPagesTable && $calcPerms->editContentPermissionIsGranted());
// The event contains all controls and their state (enabled / disabled), which might got modified by listeners
$event = $this->eventDispatcher->dispatch(new ModifyInlineElementEnabledControlsEvent($data, $rec));
if ($data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$cells['localize'] = $this->iconFactory
->getIcon('actions-edit-localize-status-low', IconSize::SMALL)
->setTitle($languageService->sL('core.misc:localize.isLocalizable'))
->render();
}
// "Info": (All records)
if ($event->isControlEnabled('info')) {
if ($isNewItem) {
$cells['info'] = '<span class="btn btn-default disabled">' . $this->iconFactory->getIcon('empty-empty', IconSize::SMALL)->render() . '</span>';
} else {
$cells['info'] = '
<button type="button" class="btn btn-default" data-action="infowindow" data-info-table="' . htmlspecialchars($foreignTable) . '" data-info-uid="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('core.mod_web_list:showInfo')) . '">
' . $this->iconFactory->getIcon('actions-document-info', IconSize::SMALL)->render() . '
</button>';
}
}
// If the table is NOT a read-only table, then show these links:
if (!$isParentReadOnly && !($tableSchema->hasCapability(TcaSchemaCapability::AccessReadOnly)) && !($data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ?? false)) {
// "New record after" link (ONLY if the records in the table are sorted by a "sortby"-row or if default values can depend on previous record):
if ($event->isControlEnabled('new') && ($enableManualSorting || (($tableSchema->getRawConfiguration()['useColumnsForDefaultValues'] ?? false)))) {
if ((!$isPagesTable && $calcPerms->editContentPermissionIsGranted()) || ($isPagesTable && $calcPerms->createPagePermissionIsGranted())) {
$cells['new'] = '
<button type="button" class="btn btn-default t3js-create-new-button" data-record-uid="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('core.mod_web_list:new' . ($isPagesTable ? 'Page' : 'Record'))) . '"' . (!empty($inlineConfig['inline']['hideNewButton']) ? ' hidden' : '') . '>
' . $this->iconFactory->getIcon('actions-' . ($isPagesTable ? 'page-new' : 'add'), IconSize::SMALL)->render() . '
</button>';
}
}
// "Up/Down" links
if ($event->isControlEnabled('sort') && $permsEdit && $enableManualSorting) {
// Up
$icon = 'actions-move-up';
$class = '';
if ($inlineConfig['inline']['first'] == $rec['uid']) {
$class = ' disabled';
$icon = 'empty-empty';
}
$cells['sort.up'] = '
<button type="button" class="btn btn-default' . $class . '" data-action="sort" data-direction="up" title="' . htmlspecialchars($languageService->sL('core.mod_web_list:moveUp')) . '">
' . $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() . '
</button>';
// Down
$icon = 'actions-move-down';
$class = '';
if ($inlineConfig['inline']['last'] == $rec['uid']) {
$class = ' disabled';
$icon = 'empty-empty';
}
$cells['sort.down'] = '
<button type="button" class="btn btn-default' . $class . '" data-action="sort" data-direction="down" title="' . htmlspecialchars($languageService->sL('core.mod_web_list:moveDown')) . '">
' . $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() . '
</button>';
}
// "Delete" link:
if ($event->isControlEnabled('delete')
&& (
($isPagesTable && $localCalcPerms->deletePagePermissionIsGranted())
|| (!$isPagesTable && $calcPerms->editContentPermissionIsGranted())
)
) {
$title = htmlspecialchars($languageService->sL('core.mod_web_list:delete'));
$icon = $this->iconFactory->getIcon('actions-edit-delete', IconSize::SMALL)->render();
$recordInfo = $data['databaseRow']['uid_local'][0]['title'] ?? $data['recordTitle'] ?? '';
if ($this->getBackendUserAuthentication()->shallDisplayDebugInformation()) {
$recordInfo .= ' [' . $data['tableName'] . ':' . $data['vanillaUid'] . ']';
}
$cells['delete'] = '
<button type="button" class="btn btn-default t3js-editform-delete-inline-record" data-record-info="' . htmlspecialchars(trim($recordInfo)) . '" title="' . $title . '">
' . $icon . '
</button>';
}
// "Hide/Unhide" links:
$hiddenField = $tableSchema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) ? $tableSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName() : '';
if ($event->isControlEnabled('hide')
&& $permsEdit
&& $hiddenField
&& ($tableSchema->hasField($hiddenField) ?? false)
&& (!($tableSchema->getField($hiddenField)->getConfiguration()['exclude'] ?? false) || $backendUser->check('non_exclude_fields', $foreignTable . ':' . $hiddenField))
) {
if ($rec[$hiddenField]) {
$title = htmlspecialchars($languageService->sL('core.mod_web_list:unHide' . ($isPagesTable ? 'Page' : '')));
$cells['hide'] = '
<button type="button" class="btn btn-default t3js-toggle-visibility-button" data-hidden-field="' . htmlspecialchars($hiddenField) . '" title="' . $title . '">
' . $this->iconFactory->getIcon('actions-edit-unhide', IconSize::SMALL)->render() . '
</button>';
} else {
$title = htmlspecialchars($languageService->sL('core.mod_web_list:hide' . ($isPagesTable ? 'Page' : '')));
$cells['hide'] = '
<button type="button" class="btn btn-default t3js-toggle-visibility-button" data-hidden-field="' . htmlspecialchars($hiddenField) . '" title="' . $title . '">
' . $this->iconFactory->getIcon('actions-edit-hide', IconSize::SMALL)->render() . '
</button>';
}
}
// Drag&Drop Sorting: Sortable handle
if ($event->isControlEnabled('dragdrop') && $permsEdit && $enableManualSorting && ($inlineConfig['appearance']['useSortable'] ?? false)) {
$cells['dragdrop'] = '
<span class="btn btn-default sortableHandle" data-id="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('core.core:labels.move')) . '">
' . $this->iconFactory->getIcon('actions-move-move', IconSize::SMALL)->render() . '
</span>';
}
} elseif (($data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ?? false) && $isParentExisting) {
if ($event->isControlEnabled('localize') && $data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$cells['localize'] = '
<button type="button" class="btn btn-default t3js-synchronizelocalize-button" data-type="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('core.misc:localize')) . '">
' . $this->iconFactory->getIcon('actions-document-localize', IconSize::SMALL)->render() . '
</button>';
}
}
// If the record is edit-locked by another user, we will show a little warning sign:
if ($lockInfo = BackendUtility::isRecordLocked($foreignTable, $rec['uid'])) {
$cells['locked'] = '
<button type="button" class="btn btn-default" title="' . htmlspecialchars($lockInfo['msg']) . '">
' . $this->iconFactory->getIcon('status-user-backend', IconSize::SMALL, 'overlay-edit')->render() . '
</button>';
}
// Get modified controls. This means their markup was modified, new controls were added or controls got removed.
$cells = $this->eventDispatcher->dispatch(new ModifyInlineElementControlsEvent($cells, $data, $rec))->getControls();
$out = '';
if (!empty($cells['hide']) || !empty($cells['delete'])) {
$out .= '<div class="btn-group btn-group-sm" role="group">' . $cells['hide'] . $cells['delete'] . '</div>';
unset($cells['hide'], $cells['delete']);
}
if (!empty($cells['info']) || !empty($cells['new']) || !empty($cells['sort.up']) || !empty($cells['sort.down']) || !empty($cells['dragdrop'])) {
$out .= '<div class="btn-group btn-group-sm" role="group">' . $cells['info'] . $cells['new'] . $cells['sort.up'] . $cells['sort.down'] . $cells['dragdrop'] . '</div>';
unset($cells['info'], $cells['new'], $cells['sort.up'], $cells['sort.down'], $cells['dragdrop']);
}
if (!empty($cells['localize'])) {
$out .= '<div class="btn-group btn-group-sm" role="group">' . $cells['localize'] . '</div>';
unset($cells['localize']);
}
if (!empty($cells)) {
$cellContent = trim(implode('', $cells));
$out .= $cellContent !== '' ? ' <div class="btn-group btn-group-sm" role="group">' . $cellContent . '</div>' : '';
}
return $out;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,100 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Render a given list of field of a TCA table.
*
* This is an entry container called from FormEngine to handle a
* list of specific fields. Access rights are checked here and globalOption array
* is prepared for further processing of single fields by PaletteAndSingleContainer.
*
* Using "hiddenFieldListToRender" it's also possible to render additional fields as
* hidden fields, which is e.g. used for the "generatorFields" of TCA type "slug".
*/
class ListOfFieldsContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$options = $this->data;
$options['fieldsArray'] = $this->sanitizeFieldList($this->data['fieldListToRender']);
if ($this->data['hiddenFieldListToRender'] ?? false) {
$hiddenFieldList = array_diff(
$this->sanitizeFieldList($this->data['hiddenFieldListToRender']),
$options['fieldsArray']
);
if ($hiddenFieldList !== []) {
$hiddenFieldList = implode(',', $hiddenFieldList);
$hiddenPaletteName = 'hiddenFieldsPalette' . md5($hiddenFieldList);
$options['processedTca']['palettes'][$hiddenPaletteName] = [
'isHiddenPalette' => true,
'showitem' => $hiddenFieldList,
];
$options['fieldsArray'][] = '--palette--;;' . $hiddenPaletteName;
}
}
$options['renderType'] = 'paletteAndSingleContainer';
return $this->nodeFactory->create($options)->render();
}
protected function sanitizeFieldList(string $fieldList): array
{
$fields = array_unique(GeneralUtility::trimExplode(',', $fieldList, true));
$fieldsByShowitem = $this->data['processedTca']['types'][$this->data['recordTypeValue']]['showitem'];
$fieldsByShowitem = GeneralUtility::trimExplode(',', $fieldsByShowitem, true);
$allowedFields = [];
foreach ($fields as $fieldName) {
foreach ($fieldsByShowitem as $fieldByShowitem) {
$fieldByShowitemArray = $this->explodeSingleFieldShowItemConfiguration($fieldByShowitem);
if ($fieldByShowitemArray['fieldName'] === $fieldName) {
$allowedFields[] = implode(';', $fieldByShowitemArray);
break;
}
if ($fieldByShowitemArray['fieldName'] === '--palette--'
&& isset($this->data['processedTca']['palettes'][$fieldByShowitemArray['paletteName']]['showitem'])
&& is_string($this->data['processedTca']['palettes'][$fieldByShowitemArray['paletteName']]['showitem'])
) {
$paletteName = $fieldByShowitemArray['paletteName'];
$paletteFields = GeneralUtility::trimExplode(',', $this->data['processedTca']['palettes'][$paletteName]['showitem'], true);
foreach ($paletteFields as $paletteField) {
$paletteFieldArray = $this->explodeSingleFieldShowItemConfiguration($paletteField);
if ($paletteFieldArray['fieldName'] === $fieldName) {
$allowedFields[] = implode(';', $paletteFieldArray);
break;
}
}
}
}
}
return $allowedFields;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,36 @@
<?php
/*
* 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\Form\Container;
/**
* Handle a record that has no tabs.
*
* This container is called by FullRecordContainer.
*/
class NoTabsContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$options = $this->data;
$options['renderType'] = 'paletteAndSingleContainer';
return $this->nodeFactory->create($options)->render();
}
}
@@ -0,0 +1,295 @@
<?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\Form\Container;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handle palettes and single fields.
*
* This container is called by TabsContainer, NoTabsContainer and ListOfFieldsContainer.
*
* This container mostly operates on TCA showItem of a specific type - the value is
* coming in from upper containers as "fieldArray". It handles palettes with all its
* different options and prepares rendering of single fields for the SingleFieldContainer.
*/
class PaletteAndSingleContainer extends AbstractContainer
{
/**
* Final result array accumulating results from children and final HTML
*/
protected array $resultArray = [];
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
/*
* The first code block creates a target structure array to later create the final
* HTML string. The single fields and sub containers are rendered here already and
* other parts of the return array from children except html are accumulated in
* $this->resultArray
*
$targetStructure = [
0 => [
'type' => 'palette',
'fieldName' => 'palette1',
'paletteLegend' => 'palette1',
'paletteDescription' => 'palette1Description',
'elements' => [
0 => [
'type' => 'single',
'fieldName' => 'paletteName',
'fieldHtml' => 'element1',
),
1 => [
'type' => 'linebreak',
),
2 => [
'type' => 'single',
'fieldName' => 'paletteName',
'fieldHtml' => 'element2',
],
],
],
1 => [
'type' => 'single',
'fieldName' => 'element3',
'fieldHtml' => 'element3',
],
2 => [
'type' => 'palette',
'fieldName' => 'palette2',
'paletteLegend' => '', // Palette label is optional
'paletteDescription' => '', // Palette description is optional
'elements' => [
0 => [
'type' => 'single',
'fieldName' => 'element4',
'fieldHtml' => 'element4',
],
1 => [
'type' => 'linebreak',
],
2 => [
'type' => 'single',
'fieldName' => 'element5',
'fieldHtml' => 'element5',
],
],
],
);
*/
// Create an intermediate structure of rendered sub elements and elements nested in palettes
$targetStructure = [];
$mainStructureCounter = -1;
$fieldsArray = $this->data['fieldsArray'];
$this->resultArray = $this->initializeResultArray();
foreach ($fieldsArray as $fieldString) {
$fieldConfiguration = $this->explodeSingleFieldShowItemConfiguration($fieldString);
$fieldName = $fieldConfiguration['fieldName'];
if ($fieldName === '--palette--') {
$paletteElementArray = $this->createPaletteContentArray($fieldConfiguration['paletteName'] ?? '');
if (!empty($paletteElementArray)) {
$mainStructureCounter++;
// If there is no label in ['types']['aType']['showitem'] for this palette: "--palette--;;aPalette",
// then use ['palettes']['aPalette']['label'] if given.
$paletteLegend = $fieldConfiguration['fieldLabel'];
if ($paletteLegend === null && !empty($this->data['processedTca']['palettes'][$fieldConfiguration['paletteName']]['label'])) {
$paletteLegend = $this->data['processedTca']['palettes'][$fieldConfiguration['paletteName']]['label'];
}
// Get description of palette.
$paletteDescription = $this->data['processedTca']['palettes'][$fieldConfiguration['paletteName']]['description'] ?? '';
$targetStructure[$mainStructureCounter] = [
'type' => 'palette',
'fieldName' => $fieldConfiguration['paletteName'],
'paletteLegend' => $languageService->sL($paletteLegend),
'paletteDescription' => $languageService->sL($paletteDescription),
'elements' => $paletteElementArray,
];
}
} else {
if (!is_array($this->data['processedTca']['columns'][$fieldName] ?? null)) {
continue;
}
$options = $this->data;
$options['fieldName'] = $fieldName;
$options['renderType'] = 'singleFieldContainer';
$childResultArray = $this->nodeFactory->create($options)->render();
if (!empty($childResultArray['html'])) {
$mainStructureCounter++;
$targetStructure[$mainStructureCounter] = [
'type' => 'single',
'fieldName' => $fieldConfiguration['fieldName'],
'fieldHtml' => $childResultArray['html'],
];
}
$this->resultArray = $this->mergeChildReturnIntoExistingResult($this->resultArray, $childResultArray, false);
}
}
// Compile final content
$content = [];
foreach ($targetStructure as $element) {
if ($element['type'] === 'palette') {
$paletteName = $element['fieldName'];
$isHiddenPalette = !empty($this->data['processedTca']['palettes'][$paletteName]['isHiddenPalette']);
$html = [];
$html[] = '<fieldset class="form-section' . ($isHiddenPalette ? ' hide' : '') . '">';
if (!empty($element['paletteLegend'])) {
$html[] = '<h3 class="form-section-headline">' . htmlspecialchars($element['paletteLegend']) . '</h3>';
}
if (!empty($element['paletteDescription'])) {
$html[] = '<p class="form-section-description">' . nl2br(htmlspecialchars($element['paletteDescription'])) . '</p>';
}
$html[] = $this->renderInnerPaletteContent($element);
$html[] = '</fieldset>';
$content[] = implode(LF, $html);
} else {
$html = [];
$html[] = '<fieldset class="form-section">';
$html[] = '<div class="form-group t3js-formengine-validation-marker t3js-formengine-palette-field">';
$html[] = $element['fieldHtml'];
$html[] = '</div>';
$html[] = '</fieldset>';
$content[] = implode(LF, $html);
}
}
$finalResultArray = $this->resultArray;
$finalResultArray['html'] = implode(LF, $content);
return $finalResultArray;
}
/**
* Render single fields of a given palette
*
* @param string $paletteName The palette to render
*/
protected function createPaletteContentArray(string $paletteName): array
{
// palette needs a palette name reference, otherwise it does not make sense to try rendering of it
if (empty($paletteName) || empty($this->data['processedTca']['palettes'][$paletteName]['showitem'])) {
return [];
}
$resultStructure = [];
$foundRealElement = false; // Set to true if not only line breaks were rendered
$fieldsArray = GeneralUtility::trimExplode(',', $this->data['processedTca']['palettes'][$paletteName]['showitem'], true);
foreach ($fieldsArray as $fieldString) {
$fieldArray = $this->explodeSingleFieldShowItemConfiguration($fieldString);
$fieldName = $fieldArray['fieldName'];
if ($fieldName === '--linebreak--') {
$resultStructure[] = [
'type' => 'linebreak',
];
} else {
if (!is_array($this->data['processedTca']['columns'][$fieldName] ?? null)) {
continue;
}
$options = $this->data;
$options['fieldName'] = $fieldName;
$options['renderType'] = 'singleFieldContainer';
$singleFieldContentArray = $this->nodeFactory->create($options)->render();
if (!empty($singleFieldContentArray['html'])) {
$foundRealElement = true;
$resultStructure[] = [
'type' => 'single',
'fieldName' => $fieldName,
'fieldHtml' => $singleFieldContentArray['html'],
];
}
$this->resultArray = $this->mergeChildReturnIntoExistingResult($this->resultArray, $singleFieldContentArray, false);
}
}
if ($foundRealElement) {
return $resultStructure;
}
return [];
}
/**
* Renders inner content of single elements of a palette and wrap it as needed
*
* @param array $elementArray Array of elements
* @return string Wrapped content
*/
protected function renderInnerPaletteContent(array $elementArray): string
{
$result = [];
$currentGroup = [];
foreach ($elementArray['elements'] as $element) {
if ($element['type'] === 'linebreak') {
// Render current group before linebreak
if (!empty($currentGroup)) {
$result[] = $this->renderFieldGroup($currentGroup);
$currentGroup = [];
}
} else {
$currentGroup[] = $element;
}
}
// Render remaining group
if (!empty($currentGroup)) {
$result[] = $this->renderFieldGroup($currentGroup);
}
return implode(LF, $result);
}
/**
* Renders a group of fields within a form-grid container
*
* @param array $fields Array of field elements
* @return string Rendered HTML
*/
protected function renderFieldGroup(array $fields): string
{
$numberOfItems = count($fields);
$result = [];
if ($numberOfItems > 1) {
$result[] = '<div class="form-grid" style="--typo3-form-grid-columns: ' . $numberOfItems . '">';
}
foreach ($fields as $element) {
$result[] = '<div class="form-group t3js-formengine-validation-marker t3js-formengine-palette-field">';
$result[] = $element['fieldHtml'];
$result[] = '</div>';
}
if ($numberOfItems > 1) {
$result[] = '</div>';
}
return implode(LF, $result);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,295 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Backend\Form\Behavior\ReloadOnFieldChange;
use TYPO3\CMS\Backend\Form\Behavior\UpdateValueOnFieldChange;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Form\Utility\FormEngineUtility;
use TYPO3\CMS\Core\Authentication\JsConfirmation;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Container around a "single field".
*
* This container is the last one in the chain before processing is handed over to single element classes.
* If a single field is of type flex or inline, it however creates FlexFormEntryContainer or InlineControlContainer.
*
* The container does various checks and processing for a given single fields.
*/
class SingleFieldContainer extends AbstractContainer
{
public function __construct(
private readonly InlineStackProcessor $inlineStackProcessor
) {}
public function render(): array
{
$backendUser = $this->getBackendUserAuthentication();
$resultArray = $this->initializeResultArray();
$table = $this->data['tableName'];
$row = $this->data['databaseRow'];
$fieldName = $this->data['fieldName'];
$parameterArray = [];
$parameterArray['fieldConf'] = $this->data['processedTca']['columns'][$fieldName];
$isOverlay = false;
// This field decides whether the current record is an overlay (as opposed to being a standalone record)
// Based on this decision we need to trigger field exclusion or special rendering (like readOnly)
if (isset($this->data['processedTca']['ctrl']['transOrigPointerField'])
&& is_array($this->data['processedTca']['columns'][$this->data['processedTca']['ctrl']['transOrigPointerField']] ?? null)
) {
$parentValue = $row[$this->data['processedTca']['ctrl']['transOrigPointerField']];
if (MathUtility::canBeInterpretedAsInteger($parentValue)) {
$isOverlay = (bool)$parentValue;
} elseif (is_array($parentValue)) {
// This case may apply if the value has been converted to an array by the select or group data provider
$isOverlay = !empty($parentValue) ? (bool)$parentValue[0] : false;
} else {
throw new \InvalidArgumentException(
'The given value "' . $parentValue . '" for the original language field ' . $this->data['processedTca']['ctrl']['transOrigPointerField']
. ' of table ' . $table . ' is invalid.',
1470742770
);
}
}
// A couple of early returns in case the field should not be rendered
$fieldIsExcluded = $parameterArray['fieldConf']['exclude'] ?? false;
$fieldNotExcludable = $backendUser->check('non_exclude_fields', $table . ':' . $fieldName);
$fieldExcludedFromTranslatedRecords = empty($parameterArray['fieldConf']['l10n_display']) && ($parameterArray['fieldConf']['l10n_mode'] ?? '') === 'exclude';
// Return if BE-user has no access rights to this field, @todo: another user access rights check!
if (($fieldIsExcluded && !$fieldNotExcludable) || ($isOverlay && $fieldExcludedFromTranslatedRecords) || $this->inlineFieldShouldBeSkipped()) {
return $resultArray;
}
$tsConfig = $this->data['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.'] ?? [];
$parameterArray['fieldTSConfig'] = is_array($tsConfig) ? $tsConfig : [];
if ($parameterArray['fieldTSConfig']['disabled'] ?? false) {
return $resultArray;
}
// Override fieldConf by fieldTSconfig:
$parameterArray['fieldConf']['config'] = FormEngineUtility::overrideFieldConf($parameterArray['fieldConf']['config'], $parameterArray['fieldTSConfig']);
$parameterArray['itemFormElName'] = 'data[' . $table . '][' . $row['uid'] . '][' . $fieldName . ']';
$newElementBaseName = isset($this->data['elementBaseName']) ? $this->data['elementBaseName'] . '[' . $table . '][' . $row['uid'] . '][' . $fieldName . ']' : '';
// The value to show in the form field.
$parameterArray['itemFormElValue'] = $row[$fieldName];
// Set field to read-only if configured for translated records to show default language content as readonly
// Note: In such case, the database value of this field was already overridden by DatabaseRowDefaultAsReadonly.
if (($parameterArray['fieldConf']['l10n_display'] ?? false)
&& GeneralUtility::inList($parameterArray['fieldConf']['l10n_display'], 'defaultAsReadonly')
&& $isOverlay
) {
$parameterArray['fieldConf']['config']['readOnly'] = true;
}
$processedTcaType = $this->data['processedTca']['ctrl']['type'] ?? '';
$typeField = !str_contains($processedTcaType, ':')
? $processedTcaType
: substr($processedTcaType, 0, (int)strpos($processedTcaType, ':'));
// JavaScript code for event handlers:
$parameterArray['fieldChangeFunc'] = [];
$parameterArray['fieldChangeFunc']['TBE_EDITOR_fieldChanged'] = new UpdateValueOnFieldChange(
$table,
(string)$row['uid'],
$fieldName,
$parameterArray['itemFormElName']
);
$requestFormEngineUpdate
= (!empty($this->data['processedTca']['ctrl']['type']) && $fieldName === $typeField)
|| (isset($parameterArray['fieldConf']['onChange']) && $parameterArray['fieldConf']['onChange'] === 'reload');
if ($requestFormEngineUpdate) {
$askForUpdate = $backendUser->jsConfirmation(JsConfirmation::TYPE_CHANGE);
$parameterArray['fieldChangeFunc']['record_type_changed'] = new ReloadOnFieldChange($askForUpdate);
}
// Based on the type of the item, call a render function on a child element
$options = $this->data;
$options['parameterArray'] = $parameterArray;
$options['elementBaseName'] = $newElementBaseName;
if (!empty($parameterArray['fieldConf']['config']['renderType'])) {
$options['renderType'] = $parameterArray['fieldConf']['config']['renderType'];
} else {
// Fallback to type if no renderType is given
$options['renderType'] = $parameterArray['fieldConf']['config']['type'];
}
return $this->nodeFactory->create($options)->render();
}
/**
* Rendering of inline fields should be skipped under certain circumstances
*/
protected function inlineFieldShouldBeSkipped(): bool
{
$table = $this->data['tableName'];
$fieldName = $this->data['fieldName'];
$fieldConfig = $this->data['processedTca']['columns'][$fieldName]['config'];
$fieldConfig += [
'MM' => '',
'foreign_table' => '',
'foreign_selector' => '',
'foreign_field' => '',
];
if (($this->data['inlineStructure']['stable'] ?? []) !== []) {
$searchArray = [
'%OR' => [
'config' => [
0 => [
'%AND' => [
'foreign_table' => $table,
'%OR' => [
'%AND' => [
'appearance' => ['useCombination' => true],
'foreign_selector' => $fieldName,
],
'MM' => $fieldConfig['MM'],
],
],
],
1 => [
'%AND' => [
'foreign_table' => $fieldConfig['foreign_table'],
'foreign_selector' => $fieldConfig['foreign_field'],
],
],
],
],
];
// If we have symmetric fields, check on which side we are and hide fields, that are set automatically:
if ($this->data['isOnSymmetricSide']) {
$searchArray['%OR']['config'][0]['%AND']['%OR']['symmetric_field'] = $fieldName;
$searchArray['%OR']['config'][0]['%AND']['%OR']['symmetric_sortby'] = $fieldName;
} else {
$searchArray['%OR']['config'][0]['%AND']['%OR']['foreign_field'] = $fieldName;
$searchArray['%OR']['config'][0]['%AND']['%OR']['foreign_sortby'] = $fieldName;
}
// Parent record from structure stack
$parent = $this->inlineStackProcessor->getStructureLevelFromStructure($this->data['inlineStructure'], -1) ?? [];
return $this->arrayCompareComplex($parent, $searchArray);
}
return false;
}
/**
* Handles complex comparison requests on an array.
* A request could look like the following:
*
* $searchArray = array(
* '%AND' => array(
* 'key1' => 'value1',
* 'key2' => 'value2',
* '%OR' => array(
* 'subarray' => array(
* 'subkey' => 'subvalue'
* ),
* 'key3' => 'value3',
* 'key4' => 'value4'
* )
* )
* );
*
* It is possible to use the array keys '%AND.1', '%AND.2', etc. to prevent
* overwriting the sub-array. It could be necessary, if you use complex comparisons.
*
* The example above means, key1 *AND* key2 (and their values) have to match with
* the $subjectArray and additional one *OR* key3 or key4 have to meet the same
* condition.
* It is also possible to compare parts of a sub-array (e.g. "subarray"), so this
* function recurses down one level in that sub-array.
*
* @param array $subjectArray The array to search in
* @param array $searchArray The array with keys and values to search for
* @param string $type Use '%AND' or '%OR' for comparison
* @return bool The result of the comparison
*/
protected function arrayCompareComplex(array $subjectArray, array $searchArray, string $type = ''): bool
{
$localMatches = 0;
$localEntries = 0;
if ($searchArray !== []) {
// If no type was passed, try to determine
if (!$type) {
reset($searchArray);
$type = (string)key($searchArray);
$searchArray = current($searchArray);
}
// We use '%AND' and '%OR' in uppercase
$type = strtoupper($type);
// Split regular elements from sub elements
foreach ($searchArray as $key => $value) {
$localEntries++;
// Process a sub-group of OR-conditions
if ($key === '%OR') {
$localMatches += $this->arrayCompareComplex($subjectArray, $value, '%OR') ? 1 : 0;
} elseif ($key === '%AND') {
$localMatches += $this->arrayCompareComplex($subjectArray, $value, '%AND') ? 1 : 0;
} elseif (is_array($value) && $this->isAssociativeArray($searchArray)) {
$localMatches += $this->arrayCompareComplex($subjectArray[$key], $value, $type) ? 1 : 0;
} elseif (is_array($value)) {
$localMatches += $this->arrayCompareComplex($subjectArray, $value, $type) ? 1 : 0;
} else {
if (isset($subjectArray[$key]) && isset($value)) {
// Boolean match:
if (is_bool($value)) {
$localMatches += !($subjectArray[$key] xor $value) ? 1 : 0;
} elseif (is_numeric($subjectArray[$key]) && is_numeric($value)) {
$localMatches += $subjectArray[$key] == $value ? 1 : 0;
} else {
$localMatches += $subjectArray[$key] === $value ? 1 : 0;
}
}
}
// If one or more matches are required ('OR'), return TRUE after the first successful match
if ($type === '%OR' && $localMatches > 0) {
return true;
}
// If all matches are required ('AND') and we have no result after the first run, return FALSE
if ($type === '%AND' && $localMatches == 0) {
return false;
}
}
}
// Return the result for '%AND' (if nothing was checked, TRUE is returned)
return $localEntries === $localMatches;
}
/**
* Checks whether an object is an associative array.
*
* @param mixed $object The object to be checked
* @return bool Returns TRUE, if the object is an associative array
*/
protected function isAssociativeArray($object)
{
return is_array($object) && !empty($object) && array_keys($object) !== range(0, count($object) - 1);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,190 @@
<?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\Form\Container;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Site\SiteLanguagePresets;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Site languages entry container
*
* @internal This container is only used in the site configuration module and is not public API
*/
class SiteLanguageContainer extends AbstractContainer
{
private const string FOREIGN_TABLE = 'site_language';
private const string FOREIGN_FIELD = 'languageId';
protected array $inlineData;
public function __construct(
private readonly InlineStackProcessor $inlineStackProcessor,
private readonly SiteLanguagePresets $siteLanguagePresets,
private readonly HashService $hashService,
) {}
public function render(): array
{
$this->inlineData = $this->data['inlineData'];
$inlineStructure = $this->data['inlineStructure'];
$row = $this->data['databaseRow'];
$parameterArray = $this->data['parameterArray'];
$config = $parameterArray['fieldConf']['config'];
$resultArray = $this->initializeResultArray();
// Add the current inline job to the structure stack
$inlineStructure['stable'][] = [
'table' => $this->data['tableName'],
'uid' => $row['uid'],
'field' => $this->data['fieldName'],
'config' => $config,
];
// Hand over original returnUrl to SiteInlineAjaxController. Needed if opening for instance a
// nested element in a new view to then go back to the original returnUrl and not the url of
// the site inline ajax controller.
$config['originalReturnUrl'] = $this->data['returnUrl'];
// e.g. data[site][1][languages]
$nameForm = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure);
// e.g. data-0-site-1-languages
$nameObject = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
// e.g. array('table' => 'site', 'uid' => '1', 'field' => 'languages', 'config' => array())
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$this->inlineData['config'][$nameObject] = [
'table' => self::FOREIGN_TABLE,
];
$configJson = (string)json_encode($config);
$this->inlineData['config'][$nameObject . '-' . self::FOREIGN_TABLE] = [
'min' => $config['minitems'],
'max' => $config['maxitems'],
'sortable' => false,
'top' => [
'table' => $top['table'],
'uid' => $top['uid'],
],
'context' => [
'config' => $configJson,
'hmac' => $this->hashService->hmac($configJson, 'InlineContext'),
],
];
$this->inlineData['nested'][$nameObject] = $this->data['tabAndInlineStack'];
$uniqueIds = [];
foreach ($parameterArray['fieldConf']['children'] as $children) {
$value = (int)($children['databaseRow'][self::FOREIGN_FIELD]['0'] ?? 0);
if (isset($children['databaseRow']['uid'])) {
$uniqueIds[$children['databaseRow']['uid']] = $value;
}
}
$uniquePossibleRecords = $config['uniquePossibleRecords'] ?? [];
$possibleRecordsUidToTitle = [];
foreach ($uniquePossibleRecords as $possibleRecord) {
$possibleRecordsUidToTitle[$possibleRecord['value']] = $possibleRecord['label'];
}
$this->inlineData['unique'][$nameObject . '-' . self::FOREIGN_TABLE] = [
// Usually "max" would be the number of possible records. However, since
// we also allow new languages to be created, we just use the maxitems value.
'max' => $config['maxitems'],
// "used" must be a string array
'used' => array_map(strval(...), $uniqueIds),
'table' => self::FOREIGN_TABLE,
'elTable' => self::FOREIGN_TABLE,
'field' => self::FOREIGN_FIELD,
'possible' => $possibleRecordsUidToTitle,
];
$resultArray['inlineData'] = $this->inlineData;
$fieldInformationResult = $this->renderFieldInformation();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$selectorOptions = $childRecordUids = $childHtml = [];
foreach ($config['uniquePossibleRecords'] ?? [] as $record) {
// Do not add the PHP_INT_MAX placeholder or already configured languages
if ($record['value'] !== PHP_INT_MAX && !in_array($record['value'], $uniqueIds, true)) {
$selectorOptions[] = ['value' => (string)$record['value'], 'label' => (string)$record['label']];
}
}
foreach ($this->data['parameterArray']['fieldConf']['children'] as $children) {
$children['inlineParentUid'] = $row['uid'];
$children['inlineFirstPid'] = $this->data['inlineFirstPid'];
$children['inlineParentConfig'] = $config;
$children['inlineData'] = $this->inlineData;
$children['inlineStructure'] = $inlineStructure;
$children['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray'];
$children['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($children)->render();
$childHtml[] = $childResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childResult, false);
if (isset($children['databaseRow']['uid'])) {
$childRecordUids[] = $children['databaseRow']['uid'];
}
}
$view = $this->backendViewFactory->create($this->data['request']);
$view->assignMultiple([
'nameObject' => $nameObject,
'nameForm' => $nameForm,
'webComponentAttributes' => GeneralUtility::implodeAttributes([
'id' => $nameObject,
'data-type' => 'language',
'data-object-group' => $nameObject . '-' . self::FOREIGN_TABLE,
'data-form-field' => $nameForm,
'data-expand-single' => (bool)($config['appearance']['expandSingle'] ?? false) ? 'true' : 'false',
'data-sortable' => 'false',
'data-min' => (int)($config['minitems'] ?? 0),
'data-max' => (int)($config['maxitems'] ?? 0),
], true),
'fieldInformation' => $fieldInformationResult['html'],
'selectorConfiguration' => [
'identifier' => $nameObject . '-' . self::FOREIGN_TABLE . '_selector',
'options' => $selectorOptions,
],
'inlineRecords' => [
'identifier' => $nameObject . '_records',
'title' => trim($parameterArray['fieldConf']['label'] ?? ''),
'records' => implode(PHP_EOL, $childHtml),
],
'childRecordUids' => implode(',', $childRecordUids),
'validationRules' => $this->getValidationDataAsJsonString([
'type' => 'inline',
'minitems' => $config['minitems'] ?? null,
'maxitems' => $config['maxitems'] ?? null,
]),
'presetOptions' => [
'identifier' => $nameObject . '_preset',
'options' => $this->siteLanguagePresets->getAllForSelector(),
],
]);
$resultArray['html'] = $this->wrapWithFieldsetAndLegend($view->render('Form/SiteLanguageContainer'));
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/form-engine/container/inline-control-container.js');
return $resultArray;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/*
* 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\Form\Container;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
/**
* Render all tabs of a record that has tabs.
*
* This container is called from FullRecordContainer and resolves the --div-- structure,
* operates on given fieldArrays and calls a PaletteAndSingleContainer for each single tab.
*/
class TabsContainer extends AbstractContainer
{
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
* @throws \RuntimeException
*/
public function render(): array
{
$languageService = $this->getLanguageService();
// All the fields to handle in a flat list
$fieldsArray = $this->data['fieldsArray'];
// Create a nested array from flat fieldArray list
$tabsArray = [];
// First element will be a --div--, so it is safe to start -1 here to trigger 0 as first array index
$currentTabIndex = -1;
foreach ($fieldsArray as $fieldString) {
$fieldArray = $this->explodeSingleFieldShowItemConfiguration($fieldString);
if ($fieldArray['fieldName'] === '--div--') {
$currentTabIndex++;
if (empty($fieldArray['fieldLabel'])) {
throw new \RuntimeException(
'A --div-- has no label (--div--;fieldLabel) in showitem of ' . implode(',', $fieldsArray),
1426454001
);
}
$tabsArray[$currentTabIndex] = [
'label' => $languageService->sL($fieldArray['fieldLabel']),
'elements' => [],
];
} else {
$tabsArray[$currentTabIndex]['elements'][] = $fieldArray;
}
}
$resultArray = $this->initializeResultArray();
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/tab.js');
$domIdPrefix = 'DTM-' . md5($this->data['tableName'] . $this->data['databaseRow']['uid']);
$tabCounter = 0;
$tabElements = [];
foreach ($tabsArray as $tabWithLabelAndElements) {
$tabCounter++;
$elements = $tabWithLabelAndElements['elements'];
// Merge elements of this tab into a single list again and hand over to
// palette and single field container to render this group
$options = $this->data;
$options['tabAndInlineStack'][] = [
'tab',
$domIdPrefix . '-' . $tabCounter,
];
$options['fieldsArray'] = [];
foreach ($elements as $element) {
$options['fieldsArray'][] = implode(';', $element);
}
$options['renderType'] = 'paletteAndSingleContainer';
$childArray = $this->nodeFactory->create($options)->render();
if ($childArray['html'] !== '') {
$tabElements[] = [
'label' => $tabWithLabelAndElements['label'],
'content' => $childArray['html'],
];
}
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray, false);
}
$resultArray['html'] = $this->renderTabMenu($tabElements, $domIdPrefix);
return $resultArray;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}