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\Core\Authentication\BackendUserAuthentication; 20use TYPO3\CMS\Core\Imaging\Icon; 21use TYPO3\CMS\Core\Localization\LanguageService; 22use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; 23use TYPO3\CMS\Core\Utility\ArrayUtility; 24use TYPO3\CMS\Core\Utility\GeneralUtility; 25use TYPO3\CMS\Core\Utility\MathUtility; 26use TYPO3\CMS\Core\Utility\StringUtility; 27 28/** 29 * Render a widget with two boxes side by side. 30 * 31 * This is rendered for config type=select, renderType=selectMultipleSideBySide set 32 */ 33class SelectMultipleSideBySideElement extends AbstractFormElement 34{ 35 use OnFieldChangeTrait; 36 37 /** 38 * Default field information enabled for this element. 39 * 40 * @var array 41 */ 42 protected $defaultFieldInformation = [ 43 'tcaDescription' => [ 44 'renderType' => 'tcaDescription', 45 ], 46 ]; 47 48 /** 49 * Default field controls for this element. 50 * 51 * @var array 52 */ 53 protected $defaultFieldControl = [ 54 'editPopup' => [ 55 'renderType' => 'editPopup', 56 'disabled' => true, 57 ], 58 'addRecord' => [ 59 'renderType' => 'addRecord', 60 'disabled' => true, 61 ], 62 'listModule' => [ 63 'renderType' => 'listModule', 64 'disabled' => true, 65 'after' => [ 'addRecord' ], 66 ], 67 ]; 68 69 /** 70 * Default field wizards enabled for this element. 71 * 72 * @var array 73 */ 74 protected $defaultFieldWizard = [ 75 'localizationStateSelector' => [ 76 'renderType' => 'localizationStateSelector', 77 ], 78 'otherLanguageContent' => [ 79 'renderType' => 'otherLanguageContent', 80 'after' => [ 81 'localizationStateSelector', 82 ], 83 ], 84 'defaultLanguageDifferences' => [ 85 'renderType' => 'defaultLanguageDifferences', 86 'after' => [ 87 'otherLanguageContent', 88 ], 89 ], 90 ]; 91 92 /** 93 * Merge field control configuration with default controls and render them. 94 * 95 * @return array Result array 96 */ 97 protected function renderFieldControl(): array 98 { 99 $alternativeResult = [ 100 // @todo deprecate inline JavaScript in TYPO3 v12.0 101 'additionalJavaScriptPost' => [], 102 'additionalHiddenFields' => [], 103 'additionalInlineLanguageLabelFiles' => [], 104 'stylesheetFiles' => [], 105 'requireJsModules' => [], 106 'inlineData' => [], 107 'html' => '', 108 ]; 109 $options = $this->data; 110 $fieldControl = $this->defaultFieldControl; 111 $fieldControlFromTca = $options['parameterArray']['fieldConf']['config']['fieldControl'] ?? []; 112 ArrayUtility::mergeRecursiveWithOverrule($fieldControl, $fieldControlFromTca); 113 $options['renderType'] = 'fieldControl'; 114 if (isset($fieldControl['editPopup'])) { 115 $editPopupControl = $fieldControl['editPopup']; 116 unset($fieldControl['editPopup']); 117 $alternativeOptions = $options; 118 $alternativeOptions['renderData']['fieldControl'] = ['editPopup' => $editPopupControl]; 119 $alternativeResult = $this->nodeFactory->create($alternativeOptions)->render(); 120 } 121 $options['renderData']['fieldControl'] = $fieldControl; 122 return [$this->nodeFactory->create($options)->render(), $alternativeResult]; 123 } 124 125 /** 126 * Render side by side element. 127 * 128 * @return array As defined in initializeResultArray() of AbstractNode 129 */ 130 public function render() 131 { 132 $filterTextfield = []; 133 $languageService = $this->getLanguageService(); 134 $resultArray = $this->initializeResultArray(); 135 136 $parameterArray = $this->data['parameterArray']; 137 $config = $parameterArray['fieldConf']['config']; 138 $elementName = $parameterArray['itemFormElName']; 139 140 if ($config['readOnly'] ?? false) { 141 // Early return for the relatively simple read only case 142 return $this->renderReadOnly(); 143 } 144 145 $possibleItems = $config['items']; 146 $selectedItems = $parameterArray['itemFormElValue'] ?: []; 147 $maxItems = $config['maxitems']; 148 149 $size = (int)($config['size'] ?? 2); 150 $autoSizeMax = (int)($config['autoSizeMax'] ?? 0); 151 if ($autoSizeMax > 0) { 152 $size = MathUtility::forceIntegerInRange($size, 1); 153 $size = MathUtility::forceIntegerInRange(count($selectedItems) + 1, $size, $autoSizeMax); 154 } 155 156 $itemCanBeSelectedMoreThanOnce = !empty($config['multiple']); 157 158 $listOfSelectedValues = []; 159 $selectedItemsHtml = []; 160 foreach ($selectedItems as $itemValue) { 161 foreach ($possibleItems as $possibleItem) { 162 if ($possibleItem[1] == $itemValue) { 163 $title = $possibleItem[0]; 164 $listOfSelectedValues[] = $itemValue; 165 $selectedItemsHtml[] = '<option value="' . htmlspecialchars((string)$itemValue) . '" title="' . htmlspecialchars((string)$title) . '">' . htmlspecialchars($this->appendValueToLabelInDebugMode($title, $itemValue)) . '</option>'; 166 break; 167 } 168 } 169 } 170 171 $selectableItemCounter = 0; 172 $selectableItemGroupCounter = 0; 173 $selectableItemGroups = []; 174 $selectableItemsHtml = []; 175 176 // Initialize groups 177 foreach ($possibleItems as $possibleItem) { 178 $disableAttributes = []; 179 if (!$itemCanBeSelectedMoreThanOnce && in_array((string)$possibleItem[1], $selectedItems, true)) { 180 $disableAttributes = [ 181 'disabled' => 'disabled', 182 'class' => 'hidden', 183 ]; 184 } 185 if ($possibleItem[1] === '--div--') { 186 if ($selectableItemCounter !== 0) { 187 $selectableItemGroupCounter++; 188 } 189 $selectableItemGroups[$selectableItemGroupCounter]['header']['title'] = $possibleItem[0]; 190 } else { 191 $selectableItemGroups[$selectableItemGroupCounter]['items'][] = [ 192 'label' => $this->appendValueToLabelInDebugMode($possibleItem[0], $possibleItem[1]), 193 'attributes' => array_merge(['title' => $possibleItem[0], 'value' => $possibleItem[1]], $disableAttributes), 194 ]; 195 // In case the item is not disabled, enable the group (if any) 196 if ($disableAttributes === [] && isset($selectableItemGroups[$selectableItemGroupCounter]['header'])) { 197 $selectableItemGroups[$selectableItemGroupCounter]['header']['disabled'] = false; 198 } 199 $selectableItemCounter++; 200 } 201 } 202 203 // Process groups 204 foreach ($selectableItemGroups as $selectableItemGroup) { 205 if (!is_array($selectableItemGroup['items'] ?? false) || $selectableItemGroup['items'] === []) { 206 continue; 207 } 208 209 $optionGroup = isset($selectableItemGroup['header']); 210 if ($optionGroup) { 211 $selectableItemsHtml[] = '<optgroup label="' . htmlspecialchars($selectableItemGroup['header']['title']) . '"' . (($selectableItemGroup['header']['disabled'] ?? true) ? 'class="hidden" disabled="disabled"' : '') . '>'; 212 } 213 214 foreach ($selectableItemGroup['items'] as $item) { 215 $selectableItemsHtml[] = ' 216 <option ' . GeneralUtility::implodeAttributes($item['attributes'], true) . '> 217 ' . htmlspecialchars($item['label']) . ' 218 </option>'; 219 } 220 221 if ($optionGroup) { 222 $selectableItemsHtml[] = '</optgroup>'; 223 } 224 } 225 226 // Html stuff for filter and select filter on top of right side of multi select boxes 227 $filterTextfield[] = '<span class="input-group input-group-sm">'; 228 $filterTextfield[] = '<span class="input-group-text">'; 229 $filterTextfield[] = '<span class="fa fa-filter"></span>'; 230 $filterTextfield[] = '</span>'; 231 $filterTextfield[] = '<input class="t3js-formengine-multiselect-filter-textfield form-control" value="">'; 232 $filterTextfield[] = '</span>'; 233 234 $filterDropDownOptions = []; 235 if (isset($config['multiSelectFilterItems']) && is_array($config['multiSelectFilterItems']) && count($config['multiSelectFilterItems']) > 1) { 236 foreach ($config['multiSelectFilterItems'] as $optionElement) { 237 $value = $languageService->sL($optionElement[0]); 238 $label = $value; 239 if (isset($optionElement[1]) && trim($optionElement[1]) !== '') { 240 $label = $languageService->sL($optionElement[1]); 241 } 242 $filterDropDownOptions[] = '<option value="' . htmlspecialchars($value) . '">' . htmlspecialchars($label) . '</option>'; 243 } 244 } 245 $filterHtml = []; 246 $filterHtml[] = '<div class="form-multigroup-item-wizard">'; 247 if (!empty($filterDropDownOptions)) { 248 $filterHtml[] = '<div class="t3js-formengine-multiselect-filter-container form-multigroup-wrap">'; 249 $filterHtml[] = '<div class="form-multigroup-item form-multigroup-element">'; 250 $filterHtml[] = '<select class="form-select form-select-sm t3js-formengine-multiselect-filter-dropdown">'; 251 $filterHtml[] = implode(LF, $filterDropDownOptions); 252 $filterHtml[] = '</select>'; 253 $filterHtml[] = '</div>'; 254 $filterHtml[] = '<div class="form-multigroup-item form-multigroup-element">'; 255 $filterHtml[] = implode(LF, $filterTextfield); 256 $filterHtml[] = '</div>'; 257 $filterHtml[] = '</div>'; 258 } else { 259 $filterHtml[] = implode(LF, $filterTextfield); 260 } 261 $filterHtml[] = '</div>'; 262 263 $multipleAttribute = ''; 264 if ($maxItems !== 1 && $size !== 1) { 265 $multipleAttribute = ' multiple="multiple"'; 266 } 267 268 $fieldInformationResult = $this->renderFieldInformation(); 269 $fieldInformationHtml = $fieldInformationResult['html']; 270 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); 271 272 [$fieldControlResult, $alternativeControlResult] = $this->renderFieldControl(); 273 $fieldControlHtml = $fieldControlResult['html']; 274 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false); 275 $alternativeFieldControlHtml = $alternativeControlResult['html']; 276 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $alternativeControlResult, false); 277 278 $fieldWizardResult = $this->renderFieldWizard(); 279 $fieldWizardHtml = $fieldWizardResult['html']; 280 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); 281 282 $selectedOptionsFieldId = StringUtility::getUniqueId('tceforms-multiselect-'); 283 $availableOptionsFieldId = StringUtility::getUniqueId('tceforms-multiselect-'); 284 285 $html = []; 286 $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; 287 $html[] = $fieldInformationHtml; 288 $html[] = '<div class="form-wizards-wrap">'; 289 $html[] = '<div class="form-wizards-element">'; 290 $html[] = '<input type="hidden" data-formengine-input-name="' . htmlspecialchars($elementName) . '" value="' . (int)$itemCanBeSelectedMoreThanOnce . '" />'; 291 $html[] = '<div class="form-multigroup-wrap t3js-formengine-field-group">'; 292 $html[] = '<div class="form-multigroup-item form-multigroup-element">'; 293 $html[] = '<label>'; 294 $html[] = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.selected')); 295 $html[] = '</label>'; 296 $html[] = '<div class="form-wizards-wrap form-wizards-aside">'; 297 $html[] = '<div class="form-wizards-element">'; 298 $html[] = '<select'; 299 $html[] = ' id="' . $selectedOptionsFieldId . '"'; 300 $html[] = ' size="' . $size . '"'; 301 $html[] = ' class="form-select"'; 302 $html[] = $multipleAttribute; 303 $html[] = ' data-formengine-input-name="' . htmlspecialchars($elementName) . '"'; 304 $html[] = '>'; 305 $html[] = implode(LF, $selectedItemsHtml); 306 $html[] = '</select>'; 307 $html[] = '</div>'; 308 $html[] = '<div class="form-wizards-items-aside form-wizards-items-aside--move">'; 309 $html[] = '<div class="btn-group-vertical">'; 310 if ($maxItems > 1 && $size >= 5) { 311 $html[] = '<a href="#"'; 312 $html[] = ' class="btn btn-default t3js-btn-option t3js-btn-moveoption-top"'; 313 $html[] = ' data-fieldname="' . htmlspecialchars($elementName) . '"'; 314 $html[] = ' title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.move_to_top')) . '"'; 315 $html[] = '>'; 316 $html[] = $this->iconFactory->getIcon('actions-move-to-top', Icon::SIZE_SMALL)->render(); 317 $html[] = '</a>'; 318 } 319 if ($maxItems > 1) { 320 $html[] = '<a href="#"'; 321 $html[] = ' class="btn btn-default t3js-btn-option t3js-btn-moveoption-up"'; 322 $html[] = ' data-fieldname="' . htmlspecialchars($elementName) . '"'; 323 $html[] = ' title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.move_up')) . '"'; 324 $html[] = '>'; 325 $html[] = $this->iconFactory->getIcon('actions-move-up', Icon::SIZE_SMALL)->render(); 326 $html[] = '</a>'; 327 $html[] = '<a href="#"'; 328 $html[] = ' class="btn btn-default t3js-btn-option t3js-btn-moveoption-down"'; 329 $html[] = ' data-fieldname="' . htmlspecialchars($elementName) . '"'; 330 $html[] = ' title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.move_down')) . '"'; 331 $html[] = '>'; 332 $html[] = $this->iconFactory->getIcon('actions-move-down', Icon::SIZE_SMALL)->render(); 333 $html[] = '</a>'; 334 } 335 if ($maxItems > 1 && $size >= 5) { 336 $html[] = '<a href="#"'; 337 $html[] = ' class="btn btn-default t3js-btn-option t3js-btn-moveoption-bottom"'; 338 $html[] = ' data-fieldname="' . htmlspecialchars($elementName) . '"'; 339 $html[] = ' title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.move_to_bottom')) . '"'; 340 $html[] = '>'; 341 $html[] = $this->iconFactory->getIcon('actions-move-to-bottom', Icon::SIZE_SMALL)->render(); 342 $html[] = '</a>'; 343 } 344 $html[] = $alternativeFieldControlHtml; 345 $html[] = '<a href="#"'; 346 $html[] = ' class="btn btn-default t3js-btn-option t3js-btn-removeoption"'; 347 $html[] = ' data-fieldname="' . htmlspecialchars($elementName) . '"'; 348 $html[] = ' title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.remove_selected')) . '"'; 349 $html[] = '>'; 350 $html[] = $this->iconFactory->getIcon('actions-selection-delete', Icon::SIZE_SMALL)->render(); 351 $html[] = '</a>'; 352 $html[] = '</div>'; 353 $html[] = '</div>'; 354 $html[] = '</div>'; 355 $html[] = '</div>'; 356 $html[] = '<div class="form-multigroup-item form-multigroup-element">'; 357 $html[] = '<label>'; 358 $html[] = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.items')); 359 $html[] = '</label>'; 360 $html[] = '<div class="form-wizards-wrap form-wizards-aside">'; 361 $html[] = '<div class="form-wizards-element">'; 362 $html[] = implode(LF, $filterHtml); 363 $selectElementAttrs = array_merge( 364 [ 365 'size' => $size, 366 'id' => $availableOptionsFieldId, 367 'class' => 'form-select t3js-formengine-select-itemstoselect', 368 'data-relatedfieldname' => $elementName, 369 'data-exclusivevalues' => $config['exclusiveKeys'] ?? '', 370 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config), 371 ], 372 $this->getOnFieldChangeAttrs('change', $parameterArray['fieldChangeFunc'] ?? []) 373 ); 374 $html[] = '<select ' . GeneralUtility::implodeAttributes($selectElementAttrs, true) . '>'; 375 $html[] = implode(LF, $selectableItemsHtml); 376 $html[] = '</select>'; 377 $html[] = '</div>'; 378 if (!empty($fieldControlHtml)) { 379 $html[] = '<div class="form-wizards-items-aside form-wizards-items-aside--field-control">'; 380 $html[] = '<div class="btn-group-vertical">'; 381 $html[] = $fieldControlHtml; 382 $html[] = '</div>'; 383 $html[] = '</div>'; 384 } 385 $html[] = '</div>'; 386 $html[] = '</div>'; 387 $html[] = '</div>'; 388 $html[] = '<input type="hidden" name="' . htmlspecialchars($elementName) . '" value="' . htmlspecialchars(implode(',', $listOfSelectedValues)) . '" />'; 389 $html[] = '</div>'; 390 if (!empty($fieldWizardHtml)) { 391 $html[] = '<div class="form-wizards-items-bottom">'; 392 $html[] = $fieldWizardHtml; 393 $html[] = '</div>'; 394 } 395 $html[] = '</div>'; 396 $html[] = '</div>'; 397 398 $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS( 399 'TYPO3/CMS/Backend/FormEngine/Element/SelectMultipleSideBySideElement' 400 )->instance($selectedOptionsFieldId, $availableOptionsFieldId); 401 402 $resultArray['html'] = implode(LF, $html); 403 return $resultArray; 404 } 405 406 /** 407 * Create HTML of a read only multi select. Right side is not 408 * rendered, but just the left side with the selected items. 409 * 410 * @return array 411 */ 412 protected function renderReadOnly() 413 { 414 $languageService = $this->getLanguageService(); 415 $resultArray = $this->initializeResultArray(); 416 417 $parameterArray = $this->data['parameterArray']; 418 $config = $parameterArray['fieldConf']['config']; 419 $fieldName = $parameterArray['itemFormElName']; 420 421 $possibleItems = $config['items']; 422 $selectedItems = $parameterArray['itemFormElValue'] ?: []; 423 if (!is_array($selectedItems)) { 424 $selectedItems = GeneralUtility::trimExplode(',', $selectedItems, true); 425 } 426 $size = (int)($config['size'] ?? 2); 427 $autoSizeMax = (int)($config['autoSizeMax'] ?? 0); 428 if ($autoSizeMax > 0) { 429 $size = MathUtility::forceIntegerInRange($size, 1); 430 $size = MathUtility::forceIntegerInRange(count($selectedItems) + 1, $size, $autoSizeMax); 431 } 432 433 $multiple = ''; 434 if ($size !== 1) { 435 $multiple = ' multiple="multiple"'; 436 } 437 438 $listOfSelectedValues = []; 439 $optionsHtml = []; 440 foreach ($selectedItems as $itemValue) { 441 foreach ($possibleItems as $possibleItem) { 442 if ($possibleItem[1] == $itemValue) { 443 $title = $possibleItem[0]; 444 $listOfSelectedValues[] = $itemValue; 445 $optionsHtml[] = '<option value="' . htmlspecialchars($itemValue) . '" title="' . htmlspecialchars($title) . '">' . htmlspecialchars($title) . '</option>'; 446 break; 447 } 448 } 449 } 450 451 $fieldInformationResult = $this->renderFieldInformation(); 452 $fieldInformationHtml = $fieldInformationResult['html']; 453 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); 454 455 $html = []; 456 $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; 457 $html[] = $fieldInformationHtml; 458 $html[] = '<div class="form-wizards-wrap">'; 459 $html[] = '<div class="form-wizards-element">'; 460 $html[] = '<label>'; 461 $html[] = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.selected')); 462 $html[] = '</label>'; 463 $html[] = '<div class="form-wizards-wrap form-wizards-aside">'; 464 $html[] = '<div class="form-wizards-element">'; 465 $html[] = '<select'; 466 $html[] = ' id="' . StringUtility::getUniqueId('tceforms-multiselect-') . '"'; 467 $html[] = ' size="' . $size . '"'; 468 $html[] = ' class="form-select"'; 469 $html[] = $multiple; 470 $html[] = ' data-formengine-input-name="' . htmlspecialchars($fieldName) . '"'; 471 $html[] = ' disabled="disabled">'; 472 $html[] = '/>'; 473 $html[] = implode(LF, $optionsHtml); 474 $html[] = '</select>'; 475 $html[] = '</div>'; 476 $html[] = '</div>'; 477 $html[] = '<input type="hidden" name="' . htmlspecialchars($fieldName) . '" value="' . htmlspecialchars(implode(',', $listOfSelectedValues)) . '" />'; 478 $html[] = '</div>'; 479 $html[] = '</div>'; 480 $html[] = '</div>'; 481 482 $resultArray['html'] = implode(LF, $html); 483 return $resultArray; 484 } 485 486 /** 487 * @return LanguageService 488 */ 489 protected function getLanguageService() 490 { 491 return $GLOBALS['LANG']; 492 } 493 494 /** 495 * @return BackendUserAuthentication 496 */ 497 protected function getBackendUserAuthentication() 498 { 499 return $GLOBALS['BE_USER']; 500 } 501} 502