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 = '
'; } 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 '