[TASK] Move LanguageService to EXT:core
[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\Localization\LanguageService;
27 use TYPO3\CMS\Core\Messaging\FlashMessage;
28 use TYPO3\CMS\Core\Messaging\FlashMessageService;
29 use TYPO3\CMS\Core\Utility\GeneralUtility;
30 use TYPO3\CMS\Core\Versioning\VersionState;
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 // @deprecated: IRRE 'localizationMode' is deprecated and will be removed in TYPO3 CMS 9
141 $mode = $result['processedTca']['columns'][$fieldName]['config']['behaviour']['localizationMode'];
142 if ($mode === 'none') {
143 $connectedUids = [];
144 // A new record that has distinct children can not have children yet, fetch connected uids for existing only
145 if ($result['command'] === 'edit') {
146 $connectedUids = $this->resolveConnectedRecordUids(
147 $result['processedTca']['columns'][$fieldName]['config'],
148 $result['tableName'],
149 $result['databaseRow']['uid'],
150 $result['databaseRow'][$fieldName]
151 );
152 }
153 $result['databaseRow'][$fieldName] = implode(',', $connectedUids);
154 $connectedUids = $this->getWorkspacedUids($connectedUids, $childTableName);
155 // @todo: If inlineCompileExistingChildren must be kept, it might be better to change the data
156 // @todo: format of databaseRow for this field and separate the child compilation to an own provider?
157 if ($result['inlineCompileExistingChildren']) {
158 foreach ($connectedUids as $childUid) {
159 $result['processedTca']['columns'][$fieldName]['children'][] = $this->compileChild($result, $fieldName, $childUid);
160 }
161 }
162 } elseif ($mode === 'keep') {
163 // Fetch connected uids of default language record
164 $connectedUids = $this->resolveConnectedRecordUids(
165 $result['processedTca']['columns'][$fieldName]['config'],
166 $result['tableName'],
167 $result['defaultLanguageRow']['uid'],
168 $result['defaultLanguageRow'][$fieldName]
169 );
170 $result['databaseRow'][$fieldName] = implode(',', $connectedUids);
171 $connectedUids = $this->getWorkspacedUids($connectedUids, $childTableName);
172 if ($result['inlineCompileExistingChildren']) {
173 foreach ($connectedUids as $childUid) {
174 $result['processedTca']['columns'][$fieldName]['children'][] = $this->compileChild($result, $fieldName, $childUid);
175 }
176 }
177 } else {
178 $connectedUidsOfLocalizedOverlay = [];
179 if ($result['command'] === 'edit') {
180 $connectedUidsOfLocalizedOverlay = $this->resolveConnectedRecordUids(
181 $result['processedTca']['columns'][$fieldName]['config'],
182 $result['tableName'],
183 $result['databaseRow']['uid'],
184 $result['databaseRow'][$fieldName]
185 );
186 }
187 $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfLocalizedOverlay);
188 if ($result['inlineCompileExistingChildren']) {
189 $tableNameWithDefaultRecords = $result['tableName'];
190 if ($tableNameWithDefaultRecords === 'pages_language_overlay') {
191 $tableNameWithDefaultRecords = 'pages';
192 }
193 $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids(
194 $result['processedTca']['columns'][$fieldName]['config'],
195 $tableNameWithDefaultRecords,
196 $result['defaultLanguageRow']['uid'],
197 $result['defaultLanguageRow'][$fieldName]
198 );
199
200 $showPossible = $result['processedTca']['columns'][$fieldName]['config']['appearance']['showPossibleLocalizationRecords'];
201
202 // Find which records are localized, which records are not localized and which are
203 // localized but miss default language record
204 $fieldNameWithDefaultLanguageUid = $GLOBALS['TCA'][$childTableName]['ctrl']['transOrigPointerField'];
205 foreach ($connectedUidsOfLocalizedOverlay as $localizedUid) {
206 $localizedRecord = $this->getRecordFromDatabase($childTableName, $localizedUid);
207 $uidOfDefaultLanguageRecord = $localizedRecord[$fieldNameWithDefaultLanguageUid];
208 if (in_array($uidOfDefaultLanguageRecord, $connectedUidsOfDefaultLanguageRecord)) {
209 // This localized child has a default language record. Remove this record from list of default language records
210 $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecord]);
211 }
212 // Compile localized record
213 $compiledChild = $this->compileChild($result, $fieldName, $localizedUid);
214 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
215 }
216 if ($showPossible) {
217 foreach ($connectedUidsOfDefaultLanguageRecord as $defaultLanguageUid) {
218 // If there are still uids in $connectedUidsOfDefaultLanguageRecord, these are records that
219 // exist in default language, but are not localized yet. Compile and mark those
220 $compiledChild = $this->compileChild($result, $fieldName, $defaultLanguageUid);
221 $compiledChild['isInlineDefaultLanguageRecordInLocalizedParentContext'] = true;
222 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
223 }
224 }
225 }
226 }
227
228 return $result;
229 }
230
231 /**
232 * If there is a foreign_selector or foreign_unique configuration, fetch
233 * the list of possible records that can be connected and attach the to the
234 * inline configuration.
235 *
236 * @param array $result Result array
237 * @param string $fieldName Current handle field name
238 * @return array Modified item array
239 */
240 protected function addForeignSelectorAndUniquePossibleRecords(array $result, $fieldName)
241 {
242 if (!is_array($result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'])) {
243 return $result;
244 }
245
246 $selectorOrUniqueConfiguration = $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'];
247 $foreignFieldName = $selectorOrUniqueConfiguration['fieldName'];
248 $selectorOrUniquePossibleRecords = [];
249
250 if ($selectorOrUniqueConfiguration['config']['type'] === 'select') {
251 // Compile child table data for this field only
252 $selectDataInput = [
253 'tableName' => $result['processedTca']['columns'][$fieldName]['config']['foreign_table'],
254 'command' => 'new',
255 // Since there is no existing record that may have a type, it does not make sense to
256 // do extra handling of pageTsConfig merged here. Just provide "parent" pageTS as is
257 'pageTsConfig' => $result['pageTsConfig'],
258 'userTsConfig' => $result['userTsConfig'],
259 'databaseRow' => $result['databaseRow'],
260 'processedTca' => [
261 'ctrl' => [],
262 'columns' => [
263 $foreignFieldName => [
264 'config' => $selectorOrUniqueConfiguration['config'],
265 ],
266 ],
267 ],
268 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
269 ];
270 /** @var OnTheFly $formDataGroup */
271 $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
272 $formDataGroup->setProviderList([ TcaSelectItems::class ]);
273 /** @var FormDataCompiler $formDataCompiler */
274 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
275 $compilerResult = $formDataCompiler->compile($selectDataInput);
276 $selectorOrUniquePossibleRecords = $compilerResult['processedTca']['columns'][$foreignFieldName]['config']['items'];
277 }
278
279 $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniquePossibleRecords'] = $selectorOrUniquePossibleRecords;
280
281 return $result;
282 }
283
284 /**
285 * Compile a full child record
286 *
287 * @param array $result Result array of parent
288 * @param string $parentFieldName Name of parent field
289 * @param int $childUid Uid of child to compile
290 * @return array Full result array
291 */
292 protected function compileChild(array $result, $parentFieldName, $childUid)
293 {
294 $parentConfig = $result['processedTca']['columns'][$parentFieldName]['config'];
295 $childTableName = $parentConfig['foreign_table'];
296
297 /** @var InlineStackProcessor $inlineStackProcessor */
298 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
299 $inlineStackProcessor->initializeByGivenStructure($result['inlineStructure']);
300 $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
301
302 /** @var TcaDatabaseRecord $formDataGroup */
303 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
304 /** @var FormDataCompiler $formDataCompiler */
305 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
306 $formDataCompilerInput = [
307 'command' => 'edit',
308 'tableName' => $childTableName,
309 'vanillaUid' => (int)$childUid,
310 'isInlineChild' => true,
311 'inlineStructure' => $result['inlineStructure'],
312 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
313 'inlineFirstPid' => $result['inlineFirstPid'],
314 'inlineParentConfig' => $parentConfig,
315
316 // values of the current parent element
317 // it is always a string either an id or new...
318 'inlineParentUid' => $result['databaseRow']['uid'],
319 'inlineParentTableName' => $result['tableName'],
320 'inlineParentFieldName' => $parentFieldName,
321
322 // values of the top most parent element set on first level and not overridden on following levels
323 'inlineTopMostParentUid' => $result['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'],
324 'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'],
325 'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'],
326 ];
327
328 // For foreign_selector with useCombination $mainChild is the mm record
329 // and $combinationChild is the child-child. For 1:n "normal" relations,
330 // $mainChild is just the normal child record and $combinationChild is empty.
331 $mainChild = $formDataCompiler->compile($formDataCompilerInput);
332 if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
333 try {
334 $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig);
335 } catch (DatabaseRecordException $e) {
336 // The child could not be compiled, probably it was deleted and a dangling mm record
337 // exists. This is a data inconsistency, we catch this exception and create a flash message
338 $message = vsprintf(
339 $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:formEngine.databaseRecordErrorInlineChildChild'),
340 [ $e->getTableName(), $e->getUid(), $childTableName, (int)$childUid ]
341 );
342 $flashMessage = GeneralUtility::makeInstance(FlashMessage::class,
343 $message,
344 '',
345 FlashMessage::ERROR
346 );
347 GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier()->enqueue($flashMessage);
348 }
349 }
350 return $mainChild;
351 }
352
353 /**
354 * With useCombination set, not only content of the intermediate table, but also
355 * the connected child should be rendered in one go. Prepare this here.
356 *
357 * @param array $child Full data array of "mm" record
358 * @param array $parentConfig TCA configuration of "parent"
359 * @return array Full data array of child
360 */
361 protected function compileChildChild(array $child, array $parentConfig)
362 {
363 // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already
364 $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0];
365 // child-child table name is set in child tca "the selector field" foreign_table
366 $childChildTableName = $child['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table'];
367 /** @var TcaDatabaseRecord $formDataGroup */
368 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
369 /** @var FormDataCompiler $formDataCompiler */
370 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
371
372 $formDataCompilerInput = [
373 'command' => 'edit',
374 'tableName' => $childChildTableName,
375 'vanillaUid' => (int)$childChildUid,
376 'isInlineChild' => true,
377 'isInlineChildExpanded' => $child['isInlineChildExpanded'],
378 // @todo: this is the wrong inline structure, isn't it? Shouldn't it contain the part from child child, too?
379 'inlineStructure' => $child['inlineStructure'],
380 'inlineFirstPid' => $child['inlineFirstPid'],
381 // values of the top most parent element set on first level and not overridden on following levels
382 'inlineTopMostParentUid' => $child['inlineTopMostParentUid'],
383 'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'],
384 'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'],
385 ];
386 $childChild = $formDataCompiler->compile($formDataCompilerInput);
387 return $childChild;
388 }
389
390 /**
391 * Substitute given list of uids in child table with workspace uid if needed
392 *
393 * @param array $connectedUids List of connected uids
394 * @param string $childTableName Name of child table
395 * @return array List of uids in workspace
396 */
397 protected function getWorkspacedUids(array $connectedUids, $childTableName)
398 {
399 $backendUser = $this->getBackendUser();
400 $newConnectedUids = [];
401 foreach ($connectedUids as $uid) {
402 // Fetch workspace version of a record (if any):
403 // @todo: Needs handling
404 if ($backendUser->workspace !== 0 && BackendUtility::isTableWorkspaceEnabled($childTableName)) {
405 $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $childTableName, $uid, 'uid,t3ver_state');
406 if (!empty($workspaceVersion)) {
407 $versionState = VersionState::cast($workspaceVersion['t3ver_state']);
408 if ($versionState->equals(VersionState::DELETE_PLACEHOLDER)) {
409 continue;
410 }
411 $uid = $workspaceVersion['uid'];
412 }
413 }
414 $newConnectedUids[] = $uid;
415 }
416 return $newConnectedUids;
417 }
418
419 /**
420 * Use RelationHandler to resolve connected uids.
421 *
422 * @param array $parentConfig TCA config section of parent
423 * @param string $parentTableName Name of parent table
424 * @param string $parentUid Uid of parent record
425 * @param string $parentFieldValue Database value of parent record of this inline field
426 * @return array Array with connected uids
427 * @todo: Cover with unit tests
428 */
429 protected function resolveConnectedRecordUids(array $parentConfig, $parentTableName, $parentUid, $parentFieldValue)
430 {
431 $directlyConnectedIds = GeneralUtility::trimExplode(',', $parentFieldValue);
432 if (empty($parentConfig['MM'])) {
433 $parentUid = $this->getLiveDefaultId($parentTableName, $parentUid);
434 }
435 /** @var RelationHandler $relationHandler */
436 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
437 $relationHandler->registerNonTableValues = (bool)$parentConfig['allowedIdValues'];
438 $relationHandler->start($parentFieldValue, $parentConfig['foreign_table'], $parentConfig['MM'], $parentUid, $parentTableName, $parentConfig);
439 $foreignRecordUids = $relationHandler->getValueArray();
440 $resolvedForeignRecordUids = [];
441 foreach ($foreignRecordUids as $aForeignRecordUid) {
442 if ($parentConfig['MM'] || $parentConfig['foreign_field']) {
443 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
444 } else {
445 foreach ($directlyConnectedIds as $id) {
446 if ((int)$aForeignRecordUid === (int)$id) {
447 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
448 }
449 }
450 }
451 }
452 return $resolvedForeignRecordUids;
453 }
454
455 /**
456 * Gets the record uid of the live default record. If already
457 * pointing to the live record, the submitted record uid is returned.
458 *
459 * @param string $tableName
460 * @param int $uid
461 * @return int
462 * @todo: the workspace mess still must be resolved somehow
463 */
464 protected function getLiveDefaultId($tableName, $uid)
465 {
466 $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $uid);
467 if ($liveDefaultId === null) {
468 $liveDefaultId = $uid;
469 }
470 return $liveDefaultId;
471 }
472
473 /**
474 * @return BackendUserAuthentication
475 */
476 protected function getBackendUser()
477 {
478 return $GLOBALS['BE_USER'];
479 }
480
481 /**
482 * @return LanguageService
483 */
484 protected function getLanguageService()
485 {
486 return $GLOBALS['LANG'];
487 }
488 }