Files
cms-backend/Classes/Controller/ContextualRecordEditController.php

454 lines
19 KiB
PHP

<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Dto\FormElementData;
use TYPO3\CMS\Backend\Form\Exception\AccessDeniedException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordWorkspaceDeletePlaceholderException;
use TYPO3\CMS\Backend\Form\Exception\NoFieldsToRenderException;
use TYPO3\CMS\Backend\Form\FormAction;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\FormResultCollection;
use TYPO3\CMS\Backend\Form\FormResultFactory;
use TYPO3\CMS\Backend\Form\FormResultHandler;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RedirectResponse;
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\Page\PageRenderer;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Lightweight controller for editing a single existing record inside the context panel.
*
* This is a focused alternative to EditDocumentController that only handles editing
* one existing record. It renders the same FormEngine form but with a minimal contextual
* template, communicating save/close signals to the parent frame via JavaScript.
*
* @internal This controller is not part of the TYPO3 public API.
*/
#[AsController]
readonly class ContextualRecordEditController
{
public function __construct(
private PageRenderer $pageRenderer,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private ModuleProvider $moduleProvider,
private FormDataCompiler $formDataCompiler,
private NodeFactory $nodeFactory,
private FormResultFactory $formResultFactory,
private FormResultHandler $formResultHandler,
private TcaSchemaFactory $tcaSchemaFactory,
private IconFactory $iconFactory,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
return $request->getMethod() === 'POST' ? $this->persistAction($request) : $this->renderAction($request);
}
/**
* Handle POST: process save/close via DataHandler and redirect back
*/
private function persistAction(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$editConf = $this->parseAndValidateEditConf($queryParams['edit'] ?? []);
$table = $editConf['table'];
$uid = $this->resolveOverlayUid($table, $editConf['uid']);
$requestAction = FormAction::createFromRequest($request);
// Handle close (without save)
if ($requestAction->shouldHandleDocumentClosing()) {
return $this->redirectToSelf($queryParams, ['closed' => '1']);
}
$saveSucceeded = false;
if ($requestAction->shouldProcessData()) {
$saveSucceeded = $this->processData($request, $table, $uid);
if ($saveSucceeded && $requestAction->shouldCloseAfterSave()) {
return $this->redirectToSelf($queryParams, ['closed' => '1', 'justSaved' => '1']);
}
}
// POST-redirect-GET
$flags = ['edit' => [$table => [$uid => 'edit']]];
if ($saveSucceeded) {
$flags['justSaved'] = '1';
}
return $this->redirectToSelf($queryParams, $flags);
}
/**
* Handle GET: compile the FormEngine form and render the contextual edit template.
*/
private function renderAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setUiBlock(true);
$queryParams = $request->getQueryParams();
$editConf = $this->parseAndValidateEditConf($queryParams['edit'] ?? []);
$table = $editConf['table'];
$uid = $this->resolveOverlayUid($table, $editConf['uid']);
$returnUrl = GeneralUtility::sanitizeLocalUrl($queryParams['returnUrl'] ?? '', $request);
$overrideVals = is_array($queryParams['overrideVals'] ?? false) ? $queryParams['overrideVals'] : [];
$columnsOnly = $this->prepareColumnsOnlyConfiguration($queryParams['columnsOnly'] ?? null, $table);
$module = $this->moduleProvider->getModule((string)($queryParams['module'] ?? ''), $this->getBackendUser());
if ($module !== null) {
$view->setModuleName($module->getIdentifier());
}
// Compile FormEngine form
$currentEditingUrl = $this->uriBuilder->buildUriFromRoute('record_edit_contextual', array_merge($queryParams, [
'edit' => [$table => [$uid => 'edit']],
'returnUrl' => $returnUrl,
]));
$formResult = $this->compileForm($request, $view, $table, $uid, $overrideVals, $columnsOnly, $currentEditingUrl);
$firstEl = $formResult['element'] ?? null;
if ($firstEl !== null) {
$this->formResultHandler->addAssets($formResult['results']);
$body = '
<form action="' . htmlspecialchars((string)$currentEditingUrl) . '" method="post" enctype="multipart/form-data" name="editform" id="ContextualRecordEditController">
' . $formResult['results']->getHtml() . '
<input type="hidden" name="returnUrl" value="' . htmlspecialchars($returnUrl) . '" />
<input type="hidden" name="closeDoc" value="0" />
</form>';
} else {
$view->setUiBlock(false);
$body = $formResult['errorHtml'] ?? $this->getInfobox(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm.message'),
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm'),
);
}
$recordTitle = $firstEl !== null && trim($firstEl->title) !== ''
? $firstEl->title
: '[' . $this->getLanguageService()->sL('core.core:labels.no_title') . ']';
$recordTitle = BackendUtility::cropToTitleLength($recordTitle);
// Contextual JS module with options
$contextualOptions = [];
if ($queryParams['justSaved'] ?? false) {
$contextualOptions['justSaved'] = true;
$contextualOptions['savedRecordTitle'] = $recordTitle;
}
if ($queryParams['closed'] ?? false) {
$contextualOptions['closed'] = true;
}
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/contextual-record-edit.js')->instance($contextualOptions)
);
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/context-menu.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/localization.js');
// Template variables
$view->assign('bodyHtml', $body);
$view->assign('recordTitle', $recordTitle);
// Full edit URL points to the standard EditDocumentController
$fullEditParams = [
'edit' => [$table => [$uid => 'edit']],
'returnUrl' => $returnUrl,
];
if ($module !== null) {
$fullEditParams['module'] = $module->getIdentifier();
}
$view->assign('fullEditUrl', (string)$this->uriBuilder->buildUriFromRoute('record_edit', $fullEditParams));
return $view->renderResponse('Form/ContextualRecordEdit');
}
/**
* Parse and validate the edit configuration. Ensures exactly one record with command "edit".
*
* @return array{table: string, uid: int}
*/
private function parseAndValidateEditConf(array|string $editConf): array
{
if (!is_array($editConf)) {
throw new \InvalidArgumentException('Invalid edit configuration', 1772580316);
}
$backendUser = $this->getBackendUser();
foreach ($editConf as $table => $conf) {
if (!is_array($conf) || !$this->tcaSchemaFactory->has($table)) {
continue;
}
if (!$backendUser->check('tables_modify', $table)) {
continue;
}
foreach ($conf as $uidList => $command) {
if ($command !== 'edit') {
continue;
}
$uid = (int)$uidList;
if ($uid > 0) {
return ['table' => $table, 'uid' => $uid];
}
}
}
throw new \InvalidArgumentException('ContextualRecordEditController requires exactly one existing record to edit', 1772580317);
}
private function prepareColumnsOnlyConfiguration(mixed $columnsOnly, string $table): array
{
if (!is_array($columnsOnly) || $columnsOnly === []) {
return [];
}
$finalColumnsOnly = array_map(
static fn($fields) => is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true),
$columnsOnly
);
// Add slug generator fields as hidden fields
if (!empty($finalColumnsOnly[$table]) && $this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
foreach ($finalColumnsOnly[$table] as $fieldName) {
if (!$schema->hasField($fieldName)) {
continue;
}
$field = $schema->getField($fieldName);
$postModifiers = $field->getConfiguration()['generatorOptions']['postModifiers'] ?? [];
if ($field->isType(\TYPO3\CMS\Core\DataHandling\TableColumnType::SLUG)
&& (!is_array($postModifiers) || $postModifiers === [])
) {
$fieldGroups = $field->getConfiguration()['generatorOptions']['fields'] ?? [];
if (is_string($fieldGroups)) {
$fieldGroups = [$fieldGroups];
}
foreach ($fieldGroups as $fields) {
$finalColumnsOnly['__hiddenGeneratorFields'][$table] = array_merge(
$finalColumnsOnly['__hiddenGeneratorFields'][$table] ?? [],
(is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true))
);
}
}
}
if (!empty($finalColumnsOnly['__hiddenGeneratorFields'][$table])) {
$finalColumnsOnly['__hiddenGeneratorFields'][$table] = array_diff(
array_unique($finalColumnsOnly['__hiddenGeneratorFields'][$table]),
$finalColumnsOnly[$table]
);
}
}
return $finalColumnsOnly;
}
/**
* Process save data via DataHandler.
*
* @return bool True if at least one record was saved without errors
*/
private function processData(ServerRequestInterface $request, string $table, int $uid): bool
{
$parsedBody = $request->getParsedBody();
$dataMap = $parsedBody['data'] ?? [];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->setControl($parsedBody['control'] ?? []);
$dataHandler->start($dataMap, $parsedBody['cmd'] ?? []);
if (is_array($parsedBody['mirror'] ?? null)) {
$dataHandler->setMirror($parsedBody['mirror']);
}
$dataHandler->process_datamap();
$dataHandler->process_cmdmap();
// Check if save succeeded (no errors for this record)
$erroneousRecords = $dataHandler->printLogErrorMessages();
return !in_array($table . '.' . $uid, $erroneousRecords, true) && isset($dataMap[$table][$uid]);
}
/**
* @return array{element: FormElementData, results: FormResultCollection}|array{errorHtml: string}
*/
private function compileForm(
ServerRequestInterface $request,
ModuleTemplate $view,
string $table,
int $uid,
array $overrideVals,
array $columnsOnly,
UriInterface $currentEditingUrl,
): array {
try {
$formDataCompilerInput = [
'request' => $request,
'tableName' => $table,
'vanillaUid' => $uid,
'command' => 'edit',
'returnUrl' => (string)$currentEditingUrl,
];
if ($overrideVals !== [] && is_array($overrideVals[$table] ?? null)) {
$formDataCompilerInput['overrideValues'] = $overrideVals[$table];
}
$formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
// Display "is-locked" message
$lockInfo = BackendUtility::isRecordLocked($table, $formData['databaseRow']['uid']);
if ($lockInfo) {
$view->addFlashMessage($lockInfo['msg'], '', \TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::WARNING);
}
$formElementData = new FormElementData(
title: $formData['recordTitle'],
table: $table,
uid: $formData['databaseRow']['uid'],
pid: $formData['databaseRow']['pid'] ?? 0,
record: $formData['databaseRow'],
viewId: 0,
command: 'edit',
userPermissionOnPage: $formData['userPermissionOnPage'],
);
BackendUtility::lockRecords($table, $formElementData->uid, $table === 'tt_content' ? $formElementData->pid : 0);
if (!empty($columnsOnly[$table])) {
$formData['fieldListToRender'] = implode(',', $columnsOnly[$table]);
if (!empty($columnsOnly['__hiddenGeneratorFields'][$table])) {
$formData['hiddenFieldListToRender'] = implode(',', $columnsOnly['__hiddenGeneratorFields'][$table]);
}
}
$formData['renderType'] = 'formWrapContainer';
$formResult = $this->nodeFactory->create($formData)->render();
$formResult = $this->formResultFactory->create($formResult);
$formResults = new FormResultCollection();
$formResults->add($formResult);
return ['element' => $formElementData, 'results' => $formResults];
} catch (NoFieldsToRenderException) {
return ['errorHtml' => $this->getInfobox(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm.message'),
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm'),
)];
} catch (AccessDeniedException $e) {
return ['errorHtml' => $this->getInfobox(
$e->getMessage(),
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noEditPermission'),
)];
} catch (DatabaseRecordException|DatabaseRecordWorkspaceDeletePlaceholderException $e) {
return ['errorHtml' => $this->getInfobox($e->getMessage())];
}
}
/**
* Redirect back to this controller with additional flags for the JS module.
*/
private function redirectToSelf(array $queryParams, array $additionalParams): ResponseInterface
{
$queryParams = array_merge($queryParams, $additionalParams);
$url = $this->uriBuilder->buildUriFromRoute('record_edit_contextual', $queryParams);
return new RedirectResponse($url, 302);
}
/**
* Resolve the workspace-aware UID for a single record.
* In a workspace, the live UID is replaced with the workspace overlay UID.
*/
private function resolveOverlayUid(string $table, int $uid): int
{
$record = $this->getRecordForEdit($table, $uid);
if (is_array($record)) {
return (int)$record['uid'];
}
return $uid;
}
/**
* Get record for editing, resolving workspace versions.
*
* @return array|false
*/
private function getRecordForEdit(string $table, int $recordId): array|bool
{
$schema = $this->tcaSchemaFactory->get($table);
$reqRecord = BackendUtility::getRecord($table, $recordId, 'uid,pid' . ($schema->isWorkspaceAware() ? ',t3ver_oid' : ''));
if (is_array($reqRecord)) {
if ($this->getBackendUser()->workspace !== 0) {
if ($schema->isWorkspaceAware()) {
if ($reqRecord['t3ver_oid'] > 0 || VersionState::tryFrom($reqRecord['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER) {
return $reqRecord;
}
$versionRec = BackendUtility::getWorkspaceVersionOfRecord(
$this->getBackendUser()->workspace,
$table,
$reqRecord['uid'],
'uid,pid,t3ver_oid'
);
return is_array($versionRec) ? $versionRec : $reqRecord;
}
return false;
}
return $reqRecord;
}
return false;
}
private function getInfobox(string $message, ?string $title = null): string
{
return '
<div class="callout callout-danger">
<div class="callout-icon">
<span class="icon-emphasized">
' . $this->iconFactory->getIcon('actions-close', IconSize::SMALL)->render() . '
</span>
</div>
<div class="callout-content">
' . ($title ? '<div class="callout-title">' . htmlspecialchars($title) . '</div>' : '') . '
<div class="callout-body">
' . htmlspecialchars($message) . '
</div>
</div>
</div>';
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}