InlineRecordContainer.php 37.1 KB
Newer Older
Christian Kuhn's avatar
Christian Kuhn committed
1
<?php
2

Christian Kuhn's avatar
Christian Kuhn committed
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

16
17
namespace TYPO3\CMS\Backend\Form\Container;

Christian Kuhn's avatar
Christian Kuhn committed
18
use TYPO3\CMS\Backend\Form\Element\InlineElementHookInterface;
19
use TYPO3\CMS\Backend\Form\Exception\AccessDeniedContentEditException;
20
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
21
use TYPO3\CMS\Backend\Form\NodeFactory;
22
use TYPO3\CMS\Backend\Routing\UriBuilder;
Christian Kuhn's avatar
Christian Kuhn committed
23
use TYPO3\CMS\Backend\Utility\BackendUtility;
24
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
25
use TYPO3\CMS\Core\Database\ConnectionPool;
26
27
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
28
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
29
use TYPO3\CMS\Core\Localization\LanguageService;
Christian Kuhn's avatar
Christian Kuhn committed
30
31
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
32
use TYPO3\CMS\Core\Type\Bitmask\Permission;
Christian Kuhn's avatar
Christian Kuhn committed
33
use TYPO3\CMS\Core\Utility\GeneralUtility;
34
use TYPO3\CMS\Core\Utility\MathUtility;
35
use TYPO3\CMS\Core\Utility\PathUtility;
Christian Kuhn's avatar
Christian Kuhn committed
36
37
38
39
40
41
42
43
44
45
46

/**
 * Render a single inline record relation.
 *
 * This container is called by InlineControlContainer to render single existing records.
 * Furthermore it is called by FormEngine for an incoming ajax request to expand an existing record
 * or to create a new one.
 *
 * This container creates the outer HTML of single inline records - eg. drag and drop and delete buttons.
 * For rendering of the record itself processing is handed over to FullRecordContainer.
 */
47
48
49
50
51
52
53
class InlineRecordContainer extends AbstractContainer
{
    /**
     * Inline data array used for JSON output
     *
     * @var array
     */
54
    protected $inlineData = [];
55
56
57
58
59
60
61
62
63
64
65

    /**
     * @var InlineStackProcessor
     */
    protected $inlineStackProcessor;

    /**
     * Array containing instances of hook classes called once for IRRE objects
     *
     * @var array
     */
66
    protected $hookObjects = [];
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

    /**
     * @var IconFactory
     */
    protected $iconFactory;

    /**
     * Default constructor
     *
     * @param NodeFactory $nodeFactory
     * @param array $data
     */
    public function __construct(NodeFactory $nodeFactory, array $data)
    {
        parent::__construct($nodeFactory, $data);
        $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
83
84
        $this->inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
        $this->initHookObjects();
85
86
87
88
89
90
91
92
93
94
    }

