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