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