    /**
     * Entry method
     *
     * @return array As defined in initializeResultArray() of AbstractNode
     * @throws AccessDeniedContentEditException
     */
    public function render()
    {
95
96
        $data = $this->data;
        $this->inlineData = $data['inlineData'];
97

98
99
        $inlineStackProcessor = $this->inlineStackProcessor;
        $inlineStackProcessor->initializeByGivenStructure($data['inlineStructure']);
100

101
102
103
        $record = $data['databaseRow'];
        $inlineConfig = $data['inlineParentConfig'];
        $foreignTable = $inlineConfig['foreign_table'];
104
105
106
107
108
109

        $resultArray = $this->initializeResultArray();

        // Send a mapping information to the browser via JSON:
        // e.g. data[<curTable>][<curId>][<curField>] => data-<pid>-<parentTable>-<parentId>-<parentField>-<curTable>-<curId>-<curField>
        $formPrefix = $inlineStackProcessor->getCurrentStructureFormPrefix();
110
        $domObjectId = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($data['inlineFirstPid']);
111
112
113
114
115
        $this->inlineData['map'][$formPrefix] = $domObjectId;

        $resultArray['inlineData'] = $this->inlineData;

        // If there is a selector field, normalize it:
116
117
        if (!empty($inlineConfig['foreign_selector'])) {
            $foreign_selector = $inlineConfig['foreign_selector'];
118
119
120
121
122
123
124
            $valueToNormalize = $record[$foreign_selector];
            if (is_array($record[$foreign_selector])) {
                // @todo: this can be kicked again if always prepared rows are handled here
                $valueToNormalize = implode(',', $record[$foreign_selector]);
            }
            $record[$foreign_selector] = $this->normalizeUid($valueToNormalize);
        }
125

126
        // Get the current naming scheme for DOM name/id attributes:
127
128
        $appendFormFieldNames = '[' . $foreignTable . '][' . $record['uid'] . ']';
        $objectId = $domObjectId . '-' . $foreignTable . '-' . $record['uid'];
129
        $classes = [];
130
131
        $html = '';
        $combinationHtml = '';
132
        $isNewRecord = $data['command'] === 'new';
133
134
135
136
        $hiddenField = '';
        if (isset($data['processedTca']['ctrl']['enablecolumns']['disabled'])) {
            $hiddenField = $data['processedTca']['ctrl']['enablecolumns']['disabled'];
        }
137
        if (!$data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
138
            if ($isNewRecord || $data['isInlineChildExpanded']) {
139
                // Render full content ONLY IF this is an AJAX request, a new record, or the record is not collapsed
140
                $combinationHtml = '';
141
142
                if (isset($data['combinationChild'])) {
                    $combinationChild = $this->renderCombinationChild($data, $appendFormFieldNames);
143
                    $combinationHtml = $combinationChild['html'];
144
                    $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $combinationChild, false);
145
                }
146
                $childArray = $this->renderChild($data);
147
                $html = $childArray['html'];
148
                $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray, false);
149
            } else {
150
151
                // This class is the marker for the JS-function to check if the full content has already been loaded
                $classes[] = 't3js-not-loaded';
152
153
            }
            if ($isNewRecord) {
154
                // Add pid of record as hidden field
155
156
                $html .= '<input type="hidden" name="data' . htmlspecialchars($appendFormFieldNames)
                    . '[pid]" value="' . htmlspecialchars($record['pid']) . '"/>';
157
158
159
160
161
                // Tell DataHandler this record is expanded
                $ucFieldName = 'uc[inlineView]'
                    . '[' . $data['inlineTopMostParentTableName'] . ']'
                    . '[' . $data['inlineTopMostParentUid'] . ']'
                    . $appendFormFieldNames;
162
163
                $html .= '<input type="hidden" name="' . htmlspecialchars($ucFieldName)
                    . '" value="' . (int)$data['isInlineChildExpanded'] . '" />';
164
165
            } else {
                // Set additional field for processing for saving
166
167
                $html .= '<input type="hidden" name="cmd' . htmlspecialchars($appendFormFieldNames)
                    . '[delete]" value="1" disabled="disabled" />';
168
169
                if (!$data['isInlineChildExpanded'] && !empty($hiddenField)) {
                    $checked = !empty($record[$hiddenField]) ? ' checked="checked"' : '';
170
171
                    $html .= '<input type="checkbox" data-formengine-input-name="data'
                        . htmlspecialchars($appendFormFieldNames)
172
                        . '[' . htmlspecialchars($hiddenField) . ']" value="1"' . $checked . ' />';
173
                    $html .= '<input type="input" name="data' . htmlspecialchars($appendFormFieldNames)
174
                        . '[' . htmlspecialchars($hiddenField) . ']" value="' . htmlspecialchars($record[$hiddenField]) . '" />';
175
176
177
                }
            }
            // If this record should be shown collapsed
178
            $classes[] = $data['isInlineChildExpanded'] ? 'panel-visible' : 'panel-collapsed';
179
        }
180
181
        $hiddenFieldHtml = implode(LF, $resultArray['additionalHiddenFields'] ?? []);

