[ 'renderType' => 'localizationStateSelector', ], 'otherLanguageContent' => [ 'renderType' => 'otherLanguageContent', 'after' => [ 'localizationStateSelector', ], ], 'defaultLanguageDifferences' => [ 'renderType' => 'defaultLanguageDifferences', 'after' => [ 'otherLanguageContent', ], ], ]; public function __construct( private readonly IconFactory $iconFactory, private readonly EventDispatcherInterface $eventDispatcher, private readonly TypoLinkCodecService $typoLinkCodecService, private readonly LinkService $linkService, ) {} /** * This will render a single-line link form field, possibly with various control/validation features * * @return array As defined in initializeResultArray() of AbstractNode */ public function render(): array { $table = $this->data['tableName']; $fieldName = $this->data['fieldName']; $parameterArray = $this->data['parameterArray']; $resultArray = $this->initializeResultArray(); $config = $parameterArray['fieldConf']['config']; if (is_array($config['allowedTypes'] ?? false) && $config['allowedTypes'] === []) { throw new \RuntimeException( 'Field "' . $fieldName . '" in table "' . $table . '" of type "link" defines an empty list of allowed link types.', 1646922484 ); } $itemValue = $parameterArray['itemFormElValue']; $width = $this->formMaxWidth( MathUtility::forceIntegerInRange($config['size'] ?? $this->defaultInputWidth, $this->minimumInputWidth, $this->maxInputWidth) ); $fieldId = StringUtility::getUniqueId('formengine-input-'); $itemName = (string)$parameterArray['itemFormElName']; $renderedLabel = $this->renderLabel($fieldId); $fieldInformationResult = $this->renderFieldInformation(); $fieldInformationHtml = $fieldInformationResult['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); if ($config['readOnly'] ?? false) { $html = []; $html[] = $renderedLabel; $html[] = '
'; $html[] = $fieldInformationHtml; $html[] = '
'; $html[] = '
'; $html[] = '
'; $html[] = ''; $html[] = '
'; $html[] = '
'; $html[] = '
'; $html[] = '
'; $resultArray['html'] = implode(LF, $html); return $resultArray; } $languageService = $this->getLanguageService(); // Always adding "trim". $evalList = ['trim']; if ($config['nullable'] ?? false) { $evalList[] = 'null'; } $attributes = [ 'value' => '', 'id' => $fieldId, 'class' => implode(' ', [ 'form-control', 'form-control-clearable', 't3js-clearable', 't3js-form-field-link-input', ]), 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config), 'data-formengine-input-params' => (string)json_encode([ 'field' => $itemName, 'evalList' => implode(',', $evalList), ], JSON_THROW_ON_ERROR), 'data-formengine-input-name' => $itemName, ]; if (!empty($config['placeholder'])) { $attributes['placeholder'] = trim($config['placeholder']); } if (isset($config['autocomplete'])) { $attributes['autocomplete'] = empty($config['autocomplete']) ? 'new-' . $fieldName : 'on'; } $valuePickerHtml = []; if (is_array($config['valuePicker']['items'] ?? false)) { $valuePickerConfiguration = [ 'linked-field' => '[data-formengine-input-name="' . $itemName . '"]', 'class' => 'form-control-clearable-wrapper', ]; $valuePickerAttributes = array_merge( [ 'class' => 'form-select form-control-adapt', ], $this->getOnFieldChangeAttrs('change', $parameterArray['fieldChangeFunc'] ?? []) ); $valuePickerHtml[] = ''; $valuePickerHtml[] = ''; $valuePickerHtml[] = ''; $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/form-engine/field-wizard/value-picker.js'); } $fieldWizardResult = $this->renderFieldWizard(); $fieldWizardHtml = $fieldWizardResult['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); // Manually initialize the "linkPopup" FieldControl configuration, based on the link type specific settings $this->initializeLinkPopup($config); $fieldControlResult = $this->renderFieldControl(); $fieldControlHtml = $fieldControlResult['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false); $linkExplanation = $this->getLinkExplanation((string)$itemValue); $hasExplanation = ($linkExplanation['text'] ?? '') !== ''; if ($hasExplanation) { $attributes['hidden'] = 'hidden'; } $explanation = htmlspecialchars($linkExplanation['text'] ?? ''); $toggleButtonTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.toggleLinkExplanation'); $expansionHtml = []; $expansionHtml[] = '
'; $expansionHtml[] = '
'; $expansionHtml[] = '
'; $expansionHtml[] = ''; $expansionHtml[] = '
'; if (!empty($valuePickerHtml) || !empty($fieldControlHtml)) { $expansionHtml[] = '
'; $expansionHtml[] = '
'; $expansionHtml[] = implode(LF, $valuePickerHtml); $expansionHtml[] = $fieldControlHtml; $expansionHtml[] = '
'; $expansionHtml[] = '
'; } if (!empty($fieldWizardHtml) || !empty($linkExplanation['additionalAttributes'])) { $expansionHtml[] = '
'; $expansionHtml[] = $linkExplanation['additionalAttributes'] ?? ''; $expansionHtml[] = $fieldWizardHtml; $expansionHtml[] = '
'; } $expansionHtml[] = '
'; $expansionHtml[] = '
'; $expansionHtml = implode(LF, $expansionHtml); $nullControlNameEscaped = htmlspecialchars('control[active][' . $table . '][' . $this->data['databaseRow']['uid'] . '][' . $fieldName . ']'); $fullElement = $expansionHtml; if ($this->hasNullCheckboxButNoPlaceholder()) { $checked = $itemValue !== null ? ' checked="checked"' : ''; $fullElement = []; $fullElement[] = '
'; $fullElement[] = '
'; $fullElement[] = ''; $fullElement[] = ''; $fullElement[] = ''; $fullElement[] = '
'; $fullElement[] = $expansionHtml; $fullElement = implode(LF, $fullElement); } elseif ($this->hasNullCheckboxWithPlaceholder()) { $checked = $itemValue !== null ? ' checked="checked"' : ''; $placeholder = $shortenedPlaceholder = (string)($config['placeholder'] ?? ''); if ($placeholder !== '') { $shortenedPlaceholder = GeneralUtility::fixed_lgd_cs($placeholder, 20); if ($placeholder !== $shortenedPlaceholder) { $overrideLabel = sprintf( $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'), '' . htmlspecialchars($shortenedPlaceholder) . '' ); } else { $overrideLabel = sprintf( $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'), htmlspecialchars($placeholder) ); } } else { $overrideLabel = $languageService->sL( 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override_not_available' ); } $fullElement = []; $fullElement[] = '
'; $fullElement[] = ''; $fullElement[] = ''; $fullElement[] = ''; $fullElement[] = '
'; $fullElement[] = '
'; $fullElement[] = '
'; $fullElement[] = ''; $fullElement[] = '
'; $fullElement[] = '
'; $fullElement[] = '
'; $fullElement[] = $expansionHtml; $fullElement[] = '
'; $fullElement = implode(LF, $fullElement); } $resultArray['html'] = $renderedLabel . ' ' . $fieldInformationHtml . ' ' . $fullElement . ' '; $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/form-engine/element/link-element.js'); return $resultArray; } protected function getLinkExplanation(string $itemValue): array { if ($itemValue === '') { return []; } $data = ['text' => '', 'icon' => '']; $linkParts = $this->typoLinkCodecService->decode($itemValue); try { $linkData = $this->linkService->resolve($linkParts['url']); } catch (FileDoesNotExistException|FolderDoesNotExistException|UnknownLinkHandlerException|InvalidPathException $e) { return $data; } // Resolving the TypoLink parts (class, title, params) $additionalAttributes = []; foreach ($linkParts as $key => $value) { if ($key === 'url') { continue; } if ($value) { $label = match ((string)$key) { 'class' => $this->getLanguageService()->sL('backend.browse_links:class'), 'title' => $this->getLanguageService()->sL('backend.browse_links:title'), 'additionalParams' => $this->getLanguageService()->sL('backend.browse_links:params'), 'rel' => $this->getLanguageService()->sL('backend.browse_links:linkRelationship'), default => (string)$key }; $additionalAttributes[] = '' . htmlspecialchars($label) . ': ' . htmlspecialchars($value) . ''; } } $backendUser = $this->getBackendUser(); // Resolve the actual link switch ($linkData['type']) { case LinkService::TYPE_PAGE: $pagePermissionClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW); $pageRecord = BackendUtility::readPageAccess($linkData['pageuid'] ?? null, $pagePermissionClause); // Is this a real page if ($pageRecord['uid'] ?? 0) { $fragmentTitle = ''; if (isset($linkData['fragment'])) { if (MathUtility::canBeInterpretedAsInteger($linkData['fragment'])) { $contentElement = BackendUtility::getRecord('tt_content', (int)$linkData['fragment'], '*', 'pid=' . $pageRecord['uid']); if ($contentElement) { $fragmentTitle = BackendUtility::getRecordTitle('tt_content', $contentElement, false, false); } } $fragmentTitle = ' #' . ($fragmentTitle ?: $linkData['fragment']); } $data = [ 'text' => $pageRecord['_thePathFull'] . '[' . $pageRecord['uid'] . ']' . $fragmentTitle, 'icon' => $this->iconFactory->getIconForRecord('pages', $pageRecord, IconSize::SMALL)->render(), ]; } break; case LinkService::TYPE_EMAIL: $data = [ 'text' => $linkData['email'] ?? '', 'icon' => $this->iconFactory->getIcon('content-elements-mailform', IconSize::SMALL)->render(), ]; break; case LinkService::TYPE_URL: $data = [ 'text' => $linkData['url'] ?? '', 'icon' => $this->iconFactory->getIcon('apps-pagetree-page-shortcut-external', IconSize::SMALL)->render(), ]; break; case LinkService::TYPE_FILE: $file = $linkData['file'] ?? null; if ($file instanceof File && $file->checkActionPermission('read') && !$file->getStorage()->isFallbackStorage()) { $data = [ 'text' => $file->getPublicUrl(), 'icon' => $this->iconFactory->getIconForFileExtension($file->getExtension(), IconSize::SMALL)->render(), ]; } break; case LinkService::TYPE_FOLDER: $folder = $linkData['folder'] ?? null; if ($folder instanceof Folder && $folder->checkActionPermission('read') && !$folder->getStorage()->isFallbackStorage()) { $data = [ 'text' => $folder->getPublicUrl(), 'icon' => $this->iconFactory->getIcon('apps-filetree-folder-default', IconSize::SMALL)->render(), ]; } break; case LinkService::TYPE_RECORD: $table = $this->data['pageTsConfig']['TCEMAIN.']['linkHandler.'][$linkData['identifier'] . '.']['configuration.']['table'] ?? ''; $record = BackendUtility::getRecord($table, $linkData['uid']); $pagePermissionClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW); $hasPageAccess = BackendUtility::readPageAccess($record['pid'] ?? null, $pagePermissionClause) !== false; if ($record && $hasPageAccess && $backendUser->check('tables_select', $table)) { $recordTitle = BackendUtility::getRecordTitle($table, $record); $tableTitle = $this->getLanguageService()->sL($this->data['tcaSchemata']->get($table)->getTitle()); $data = [ 'text' => sprintf('%s [%s:%d]', $recordTitle, $tableTitle, $linkData['uid']), 'icon' => $this->iconFactory->getIconForRecord($table, $record, IconSize::SMALL)->render(), ]; } else { $data = [ 'text' => sprintf('%s', $linkData['uid']), 'icon' => $this->iconFactory->getIcon('tcarecords-' . $table . '-default', IconSize::SMALL, 'overlay-missing')->render(), ]; } break; case LinkService::TYPE_TELEPHONE: $telephone = $linkData['telephone']; if ($telephone) { $data = [ 'text' => $telephone, 'icon' => $this->iconFactory->getIcon('actions-device-mobile', IconSize::SMALL)->render(), ]; } break; case LinkService::TYPE_UNKNOWN: $data = [ 'text' => $linkData['file'] ?? $linkData['url'] ?? '', 'icon' => $this->iconFactory->getIcon('actions-link', IconSize::SMALL)->render(), ]; break; default: $data = [ 'text' => 'not implemented type ' . $linkData['type'], 'icon' => '', ]; } $data['additionalAttributes'] = $additionalAttributes !== [] ? '
' . implode(' - ', $additionalAttributes) . '
' : ''; // Ensure "additionalAttributes" is always set (bw compatibility for the event) return $this->eventDispatcher->dispatch( new ModifyLinkExplanationEvent($data, $linkData, $linkParts, $this->data) )->getLinkExplanation(); } /** * Initializes the LinkPopup FieldControl by processing the * field specific configuration and by creating the necessary * options array for the FieldControl. */ protected function initializeLinkPopup(array $fieldConfig): void { if (!($fieldConfig['appearance']['enableBrowser'] ?? true)) { return; } $options = []; if (is_array($fieldConfig['allowedTypes'] ?? null) && ($fieldConfig['allowedTypes'][0] ?? '') !== '*' ) { $options['allowedTypes'] = $this->resolveAllowedTypes($fieldConfig['allowedTypes']); } if (is_array($fieldConfig['appearance']['allowedOptions'] ?? null) && ($fieldConfig['appearance']['allowedOptions'][0] ?? '') !== '*' ) { $options['allowedOptions'] = $fieldConfig['appearance']['allowedOptions']; } if (is_array($fieldConfig['appearance']['allowedFileExtensions'] ?? null) && ($fieldConfig['appearance']['allowedFileExtensions'][0] ?? '') !== '*' ) { $options['allowedFileExtensions'] = $fieldConfig['appearance']['allowedFileExtensions']; } if ($fieldConfig['appearance']['browserTitle'] ?? false) { $options['title'] = $fieldConfig['appearance']['browserTitle']; } // Add the LinkPopup configuration to the field configuration $this->data['parameterArray']['fieldConf']['config']['fieldControl']['linkPopup'] = [ 'renderType' => 'linkPopup', 'options' => $options, ]; } /** * This method applies further processing to a given allow list */ protected function resolveAllowedTypes(array $allowedTypes): array { // First, remove duplicate entries $allowedTypes = array_unique($allowedTypes); // Replace "record" with available record link handlers if (in_array('record', $allowedTypes, true)) { unset($allowedTypes[(int)array_search('record', $allowedTypes, true)]); $allowedTypes = array_merge($allowedTypes, $this->getRecordLinkHandlers()); } // Return the resolves types, while removing duplicate entries return array_unique($allowedTypes); } /** * Returns the identifiers of link handlers, using the RecordLinkHandler class */ protected function getRecordLinkHandlers(): array { return $this->getLinkHandlerIdentifiers( array_filter( (array)($this->data['pageTsConfig']['TCEMAIN.']['linkHandler.'] ?? []), static fn(array $handler): bool => ($handler['handler'] ?? '') === RecordLinkHandler::class ) ); } protected function getLinkHandlerIdentifiers(array $linkHandlers): array { return array_map(static fn(string $handler): string => trim($handler, '.'), array_keys($linkHandlers)); } }