1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\Backend\Form\Element; 17 18use TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeTrait; 19use TYPO3\CMS\Backend\Utility\BackendUtility; 20use TYPO3\CMS\Core\Imaging\Icon; 21use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException; 22use TYPO3\CMS\Core\LinkHandling\LinkService; 23use TYPO3\CMS\Core\Localization\LanguageService; 24use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; 25use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException; 26use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException; 27use TYPO3\CMS\Core\Resource\Exception\InvalidPathException; 28use TYPO3\CMS\Core\Resource\File; 29use TYPO3\CMS\Core\Resource\Folder; 30use TYPO3\CMS\Core\Utility\GeneralUtility; 31use TYPO3\CMS\Core\Utility\MathUtility; 32use TYPO3\CMS\Core\Utility\StringUtility; 33use TYPO3\CMS\Frontend\Service\TypoLinkCodecService; 34 35/** 36 * Link input element. 37 * 38 * Shows current link and the link popup. 39 */ 40class InputLinkElement extends AbstractFormElement 41{ 42 use OnFieldChangeTrait; 43 44 /** 45 * Default field information enabled for this element. 46 * 47 * @var array 48 */ 49 protected $defaultFieldInformation = [ 50 'tcaDescription' => [ 51 'renderType' => 'tcaDescription', 52 ], 53 ]; 54 55 /** 56 * Default field controls render the link icon 57 * 58 * @var array 59 */ 60 protected $defaultFieldControl = [ 61 'linkPopup' => [ 62 'renderType' => 'linkPopup', 63 'options' => [], 64 ], 65 ]; 66 67 /** 68 * Default field wizards enabled for this element. 69 * 70 * @var array 71 */ 72 protected $defaultFieldWizard = [ 73 'localizationStateSelector' => [ 74 'renderType' => 'localizationStateSelector', 75 ], 76 'otherLanguageContent' => [ 77 'renderType' => 'otherLanguageContent', 78 'after' => [ 79 'localizationStateSelector', 80 ], 81 ], 82 'defaultLanguageDifferences' => [ 83 'renderType' => 'defaultLanguageDifferences', 84 'after' => [ 85 'otherLanguageContent', 86 ], 87 ], 88 ]; 89 90 /** 91 * This will render a single-line input form field, possibly with various control/validation features 92 * 93 * @return array As defined in initializeResultArray() of AbstractNode 94 */ 95 public function render() 96 { 97 $languageService = $this->getLanguageService(); 98 99 $table = $this->data['tableName']; 100 $fieldName = $this->data['fieldName']; 101 $row = $this->data['databaseRow']; 102 $parameterArray = $this->data['parameterArray']; 103 $resultArray = $this->initializeResultArray(); 104 $config = $parameterArray['fieldConf']['config']; 105 106 $itemValue = $parameterArray['itemFormElValue']; 107 $evalList = GeneralUtility::trimExplode(',', $config['eval'] ?? '', true); 108 $size = MathUtility::forceIntegerInRange($config['size'] ?? $this->defaultInputWidth, $this->minimumInputWidth, $this->maxInputWidth); 109 $width = (int)$this->formMaxWidth($size); 110 $nullControlNameEscaped = htmlspecialchars('control[active][' . $table . '][' . $row['uid'] . '][' . $fieldName . ']'); 111 112 $fieldInformationResult = $this->renderFieldInformation(); 113 $fieldInformationHtml = $fieldInformationResult['html']; 114 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); 115 116 if ($config['readOnly'] ?? false) { 117 // Early return for read only fields 118 $html = []; 119 $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; 120 $html[] = $fieldInformationHtml; 121 $html[] = '<div class="form-wizards-wrap">'; 122 $html[] = '<div class="form-wizards-element">'; 123 $html[] = '<div class="form-control-wrap" style="max-width: ' . $width . 'px">'; 124 $html[] = '<input class="form-control" value="' . htmlspecialchars($itemValue) . '" type="text" disabled>'; 125 $html[] = '</div>'; 126 $html[] = '</div>'; 127 $html[] = '</div>'; 128 $html[] = '</div>'; 129 $resultArray['html'] = implode(LF, $html); 130 return $resultArray; 131 } 132 133 // @todo: The whole eval handling is a mess and needs refactoring 134 foreach ($evalList as $func) { 135 // @todo: This is ugly: The code should find out on it's own whether an eval definition is a 136 // @todo: keyword like "date", or a class reference. The global registration could be dropped then 137 // Pair hook to the one in \TYPO3\CMS\Core\DataHandling\DataHandler::checkValue_input_Eval() 138 if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) { 139 if (class_exists($func)) { 140 $evalObj = GeneralUtility::makeInstance($func); 141 if (method_exists($evalObj, 'deevaluateFieldValue')) { 142 $_params = [ 143 'value' => $itemValue, 144 ]; 145 $itemValue = $evalObj->deevaluateFieldValue($_params); 146 } 147 $resultArray = $this->resolveJavaScriptEvaluation($resultArray, $func, $evalObj); 148 } 149 } 150 } 151 152 $fieldId = StringUtility::getUniqueId('formengine-input-'); 153 154 $attributes = [ 155 'value' => '', 156 'id' => $fieldId, 157 'class' => implode(' ', [ 158 'form-control', 159 't3js-clearable', 160 't3js-form-field-inputlink-input', 161 'hidden', 162 'hasDefaultValue', 163 ]), 164 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config), 165 'data-formengine-input-params' => (string)json_encode([ 166 'field' => $parameterArray['itemFormElName'], 167 'evalList' => implode(',', $evalList), 168 ]), 169 'data-formengine-input-name' => (string)($parameterArray['itemFormElName'] ?? ''), 170 ]; 171 172 $maxLength = $config['max'] ?? 0; 173 if ((int)$maxLength > 0) { 174 $attributes['maxlength'] = (string)(int)$maxLength; 175 } 176 if (!empty($config['placeholder'])) { 177 $attributes['placeholder'] = trim($config['placeholder']); 178 } 179 if (isset($config['autocomplete'])) { 180 $attributes['autocomplete'] = empty($config['autocomplete']) ? 'new-' . $fieldName : 'on'; 181 } 182 183 $valuePickerHtml = []; 184 if (isset($config['valuePicker']['items']) && is_array($config['valuePicker']['items'])) { 185 $valuePickerConfiguration = [ 186 'mode' => $config['valuePicker']['mode'] ?? 'replace', 187 'linked-field' => '[data-formengine-input-name="' . $parameterArray['itemFormElName'] . '"]', 188 ]; 189 $valuePickerAttributes = array_merge( 190 [ 191 'class' => 'form-select form-control-adapt', 192 ], 193 $this->getOnFieldChangeAttrs('change', $parameterArray['fieldChangeFunc'] ?? []) 194 ); 195 196 $valuePickerHtml[] = '<typo3-formengine-valuepicker ' . GeneralUtility::implodeAttributes($valuePickerConfiguration, true) . '>'; 197 $valuePickerHtml[] = '<select ' . GeneralUtility::implodeAttributes($valuePickerAttributes, true) . '>'; 198 $valuePickerHtml[] = '<option></option>'; 199 foreach ($config['valuePicker']['items'] as $item) { 200 $valuePickerHtml[] = '<option value="' . htmlspecialchars($item[1]) . '">' . htmlspecialchars($languageService->sL($item[0])) . '</option>'; 201 } 202 $valuePickerHtml[] = '</select>'; 203 $valuePickerHtml[] = '</typo3-formengine-valuepicker>'; 204 205 $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/FormEngine/FieldWizard/ValuePicker'); 206 } 207 208 $fieldWizardResult = $this->renderFieldWizard(); 209 $fieldWizardHtml = $fieldWizardResult['html']; 210 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); 211 212 $fieldControlResult = $this->renderFieldControl(); 213 $fieldControlHtml = $fieldControlResult['html']; 214 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false); 215 216 $linkExplanation = $this->getLinkExplanation($itemValue ?: ''); 217 $explanation = htmlspecialchars($linkExplanation['text'] ?? ''); 218 $toggleButtonTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.toggleLinkExplanation'); 219 220 $expansionHtml = []; 221 $expansionHtml[] = '<div class="form-control-wrap" style="max-width: ' . $width . 'px">'; 222 $expansionHtml[] = '<div class="form-wizards-wrap">'; 223 $expansionHtml[] = '<div class="form-wizards-element">'; 224 $expansionHtml[] = '<div class="input-group t3js-form-field-inputlink">'; 225 $expansionHtml[] = '<span class="t3js-form-field-inputlink-icon input-group-addon">' . ($linkExplanation['icon'] ?? '') . '</span>'; 226 $expansionHtml[] = '<input class="form-control t3js-form-field-inputlink-explanation" data-bs-toggle="tooltip" title="' . $explanation . '" value="' . $explanation . '" readonly>'; 227 $expansionHtml[] = '<input type="text" ' . GeneralUtility::implodeAttributes($attributes, true) . ' />'; 228 $expansionHtml[] = '<button class="btn btn-default t3js-form-field-inputlink-explanation-toggle" type="button" title="' . htmlspecialchars($toggleButtonTitle) . '">'; 229 $expansionHtml[] = $this->iconFactory->getIcon('actions-version-workspaces-preview-link', Icon::SIZE_SMALL)->render(); 230 $expansionHtml[] = '</button>'; 231 $expansionHtml[] = '<input type="hidden" name="' . $parameterArray['itemFormElName'] . '" value="' . htmlspecialchars($itemValue) . '" />'; 232 $expansionHtml[] = '</div>'; 233 $expansionHtml[] = '</div>'; 234 if (!empty($valuePickerHtml) || !empty($fieldControlHtml)) { 235 $expansionHtml[] = '<div class="form-wizards-items-aside form-wizards-items-aside--field-control">'; 236 $expansionHtml[] = '<div class="btn-group">'; 237 $expansionHtml[] = implode(LF, $valuePickerHtml); 238 $expansionHtml[] = $fieldControlHtml; 239 $expansionHtml[] = '</div>'; 240 $expansionHtml[] = '</div>'; 241 } 242 $expansionHtml[] = '<div class="form-wizards-items-bottom">'; 243 $expansionHtml[] = $linkExplanation['additionalAttributes'] ?? ''; 244 $expansionHtml[] = $fieldWizardHtml; 245 $expansionHtml[] = '</div>'; 246 $expansionHtml[] = '</div>'; 247 $expansionHtml[] = '</div>'; 248 $expansionHtml = implode(LF, $expansionHtml); 249 250 $fullElement = $expansionHtml; 251 if ($this->hasNullCheckboxButNoPlaceholder()) { 252 $checked = $itemValue !== null ? ' checked="checked"' : ''; 253 $fullElement = []; 254 $fullElement[] = '<div class="t3-form-field-disable"></div>'; 255 $fullElement[] = '<div class="form-check t3-form-field-eval-null-checkbox">'; 256 $fullElement[] = '<input type="hidden" name="' . $nullControlNameEscaped . '" value="0" />'; 257 $fullElement[] = '<input type="checkbox" class="form-check-input" name="' . $nullControlNameEscaped . '" id="' . $nullControlNameEscaped . '" value="1"' . $checked . ' />'; 258 $fullElement[] = '<label class="form-check-label" for="' . $nullControlNameEscaped . '">'; 259 $fullElement[] = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.nullCheckbox'); 260 $fullElement[] = '</label>'; 261 $fullElement[] = '</div>'; 262 $fullElement[] = $expansionHtml; 263 $fullElement = implode(LF, $fullElement); 264 } elseif ($this->hasNullCheckboxWithPlaceholder()) { 265 $checked = $itemValue !== null ? ' checked="checked"' : ''; 266 $placeholder = $shortenedPlaceholder = $config['placeholder'] ?? ''; 267 $disabled = ''; 268 $fallbackValue = 0; 269 if (strlen($placeholder) > 0) { 270 $shortenedPlaceholder = GeneralUtility::fixed_lgd_cs($placeholder, 20); 271 if ($placeholder !== $shortenedPlaceholder) { 272 $overrideLabel = sprintf( 273 $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'), 274 '<span title="' . htmlspecialchars($placeholder) . '">' . htmlspecialchars($shortenedPlaceholder) . '</span>' 275 ); 276 } else { 277 $overrideLabel = sprintf( 278 $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'), 279 htmlspecialchars($placeholder) 280 ); 281 } 282 } else { 283 $overrideLabel = $languageService->sL( 284 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override_not_available' 285 ); 286 } 287 $fullElement = []; 288 $fullElement[] = '<div class="form-check t3js-form-field-eval-null-placeholder-checkbox">'; 289 $fullElement[] = '<input type="hidden" name="' . $nullControlNameEscaped . '" value="' . $fallbackValue . '" />'; 290 $fullElement[] = '<input type="checkbox" class="form-check-input" name="' . $nullControlNameEscaped . '" id="' . $nullControlNameEscaped . '" value="1"' . $checked . $disabled . ' />'; 291 $fullElement[] = '<label class="form-check-label" for="' . $nullControlNameEscaped . '">'; 292 $fullElement[] = $overrideLabel; 293 $fullElement[] = '</label>'; 294 $fullElement[] = '</div>'; 295 $fullElement[] = '<div class="t3js-formengine-placeholder-placeholder">'; 296 $fullElement[] = '<div class="form-control-wrap" style="max-width:' . $width . 'px">'; 297 $fullElement[] = '<input type="text" class="form-control" disabled="disabled" value="' . htmlspecialchars($shortenedPlaceholder) . '" />'; 298 $fullElement[] = '</div>'; 299 $fullElement[] = '</div>'; 300 $fullElement[] = '<div class="t3js-formengine-placeholder-formfield">'; 301 $fullElement[] = $expansionHtml; 302 $fullElement[] = '</div>'; 303 $fullElement = implode(LF, $fullElement); 304 } 305 306 $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS( 307 'TYPO3/CMS/Backend/FormEngine/Element/InputLinkElement' 308 )->instance($fieldId); 309 $resultArray['html'] = '<div class="formengine-field-item t3js-formengine-field-item">' . $fieldInformationHtml . $fullElement . '</div>'; 310 return $resultArray; 311 } 312 313 /** 314 * @param string $itemValue 315 * @return array 316 */ 317 protected function getLinkExplanation(string $itemValue): array 318 { 319 if (empty($itemValue)) { 320 return []; 321 } 322 $data = ['text' => '', 'icon' => '']; 323 $typolinkService = GeneralUtility::makeInstance(TypoLinkCodecService::class); 324 $linkParts = $typolinkService->decode($itemValue); 325 $linkService = GeneralUtility::makeInstance(LinkService::class); 326 327 try { 328 $linkData = $linkService->resolve($linkParts['url']); 329 } catch (FileDoesNotExistException|FolderDoesNotExistException|UnknownLinkHandlerException|InvalidPathException $e) { 330 return $data; 331 } 332 333 // Resolving the TypoLink parts (class, title, params) 334 $additionalAttributes = []; 335 foreach ($linkParts as $key => $value) { 336 if ($key === 'url') { 337 continue; 338 } 339 if ($value) { 340 switch ($key) { 341 case 'class': 342 $label = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_browse_links.xlf:class'); 343 break; 344 case 'title': 345 $label = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_browse_links.xlf:title'); 346 break; 347 case 'additionalParams': 348 $label = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_browse_links.xlf:params'); 349 break; 350 default: 351 $label = (string)$key; 352 } 353 354 $additionalAttributes[] = '<span><strong>' . htmlspecialchars($label) . ': </strong> ' . htmlspecialchars($value) . '</span>'; 355 } 356 } 357 358 // Resolve the actual link 359 switch ($linkData['type']) { 360 case LinkService::TYPE_PAGE: 361 $pageRecord = BackendUtility::readPageAccess($linkData['pageuid'], '1=1'); 362 // Is this a real page 363 if ($pageRecord['uid'] ?? 0) { 364 $fragmentTitle = ''; 365 if (isset($linkData['fragment'])) { 366 if (MathUtility::canBeInterpretedAsInteger($linkData['fragment'])) { 367 $contentElement = BackendUtility::getRecord('tt_content', (int)$linkData['fragment'], '*', 'pid=' . $pageRecord['uid']); 368 if ($contentElement) { 369 $fragmentTitle = BackendUtility::getRecordTitle('tt_content', $contentElement, false, false); 370 } 371 } 372 $fragmentTitle = ' #' . ($fragmentTitle ?: $linkData['fragment']); 373 } 374 $data = [ 375 'text' => $pageRecord['_thePathFull'] . '[' . $pageRecord['uid'] . ']' . $fragmentTitle, 376 'icon' => $this->iconFactory->getIconForRecord('pages', $pageRecord, Icon::SIZE_SMALL)->render(), 377 ]; 378 } 379 break; 380 case LinkService::TYPE_EMAIL: 381 $data = [ 382 'text' => $linkData['email'], 383 'icon' => $this->iconFactory->getIcon('content-elements-mailform', Icon::SIZE_SMALL)->render(), 384 ]; 385 break; 386 case LinkService::TYPE_URL: 387 $data = [ 388 'text' => $linkData['url'], 389 'icon' => $this->iconFactory->getIcon('apps-pagetree-page-shortcut-external', Icon::SIZE_SMALL)->render(), 390 391 ]; 392 break; 393 case LinkService::TYPE_FILE: 394 /** @var File $file */ 395 $file = $linkData['file']; 396 if ($file) { 397 $data = [ 398 'text' => $file->getPublicUrl(), 399 'icon' => $this->iconFactory->getIconForFileExtension($file->getExtension(), Icon::SIZE_SMALL)->render(), 400 ]; 401 } 402 break; 403 case LinkService::TYPE_FOLDER: 404 /** @var Folder $folder */ 405 $folder = $linkData['folder']; 406 if ($folder) { 407 $data = [ 408 'text' => $folder->getPublicUrl(), 409 'icon' => $this->iconFactory->getIcon('apps-filetree-folder-default', Icon::SIZE_SMALL)->render(), 410 ]; 411 } 412 break; 413 case LinkService::TYPE_RECORD: 414 $table = $this->data['pageTsConfig']['TCEMAIN.']['linkHandler.'][$linkData['identifier'] . '.']['configuration.']['table'] ?? ''; 415 $record = BackendUtility::getRecord($table, $linkData['uid']); 416 if ($record) { 417 $recordTitle = BackendUtility::getRecordTitle($table, $record); 418 $tableTitle = $this->getLanguageService()->sL($GLOBALS['TCA'][$table]['ctrl']['title']); 419 $data = [ 420 'text' => sprintf('%s [%s:%d]', $recordTitle, $tableTitle, $linkData['uid']), 421 'icon' => $this->iconFactory->getIconForRecord($table, $record, Icon::SIZE_SMALL)->render(), 422 ]; 423 } else { 424 $data = [ 425 'text' => sprintf('%s', $linkData['uid']), 426 'icon' => $this->iconFactory->getIcon('tcarecords-' . $table . '-default', Icon::SIZE_SMALL, 'overlay-missing')->render(), 427 ]; 428 } 429 break; 430 case LinkService::TYPE_TELEPHONE: 431 $telephone = $linkData['telephone']; 432 if ($telephone) { 433 $data = [ 434 'text' => $telephone, 435 'icon' => $this->iconFactory->getIcon('actions-device-mobile', Icon::SIZE_SMALL)->render(), 436 ]; 437 } 438 break; 439 default: 440 // Please note that this hook is preliminary and might change, as this element could become its own 441 // TCA type in the future 442 if (isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['linkHandler'][$linkData['type']])) { 443 $linkBuilder = GeneralUtility::makeInstance($GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['linkHandler'][$linkData['type']]); 444 $data = $linkBuilder->getFormData($linkData, $linkParts, $this->data, $this); 445 } elseif ($linkData['type'] === LinkService::TYPE_UNKNOWN) { 446 $data = [ 447 'text' => $linkData['file'], 448 'icon' => $this->iconFactory->getIcon('actions-link', Icon::SIZE_SMALL)->render(), 449 ]; 450 } else { 451 $data = [ 452 'text' => 'not implemented type ' . $linkData['type'], 453 'icon' => '', 454 ]; 455 } 456 } 457 458 $data['additionalAttributes'] = '<div class="help-block">' . implode(' - ', $additionalAttributes) . '</div>'; 459 return $data; 460 } 461 462 /** 463 * @return LanguageService 464 */ 465 protected function getLanguageService() 466 { 467 return $GLOBALS['LANG']; 468 } 469} 470