182
183
        if ($inlineConfig['renderFieldsOnly']) {
            // Render "body" part only
184
            $html .= $combinationHtml;
185
        } else {
186
187
            // Render header row and content (if expanded)
            if ($data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
188
                $classes[] = 't3-form-field-container-inline-placeHolder';
189
            }
190
            if (!empty($hiddenField) && isset($record[$hiddenField]) && (int)$record[$hiddenField]) {
191
192
193
194
                $classes[] = 't3-form-field-container-inline-hidden';
            }
            if ($isNewRecord) {
                $classes[] = 'inlineIsNewRecord';
195
            }
196

197
198
199
            $originalUniqueValue = '';
            if (isset($data['inlineData']['unique'][$domObjectId . '-' . $foreignTable]['used'][$record['uid']])) {
                $uniqueValueValues = $data['inlineData']['unique'][$domObjectId . '-' . $foreignTable]['used'][$record['uid']];
200
201
                // in case of site_language we don't have the full form engine options, so fallbacks need to be taken into account
                $originalUniqueValue = ($uniqueValueValues['table'] ?? $foreignTable) . '_' . ($uniqueValueValues['uid'] ?? $uniqueValueValues);
202
            }
203
204
205

            // The hashed object id needs a non-numeric prefix, the value is used as ID selector in JavaScript
            $hashedObjectId = 'hash-' . md5($objectId);
206
207
            $containerAttributes = [
                'id' => $objectId . '_div',
208
                'class' => 'form-irre-object panel panel-default panel-condensed ' . trim(implode(' ', $classes)),
209
210
                'data-object-uid' => $record['uid'],
                'data-object-id' => $objectId,
211
                'data-object-id-hash' => $hashedObjectId,
212
213
214
                'data-field-name' => $appendFormFieldNames,
                'data-topmost-parent-table' => $data['inlineTopMostParentTableName'],
                'data-topmost-parent-uid' => $data['inlineTopMostParentUid'],
215
                'data-table-unique-original-value' => $originalUniqueValue,
216
                'data-placeholder-record' => $data['isInlineDefaultLanguageRecordInLocalizedParentContext'] ? '1' : '0'
217
218
            ];

219
220
221
            $ariaExpanded = $data['isInlineChildExpanded'] ? 'true' : 'false';
            $ariaControls = htmlspecialchars($objectId . '_fields', ENT_QUOTES | ENT_HTML5);
            $ariaAttributesString = 'aria-expanded="' . $ariaExpanded . '" aria-controls="' . $ariaControls . '"';
222
            $html = '
223
				<div ' . GeneralUtility::implodeAttributes($containerAttributes, true) . '>
224
					<div class="panel-heading" data-bs-toggle="formengine-inline" id="' . htmlspecialchars($hashedObjectId, ENT_QUOTES | ENT_HTML5) . '_header" data-expandSingle="' . ($inlineConfig['appearance']['expandSingle'] ? 1 : 0) . '">
Christian Kuhn's avatar
Christian Kuhn committed
225
226
227
228
						<div class="form-irre-header">
							<div class="form-irre-header-cell form-irre-header-icon">
								<span class="caret"></span>
							</div>
229
							' . $this->renderForeignRecordHeader($data, $ariaAttributesString) . '
Christian Kuhn's avatar
Christian Kuhn committed
230
231
						</div>
					</div>
232
					<div class="panel-collapse" id="' . $ariaControls . '">' . $html . $hiddenFieldHtml . $combinationHtml . '</div>
Christian Kuhn's avatar
Christian Kuhn committed
233
				</div>';
234
235
236
237
238
239
240
241
242
        }

        $resultArray['html'] = $html;
        return $resultArray;
    }

    /**
     * Render inner child
     *
243
     * @param array $data
244
245
     * @return array Result array
     */
246
    protected function renderChild(array $data)
247
    {
248
249
        $domObjectId = $this->inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($data['inlineFirstPid']);
        $data['tabAndInlineStack'][] = [
250
            'inline',
251
            $domObjectId . '-' . $data['tableName'] . '-' . $data['databaseRow']['uid'],
252
253
        ];
        // @todo: ugly construct ...
254
255
256
        $data['inlineData'] = $this->inlineData;
        $data['renderType'] = 'fullRecordContainer';
        return $this->nodeFactory->create($data)->render();
257
258
259
260
261
262
263
264
265
    }

    /**
     * Render child child
     *
     * Render a table with FormEngine, that occurs on an intermediate table but should be editable directly,
     * so two tables are combined (the intermediate table with attributes and the sub-embedded table).
     * -> This is a direct embedding over two levels!
     *
266
     * @param array $data
267
268
269
     * @param string $appendFormFieldNames The [<table>][<uid>] of the parent record (the intermediate table)
     * @return array Result array
     */
270
    protected function renderCombinationChild(array $data, $appendFormFieldNames)
