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