*/ protected array $editconf = []; /** * Array of tables with a lists of field names to edit for those tables. If specified, only those fields * will be rendered. Otherwise, all (available) fields in the record are shown according to the TCA type. */ protected array $columnsOnly = []; /** * Default values for fields * * @var array|null [table][field] */ protected $defVals; /** * Array of values to force being set as hidden fields in FormEngine * * @var array|null [table][field] */ protected $overrideVals; /** * If set, this value will be set in $this->retUrl as "returnUrl", if not, * $this->retUrl will link to dummy action * * @var string|null */ protected $returnUrl; /** * Prepared return URL. Contains the URL that we should return to from FormEngine if * close button is clicked. Usually passed along as 'returnUrl', but falls back to * "dummy" action. * * @var string */ protected $retUrl; /** * Boolean: If set, then the GET var "&id=" will be added to the * retUrl string so that the NEW id of something is returned to the script calling the form. */ protected bool $returnNewPageId = false; /** * The preview page id. * ID for displaying the page in the frontend, "save and view" * Is set to the pid value of the last shown record from "viewId" - thus indicating which page to * show when clicking the SAVE/VIEW button and transferred via GET/POST parameter "popViewId" */ protected int $popViewId = 0; /** * If true, $this->editconf array is added a redirect response, used by Wizard/AddController */ protected bool $returnEditConf = false; /** * @var array */ protected $pageinfo; /** * Array of the elements to create edit forms for. * * @var FormElementData[] */ protected array $elementsData = []; /** * Pointer to the first element in $elementsData */ protected ?FormElementData $firstEl = null; /** * Counter, used to count the number of errors (when users do not have edit permissions) */ protected int $numberOfErrors = 0; protected ?ModuleInterface $module = null; public function __construct( private readonly ComponentFactory $componentFactory, protected readonly EventDispatcherInterface $eventDispatcher, protected readonly IconFactory $iconFactory, protected readonly RecordFactory $recordFactory, protected readonly BreadcrumbFactory $breadcrumbFactory, protected readonly PageRenderer $pageRenderer, protected readonly UriBuilder $uriBuilder, protected readonly ModuleTemplateFactory $moduleTemplateFactory, protected readonly BackendEntryPointResolver $backendEntryPointResolver, protected readonly ModuleProvider $moduleProvider, private readonly FormDataCompiler $formDataCompiler, private readonly NodeFactory $nodeFactory, private readonly FormResultFactory $formResultFactory, private readonly FormResultHandler $formResultHandler, protected TcaSchemaFactory $tcaSchemaFactory, protected readonly LocalizationRepository $localizationRepository, private readonly SchemaLabelResolver $schemaLabelResolver, protected readonly ResourceFactory $resourceFactory, protected readonly FlashMessageService $flashMessageService, ) {} /** * Main dispatcher entry method registered as "record_edit" end point. */ public function mainAction(ServerRequestInterface $request): ResponseInterface { $view = $this->moduleTemplateFactory->create($request); $view->setLayout(ModuleLayout::NORMAL); $view->setUiBlock(true); $body = ''; // Unlock all locked records BackendUtility::lockRecords(); $queryParams = $request->getQueryParams(); $parsedBody = $request->getParsedBody(); $this->module = $this->moduleProvider->getModule((string)($queryParams['module'] ?? ''), $this->getBackendUser()); $this->editconf = $this->sanitizeEditConf($parsedBody['edit'] ?? $queryParams['edit'] ?? []); $this->defVals = $parsedBody['defVals'] ?? $queryParams['defVals'] ?? null; $this->overrideVals = $parsedBody['overrideVals'] ?? $queryParams['overrideVals'] ?? null; $this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request); $this->returnEditConf = (bool)($parsedBody['returnEditConf'] ?? $queryParams['returnEditConf'] ?? false); $this->columnsOnly = $this->prepareColumnsOnlyConfigurationFromRequest($request); $this->popViewId = (int)($parsedBody['popViewId'] ?? $queryParams['popViewId'] ?? 0); // Set overrideVals as default values if defVals does not exist. // @todo: Why? if (!is_array($this->defVals) && is_array($this->overrideVals)) { $this->defVals = $this->overrideVals; } // Set final return URL $this->retUrl = $this->returnUrl ?: $this->resolveDefaultReturnUrl(); // Close document if a request for closing the document has been sent $requestAction = FormAction::createFromRequest($request); if ($requestAction->shouldHandleDocumentClosing()) { if ($response = $this->closeAndPossiblyRedirectAction($requestAction)) { return $response; } } $event = new BeforeFormEnginePageInitializedEvent($this, $request); $this->eventDispatcher->dispatch($event); // Process incoming data via DataHandler? if ($requestAction->shouldProcessData()) { $this->processData($view, $request); // Redirect if element should be closed after save if ($requestAction->shouldCloseAfterSave()) { return $this->closeAndPossiblyRedirectAction($requestAction); } } // Prepare current request url parameters (which might have been changed already, especially "editconf") // Contains $request query parameters. This array is the foundation for creating // the $currentEditingUrl var which becomes the url to which forms are submitted. $queryParamsForGeneratingCurrentUrl = $queryParams; $queryParamsForGeneratingCurrentUrl['edit'] = $this->editconf; $queryParamsForGeneratingCurrentUrl['returnUrl'] = $this->retUrl; if ($requestAction->shouldProcessData()) { // Unset default values since we don't need them anymore. But only if all // records have been persisted. If a DataHandler hook rejected a new record, // editconf still contains the NEW* key and defVals must be preserved so // the form reloads with the correct default values, such as the record type. $hasUnresolvedNewRecords = false; foreach ($this->editconf as $tableCmds) { foreach (array_keys($tableCmds) as $uid) { if (str_contains((string)$uid, 'NEW')) { $hasUnresolvedNewRecords = true; break 2; } } } if (!$hasUnresolvedNewRecords) { unset($queryParamsForGeneratingCurrentUrl['defVals']); } } // Preview code is implicit only generated for GET requests, having the query // parameters "popViewId" (the preview page id) and "showPreview" set. if ($this->popViewId && ($queryParams['showPreview'] ?? false)) { // Generate the preview code (markup), which is added to the module body later $body = $this->getPreviewUriBuilderForRecordPreview($this->popViewId)->buildImmediateActionElement([PreviewUriBuilder::OPTION_SWITCH_FOCUS => null]); // After generating the preview code, those params should no longer be applied to the form // action, as this would otherwise always refresh the preview window on saving the record. unset($queryParamsForGeneratingCurrentUrl['showPreview'], $queryParamsForGeneratingCurrentUrl['popViewId']); } $event = new AfterFormEnginePageInitializedEvent($this, $request); $this->eventDispatcher->dispatch($event); if ($requestAction->isPostRequest) { // In case save&view is requested, we have to add this information to the redirect // URL, since the ImmediateAction will be added to the module body afterward. if ($requestAction->savedokview()) { $queryParamsForGeneratingCurrentUrl['showPreview'] = true; $queryParamsForGeneratingCurrentUrl['popViewId'] = $this->popViewId; } $url = $this->uriBuilder->buildUriFromRoute('record_edit', $queryParamsForGeneratingCurrentUrl); return new RedirectResponse($url, 302); } // Begin to show the edit form $this->setModuleContext($view); $this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf'); $this->pageRenderer->addInlineSetting('ShowItem', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('show_item')); // Generate the URL to the current request with modified GET parameters // This is used in various places to "return to" in the form and for buttons etc. $currentEditingUrl = $this->uriBuilder->buildUriFromRoute('record_edit', $queryParamsForGeneratingCurrentUrl); // Creating the editing form, wrap it with buttons, document selector etc. $formResults = $this->makeEditForm($request, $view, $currentEditingUrl); if (count($formResults) > 0) { $this->firstEl = $this->elementsData !== [] ? reset($this->elementsData) : null; $lastEl = $this->elementsData !== [] ? end($this->elementsData) : null; // Dispatch event for extensions to track open documents $this->openCurrentDocuments(); $this->formResultHandler->addAssets($formResults); // Put together the various elements (buttons, selectors, form) into a table $body .= '
' . $formResults->getHtml() . ' '; $body .= '
'; } if ($this->firstEl === null) { // In case firstEl is null, no edit form could be created. Therefore, add an // info box and remove the spinner, since it will never be resolved by FormEngine. $view->setUiBlock(false); $body .= $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'), ); } // Access check... // The page will show only if there is a valid page and if this page may be viewed by the user $perms_clause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW); $this->pageinfo = BackendUtility::readPageAccess($this->firstEl?->viewId, $perms_clause) ?: []; $documentTitle = $this->resolveDocumentTitle($this->getLanguageService()); // Setting up the buttons, markers for doc header and navigation component state $this->createBreadcrumb($view); $this->getButtons($view, $request, $this->firstEl, $currentEditingUrl, $documentTitle); // Create language switch options if the record is already persisted, and it is a single record to edit if ($this->isSingleRecordView() && $this->firstEl?->isSavedRecord()) { $this->languageSwitch($view, $this->firstEl); } $view->setTitle($documentTitle); $view->setLayout(ModuleLayout::NORMAL); $view->assign('bodyHtml', $body); return $view->renderResponse('Form/EditDocument'); } protected function sanitizeEditConf(array $editConf): array { $newConfiguration = []; $beUser = $this->getBackendUser(); // Traverse the GPvar edit array tables foreach ($editConf as $table => $conf) { if (!is_array($conf) || !$this->tcaSchemaFactory->has($table)) { // Skip for invalid config or in case no TCA exists continue; } if (!$beUser->check('tables_modify', $table)) { // Skip in case the user has insufficient permissions and increment the error counter $this->numberOfErrors++; continue; } // Traverse the keys/comments of each table (keys can be a comma list of uids) foreach ($conf as $cKey => $command) { if ($command !== 'edit' && $command !== 'new') { // Skip if invalid command continue; } $ids = GeneralUtility::trimExplode(',', (string)$cKey, true); foreach ($ids as $id) { $newConfiguration[$table][$id] = $command; } } } // Change $this->editconf if versioning applies to any of the records return $this->fixWSversioningInEditConf($newConfiguration); } protected function setModuleContext(ModuleTemplate $view): void { $view->assign('moduleContext', ''); $view->assign('moduleContextId', ''); if ($this->module === null) { return; } $view->setModuleName($this->module->getIdentifier()); if ($this->module->isStandalone()) { $parent = $this->module; } else { $parent = $this->module->getParentModule(); while ($parent?->getParentModule() !== null) { $parent = $parent->getParentModule(); } } if ($parent === null) { return; } $moduleContext = $parent->getIdentifier(); if ($moduleContext === 'file') { // Workaround for filelist using 'media' as ModuleStorage module context… :\ $moduleContext = 'media'; } $view->assign('moduleContext', $moduleContext); } protected function prepareColumnsOnlyConfigurationFromRequest(ServerRequestInterface $request): array { $columnsOnly = $request->getParsedBody()['columnsOnly'] ?? $request->getQueryParams()['columnsOnly'] ?? null; $usedTables = array_keys($request->getQueryParams()['edit'] ?? []); $finalColumnsOnly = []; if (is_array($columnsOnly) && $columnsOnly !== []) { $finalColumnsOnly = array_map(function ($fields) { return is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true); }, $columnsOnly); $finalColumnsOnly = $this->addSlugFieldsToColumnsOnly($finalColumnsOnly, $usedTables); } return $finalColumnsOnly; } /** * Always add required fields of slug field */ protected function addSlugFieldsToColumnsOnly(array $finalColumnsOnly, array $tables): array { foreach ($tables as $table) { if (!empty($finalColumnsOnly[$table]) && $this->tcaSchemaFactory->has($table)) { $schema = $this->tcaSchemaFactory->get($table); foreach ($finalColumnsOnly[$table] as $field) { if (!$schema->hasField($field)) { continue; } $field = $schema->getField($field); $postModifiers = $field->getConfiguration()['generatorOptions']['postModifiers'] ?? []; if ($field->isType(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; } /** * Do processing of data, submitting it to DataHandler. * * Also handles the "duplication" of a record. */ protected function processData(ModuleTemplate $view, ServerRequestInterface $request): void { $requestAction = FormAction::createFromRequest($request); $parsedBody = $request->getParsedBody(); $beUser = $this->getBackendUser(); $dataMap = $parsedBody['data'] ?? []; $dataHandlerIncomingCommandMap = $parsedBody['cmd'] ?? []; $this->returnNewPageId = (bool)($parsedBody['returnNewPageId'] ?? false); /** @var DataHandler $dataHandler */ $dataHandler = GeneralUtility::makeInstance(DataHandler::class); // Only options related to $dataMap submission are included here $dataHandler->setControl($parsedBody['control'] ?? []); // Set default values fetched previously from GET / POST vars if (is_array($dataMap)) { foreach ($dataMap as $tableName => $records) { if (is_array($this->defVals[$tableName] ?? null)) { foreach ($records as $uid => $_) { if (str_contains((string)$uid, 'NEW')) { $dataMap[$tableName][$uid] = array_merge($this->defVals[$tableName], $dataMap[$tableName][$uid]); } } } } } // Load DataHandler with data $dataHandler->start($dataMap, $dataHandlerIncomingCommandMap); if (is_array($parsedBody['mirror'] ?? null)) { $dataHandler->setMirror($parsedBody['mirror']); } // Perform the saving operation with DataHandler: if ($requestAction->shouldProcessData()) { $dataHandler->process_datamap(); $dataHandler->process_cmdmap(); // Update the module menu for the current backend user, as they updated their UI language $currentUserId = $beUser->getUserId(); if ($currentUserId && (string)($dataMap['be_users'][$currentUserId]['lang'] ?? '') !== '' && $dataMap['be_users'][$currentUserId]['lang'] !== $beUser->user['lang'] ) { $newLanguageKey = $dataMap['be_users'][$currentUserId]['lang']; // Update the current backend user language as well $beUser->user['lang'] = $newLanguageKey; // Re-create LANG to have the current request updated the translated page as well $this->getLanguageService()->init($newLanguageKey); BackendUtility::setUpdateSignal('updateBackendLanguage', [ 'language' => $newLanguageKey ]); } // If pages are being edited, we set an instruction about updating the page tree after this operation. if ($dataHandler->pagetreeNeedsRefresh && (isset($dataMap['pages']) || $beUser->workspace !== 0 && !empty($dataMap)) ) { BackendUtility::setUpdateSignal('updatePageTree'); } // If there was saved any new items, load them: if (!empty($dataHandler->substNEWwithIDs)) { // Save the expanded/collapsed states for new inline records, if any $this->updateInlineView($request->getParsedBody()['uc'] ?? $request->getQueryParams()['uc'] ?? null, $dataHandler); $newEditConf = []; // Traverse all new records and forge the content of $this->editconf so we can continue to edit these records! // $this->editconf is now updated to replace NEW-Ids with the actual persisted IDs. foreach ($this->editconf as $tableName => $tableCmds) { $keys = array_keys($dataHandler->substNEWwithIDs_table, $tableName); if ($keys !== []) { foreach ($keys as $key) { $editId = $dataHandler->substNEWwithIDs[$key]; // Check if the $editId isn't a child record of an IRRE action if (!(is_array($dataHandler->newRelatedIDs[$tableName] ?? null) && in_array($editId, $dataHandler->newRelatedIDs[$tableName])) ) { // Translate new id to the workspace version if ($versionRec = BackendUtility::getWorkspaceVersionOfRecord( $beUser->workspace, $tableName, $editId, 'uid' )) { $editId = $versionRec['uid']; } $newEditConf[$tableName][$editId] = 'edit'; } if ($tableName === 'pages' && !$this->shouldRedirectToEmptyPage() && $this->retUrl !== $this->getCloseUrl($request) && $this->returnNewPageId ) { $this->retUrl .= '&id=' . $dataHandler->substNEWwithIDs[$key]; } } } else { $newEditConf[$tableName] = $tableCmds; } } if ($newEditConf !== []) { $this->editconf = $newEditConf; } } // See if any records was auto-created as new versions? if (!empty($dataHandler->autoVersionIdMap)) { $this->editconf = $this->fixWSversioningInEditConf($this->editconf, $dataHandler->autoVersionIdMap); } } // If a document is saved and a new one is created right after. if ($requestAction->savedoknew()) { // Find the current table reset($this->editconf); $nTable = (string)key($this->editconf); // Determine insertion mode: 'top' is self-explaining, // otherwise new elements are inserted after one using a negative uid $insertRecordOnTop = ($this->getTsConfigOption($nTable, 'saveDocNew') === 'top'); $ids = array_keys($this->editconf[$nTable]); // Depending on $insertRecordOnTop, retrieve either the first or last id to get the records' pid+uid if ($insertRecordOnTop) { $nUid = (int)reset($ids); } else { $nUid = (int)end($ids); } $nRec = BackendUtility::getRecord($nTable, $nUid); if ($insertRecordOnTop) { $relatedPageId = $nRec['pid']; } else { if ((int)($nRec['t3ver_oid'] ?? 0) === 0) { $relatedPageId = -$nRec['uid']; } else { // Use uid of live version of workspace version $relatedPageId = -$nRec['t3ver_oid']; } } // Setting a blank editconf array for a new record: $this->editconf = []; $this->editconf[$nTable][$relatedPageId] = 'new'; } // Explicitly require a save operation if ($requestAction->shouldProcessData()) { $erroneousRecords = $dataHandler->printLogErrorMessages(); $messages = []; $table = (string)key($this->editconf); $uidList = array_keys($this->editconf[$table]); foreach ($uidList as $uid) { $uid = (int)abs($uid); if (!in_array($table . '.' . $uid, $erroneousRecords, true)) { $realUidInPayload = ($tceSubstId = array_search($uid, $dataHandler->substNEWwithIDs, true)) !== false ? $tceSubstId : $uid; $row = $dataMap[$table][$uid] ?? $dataMap[$table][$realUidInPayload] ?? null; if ($row === null) { continue; } // Ensure, uid is always available to make labels with foreign table lookups possible $row['uid'] ??= $realUidInPayload; // If the label column of the record is not available, fetch it from database. // This is the case when EditDocumentController is booted in single field mode (e.g. // Template module > 'info/modify' > edit 'setup' field) or in case the field is // not in "showitem" or is set to readonly (e.g. "file" in sys_file_metadata). $labelCapability = $this->tcaSchemaFactory->get($table)->getCapability(TcaSchemaCapability::Label); $labelFields = $labelCapability->getAllLabelFieldNames(); foreach ($labelFields as $labelField) { if (!isset($row[$labelField])) { $tmpRecord = BackendUtility::getRecord($table, $uid, $labelFields); if ($tmpRecord !== null) { $row = array_merge($row, $tmpRecord); } break; } } $recordTitle = BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle($table, $row)); $messages[] = sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.record_saved.message'), $recordTitle); } } // Add messages to the flash message container only if the request is a save action (excludes "duplicate") if ($messages !== []) { $label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.record_saved.title.plural'); if (count($messages) === 1) { $label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.record_saved.title.singular'); } if (count($messages) > 10) { $messages = [sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.mass_saving.message'), count($messages))]; } $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE); $flashMessage = new FlashMessage( implode(LF, $messages), $label, ContextualFeedbackSeverity::OK, true ); $defaultFlashMessageQueue->enqueue($flashMessage); } } // If a document should be duplicated. if ($requestAction->duplicatedoc()) { // Find current table reset($this->editconf); $nTable = (string)key($this->editconf); // Find the first id, getting the records pid+uid $nUid = array_keys($this->editconf[$nTable]); $nUid = reset($nUid); if (!MathUtility::canBeInterpretedAsInteger($nUid)) { $nUid = $dataHandler->substNEWwithIDs[$nUid]; } $nRec = BackendUtility::getRecord($nTable, $nUid); // Setting a blank editconf array for a new record $this->editconf = []; if ((int)($nRec['t3ver_oid'] ?? 0) > 0) { $relatedPageId = -$nRec['t3ver_oid']; } else { $relatedPageId = -$nRec['uid']; } /** @var DataHandler $duplicateTce */ $duplicateTce = GeneralUtility::makeInstance(DataHandler::class); $duplicateCmd = [ $nTable => [ $nUid => [ 'copy' => $relatedPageId, ], ], ]; $duplicateTce->start([], $duplicateCmd); $duplicateTce->process_cmdmap(); $duplicateUid = $duplicateTce->copyMappingArray[$nTable][$nUid] ?? null; if ($duplicateUid !== null) { if ($nTable === 'pages') { BackendUtility::setUpdateSignal('updatePageTree'); } $this->editconf[$nTable][$duplicateUid] = 'edit'; // Inform the user of the duplication $view->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.recordDuplicated')); } else { $this->numberOfErrors++; // Inform the user about the failed duplication $view->addFlashMessage( $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.recordDuplicationFailed'), '', ContextualFeedbackSeverity::ERROR ); } } } protected function getPreviewUriBuilderForRecordPreview($pageId): PreviewUriBuilder { $array_keys = array_keys($this->editconf); $table = reset($array_keys); $recordId = 0; if ($table) { $uids = array_keys($this->editconf[$table]); $recordId = (int)((reset($uids) ?: null) ?? ''); } return PreviewUriBuilder::createForRecordPreview($table, $recordId, $pageId); } protected function createBreadcrumb(ModuleTemplate $view): void { // Handle file metadata records $file = null; if ($this->firstEl !== null && $this->firstEl->table === 'sys_file_metadata' && $this->firstEl->uid > 0) { // Happens if it is a select/group $fileUid = $this->firstEl->record['file'] ?? 0; if (is_array($fileUid)) { $fileUid = reset($fileUid); } try { $file = $this->resourceFactory->getFileObject((int)$fileUid); } catch (FileDoesNotExistException|InsufficientUserPermissionsException $e) { // do nothing when file is not accessible } } if ($file instanceof FileInterface) { $view->assign('moduleContextId', $file->getParentFolder()->getCombinedIdentifier()); $view->getDocHeaderComponent()->setResourceBreadcrumb($file); } elseif ($this->pageinfo !== [] && $this->firstEl !== null) { $l10nParent = (int)($this->pageinfo['l10n_parent'] ?? 0); $pageUid = $this->pageinfo['uid'] ?? ''; $view->assign('moduleContextId', $l10nParent !== 0 ? $l10nParent : $pageUid); // Determine breadcrumb based on action (edit existing vs. create new) if ($this->firstEl->isSavedRecord()) { if ($this->isSingleRecordView()) { // Edit single existing record $breadcrumbContext = $this->breadcrumbFactory->forEditAction( $this->firstEl->table, (int)$this->firstEl->uid ); } else { // Edit multiple records $breadcrumbContext = $this->breadcrumbFactory->forEditMultipleAction( $this->firstEl->table, (int)($this->pageinfo['uid'] ?? 0) ); } } else { // Create new record $breadcrumbContext = $this->breadcrumbFactory->forNewAction( $this->firstEl->table, (int)($this->pageinfo['uid'] ?? 0), $this->defVals[$this->firstEl->table] ?? [] ); } $view->getDocHeaderComponent()->setBreadcrumbContext($breadcrumbContext); } } /** * Creates the editing form with FormEngine, based on the input from GPvars. * * @return FormResultCollection Form result objects */ protected function makeEditForm(ServerRequestInterface $request, ModuleTemplate $view, UriInterface $currentRequestUrl): FormResultCollection { // Initialize variables $formResults = new FormResultCollection(); // Traverse the GPvar edit array tables foreach ($this->editconf as $table => $conf) { // Traverse the keys/comments of each table (keys can be a comma list of uids) foreach ($conf as $theUid => $command) { try { $formDataCompilerInput = [ 'request' => $request, 'tableName' => $table, 'vanillaUid' => (int)$theUid, 'command' => $command, 'returnUrl' => (string)$currentRequestUrl, ]; if (is_array($this->overrideVals) && is_array($this->overrideVals[$table])) { $formDataCompilerInput['overrideValues'] = $this->overrideVals[$table]; } if (is_array($this->defVals) && $this->defVals !== []) { $formDataCompilerInput['defaultValues'] = $this->defVals; } $formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); $viewId = 0; if ($table === 'pages') { // Only set viewId in case it's not a new page - as this can not be viewed before being saved if ($command !== 'new' && MathUtility::canBeInterpretedAsInteger($formData['databaseRow']['uid'])) { $viewId = (int)$formData['databaseRow']['uid']; } } elseif (!empty($formData['parentPageRow']['uid'])) { $viewId = $formData['parentPageRow']['uid']; } // Display "is-locked" message if ($command === 'edit') { $lockInfo = BackendUtility::isRecordLocked($table, $formData['databaseRow']['uid']); if ($lockInfo) { $view->addFlashMessage($lockInfo['msg'], '', ContextualFeedbackSeverity::WARNING); } } $el = new FormElementData( title: $formData['recordTitle'], table: $table, uid: $formData['databaseRow']['uid'], pid: ($formData['databaseRow']['pid'] ?? $viewId), record: $formData['databaseRow'], viewId: (int)$viewId, command: $command, userPermissionOnPage: $formData['userPermissionOnPage'], ); $this->elementsData[] = $el; if ($command !== 'new') { BackendUtility::lockRecords($table, $el->uid, $table === 'tt_content' ? $el->pid : 0); } // Set list if only specific fields should be rendered. This will trigger // ListOfFieldsContainer instead of FullRecordContainer in FormWrapContainer if (!empty($this->columnsOnly[$table])) { $formData['fieldListToRender'] = implode(',', $this->columnsOnly[$table]); if (!empty($this->columnsOnly['__hiddenGeneratorFields'][$table])) { $formData['hiddenFieldListToRender'] = implode(',', $this->columnsOnly['__hiddenGeneratorFields'][$table]); } } $formData['renderType'] = 'formWrapContainer'; $formResult = $this->nodeFactory->create($formData)->render(); if ($command === 'new') { $tableTitle = htmlspecialchars($this->resolveTypeLabel($table, $formData['databaseRow'])); $formHeading = $this->getLanguageService()->sL('core.core:labels.createNew') . ' ' . $tableTitle; $formResult['html'] = '