271
    {
272
273
        $childData = $data['combinationChild'];
        $parentConfig = $data['inlineParentConfig'];
274

275
276
277
278
279
280
281
        // If field is set to readOnly, set all fields of the relation to readOnly as well
        if (isset($parentConfig['readOnly']) && $parentConfig['readOnly']) {
            foreach ($childData['processedTca']['columns'] as $columnName => $columnConfiguration) {
                $childData['processedTca']['columns'][$columnName]['config']['readOnly'] = true;
            }
        }

282
283
284
285
        $resultArray = $this->initializeResultArray();

        // Display Warning FlashMessage if it is not suppressed
        if (!isset($parentConfig['appearance']['suppressCombinationWarning']) || empty($parentConfig['appearance']['suppressCombinationWarning'])) {
286
            $combinationWarningMessage = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:warning.inline_use_combination';
287
288
289
            if (!empty($parentConfig['appearance']['overwriteCombinationWarningMessage'])) {
                $combinationWarningMessage = $parentConfig['appearance']['overwriteCombinationWarningMessage'];
            }
290
291
            $message = $this->getLanguageService()->sL($combinationWarningMessage);
            $markup = [];
292
293
            // @TODO: This is not a FlashMessage! The markup must be changed and special CSS
            // @TODO: should be created, in order to prevent confusion.
294
295
296
297
298
299
300
301
302
            $markup[] = '<div class="alert alert-warning">';
            $markup[] = '    <div class="media">';
            $markup[] = '        <div class="media-left">';
            $markup[] = '            <span class="fa-stack fa-lg">';
            $markup[] = '                <i class="fa fa-circle fa-stack-2x"></i>';
            $markup[] = '                <i class="fa fa-exclamation fa-stack-1x"></i>';
            $markup[] = '            </span>';
            $markup[] = '        </div>';
            $markup[] = '        <div class="media-body">';
303
            $markup[] = '            <div class="alert-message">' . htmlspecialchars($message) . '</div>';
304
305
306
307
            $markup[] = '        </div>';
            $markup[] = '    </div>';
            $markup[] = '</div>';
            $resultArray['html'] = implode(LF, $markup);
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
        }

        $childArray = $this->renderChild($childData);
        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray);

        // If this is a new record, add a pid value to store this record and the pointer value for the intermediate table
        if ($childData['command'] === 'new') {
            $comboFormFieldName = 'data[' . $childData['tableName'] . '][' . $childData['databaseRow']['uid'] . '][pid]';
            $resultArray['html'] .= '<input type="hidden" name="' . htmlspecialchars($comboFormFieldName) . '" value="' . htmlspecialchars($childData['databaseRow']['pid']) . '" />';
        }
        // If the foreign_selector field is also responsible for uniqueness, tell the browser the uid of the "other" side of the relation
        if ($childData['command'] === 'new' || $parentConfig['foreign_unique'] === $parentConfig['foreign_selector']) {
            $parentFormFieldName = 'data' . $appendFormFieldNames . '[' . $parentConfig['foreign_selector'] . ']';
            $resultArray['html'] .= '<input type="hidden" name="' . htmlspecialchars($parentFormFieldName) . '" value="' . htmlspecialchars($childData['databaseRow']['uid']) . '" />';
        }

        return $resultArray;
    }

    /**
     * Renders the HTML header for a foreign record, such as the title, toggle-function, drag'n'drop, etc.
     * Later on the command-icons are inserted here.
     *
     * @param array $data Current data
332
     * @param string $ariaAttributesString HTML aria attributes for the collapse button
333
334
     * @return string The HTML code of the header
     */
335
    protected function renderForeignRecordHeader(array $data, string $ariaAttributesString)
336
    {
337
338
339
        $languageService = $this->getLanguageService();
        $inlineConfig = $data['inlineParentConfig'];
        $foreignTable = $inlineConfig['foreign_table'];
340
341
        $rec = $data['databaseRow'];
        // Init:
342
343
344
345
        $domObjectId = $this->inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($data['inlineFirstPid']);
        $objectId = $domObjectId . '-' . $foreignTable . '-' . $rec['uid'];

        $recordTitle = $data['recordTitle'];
346
        if (!empty($recordTitle)) {
347
348
349
350
            // The user function may return HTML, therefore we can't escape it
            if (empty($data['processedTca']['ctrl']['formattedLabel_userFunc'])) {
                $recordTitle = BackendUtility::getRecordTitlePrep($recordTitle);
            }
351
        } else {
352
            $recordTitle = '<em>[' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title')) . ']</em>';
353
354
        }

355
        $altText = BackendUtility::getRecordIconAltText($rec, $foreignTable);
356

357
        $iconImg = '<span title="' . $altText . '" id="' . htmlspecialchars($objectId) . '_icon">' . $this->iconFactory->getIconForRecord($foreignTable, $rec, Icon::SIZE_SMALL)->render() . '</span>';
358
359
        $label = '<span id="' . $objectId . '_label">' . $recordTitle . '</span>';
        $ctrl = $this->renderForeignRecordHeaderControl($data);
360
361
362
        $thumbnail = false;

        // Renders a thumbnail for the header
