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