[!!!][TASK] Improve flex and TCA handling in FormEngine
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Form / FormDataProvider / TcaInline.php
1 <?php
2 namespace TYPO3\CMS\Backend\Form\FormDataProvider;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
18 use TYPO3\CMS\Backend\Form\FormDataCompiler;
19 use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly;
20 use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
21 use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
22 use TYPO3\CMS\Backend\Form\InlineStackProcessor;
23 use TYPO3\CMS\Backend\Utility\BackendUtility;
24 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
25 use TYPO3\CMS\Core\Database\RelationHandler;
26 use TYPO3\CMS\Core\Messaging\FlashMessage;
27 use TYPO3\CMS\Core\Messaging\FlashMessageService;
28 use TYPO3\CMS\Core\Utility\GeneralUtility;
29 use TYPO3\CMS\Core\Versioning\VersionState;
30 use TYPO3\CMS\Lang\LanguageService;
31
32 /**
33 * Resolve and prepare inline data.
34 */
35 class TcaInline extends AbstractDatabaseRecordProvider implements FormDataProviderInterface
36 {
37 /**
38 * Resolve inline fields
39 *
40 * @param array $result
41 * @return array
42 */
43 public function addData(array $result)
44 {
45 $result = $this->addInlineFirstPid($result);
46
47 foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) {
48 if (!$this->isInlineField($fieldConfig)) {
49 continue;
50 }
51 $result['processedTca']['columns'][$fieldName]['children'] = [];
52 if (!$this->isUserAllowedToModify($fieldConfig)) {
53 continue;
54 }
55 if ($result['inlineResolveExistingChildren']) {
56 $result = $this->resolveRelatedRecords($result, $fieldName);
57 $result = $this->addForeignSelectorAndUniquePossibleRecords($result, $fieldName);
58 }
59 }
60
61 return $result;
62 }
63
64 /**
65 * Is column of type "inline"
66 *
67 * @param array $fieldConfig
68 * @return bool
69 */
70 protected function isInlineField($fieldConfig)
71 {
72 return !empty($fieldConfig['config']['type']) && $fieldConfig['config']['type'] === 'inline';
73 }
74
75 /**
76 * Is user allowed to modify child elements
77 *
78 * @param array $fieldConfig
79 * @return bool
80 */
81 protected function isUserAllowedToModify($fieldConfig)
82 {
83 return $this->getBackendUser()->check('tables_modify', $fieldConfig['config']['foreign_table']);
84 }
85
86 /**
87 * The "entry" pid for inline records. Nested inline records can potentially hang around on different
88 * pid's, but the entry pid is needed for AJAX calls, so that they would know where the action takes place on the page structure.
89 *
90 * @param array $result Incoming result
91 * @return array Modified result
92 * @todo: Find out when and if this is different from 'effectivePid'
93 */
94 protected function addInlineFirstPid(array $result)
95 {
96 if (is_null($result['inlineFirstPid'])) {
97 $table = $result['tableName'];
98 $row = $result['databaseRow'];
99 // If the parent is a page, use the uid(!) of the (new?) page as pid for the child records:
100 if ($table == 'pages') {
101 $liveVersionId = BackendUtility::getLiveVersionIdOfRecord('pages', $row['uid']);
102 $pid = is_null($liveVersionId) ? $row['uid'] : $liveVersionId;
103 } elseif ($row['pid'] < 0) {
104 $prevRec = BackendUtility::getRecord($table, abs($row['pid']));
105 $pid = $prevRec['pid'];
106 } else {
107 $pid = $row['pid'];
108 }
109 $result['inlineFirstPid'] = (int)$pid;
110 }
111 return $result;
112 }
113
114 /**
115 * Substitute the value in databaseRow of this inline field with an array
116 * that contains the databaseRows of currently connected records and some meta information.
117 *
118 * @param array $result Result array
119 * @param string $fieldName Current handle field name
120 * @return array Modified item array
121 */
122 protected function resolveRelatedRecords(array $result, $fieldName)
123 {
124 $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table'];
125
126 // localizationMode is either "none", "keep" or "select":
127 // * none: Handled parent row is not a localized record, or if it is a localized row, this is ignored.
128 // Default language records and overlays have distinct children that are not connected to each other.
129 // * keep: Handled parent row is a localized record, but child table is either not localizable, or
130 // "keep" is explicitly set. A localized parent and its default language row share the same
131 // children records. Editing a child from a localized record will change this record for the
132 // default language row, too.
133 // * select: Handled parent row is a localized record, child table is localizable. Children records are
134 // localized overlays of a default language record. Three scenarios can happen:
135 // ** Localized child overlay and its default language row exist - show localized overlay record
136 // ** Default child language row exists but child overlay doesn't - show a "synchronize this record" button
137 // ** Localized child overlay exists but default language row does not - this dangling child is a data inconsistency
138
139 // Mode was prepared by TcaInlineConfiguration provider
140 $mode = $result['processedTca']['columns'][$fieldName]['config']['behaviour']['localizationMode'];
141 if ($mode === 'none') {
142 $connectedUids = [];
143 // A new record that has distinct children can not have children yet, fetch connected uids for existing only
144 if ($result['command'] === 'edit') {
145 $connectedUids = $this->resolveConnectedRecordUids(
146 $result['processedTca']['columns'][$fieldName]['config'],
147 $result['tableName'],
148 $result['databaseRow']['uid'],
149 $result['databaseRow'][$fieldName]
150 );
151 }
152 $result['databaseRow'][$fieldName] = implode(',', $connectedUids);
153 $connectedUids = $this->getWorkspacedUids($connectedUids, $childTableName);
154 // @todo: If inlineCompileExistingChildren must be kept, it might be better to change the data
155 // @todo: format of databaseRow for this field and separate the child compilation to an own provider?
156 if ($result['inlineCompileExistingChildren']) {
157 foreach ($connectedUids as $childUid) {
158 $result['processedTca']['columns'][$fieldName]['children'][] = $this->compileChild($result, $fieldName, $childUid);
159 }
160 }
161 } elseif ($mode === 'keep') {
162 // Fetch connected uids of default language record
163 $connectedUids = $this->resolveConnectedRecordUids(
164 $result['processedTca']['columns'][$fieldName]['config'],
165 $result['tableName'],
166 $result['defaultLanguageRow']['uid'],
167 $result['defaultLanguageRow'][$fieldName]
168 );
169 $result['databaseRow'][$fieldName] = implode(',', $connectedUids);
170 $connectedUids = $this->getWorkspacedUids($connectedUids, $childTableName);
171 if ($result['inlineCompileExistingChildren']) {
172 foreach ($connectedUids as $childUid) {
173 $result['processedTca']['columns'][$fieldName]['children'][] = $this->compileChild($result, $fieldName, $childUid);
174 }
175 }
176 } else {
177 $connectedUidsOfLocalizedOverlay = [];
178 if ($result['command'] === 'edit') {
179 $connectedUidsOfLocalizedOverlay = $this->resolveConnectedRecordUids(
180 $result['processedTca']['columns'][$fieldName]['config'],
181 $result['tableName'],
182 $result['databaseRow']['uid'],
183 $result['databaseRow'][$fieldName]
184 );
185 }
186 $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfLocalizedOverlay);
187 if ($result['inlineCompileExistingChildren']) {
188 $tableNameWithDefaultRecords = $result['tableName'];
189 if ($tableNameWithDefaultRecords === 'pages_language_overlay') {
190 $tableNameWithDefaultRecords = 'pages';
191 }
192 $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids(
193 $result['processedTca']['columns'][$fieldName]['config'],
194 $tableNameWithDefaultRecords,
195 $result['defaultLanguageRow']['uid'],
196 $result['defaultLanguageRow'][$fieldName]
197 );
198
199 $showPossible = $result['processedTca']['columns'][$fieldName]['config']['appearance']['showPossibleLocalizationRecords'];
200
201 // Find which records are localized, which records are not localized and which are
202 // localized but miss default language record
203 $fieldNameWithDefaultLanguageUid = $GLOBALS['TCA'][$childTableName]['ctrl']['transOrigPointerField'];
204 foreach ($connectedUidsOfLocalizedOverlay as $localizedUid) {
205 $localizedRecord = $this->getRecordFromDatabase($childTableName, $localizedUid);
206 $uidOfDefaultLanguageRecord = $localizedRecord[$fieldNameWithDefaultLanguageUid];
207 if (in_array($uidOfDefaultLanguageRecord, $connectedUidsOfDefaultLanguageRecord)) {
208 // This localized child has a default language record. Remove this record from list of default language records
209 $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecord]);
210 }
211 // Compile localized record
212 $compiledChild = $this->compileChild($result, $fieldName, $localizedUid);
213 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
214 }
215 if ($showPossible) {
216 foreach ($connectedUidsOfDefaultLanguageRecord as $defaultLanguageUid) {
217 // If there are still uids in $connectedUidsOfDefaultLanguageRecord, these are records that
218 // exist in default language, but are not localized yet. Compile and mark those
219 $compiledChild = $this->compileChild($result, $fieldName, $defaultLanguageUid);
220 $compiledChild['isInlineDefaultLanguageRecordInLocalizedParentContext'] = true;
221 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
222 }
223 }
224 }
225 }
226
227 return $result;
228 }
229
230 /**
231 * If there is a foreign_selector or foreign_unique configuration, fetch
232 * the list of possible records that can be connected and attach the to the
233 * inline configuration.
234 *
235 * @param array $result Result array
236 * @param string $fieldName Current handle field name
237 * @return array Modified item array
238 */
239 protected function addForeignSelectorAndUniquePossibleRecords(array $result, $fieldName)
240 {
241 if (!is_array($result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'])) {
242 return $result;
243 }
244
245 $selectorOrUniqueConfiguration = $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'];
246 $foreignFieldName = $selectorOrUniqueConfiguration['fieldName'];
247 $selectorOrUniquePossibleRecords = [];
248
249 if ($selectorOrUniqueConfiguration['config']['type'] === 'select') {
250 // Compile child table data for this field only
251 $selectDataInput = [
252 'tableName' => $result['processedTca']['columns'][$fieldName]['config']['foreign_table'],
253 'command' => 'new',
254 // Since there is no existing record that may have a type, it does not make sense to
255 // do extra handling of pageTsConfig merged here. Just provide "parent" pageTS as is
256 'pageTsConfig' => $result['pageTsConfig'],
257 'userTsConfig' => $result['userTsConfig'],
258 'processedTca' => [
259 'ctrl' => [],
260 'columns' => [
261 $foreignFieldName => [
262 'config' => $selectorOrUniqueConfiguration['config'],
263 ],
264 ],
265 ],
266 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
267 ];
268 /** @var OnTheFly $formDataGroup */
269 $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
270 $formDataGroup->setProviderList([ TcaSelectItems::class ]);
271 /** @var FormDataCompiler $formDataCompiler */
272 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
273 $compilerResult = $formDataCompiler->compile($selectDataInput);
274 $selectorOrUniquePossibleRecords = $compilerResult['processedTca']['columns'][$foreignFieldName]['config']['items'];
275 }
276
277 $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniquePossibleRecords'] = $selectorOrUniquePossibleRecords;
278
279 return $result;
280 }
281
282 /**
283 * Compile a full child record
284 *
285 * @param array $result Result array of parent
286 * @param string $parentFieldName Name of parent field
287 * @param int $childUid Uid of child to compile
288 * @return array Full result array
289 */
290 protected function compileChild(array $result, $parentFieldName, $childUid)
291 {
292 $parentConfig = $result['processedTca']['columns'][$parentFieldName]['config'];
293 $childTableName = $parentConfig['foreign_table'];
294
295 /** @var InlineStackProcessor $inlineStackProcessor */
296 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
297 $inlineStackProcessor->initializeByGivenStructure($result['inlineStructure']);
298 $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
299
300 /** @var TcaDatabaseRecord $formDataGroup */
301 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
302 /** @var FormDataCompiler $formDataCompiler */
303 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
304 $formDataCompilerInput = [
305 'command' => 'edit',
306 'tableName' => $childTableName,
307 'vanillaUid' => (int)$childUid,
308 'isInlineChild' => true,
309 'inlineStructure' => $result['inlineStructure'],
310 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
311 'inlineFirstPid' => $result['inlineFirstPid'],
312 'inlineParentConfig' => $parentConfig,
313
314 // values of the current parent element
315 // it is always a string either an id or new...
316 'inlineParentUid' => $result['databaseRow']['uid'],
317 'inlineParentTableName' => $result['tableName'],
318 'inlineParentFieldName' => $parentFieldName,
319
320 // values of the top most parent element set on first level and not overridden on following levels
321 'inlineTopMostParentUid' => $result['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'],
322 'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'],
323 'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'],
324 ];
325
326 // For foreign_selector with useCombination $mainChild is the mm record
327 // and $combinationChild is the child-child. For 1:n "normal" relations,
328 // $mainChild is just the normal child record and $combinationChild is empty.
329 $mainChild = $formDataCompiler->compile($formDataCompilerInput);
330 if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
331 try {
332 $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig);
333 } catch (DatabaseRecordException $e) {
334 // The child could not be compiled, probably it was deleted and a dangling mm record
335 // exists. This is a data inconsistency, we catch this exception and create a flash message
336 $message = vsprintf(
337 $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:formEngine.databaseRecordErrorInlineChildChild'),
338 [ $e->getTableName(), $e->getUid(), $childTableName, (int)$childUid ]
339 );
340 $flashMessage = GeneralUtility::makeInstance(FlashMessage::class,
341 $message,
342 '',
343 FlashMessage::ERROR
344 );
345 GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier()->enqueue($flashMessage);
346 }
347 }
348 return $mainChild;
349 }
350
351 /**
352 * With useCombination set, not only content of the intermediate table, but also
353 * the connected child should be rendered in one go. Prepare this here.
354 *
355 * @param array $child Full data array of "mm" record
356 * @param array $parentConfig TCA configuration of "parent"
357 * @return array Full data array of child
358 */
359 protected function compileChildChild(array $child, array $parentConfig)
360 {
361 // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already
362 $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0];
363 // child-child table name is set in child tca "the selector field" foreign_table
364 $childChildTableName = $child['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table'];
365 /** @var TcaDatabaseRecord $formDataGroup */
366 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
367 /** @var FormDataCompiler $formDataCompiler */
368 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
369
370 $formDataCompilerInput = [
371 'command' => 'edit',
372 'tableName' => $childChildTableName,
373 'vanillaUid' => (int)$childChildUid,
374 'isInlineChild' => true,
375 'isInlineChildExpanded' => $child['isInlineChildExpanded'],
376 // @todo: this is the wrong inline structure, isn't it? Shouldn't it contain the part from child child, too?
377 'inlineStructure' => $child['inlineStructure'],
378 'inlineFirstPid' => $child['inlineFirstPid'],
379 // values of the top most parent element set on first level and not overridden on following levels
380 'inlineTopMostParentUid' => $child['inlineTopMostParentUid'],
381 'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'],
382 'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'],
383 ];
384 $childChild = $formDataCompiler->compile($formDataCompilerInput);
385 return $childChild;
386 }
387
388 /**
389 * Substitute given list of uids in child table with workspace uid if needed
390 *
391 * @param array $connectedUids List of connected uids
392 * @param string $childTableName Name of child table
393 * @return array List of uids in workspace
394 */
395 protected function getWorkspacedUids(array $connectedUids, $childTableName)
396 {
397 $backendUser = $this->getBackendUser();
398 $newConnectedUids = [];
399 foreach ($connectedUids as $uid) {
400 // Fetch workspace version of a record (if any):
401 // @todo: Needs handling
402 if ($backendUser->workspace !== 0 && BackendUtility::isTableWorkspaceEnabled($childTableName)) {
403 $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $childTableName, $uid, 'uid,t3ver_state');
404 if (!empty($workspaceVersion)) {
405 $versionState = VersionState::cast($workspaceVersion['t3ver_state']);
406 if ($versionState->equals(VersionState::DELETE_PLACEHOLDER)) {
407 continue;
408 }
409 $uid = $workspaceVersion['uid'];
410 }
411 }
412 $newConnectedUids[] = $uid;
413 }
414 return $newConnectedUids;
415 }
416
417 /**
418 * Use RelationHandler to resolve connected uids.
419 *
420 * @param array $parentConfig TCA config section of parent
421 * @param string $parentTableName Name of parent table
422 * @param string $parentUid Uid of parent record
423 * @param string $parentFieldValue Database value of parent record of this inline field
424 * @return array Array with connected uids
425 * @todo: Cover with unit tests
426 */
427 protected function resolveConnectedRecordUids(array $parentConfig, $parentTableName, $parentUid, $parentFieldValue)
428 {
429 $directlyConnectedIds = GeneralUtility::trimExplode(',', $parentFieldValue);
430 if (empty($parentConfig['MM'])) {
431 $parentUid = $this->getLiveDefaultId($parentTableName, $parentUid);
432 }
433 /** @var RelationHandler $relationHandler */
434 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
435 $relationHandler->registerNonTableValues = (bool)$parentConfig['allowedIdValues'];
436 $relationHandler->start($parentFieldValue, $parentConfig['foreign_table'], $parentConfig['MM'], $parentUid, $parentTableName, $parentConfig);
437 $foreignRecordUids = $relationHandler->getValueArray();
438 $resolvedForeignRecordUids = [];
439 foreach ($foreignRecordUids as $aForeignRecordUid) {
440 if ($parentConfig['MM'] || $parentConfig['foreign_field']) {
441 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
442 } else {
443 foreach ($directlyConnectedIds as $id) {
444 if ((int)$aForeignRecordUid === (int)$id) {
445 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
446 }
447 }
448 }
449 }
450 return $resolvedForeignRecordUids;
451 }
452
453 /**
454 * Gets the record uid of the live default record. If already
455 * pointing to the live record, the submitted record uid is returned.
456 *
457 * @param string $tableName
458 * @param int $uid
459 * @return int
460 * @todo: the workspace mess still must be resolved somehow
461 */
462 protected function getLiveDefaultId($tableName, $uid)
463 {
464 $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $uid);
465 if ($liveDefaultId === null) {
466 $liveDefaultId = $uid;
467 }
468 return $liveDefaultId;
469 }
470
471 /**
472 * @return BackendUserAuthentication
473 */
474 protected function getBackendUser()
475 {
476 return $GLOBALS['BE_USER'];
477 }
478
479 /**
480 * @return LanguageService
481 */
482 protected function getLanguageService()
483 {
484 return $GLOBALS['LANG'];
485 }
486 }