363
        if ($GLOBALS['TYPO3_CONF_VARS']['GFX']['thumbnails'] && !empty($inlineConfig['appearance']['headerThumbnail']['field'])) {
364
            $fieldValue = $rec[$inlineConfig['appearance']['headerThumbnail']['field']];
365
            $fileUid = $fieldValue[0]['uid'];
366
367

            if (!empty($fileUid)) {
368
                try {
369
                    $fileObject = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
370
371
372
                } catch (\InvalidArgumentException $e) {
                    $fileObject = null;
                }
373
                if ($fileObject && $fileObject->isMissing()) {
374
                    $thumbnail .= '<span class="label label-danger">'
375
                        . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:warning.file_missing'))
376
                        . '</span>&nbsp;' . htmlspecialchars($fileObject->getName()) . '<br />';
377
                } elseif ($fileObject) {
378
                    $imageSetup = $inlineConfig['appearance']['headerThumbnail'];
379
                    unset($imageSetup['field']);
380
381
382
                    $cropVariantCollection = CropVariantCollection::create($rec['crop'] ?? '');
                    if (!$cropVariantCollection->getCropArea()->isEmpty()) {
                        $imageSetup['crop'] = $cropVariantCollection->getCropArea()->makeAbsoluteBasedOnFile($fileObject);
383
                    }
384
                    $imageSetup = array_merge(['maxWidth' => '145', 'maxHeight' => '45'], $imageSetup);
385

386
                    if ($GLOBALS['TYPO3_CONF_VARS']['GFX']['thumbnails'] && $fileObject->isImage()) {
387
                        $processedImage = $fileObject->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $imageSetup);
388
389
                        // Only use a thumbnail if the processing process was successful by checking if image width is set
                        if ($processedImage->getProperty('width')) {
390
391
                            $imageUrl = $processedImage->getPublicUrl();
                            $thumbnail = '<img src="' . PathUtility::getAbsoluteWebPath($imageUrl) . '" ' .
392
393
394
395
396
397
398
                                'width="' . $processedImage->getProperty('width') . '" ' .
                                'height="' . $processedImage->getProperty('height') . '" ' .
                                'alt="' . htmlspecialchars($altText) . '" ' .
                                'title="' . htmlspecialchars($altText) . '">';
                        }
                    } else {
                        $thumbnail = '';
399
400
401
402
403
                    }
                }
            }
        }

404
        if (!empty($inlineConfig['appearance']['headerThumbnail']['field']) && $thumbnail) {
405
            $mediaContainer = '<div class="form-irre-header-thumbnail" id="' . $objectId . '_thumbnailcontainer">' . $thumbnail . '</div>';
406
        } else {
407
            $mediaContainer = '<div class="form-irre-header-icon" id="' . $objectId . '_iconcontainer">' . $iconImg . '</div>';
408
        }
409
410
411
412
413
        $header = '<button class="form-irre-header-cell form-irre-header-button" ' . $ariaAttributesString . '>' .
            $mediaContainer .
            '<div class="form-irre-header-body">' . $label . '</div>' .
            '</button>' .
            '<div class="form-irre-header-cell form-irre-header-control t3js-formengine-irre-control">' . $ctrl . '</div>';
Christian Kuhn's avatar
Christian Kuhn committed
414

415
416
417
418
419
420
421
422
423
424
425
        return $header;
    }

    /**
     * Render the control-icons for a record header (create new, sorting, delete, disable/enable).
     * Most of the parts are copy&paste from TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList and
     * modified for the JavaScript calls here
     *
     * @param array $data Current data
     * @return string The HTML code with the control-icons
     */
426
    protected function renderForeignRecordHeaderControl(array $data)
427
428
    {
        $rec = $data['databaseRow'];
429
430
        $inlineConfig = $data['inlineParentConfig'];
        $foreignTable = $inlineConfig['foreign_table'];
431
432
433
        $languageService = $this->getLanguageService();
        $backendUser = $this->getBackendUserAuthentication();
        // Initialize:
434
435
436
437
438
439
440
441
442
443
444
445
        $cells = [
            'edit' => '',
            'hide' => '',
            'delete' => '',
            'info' => '',
            'new' => '',
            'sort.up' => '',
            'sort.down' => '',
            'dragdrop' => '',
            'localize' => '',
            'locked' => '',
        ];
446
        $isNewItem = strpos($rec['uid'], 'NEW') === 0;
447
        $isParentReadOnly = isset($inlineConfig['readOnly']) && $inlineConfig['readOnly'];
448
        $isParentExisting = MathUtility::canBeInterpretedAsInteger($data['inlineParentUid']);
449
450
        $tcaTableCtrl = $GLOBALS['TCA'][$foreignTable]['ctrl'];
        $tcaTableCols = $GLOBALS['TCA'][$foreignTable]['columns'];
451
452
453
454
        $isPagesTable = $foreignTable === 'pages';
        $isSysFileReferenceTable = $foreignTable === 'sys_file_reference';
        $enableManualSorting = $tcaTableCtrl['sortby'] || $inlineConfig['MM'] || !$data['isOnSymmetricSide']
            && $inlineConfig['foreign_sortby'] || $data['isOnSymmetricSide'] && $inlineConfig['symmetric_sortby'];
455
        $calcPerms = new Permission($backendUser->calcPerms(BackendUtility::readPageAccess((int)($data['parentPageRow']['uid'] ?? 0), $backendUser->getPagePermsClause(Permission::PAGE_SHOW))));
456
        // If the listed table is 'pages' we have to request the permission settings for each page:
457
        $localCalcPerms = new Permission(Permission::NOTHING);
458
        if ($isPagesTable) {
459
            $localCalcPerms = new Permission($backendUser->calcPerms(BackendUtility::getRecord('pages', $rec['uid'])));
460
461
        }
        // This expresses the edit permissions for this particular element:
462
        $permsEdit = ($isPagesTable && $localCalcPerms->editPagePermissionIsGranted()) || (!$isPagesTable && $calcPerms->editContentPermissionIsGranted());
463
        // Controls: Defines which controls should be shown
464
        $enabledControls = $inlineConfig['appearance']['enabledControls'];
465
466
467
        // Hook: Can disable/enable single controls for specific child records:
        foreach ($this->hookObjects as $hookObj) {
            /** @var InlineElementHookInterface $hookObj */
468
            $hookObj->renderForeignRecordHeaderControl_preProcess($data['inlineParentUid'], $foreignTable, $rec, $inlineConfig, $data['isInlineDefaultLanguageRecordInLocalizedParentContext'], $enabledControls);
469
        }
470
        if ($data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
471
            $cells['localize'] = '<span title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:localize.isLocalizable')) . '">
472
473
                    ' . $this->iconFactory->getIcon('actions-edit-localize-status-low', Icon::SIZE_SMALL)->render() . '
                </span>';
474
475
        }
        // "Info": (All records)
476
477
478
479
480
481
482
483
        // @todo: hardcoded sys_file!
        if ($rec['table_local'] === 'sys_file') {
            $uid = $rec['uid_local'][0]['uid'];
            $table = '_FILE';
        } else {
            $uid = $rec['uid'];
            $table = $foreignTable;
        }
484
485
486
487
488
        if ($enabledControls['info']) {
            if ($isNewItem) {
                $cells['info'] = '<span class="btn btn-default disabled">' . $this->iconFactory->getIcon('empty-empty', Icon::SIZE_SMALL)->render() . '</span>';
            } else {
                $cells['info'] = '
489
				<button type="button" class="btn btn-default" data-action="infowindow" data-info-table="' . htmlspecialchars($table) . '" data-info-uid="' . htmlspecialchars($uid) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:showInfo')) . '">
490
					' . $this->iconFactory->getIcon('actions-document-info', Icon::SIZE_SMALL)->render() . '
491
				</button>';
492
            }
493
494
        }
        // If the table is NOT a read-only table, then show these links:
495
        if (!$isParentReadOnly && !$tcaTableCtrl['readOnly'] && !$data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
496
497
            // "New record after" link (ONLY if the records in the table are sorted by a "sortby"-row or if default values can depend on previous record):
            if ($enabledControls['new'] && ($enableManualSorting || $tcaTableCtrl['useColumnsForDefaultValues'])) {
498
                if (!$isPagesTable && $calcPerms->editContentPermissionIsGranted() || $isPagesTable && $calcPerms->createPagePermissionIsGranted()) {
499
                    $style = '';
500
501
                    if ($inlineConfig['inline']['inlineNewButtonStyle']) {
                        $style = ' style="' . $inlineConfig['inline']['inlineNewButtonStyle'] . '"';
502
503
                    }
                    $cells['new'] = '
504
                        <button type="button" class="btn btn-default t3js-create-new-button" data-record-uid="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:new' . ($isPagesTable ? 'Page' : 'Record'))) . '" ' . $style . '>
505
                            ' . $this->iconFactory->getIcon('actions-' . ($isPagesTable ? 'page-new' : 'add'), Icon::SIZE_SMALL)->render() . '
506
                        </button>';
507
508
509
510
511
                }
            }
            // "Up/Down" links
            if ($enabledControls['sort'] && $permsEdit && $enableManualSorting) {
                // Up
512
513
514
515
516
517
                $icon = 'actions-move-up';
                $class = '';
                if ($inlineConfig['inline']['first'] == $rec['uid']) {
                    $class = ' disabled';
                    $icon = 'empty-empty';
                }
518
                $cells['sort.up'] = '
519
                    <button type="button" class="btn btn-default' . $class . '" data-action="sort" data-direction="up" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:moveUp')) . '">
520
                        ' . $this->iconFactory->getIcon($icon, Icon::SIZE_SMALL)->render() . '
521
                    </button>';
522
                // Down
523
524
525
526
527
528
529
                $icon = 'actions-move-down';
                $class = '';
                if ($inlineConfig['inline']['last'] == $rec['uid']) {
                    $class = ' disabled';
                    $icon = 'empty-empty';
                }

530
                $cells['sort.down'] = '
531
                    <button type="button" class="btn btn-default' . $class . '" data-action="sort" data-direction="down" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:moveDown')) . '">
532
                        ' . $this->iconFactory->getIcon($icon, Icon::SIZE_SMALL)->render() . '
533
                    </button>';
534
535
            }
            // "Edit" link:
536
            if (($rec['table_local'] === 'sys_file') && !$isNewItem && $backendUser->check('tables_modify', 'sys_file_metadata')) {
537
538
539
540
                $sys_language_uid = 0;
                if (!empty($rec['sys_language_uid'])) {
                    $sys_language_uid = $rec['sys_language_uid'][0];
                }
541
542
543
544
545
546
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
                    ->getQueryBuilderForTable('sys_file_metadata');
                $recordInDatabase = $queryBuilder
                    ->select('uid')
                    ->from('sys_file_metadata')
                    ->where(
547
548
                        $queryBuilder->expr()->eq(
                            'file',
549
                            $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
550
551
552
553
554
                        ),
                        $queryBuilder->expr()->eq(
                            'sys_language_uid',
                            $queryBuilder->createNamedParameter($sys_language_uid, \PDO::PARAM_INT)
                        )
555
556
557
558
                    )
                    ->setMaxResults(1)
                    ->execute()
                    ->fetch();
559
                if (!empty($recordInDatabase)) {
560
                    $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
561
                    $url = (string)$uriBuilder->buildUriFromRoute('record_edit', [
562
                        'edit[sys_file_metadata][' . (int)$recordInDatabase['uid'] . ']' => 'edit',
563
                        'returnUrl' => $this->data['returnUrl']
564
                    ]);
565
                    $title = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.editMetadata');
566
567
568
569
                    $cells['edit'] = '
                        <a class="btn btn-default" href="' . htmlspecialchars($url) . '" title="' . htmlspecialchars($title) . '">
                            ' . $this->iconFactory->getIcon('actions-open', Icon::SIZE_SMALL)->render() . '
                        </a>';
570
571
572
                }
            }
            // "Delete" link:
573
574
575
            if ($enabledControls['delete'] && ($isPagesTable && $localCalcPerms->deletePagePermissionIsGranted()
                    || !$isPagesTable && $calcPerms->editContentPermissionIsGranted()
                    || $isSysFileReferenceTable && $calcPerms->editPagePermissionIsGranted())
576
            ) {
577
                $title = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete'));
578
                $icon = $this->iconFactory->getIcon('actions-edit-delete', Icon::SIZE_SMALL)->render();
579
                $cells['delete'] = '<button type="button" class="btn btn-default t3js-editform-delete-inline-record" title="' . $title . '">' . $icon . '</button>';
580
581
582
583
            }

            // "Hide/Unhide" links:
            $hiddenField = $tcaTableCtrl['enablecolumns']['disabled'];
584
            if ($enabledControls['hide'] && $permsEdit && $hiddenField && $tcaTableCols[$hiddenField] && (!$tcaTableCols[$hiddenField]['exclude'] || $backendUser->check('non_exclude_fields', $foreignTable . ':' . $hiddenField))) {
585
                if ($rec[$hiddenField]) {
586
                    $title = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide' . ($isPagesTable ? 'Page' : '')));
587
                    $cells['hide'] = '
588
                        <button type="button" class="btn btn-default t3js-toggle-visibility-button" data-hidden-field="' . htmlspecialchars($hiddenField) . '" title="' . $title . '">
589
                            ' . $this->iconFactory->getIcon('actions-edit-unhide', Icon::SIZE_SMALL)->render() . '
590
                        </button>';
591
                } else {
592
                    $title = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide' . ($isPagesTable ? 'Page' : '')));
593
                    $cells['hide'] = '
594
                        <button type="button" class="btn btn-default t3js-toggle-visibility-button" data-hidden-field="' . htmlspecialchars($hiddenField) . '" title="' . $title . '">
595
                            ' . $this->iconFactory->getIcon('actions-edit-hide', Icon::SIZE_SMALL)->render() . '
596
                        </button>';
597
598
599
                }
            }
            // Drag&Drop Sorting: Sortable handler for script.aculo.us
600
            if ($enabledControls['dragdrop'] && $permsEdit && $enableManualSorting && $inlineConfig['appearance']['useSortable']) {
601
                $cells['dragdrop'] = '
602
                    <span class="btn btn-default sortableHandle" data-id="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.move')) . '">
603
604
                        ' . $this->iconFactory->getIcon('actions-move-move', Icon::SIZE_SMALL)->render() . '
                    </span>';
605
            }
606
        } elseif ($data['isInlineDefaultLanguageRecordInLocalizedParentContext'] && $isParentExisting) {
607
            if ($enabledControls['localize'] && $data['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
608
                $cells['localize'] = '
609
                    <button type="button" class="btn btn-default t3js-synchronizelocalize-button" data-type="' . htmlspecialchars($rec['uid']) . '" title="' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:localize')) . '">
610
                        ' . $this->iconFactory->getIcon('actions-document-localize', Icon::SIZE_SMALL)->render() . '
611
                    </button>';
612
613
614
            }
        }
        // If the record is edit-locked by another user, we will show a little warning sign:
615
        if ($lockInfo = BackendUtility::isRecordLocked($foreignTable, $rec['uid'])) {
616
            $cells['locked'] = '
617
				<button type="button" class="btn btn-default" data-bs-toggle="tooltip" data-title="' . htmlspecialchars($lockInfo['msg']) . '">
618
					<span title="' . htmlspecialchars($lockInfo['msg']) . '">' . $this->iconFactory->getIcon('warning-in-use', Icon::SIZE_SMALL)->render() . '</span>
619
				</button>';
620
621
622
        }
        // Hook: Post-processing of single controls for specific child records:
        foreach ($this->hookObjects as $hookObj) {
623
            $hookObj->renderForeignRecordHeaderControl_postProcess($data['inlineParentUid'], $foreignTable, $rec, $inlineConfig, $data['isInlineDefaultLanguageRecordInLocalizedParentContext'], $cells);
624
625
626
        }

        $out = '';
627
628
629
630
631
632
633
634
635
636
637
638
        if (!empty($cells['edit']) || !empty($cells['hide']) || !empty($cells['delete'])) {
            $out .= '<div class="btn-group btn-group-sm" role="group">' . $cells['edit'] . $cells['hide'] . $cells['delete'] . '</div>';
            unset($cells['edit'], $cells['hide'], $cells['delete']);
        }
        if (!empty($cells['info']) || !empty($cells['new']) || !empty($cells['sort.up']) || !empty($cells['sort.down']) || !empty($cells['dragdrop'])) {
            $out .= '<div class="btn-group btn-group-sm" role="group">' . $cells['info'] . $cells['new'] . $cells['sort.up'] . $cells['sort.down'] . $cells['dragdrop'] . '</div>';
            unset($cells['info'], $cells['new'], $cells['sort.up'], $cells['sort.down'], $cells['dragdrop']);
        }
        if (!empty($cells['localize'])) {
            $out .= '<div class="btn-group btn-group-sm" role="group">' . $cells['localize'] . '</div>';
            unset($cells['localize']);
        }
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
        if (!empty($cells)) {
            $out .= ' <div class="btn-group btn-group-sm" role="group">' . implode('', $cells) . '</div>';
        }
        return $out;
    }

    /**
     * Normalize a relation "uid" published by transferData, like "1|Company%201"
     *
     * @param string $string A transferData reference string, containing the uid
     * @return string The normalized uid
     */
    protected function normalizeUid($string)
    {
        $parts = explode('|', $string);
        return $parts[0];
    }

    /**
     * Initialized the hook objects for this class.
     * Each hook object has to implement the interface
     * \TYPO3\CMS\Backend\Form\Element\InlineElementHookInterface
     *
     * @throws \UnexpectedValueException
     */
    protected function initHookObjects()
    {
666
        $this->hookObjects = [];
667
668
669
670
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tceforms_inline.php']['tceformsInlineHook'] ?? [] as $className) {
            $processObject = GeneralUtility::makeInstance($className);
            if (!$processObject instanceof InlineElementHookInterface) {
                throw new \UnexpectedValueException($className . ' must implement interface ' . InlineElementHookInterface::class, 1202072000);
671
            }
672
            $this->hookObjects[] = $processObject;
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
        }
    }

    /**
     * @return BackendUserAuthentication
     */
    protected function getBackendUserAuthentication()
    {
        return $GLOBALS['BE_USER'];
    }

    /**
     * @return LanguageService
     */
    protected function getLanguageService()
    {
        return $GLOBALS['LANG'];
    }
Christian Kuhn's avatar
Christian Kuhn committed
691
}