' . $formHeading . '

' . $formResult['html']; } else { $recordTitle = trim($formData['recordTitle']) !== '' ? $formData['recordTitle'] : '[' . $this->getLanguageService()->sL('core.core:labels.no_title') . ']'; $recordTitle = BackendUtility::cropToTitleLength($recordTitle); $recordIdentity = $this->getRecordIdentityHtml($table, $formData['databaseRow']); $formResult['html'] = '

' . htmlspecialchars($recordTitle) . '

' . $recordIdentity . $formResult['html']; } // Seems the pid is set as hidden field (again) at end?! if ($command === 'new') { $formResult['html'] .= ''; } $formResult = $this->formResultFactory->create($formResult); $formResults->add($formResult); } catch (NoFieldsToRenderException $e) { $this->numberOfErrors++; $formResults->add(new FormResult( $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) { $this->numberOfErrors++; $message = $e->getMessage(); $title = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noEditPermission'); $formResults->add(new FormResult($this->getInfobox($message, $title))); } catch (DatabaseRecordException|DatabaseRecordWorkspaceDeletePlaceholderException $e) { $formResults->add(new FormResult($this->getInfobox($e->getMessage()))); } } } return $formResults; } /** * Helper function for rendering an Infobox */ protected function getInfobox(string $message, ?string $title = null): string { return '
' . '
' . '' . $this->iconFactory->getIcon('actions-close', IconSize::SMALL)->render() . '' . '
' . '
' . ($title ? '
' . htmlspecialchars($title) . '
' : '') . '
' . htmlspecialchars($message) . '
' . '
' . '
'; } /** * Create the panel of buttons for submitting the form or otherwise perform operations. */ protected function getButtons(ModuleTemplate $view, ServerRequestInterface $request, ?FormElementData $mainFormElement, UriInterface $currentEditingUrl, string $documentTitle): void { if ($mainFormElement !== null) { $record = $mainFormElement->record; $schema = $this->tcaSchemaFactory->get($mainFormElement->table); $this->registerCloseButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_LEFT, 1); // Show buttons (duplicate, new, view save) when table is not read-only if (!$this->numberOfErrors && !$schema->hasCapability(TcaSchemaCapability::AccessReadOnly)) { $view->addButtonToButtonBar($this->componentFactory->createSaveButton('EditDocumentController')->setDisabled(true), ButtonBar::BUTTON_POSITION_LEFT, 2); $this->registerViewButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_LEFT, 3); if ($mainFormElement->command !== 'new') { $languageCapability = $schema->isLanguageAware() ? $schema->getCapability(TcaSchemaCapability::Language) : null; $languageId = 0; if ( $mainFormElement->isSavedRecord() && $schema->isLanguageAware() && isset($record[($languageField = $languageCapability->getLanguageField()->getName())]) ) { $languageId = (int)$record[$languageField]; } elseif (isset($this->defVals[$mainFormElement->table]['language_tag'])) { $languageId = (int)$this->defVals[$mainFormElement->table]['language_tag']; } $l10nParent = 0; $translationOriginPointerField = $languageCapability ? $languageCapability->getTranslationOriginPointerField()->getName() : null; if ($translationOriginPointerField && isset($record[$translationOriginPointerField])) { $value = $record[$translationOriginPointerField]; if (is_array($value)) { $value = reset($value); } // Happens on group if (is_array($value) && isset($value['uid'])) { $value = $value['uid']; } $l10nParent = (int)$value; } if ($mainFormElement->table === 'tt_content') { $canCreateNewOrDuplicate = $this->isInconsistentLanguageHandlingAllowed() || $this->isPageContentFreeTranslationMode($mainFormElement, $languageId); } else { $canCreateNewOrDuplicate = $languageId === 0 || $l10nParent === 0; } if ($canCreateNewOrDuplicate) { $this->registerNewButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_LEFT, 4); $this->registerDuplicationButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_LEFT, 5); } } if ($mainFormElement->isSavedRecord()) { $this->registerHistoryButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_RIGHT, 1, $currentEditingUrl); $this->registerDeleteButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_LEFT, 6, $request); } $this->registerColumnsOnlyButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_LEFT, 7, $currentEditingUrl); } } $this->registerInfoButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_RIGHT, 2); $this->registerOpenInNewWindowButtonToButtonBar($view, ButtonBar::BUTTON_POSITION_RIGHT, 3, $request); $this->registerShortcutButtonToButtonBar($view, $request, $documentTitle); } /** * Return true if inconsistent language handling is allowed */ protected function isInconsistentLanguageHandlingAllowed(): bool { $allowInconsistentLanguageHandling = BackendUtility::getPagesTSconfig( $this->pageinfo['uid'] ?? 0 )['mod']['web_layout']['allowInconsistentLanguageHandling'] ?? ['value' => '0']; return $allowInconsistentLanguageHandling['value'] === '1'; } /** * Checks if the page is in free translation mode for tt_content */ protected function isPageContentFreeTranslationMode(FormElementData $formElementData, int $languageId): bool { if ($formElementData->table !== 'tt_content') { return false; } if (!$formElementData->isSavedRecord()) { return $this->getFreeTranslationMode( (int)($this->pageinfo['uid'] ?? 0), (int)($this->defVals[$formElementData->table]['colPos'] ?? 0), $languageId ); } return $this->getFreeTranslationMode( (int)($this->pageinfo['uid'] ?? 0), (int)($formElementData->record['colPos'] ?? 0), $languageId ); } /** * True if the page is in free translation mode. */ protected function getFreeTranslationMode(int $page, int $column, int $language): bool { $freeTranslationMode = false; if ($this->getConnectedContentElementTranslationsCount($page, $column, $language) === 0 && $this->getStandAloneContentElementTranslationsCount($page, $column, $language) >= 0 ) { $freeTranslationMode = true; } return $freeTranslationMode; } /** * Register the close button to the button bar */ protected function registerCloseButtonToButtonBar(ModuleTemplate $view, string $position, int $group): void { $closeButton = $this->componentFactory->createLinkButton() ->setHref('#') ->setClasses('t3js-editform-close') ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc')) ->setShowLabelText(true) ->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL)) ->setDisabled(true); $view->addButtonToButtonBar($closeButton, $position, $group); } /** * Register the view button to the button bar */ protected function registerViewButtonToButtonBar(ModuleTemplate $view, string $position, int $group): void { if ($this->firstEl === null) { return; } // Pid to show the record if (!$this->firstEl->viewId) { return; } if ($this->firstEl->table === '') { return; } // @TODO: TsConfig option should change to viewDoc if (!$this->getTsConfigOption($this->firstEl->table, 'saveDocView')) { return; } $previewUriBuilderForCurrentPage = PreviewUriBuilder::create($this->pageinfo)->isPreviewable(); $pageId = $this->popViewId ?: $this->firstEl->viewId; $previewUriBuilder = PreviewUriBuilder::createForRecordPreview($this->firstEl->table, $this->firstEl->record, $pageId); if ($previewUriBuilderForCurrentPage || $previewUriBuilder->isPreviewable()) { $previewUrl = $previewUriBuilder->buildUri(); if ($previewUrl) { $viewButton = $this->componentFactory->createLinkButton() ->setHref((string)$previewUrl) ->setIcon($this->iconFactory->getIcon('actions-view', IconSize::SMALL)) ->setShowLabelText(true) ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.viewDoc')) ->setClasses('t3js-editform-view') ->setDisabled(true); if (!$this->firstEl->isSavedRecord() && $this->firstEl->table === 'pages') { $viewButton->setDataAttributes(['is-new' => '']); } $view->addButtonToButtonBar($viewButton, $position, $group); } } } /** * Register the new button to the button bar */ protected function registerNewButtonToButtonBar(ModuleTemplate $view, string $position, int $group): void { if ($this->firstEl === null) { return; } if ($this->firstEl->table === '') { return; } if ($this->firstEl->table === 'sys_file_metadata') { return; } if (!$this->getTsConfigOption($this->firstEl->table, 'saveDocNew')) { return; } $newButton = $this->componentFactory->createLinkButton() ->setHref('#') ->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL)) ->setShowLabelText(true) ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.newDoc')) ->setClasses('t3js-editform-new') ->setDisabled(true); if (!$this->firstEl->isSavedRecord()) { $newButton->setDataAttributes(['is-new' => '']); } $view->addButtonToButtonBar($newButton, $position, $group); } /** * Register the duplication button to the button bar */ protected function registerDuplicationButtonToButtonBar(ModuleTemplate $view, string $position, int $group): void { if (!$this->isSingleRecordView()) { return; } if ($this->firstEl->table === '') { return; } if ($this->firstEl->table === 'sys_file_metadata') { return; } if (!$this->getTsConfigOption($this->firstEl->table, 'showDuplicate')) { return; } $duplicateButton = $this->componentFactory->createLinkButton() ->setHref('#') ->setShowLabelText(true) ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.duplicateDoc')) ->setIcon($this->iconFactory->getIcon('actions-document-duplicates-select', IconSize::SMALL)) ->setClasses('t3js-editform-duplicate') ->setDisabled(true); if (!$this->firstEl->isSavedRecord()) { $duplicateButton->setDataAttributes(['is-new' => '']); } $view->addButtonToButtonBar($duplicateButton, $position, $group); } /** * Register the delete button to the button bar */ protected function registerDeleteButtonToButtonBar(ModuleTemplate $view, string $position, int $group, ServerRequestInterface $request): void { if (!$this->isSingleRecordView()) { return; } if (!$this->firstEl?->isSavedRecord()) { return; } if ($this->getDisableDelete()) { return; } if ($this->isRecordCurrentBackendUser()) { return; } if (!$this->firstEl->hasDeleteAccess()) { return; } $returnUrl = $this->retUrl; if ($this->firstEl->table === 'pages') { // The below is a hack to replace the return url with an url to the current module on id=0. Otherwise, // this might lead to empty views, since the current id is the page, which is about to be deleted. $parsedUrl = parse_url($returnUrl); // @todo consider using $this->module here $routePath = str_replace($this->backendEntryPointResolver->getPathFromRequest($request), '', $parsedUrl['path'] ?? ''); parse_str($parsedUrl['query'] ?? '', $queryParams); if ($routePath && isset($queryParams['id']) && (string)$this->firstEl->uid === (string)$queryParams['id'] ) { try { // TODO: Use the page's pid instead of 0, this requires a clean API to manipulate the page // tree from the outside to be able to mark the pid as active $returnUrl = (string)$this->uriBuilder->buildUriFromRoutePath($routePath, ['id' => 0]); } catch (ResourceNotFoundException $e) { // Resolved path can not be matched to a configured route } } } $referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class); $numberOfReferences = $referenceIndex->getNumberOfReferencedRecords( $this->firstEl->table, (int)$this->firstEl->uid ); $referenceCountMessage = BackendUtility::referenceCount( $this->firstEl->table, (int)$this->firstEl->uid, $this->getLanguageService()->sL( 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.referencesToRecord' ), (string)$numberOfReferences ); $translations = $this->localizationRepository->getRecordTranslations( $this->firstEl->table, (int)$this->firstEl->uid, ); $count = count($translations); $translationCountMessage = ''; if ($count > 0) { $translationCountMessage = sprintf($this->getLanguageService()->sL( 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.translationsOfRecord' ), $count); } $deleteUrl = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [ 'cmd' => [ $this->firstEl->table => [ $this->firstEl->uid => [ 'delete' => '1', ], ], ], 'redirect' => $returnUrl, ]); $recordInfo = $this->firstEl->title; if ($this->getBackendUser()->shallDisplayDebugInformation()) { $recordInfo .= ' [' . $this->firstEl->table . ':' . $this->firstEl->uid . ']'; } $deleteButton = $this->componentFactory->createLinkButton() ->setClasses('t3js-editform-delete-record') ->setDataAttributes([ 'uid' => $this->firstEl->uid, 'table' => $this->firstEl->table, 'record-info' => trim($recordInfo), 'reference-count-message' => $referenceCountMessage, 'translation-count-message' => $translationCountMessage, ]) ->setHref($deleteUrl) ->setIcon($this->iconFactory->getIcon('actions-edit-delete', IconSize::SMALL)) ->setShowLabelText(true) ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteItem')) ->setDisabled(true); $view->addButtonToButtonBar($deleteButton, $position, $group); } /** * Register the info button to the button bar */ protected function registerInfoButtonToButtonBar(ModuleTemplate $view, string $position, int $group): void { if (!$this->isSingleRecordView()) { return; } if (!$this->firstEl?->isSavedRecord()) { return; } $button = $this->componentFactory->createGenericButton(); $button->setLabel($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:showInfo')); $button->setAttributes([ 'type' => 'button', 'data-dispatch-action' => 'TYPO3.InfoWindow.showItem', 'data-dispatch-args-list' => $this->firstEl->table . ',' . $this->firstEl->uid, 'disabled' => 'disabled', ]); $button->setIcon($this->iconFactory->getIcon('actions-document-info', IconSize::SMALL)); $view->addButtonToButtonBar($button, $position, $group); } /** * Register the history button to the button bar */ protected function registerHistoryButtonToButtonBar(ModuleTemplate $view, string $position, int $group, UriInterface $currentEditingUrl): void { if (!$this->isSingleRecordView()) { return; } if ($this->firstEl === null) { return; } if ($this->firstEl->table === '') { return; } if (!$this->getTsConfigOption($this->firstEl->table, 'showHistory', '1')) { return; } $historyUrl = (string)$this->uriBuilder->buildUriFromRoute('record_history', [ 'element' => $this->firstEl->table . ':' . $this->firstEl->uid, 'returnUrl' => (string)$currentEditingUrl, ]); $historyButton = $this->componentFactory->createLinkButton() ->setHref($historyUrl) ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:recordHistory')) ->setIcon($this->iconFactory->getIcon('actions-document-history-open', IconSize::SMALL)) ->setDisabled(true); $view->addButtonToButtonBar($historyButton, $position, $group); } /** * Register "Edit whole record" button to the button bar */ protected function registerColumnsOnlyButtonToButtonBar(ModuleTemplate $view, string $position, int $group, UriInterface $currentEditingUrl): void { if (!$this->isSingleRecordView()) { return; } if ($this->columnsOnly === []) { return; } $query = $currentEditingUrl->getQuery(); $query .= '&columnsOnly='; $url = $currentEditingUrl->withQuery($query); $columnsOnlyButton = $this->componentFactory->createLinkButton() ->setHref((string)$url) ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:editWholeRecord')) ->setShowLabelText(true) ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL)) ->setDisabled(true); $view->addButtonToButtonBar($columnsOnlyButton, $position, $group); } /** * Register the open in new window button to the button bar */ protected function registerOpenInNewWindowButtonToButtonBar(ModuleTemplate $view, string $position, int $group, ServerRequestInterface $request): void { $closeUrl = $this->getCloseUrl($request); if ($this->returnUrl === $closeUrl) { return; } // Generate a URL to the current edit form $arguments = $this->getUrlQueryParamsForCurrentRequest($request); $arguments['returnUrl'] = $closeUrl; $requestUri = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $arguments); $openInNewWindowButton = $this->componentFactory ->createLinkButton() ->setHref('#') ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.openInNewWindow')) ->setIcon($this->iconFactory->getIcon('actions-window-open', IconSize::SMALL)) ->setDataAttributes([ 'dispatch-action' => 'TYPO3.WindowManager.localOpen', 'dispatch-args' => GeneralUtility::jsonEncodeForHtmlAttribute([ $requestUri, true, // switchFocus md5($requestUri), // windowName, 'width=670,height=500,status=0,menubar=0,scrollbars=1,resizable=1', // windowFeatures ]), ]) ->setDisabled(true); $view->addButtonToButtonBar($openInNewWindowButton, $position, $group); } /** * Register the shortcut button to the button bar */ protected function registerShortcutButtonToButtonBar(ModuleTemplate $view, ServerRequestInterface $request, string $documentTitle): void { if ($this->returnUrl === $this->getCloseUrl($request)) { return; } $arguments = $this->getUrlQueryParamsForCurrentRequest($request); $view->getDocHeaderComponent()->setShortcutContext( 'record_edit', $documentTitle, $arguments ); } protected function getUrlQueryParamsForCurrentRequest(ServerRequestInterface $request): array { $queryParams = $request->getQueryParams(); $potentialArguments = [ 'edit', 'defVals', 'overrideVals', 'columnsOnly', 'returnNewPageId', 'module', ]; $arguments = []; foreach ($potentialArguments as $argument) { if (!empty($queryParams[$argument])) { $arguments[$argument] = $queryParams[$argument]; } } return $arguments; } /** * Get the count of connected translated content elements */ protected function getConnectedContentElementTranslationsCount(int $page, int $column, int $language): int { $queryBuilder = $this->getQueryBuilderForTranslationMode($page, $column, $language); return (int)$queryBuilder ->andWhere( $queryBuilder->expr()->gt( $this->tcaSchemaFactory->get('tt_content')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(), $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) ) ) ->executeQuery() ->fetchOne(); } /** * Get the count of standalone translated content elements */ protected function getStandAloneContentElementTranslationsCount(int $page, int $column, int $language): int { $queryBuilder = $this->getQueryBuilderForTranslationMode($page, $column, $language); return (int)$queryBuilder ->andWhere( $queryBuilder->expr()->eq( $this->tcaSchemaFactory->get('tt_content')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(), $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) ) ) ->executeQuery() ->fetchOne(); } /** * Get the query builder for the translation mode */ protected function getQueryBuilderForTranslationMode(int $page, int $column, int $language): QueryBuilder { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); $queryBuilder->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace)); return $queryBuilder ->count('uid') ->from('tt_content') ->where( $queryBuilder->expr()->eq( 'pid', $queryBuilder->createNamedParameter($page, Connection::PARAM_INT) ), $queryBuilder->expr()->eq( $this->tcaSchemaFactory->get('tt_content')->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(), $queryBuilder->createNamedParameter($language, Connection::PARAM_INT) ), $queryBuilder->expr()->eq( 'colPos', $queryBuilder->createNamedParameter($column, Connection::PARAM_INT) ) ); } /** * Update expanded/collapsed states on new inline records if any within backendUser->uc. * * @param array|null $uc The uc array to be processed and saved - uc[inlineView][...] * @param DataHandler $dataHandler Instance of DataHandler that saved data before */ protected function updateInlineView(?array $uc, DataHandler $dataHandler): void { if (!is_array($uc['inlineView'] ?? null)) { return; } $backendUser = $this->getBackendUser(); $inlineView = (array)json_decode(is_string($backendUser->uc['inlineView'] ?? false) ? $backendUser->uc['inlineView'] : '', true); foreach ($uc['inlineView'] as $topTable => $topRecords) { foreach ($topRecords as $topUid => $childElements) { foreach ($childElements as $childTable => $childRecords) { $uids = array_keys($dataHandler->substNEWwithIDs_table, $childTable); if (!empty($uids)) { $newExpandedChildren = []; foreach ($childRecords as $childUid => $state) { if ($state && in_array($childUid, $uids)) { $newChildUid = $dataHandler->substNEWwithIDs[$childUid]; $newExpandedChildren[] = $newChildUid; } } // Add new expanded child records to UC (if any): if (!empty($newExpandedChildren)) { $inlineViewCurrent = &$inlineView[$topTable][$topUid][$childTable]; if (is_array($inlineViewCurrent)) { $inlineViewCurrent = array_unique(array_merge($inlineViewCurrent, $newExpandedChildren)); } else { $inlineViewCurrent = $newExpandedChildren; } } } } } } $backendUser->uc['inlineView'] = json_encode($inlineView); $backendUser->writeUC(); } /** * Returns if delete for the current table is disabled by configuration. * For sys_file_metadata in default language delete is always disabled. */ protected function getDisableDelete(): bool { $disableDelete = false; if ($this->firstEl?->table === 'sys_file_metadata') { $row = $this->firstEl->record; if ((int)($row['language_tag'] ?? 0) === 0) { // Always disable for default language $disableDelete = true; } } else { $disableDelete = (bool)$this->getTsConfigOption($this->firstEl->table, 'disableDelete'); } return $disableDelete; } /** * Return true in case the current record is the current backend user */ protected function isRecordCurrentBackendUser(): bool { $backendUser = $this->getBackendUser(); return $this->firstEl?->table === 'be_users' && (int)($this->firstEl->uid) === $backendUser->getUserId(); } /** * Returns the URL (usually for the "returnUrl") which closes the current window. * Used when editing a record in a popup. */ protected function getCloseUrl(ServerRequestInterface $request): string { return (string)PathUtility::getSystemResourceUri('EXT:backend/Resources/Public/Html/Close.html', $request); } /** * Make selector box for creating new translation for a record or switching to edit the record * in an existing language. Displays only languages which are available for the current page. */ protected function languageSwitch(ModuleTemplate $view, FormElementData $formElement): void { $backendUser = $this->getBackendUser(); if (!$this->tcaSchemaFactory->has($formElement->table)) { return; } $schema = $this->tcaSchemaFactory->get($formElement->table); if (!$schema->isLanguageAware()) { return; } $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); $languageField = $languageCapability->getLanguageField()->getName(); $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); $table = $formElement->table; if (!$backendUser->check('tables_modify', $table)) { return; } $uid = $formElement->uid; // Get all available languages for the page // If editing a page, the translations of the current UID need to be fetched if ($table === 'pages') { if (is_array($formElement->record[$transOrigPointerField] ?? null)) { $l10nParent = $formElement->record[$transOrigPointerField]; $l10nParent = reset($l10nParent); } else { $l10nParent = $formElement->record[$transOrigPointerField] ?? 0; } // Ensure the check is always done against the default language page $availableLanguages = $this->getLanguages( (int)($l10nParent ?: $uid), $table ); } else { $availableLanguages = $this->getLanguages($formElement->pid, $table); } // Remove default language, if user does not have access. This is necessary, since // the default language is always added when fetching the system languages (#88504). if (isset($availableLanguages[0]) && !$this->getBackendUser()->checkLanguageAccess(0)) { unset($availableLanguages[0]); } // Page available in other languages than default language? if (count($availableLanguages) > 1) { $rowsByLang = []; $fetchFields = ['uid', $languageField, $transOrigPointerField]; // Get record in current language $rowCurrent = BackendUtility::getLiveVersionOfRecord($table, $uid, $fetchFields); if (!is_array($rowCurrent)) { $rowCurrent = BackendUtility::getRecord($table, $uid, $fetchFields); } $currentLanguage = (int)$rowCurrent[$languageField]; // Disabled for records with [all] language! if ($currentLanguage > -1) { // Get record in default language if needed if ($currentLanguage && $rowCurrent[$transOrigPointerField]) { $rowsByLang[0] = BackendUtility::getLiveVersionOfRecord( $table, $rowCurrent[$transOrigPointerField], $fetchFields ); if (!is_array($rowsByLang[0])) { $rowsByLang[0] = BackendUtility::getRecord( $table, $rowCurrent[$transOrigPointerField], $fetchFields ); } } else { $rowsByLang[$rowCurrent[$languageField]] = $rowCurrent; } // List of language id's that should not be added to the selector $noAddOption = []; if ($rowCurrent[$transOrigPointerField] || $currentLanguage === 0) { // Get record in other languages to see what's already available $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); $queryBuilder->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $backendUser->workspace)); $result = $queryBuilder->select(...$fetchFields) ->from($table) ->where( $queryBuilder->expr()->eq( 'pid', $queryBuilder->createNamedParameter($formElement->pid, Connection::PARAM_INT) ), $queryBuilder->expr()->gt( $languageField, $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) ), $queryBuilder->expr()->eq( $transOrigPointerField, $queryBuilder->createNamedParameter($rowsByLang[0]['uid'], Connection::PARAM_INT) ) ) ->executeQuery(); while ($row = $result->fetchAssociative()) { if ($backendUser->workspace !== 0 && $schema->isWorkspaceAware()) { $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $table, $row['uid'], 'uid,t3ver_state'); if (!empty($workspaceVersion)) { $versionState = VersionState::tryFrom($workspaceVersion['t3ver_state'] ?? 0); if ($versionState === VersionState::DELETE_PLACEHOLDER) { // If a workspace delete placeholder exists for this translation: Mark // this language as "don't add to selector" and continue with next row, // otherwise an edit link to a delete placeholder would be created, which // does not make sense. $noAddOption[] = (int)$row[$languageField]; continue; } } } $rowsByLang[$row[$languageField]] = $row; } } $languageDropDownButton = $this->componentFactory->createDropDownButton() ->setLabel($this->getLanguageService()->sL('core.core:labels.language')) ->setShowActiveLabelText(true) ->setShowLabelText(true); $existingLanguageItems = []; $newLanguageItems = []; foreach ($availableLanguages as $languageId => $language) { $selectorOptionLabel = $language['title']; // Create url for creating a localized record $addOption = true; $createNewLanguageLink = ''; if (!isset($rowsByLang[$languageId])) { // Translation in this language does not exist if ($this->columnsOnly[$table] ?? false) { // Don't add option since we are in a view with just a subset of fields, those views // are specific editing fields only views and are not meant for translation handling. $addOption = false; } elseif (!isset($rowsByLang[0]['uid'])) { // Don't add option since no default row to localize from exists // TODO: Actually tt_content is able to localize from another l10n_source then L=0. // This however is currently only possible via the translation wizard. $addOption = false; } } else { $params = [ 'edit[' . $table . '][' . $rowsByLang[$languageId]['uid'] . ']' => 'edit', 'module' => $this->module?->getIdentifier() ?? '', 'returnUrl' => $this->retUrl, ]; if ($this->columnsOnly[$table] ?? false) { $params['columnsOnly'] = [$table => $this->columnsOnly[$table]]; } if ($table === 'pages') { // Disallow manual adjustment of the language field for pages $params['overrideVals'] = [ 'pages' => [ 'sys_language_uid' => $languageId, ], ]; } $createNewLanguageLink = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $params); } if ($addOption && !in_array($languageId, $noAddOption, true)) { if (!$createNewLanguageLink) { $languageItem = $this->componentFactory->createDropDownItem() ->setTag('typo3-backend-localization-button') ->setAttribute('record-type', $table) ->setAttribute('record-uid', (string)$rowsByLang[0]['uid']) ->setAttribute('target-language', (string)$languageId) ->setLabel($selectorOptionLabel); if (!empty($language['flagIcon'])) { $languageItem->setIcon($this->iconFactory->getIcon($language['flagIcon'])); } $newLanguageItems[] = $languageItem; } else { $isActive = $languageId === $currentLanguage; $languageItem = $this->componentFactory->createDropDownRadio() ->setLabel($selectorOptionLabel) ->setHref($createNewLanguageLink) ->setActive($isActive); if (!empty($language['flagIcon'])) { $languageItem->setIcon($this->iconFactory->getIcon($language['flagIcon'])); } $existingLanguageItems[] = $languageItem; } } } // Add existing languages first foreach ($existingLanguageItems as $item) { $languageDropDownButton->addItem($item); } // Add separator and new languages if any if (!empty($newLanguageItems)) { $languageDropDownButton->addItem($this->componentFactory->createDropDownDivider()); $languageDropDownButton->addItem( $this->componentFactory->createDropDownHeader() ->setLabel($this->getLanguageService()->sL('core.core:labels.new_page_translation')) ); foreach ($newLanguageItems as $item) { $languageDropDownButton->addItem($item); } } $view->getDocHeaderComponent()->setLanguageSelector($languageDropDownButton); } } } /** * Returns languages available for record translations on given page. * * @param int $id Page id: If zero, all available system languages will be returned. If set to * another value, only languages, a page translation exists for, will be returned. * @param string $table For pages we want all languages, for other records the languages of the page translations * @return array Array with languages (uid, title, ISOcode, flagIcon) */ protected function getLanguages(int $id, string $table): array { // This usually happens when a non-pages record is added after another, so we are fetching the proper page ID if ($id < 0 && $table !== 'pages') { $pageId = $this->pageinfo['uid'] ?? null; if ($pageId !== null) { $pageId = (int)$pageId; } else { $fullRecord = BackendUtility::getRecord($table, abs($id)); $pageId = (int)$fullRecord['pid']; } } else { if ($table === 'pages' && $id > 0) { $fullRecord = BackendUtility::getRecordWSOL('pages', $id); $id = (int)($fullRecord['t3ver_oid'] ?: $fullRecord['uid']); } $pageId = $id; } // Fetch the current translations of this page, to only show the ones where there is a page translation $allLanguages = array_filter( GeneralUtility::makeInstance(TranslationConfigurationProvider::class)->getSystemLanguages($pageId), static fn(array $language): bool => (int)$language['uid'] !== -1 ); if ($table !== 'pages' && $id > 0) { $translatedPages = $this->localizationRepository->getPageTranslations($pageId, [], $this->getBackendUser()->workspace); $availableLanguages = []; if ($allLanguages[0] ?? false) { $availableLanguages = [ 0 => $allLanguages[0], ]; } foreach ($translatedPages as $translatedPage) { $languageId = $translatedPage->get('sys_language_uid'); if (isset($allLanguages[$languageId])) { $availableLanguages[$languageId] = $allLanguages[$languageId]; } } return $availableLanguages; } return $allLanguages; } /** * Fix $this->editconf if versioning applies to any of the records * * @param array|null $mapArray Mapping between old and new ids if auto-versioning has been performed. */ protected function fixWSversioningInEditConf(array $editConf, ?array $mapArray = null): array { $finalConfiguration = []; foreach ($editConf as $table => $conf) { // Traverse the keys/comments of each table (keys can be a comma list of uids) $newConf = []; foreach ($conf as $theUid => $cmd) { if ($cmd === 'edit') { if (is_array($mapArray)) { if ($mapArray[$table][$theUid] ?? false) { $theUid = $mapArray[$table][$theUid]; } } else { // Default, look for versions in workspace for record: $calcPRec = $this->getRecordForEdit($table, (int)$theUid); if (is_array($calcPRec)) { // Setting UID again if it had changed, due to workspace versioning. $theUid = (int)$calcPRec['uid']; } } // Add the possibly manipulated IDs to the new-build newConf array: $newConf[$theUid] = $cmd; } else { $newConf[$theUid] = $cmd; } } $finalConfiguration[$table] = $newConf; } return $finalConfiguration; } /** * Get record for editing. * * @return array|false Returns record to edit, false if none */ protected function getRecordForEdit(string $table, int $recordId): array|bool { $schema = $this->tcaSchemaFactory->get($table); // Fetch requested record: $reqRecord = BackendUtility::getRecord($table, $recordId, 'uid,pid' . ($schema->isWorkspaceAware() ? ',t3ver_oid' : '')); if (is_array($reqRecord)) { // If workspace is OFFLINE: if ($this->getBackendUser()->workspace !== 0) { // Check for versioning support of the table: if ($schema->isWorkspaceAware()) { // If the record is already a version of "something" pass it by. if ($reqRecord['t3ver_oid'] > 0 || VersionState::tryFrom($reqRecord['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER) { // (If it turns out not to be a version of the current workspace there will be trouble, but // that is handled inside DataHandler then and in the interface it would clearly be an error of // links if the user accesses such a scenario) return $reqRecord; } // The input record was online and an offline version must be found or made: // Look for version of this workspace: $versionRec = BackendUtility::getWorkspaceVersionOfRecord( $this->getBackendUser()->workspace, $table, $reqRecord['uid'], 'uid,pid,t3ver_oid' ); return is_array($versionRec) ? $versionRec : $reqRecord; } // This means that editing cannot occur on this record because it was not supporting versioning // which is required inside an offline workspace. return false; } // In ONLINE workspace, just return the originally requested record: return $reqRecord; } // Return FALSE because the table/uid was not found anyway. return false; } /** * The return value is used for the variable $this->storeArray to prepare 'open documents' urls */ protected function compileStoreData(ServerRequestInterface $request, array $overriddenValues): array { $queryParams = $request->getQueryParams(); $parsedBody = $request->getParsedBody(); $storeArray = []; foreach (['edit', 'defVals', 'overrideVals' , 'columnsOnly'] as $key) { $value = $overriddenValues[$key] ?? $parsedBody[$key] ?? $queryParams[$key] ?? null; if ($value !== null) { $storeArray[$key] = $value; } } return $storeArray; } /** * Get a TSConfig 'option.' array, possibly for a specific table. */ protected function getTsConfigOption(string $table, string $key, string $defaultValue = ''): string { return trim((string)( $this->getBackendUser()->getTSConfig()['options.'][$key . '.'][$table] ?? $this->getBackendUser()->getTSConfig()['options.'][$key] ?? $defaultValue )); } /** * Called when someone is done finishing editing - either by just hitting "close" or "save + close", * but also for New/AddController when using returnEditConf. * * At this time, "closing" open documents in the session and unlocking should be done already. * * @return ResponseInterface|null Redirect response if needed */ protected function closeAndPossiblyRedirectAction(FormAction $requestAction): ?ResponseInterface { if ($requestAction->shouldCloseWithARedirect()) { // If ->returnEditConf is set, then add the current content of editconf to the ->retUrl variable: used by // other scripts, like wizard_add, to know which records was created or so... if ($this->returnEditConf && !$this->shouldRedirectToEmptyPage()) { $this->retUrl .= '&returnEditConf=' . rawurlencode((string)json_encode($this->editconf)); } return new RedirectResponse($this->retUrl, 303); } if ($this->retUrl === '') { return null; } return new RedirectResponse((string)$this->returnUrl, 303); } protected function shouldRedirectToEmptyPage(): bool { return $this->retUrl === (string)$this->uriBuilder->buildUriFromRoute('dummy'); } /** * Close the currently open document(s) by dispatching the appropriate event. * This is called when the user explicitly closes, or when transitioning to a new/duplicated record. */ /** * Notify extensions that document(s) have been opened for editing. * Dispatches one event per record being opened. */ protected function openCurrentDocuments(): void { // Dispatch one event per record foreach ($this->elementsData as $element) { $this->eventDispatcher->dispatch( new AfterRecordOpenedEvent( table: $element->table, uid: $element->uid, record: $element->record, ) ); } } /** * Resolves the document title used for the browser tab and shortcut. * * Examples: * - No form: "Edit form could not be loaded" * - New record: "Create new {table}" * - Single record: "{record title} · {table} · #{uid}" * - Record with subtype: "{record title} · {subtype} · #{uid}" * - Multiple records: "Edit multiple · {table} · N records" */ protected function resolveDocumentTitle(LanguageService $languageService): string { $firstEl = $this->elementsData[0] ?? null; if ($firstEl === null) { return $languageService->sL('backend.alt_doc:noEditForm'); } $typeLabel = htmlspecialchars($this->resolveTypeLabel($firstEl->table, $firstEl->record)); if ($firstEl->command === 'new') { return $languageService->sL('core.core:labels.createNew') . ' ' . $typeLabel; } $recordCount = count($this->elementsData); if ($recordCount > 1) { return implode(' · ', [ $languageService->sL('core.core:labels.editMultiple'), $typeLabel, $languageService->translate('labels.records', 'core.core', ['count' => $recordCount]), ]); } $recordTitle = trim($firstEl->title) !== '' ? $firstEl->title : '[' . $languageService->sL('core.core:labels.no_title') . ']'; $recordTitle = BackendUtility::cropToTitleLength($recordTitle); $recordTitle = htmlspecialchars($recordTitle); return implode(' · ', array_filter([$recordTitle, $typeLabel, '#' . $firstEl->uid])); } /** * Returns an HTML snippet showing the record type icon, table title and uid. */ protected function getRecordIdentityHtml(string $table, array $row): string { $icon = $this->iconFactory->getIconForRecord($table, $row, IconSize::SMALL)->render(); $tableTitle = $this->resolveTypeLabel($table, $row); $uid = (string)($row['uid'] ?? ''); $recordType = '' . $icon . htmlspecialchars($tableTitle) . ''; $debugInfo = $this->getBackendUser()->shallDisplayDebugInformation() ? $table . ':' : ''; $recordIdentity = sprintf( '[%s%s]', htmlspecialchars($this->getLanguageService()->sL('core.core:labels.uid') . ' ' . $uid), htmlspecialchars($debugInfo), htmlspecialchars($uid), ); return '
' . $recordType . $recordIdentity . '
'; } /** * Resolves a human-readable type label for a given table and record. */ protected function resolveTypeLabel(string $table, array $record): string { $languageService = $this->getLanguageService(); $schema = $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null; $typeLabel = $schema !== null ? $schema->getTitle($languageService->sL(...)) : $table; if ($schema !== null && $schema->supportsSubSchema()) { $fieldName = $schema->getSubSchemaTypeInformation()->getFieldName(); $rawTypeValue = $record[$fieldName] ?? ''; $typeValue = is_array($rawTypeValue) ? (string)($rawTypeValue[0] ?? '') : (string)$rawTypeValue; if ($typeValue !== '') { $label = $languageService->sL($this->schemaLabelResolver->getLabelForFieldValue($table, $fieldName, $typeValue, $record)); if ($label === '' && $schema->hasSubSchema($typeValue)) { $label = $schema->getSubSchema($typeValue)->getTitle($languageService->sL(...)); } if ($label !== '') { $typeLabel = $label; } } } return $typeLabel; } protected function resolveDefaultReturnUrl(): string { $module = $this->moduleProvider->getFirstAccessibleModule($this->getBackendUser()); $routeName = $module ? $module->getIdentifier() : 'dummy'; return (string)$this->uriBuilder->buildUriFromRoute($routeName); } /** * Whether a single record view is requested. This * means, only one element exists in $elementsData. */ protected function isSingleRecordView(): bool { return count($this->elementsData) === 1; } protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } }