[!!!][TASK] Improve flex and TCA handling in FormEngine
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Controller / FormInlineAjaxController.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Backend\Controller;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use Psr\Http\Message\ResponseInterface;
19 use Psr\Http\Message\ServerRequestInterface;
20 use TYPO3\CMS\Backend\Form\FormDataCompiler;
21 use TYPO3\CMS\Backend\Form\FormDataGroup\InlineParentRecord;
22 use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
23 use TYPO3\CMS\Backend\Form\InlineStackProcessor;
24 use TYPO3\CMS\Backend\Form\NodeFactory;
25 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
26 use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
27 use TYPO3\CMS\Core\DataHandling\DataHandler;
28 use TYPO3\CMS\Core\Localization\LocalizationFactory;
29 use TYPO3\CMS\Core\Utility\ArrayUtility;
30 use TYPO3\CMS\Core\Utility\GeneralUtility;
31 use TYPO3\CMS\Core\Utility\MathUtility;
32
33 /**
34 * Handle FormEngine inline ajax calls
35 */
36 class FormInlineAjaxController
37 {
38 /**
39 * Create a new inline child via AJAX.
40 *
41 * @param ServerRequestInterface $request
42 * @param ResponseInterface $response
43 * @return ResponseInterface
44 */
45 public function createAction(ServerRequestInterface $request, ResponseInterface $response)
46 {
47 $ajaxArguments = isset($request->getParsedBody()['ajax']) ? $request->getParsedBody()['ajax'] : $request->getQueryParams()['ajax'];
48
49 $domObjectId = $ajaxArguments[0];
50 $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
51 $childChildUid = null;
52 if (isset($ajaxArguments[1]) && MathUtility::canBeInterpretedAsInteger($ajaxArguments[1])) {
53 $childChildUid = (int)$ajaxArguments[1];
54 }
55
56 // Parse the DOM identifier, add the levels to the structure stack
57 /** @var InlineStackProcessor $inlineStackProcessor */
58 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
59 $inlineStackProcessor->initializeByParsingDomObjectIdString($domObjectId);
60 $inlineStackProcessor->injectAjaxConfiguration($ajaxArguments['context']);
61 $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
62
63 // Parent, this table embeds the child table
64 $parent = $inlineStackProcessor->getStructureLevel(-1);
65 $parentFieldName = $parent['field'];
66
67 if (MathUtility::canBeInterpretedAsInteger($parent['uid'])) {
68 $command = 'edit';
69 $vanillaUid = (int)$parent['uid'];
70 $databaseRow = [
71 // TcaInlineExpandCollapseState needs the record uid
72 'uid' => (int)$parent['uid'],
73 ];
74 } else {
75 $command = 'new';
76 $databaseRow = [];
77 $vanillaUid = (int)$inlineFirstPid;
78 }
79
80 $flexDataStructureIdentifier = $this->getFlexFormDataStructureIdentifierFromAjaxContext($ajaxArguments);
81 $processedTca = [];
82 if ($flexDataStructureIdentifier) {
83 $processedTca = $GLOBALS['TCA'][$parent['table']];
84 $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
85 $dataStructure = $flexFormTools->parseDataStructureByIdentifier($flexDataStructureIdentifier);
86 $processedTca['columns'][$parentFieldName]['config']['dataStructureIdentifier'] = $flexDataStructureIdentifier;
87 $processedTca['columns'][$parentFieldName]['config']['ds'] = $dataStructure;
88 }
89
90 $formDataCompilerInputForParent = [
91 'vanillaUid' => $vanillaUid,
92 'command' => $command,
93 'tableName' => $parent['table'],
94 'databaseRow' => $databaseRow,
95 'processedTca' => $processedTca,
96 'inlineFirstPid' => $inlineFirstPid,
97 'columnsToProcess' => [
98 $parentFieldName,
99 ],
100 // Do not resolve existing children, we don't need them now
101 'inlineResolveExistingChildren' => false,
102 ];
103 /** @var TcaDatabaseRecord $formDataGroup */
104 $formDataGroup = GeneralUtility::makeInstance(InlineParentRecord::class);
105 /** @var FormDataCompiler $formDataCompiler */
106 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
107 $parentData = $formDataCompiler->compile($formDataCompilerInputForParent);
108 $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
109
110 // Child, a record from this table should be rendered
111 $child = $inlineStackProcessor->getUnstableStructure();
112 if (MathUtility::canBeInterpretedAsInteger($child['uid'])) {
113 // If uid comes in, it is the id of the record neighbor record "create after"
114 $childVanillaUid = -1 * abs((int)$child['uid']);
115 } else {
116 // Else inline first Pid is the storage pid of new inline records
117 $childVanillaUid = (int)$inlineFirstPid;
118 }
119
120 if ($parentConfig['type'] === 'flex') {
121 $parentConfig = $this->getParentConfigFromFlexForm($parentConfig, $domObjectId);
122 }
123 $childTableName = $parentConfig['foreign_table'];
124
125 /** @var TcaDatabaseRecord $formDataGroup */
126 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
127 /** @var FormDataCompiler $formDataCompiler */
128 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
129 $formDataCompilerInput = [
130 'command' => 'new',
131 'tableName' => $childTableName,
132 'vanillaUid' => $childVanillaUid,
133 'isInlineChild' => true,
134 'inlineStructure' => $inlineStackProcessor->getStructure(),
135 'inlineFirstPid' => $inlineFirstPid,
136 'inlineParentUid' => $parent['uid'],
137 'inlineParentTableName' => $parent['table'],
138 'inlineParentFieldName' => $parent['field'],
139 'inlineParentConfig' => $parentConfig,
140 // Fallback to $parentData is probably not needed here.
141 'inlineTopMostParentUid' => $parentData['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'],
142 'inlineTopMostParentTableName' => $parentData['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'],
143 'inlineTopMostParentFieldName' => $parentData['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'],
144 ];
145 if ($childChildUid) {
146 $formDataCompilerInput['inlineChildChildUid'] = $childChildUid;
147 }
148 $childData = $formDataCompiler->compile($formDataCompilerInput);
149
150 // Set language of new child record to the language of the parent record:
151 // @todo: To my understanding, the below case can't happen: With localizationMode select, lang overlays
152 // @todo: of children are only created with the "synchronize" button that will trigger a different ajax action.
153 // @todo: The edge case of new page overlay together with localized media field, this code won't kick in either.
154 /**
155 if ($parent['localizationMode'] === 'select' && MathUtility::canBeInterpretedAsInteger($parent['uid'])) {
156 $parentRecord = $inlineRelatedRecordResolver->getRecord($parent['table'], $parent['uid']);
157 $parentLanguageField = $GLOBALS['TCA'][$parent['table']]['ctrl']['languageField'];
158 $childLanguageField = $GLOBALS['TCA'][$child['table']]['ctrl']['languageField'];
159 if ($parentRecord[$parentLanguageField] > 0) {
160 $record[$childLanguageField] = $parentRecord[$parentLanguageField];
161 }
162 }
163 */
164 if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
165 // We have a foreign_selector. So, we just created a new record on an intermediate table in $childData.
166 // Now, if a valid id is given as second ajax parameter, the intermediate row should be connected to an
167 // existing record of the child-child table specified by the given uid. If there is no such id, user
168 // clicked on "created new" and a new child-child should be created, too.
169 if ($childChildUid) {
170 // Fetch existing child child
171 $childData['databaseRow'][$parentConfig['foreign_selector']] = [
172 $childChildUid,
173 ];
174 $childData['combinationChild'] = $this->compileChildChild($childData, $parentConfig, $inlineStackProcessor->getStructure());
175 } else {
176 /** @var TcaDatabaseRecord $formDataGroup */
177 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
178 /** @var FormDataCompiler $formDataCompiler */
179 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
180 $formDataCompilerInput = [
181 'command' => 'new',
182 'tableName' => $childData['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table'],
183 'vanillaUid' => (int)$inlineFirstPid,
184 'isInlineChild' => true,
185 'isInlineAjaxOpeningContext' => true,
186 'inlineStructure' => $inlineStackProcessor->getStructure(),
187 'inlineFirstPid' => (int)$inlineFirstPid,
188 ];
189 $childData['combinationChild'] = $formDataCompiler->compile($formDataCompilerInput);
190 }
191 }
192
193 $childData['inlineParentUid'] = (int)$parent['uid'];
194 $childData['renderType'] = 'inlineRecordContainer';
195 $nodeFactory = GeneralUtility::makeInstance(NodeFactory::class);
196 $childResult = $nodeFactory->create($childData)->render();
197
198 $jsonArray = [
199 'data' => '',
200 'stylesheetFiles' => [],
201 'scriptCall' => [],
202 ];
203
204 // The HTML-object-id's prefix of the dynamically created record
205 $objectName = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($inlineFirstPid);
206 $objectPrefix = $objectName . '-' . $child['table'];
207 $objectId = $objectPrefix . '-' . $childData['databaseRow']['uid'];
208 $expandSingle = $parentConfig['appearance']['expandSingle'];
209 if (!$child['uid']) {
210 $jsonArray['scriptCall'][] = 'inline.domAddNewRecord(\'bottom\',' . GeneralUtility::quoteJSvalue($objectName . '_records') . ',' . GeneralUtility::quoteJSvalue($objectPrefix) . ',json.data);';
211 $jsonArray['scriptCall'][] = 'inline.memorizeAddRecord(' . GeneralUtility::quoteJSvalue($objectPrefix) . ',' . GeneralUtility::quoteJSvalue($childData['databaseRow']['uid']) . ',null,' . GeneralUtility::quoteJSvalue($childChildUid) . ');';
212 } else {
213 $jsonArray['scriptCall'][] = 'inline.domAddNewRecord(\'after\',' . GeneralUtility::quoteJSvalue($domObjectId . '_div') . ',' . GeneralUtility::quoteJSvalue($objectPrefix) . ',json.data);';
214 $jsonArray['scriptCall'][] = 'inline.memorizeAddRecord(' . GeneralUtility::quoteJSvalue($objectPrefix) . ',' . GeneralUtility::quoteJSvalue($childData['databaseRow']['uid']) . ',' . GeneralUtility::quoteJSvalue($child['uid']) . ',' . GeneralUtility::quoteJSvalue($childChildUid) . ');';
215 }
216 $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
217 if ($parentConfig['appearance']['useSortable']) {
218 $inlineObjectName = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($inlineFirstPid);
219 $jsonArray['scriptCall'][] = 'inline.createDragAndDropSorting(' . GeneralUtility::quoteJSvalue($inlineObjectName . '_records') . ');';
220 }
221 if (!$parentConfig['appearance']['collapseAll'] && $expandSingle) {
222 $jsonArray['scriptCall'][] = 'inline.collapseAllRecords(' . GeneralUtility::quoteJSvalue($objectId) . ',' . GeneralUtility::quoteJSvalue($objectPrefix) . ',' . GeneralUtility::quoteJSvalue($childData['databaseRow']['uid']) . ');';
223 }
224 // Fade out and fade in the new record in the browser view to catch the user's eye
225 $jsonArray['scriptCall'][] = 'inline.fadeOutFadeIn(' . GeneralUtility::quoteJSvalue($objectId . '_div') . ');';
226
227 $response->getBody()->write(json_encode($jsonArray));
228
229 return $response;
230 }
231
232 /**
233 * Show the details of a child record.
234 *
235 * @param ServerRequestInterface $request
236 * @param ResponseInterface $response
237 * @return ResponseInterface
238 */
239 public function detailsAction(ServerRequestInterface $request, ResponseInterface $response)
240 {
241 $ajaxArguments = isset($request->getParsedBody()['ajax']) ? $request->getParsedBody()['ajax'] : $request->getQueryParams()['ajax'];
242
243 $domObjectId = $ajaxArguments[0];
244 $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
245
246 // Parse the DOM identifier, add the levels to the structure stack
247 /** @var InlineStackProcessor $inlineStackProcessor */
248 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
249 $inlineStackProcessor->initializeByParsingDomObjectIdString($domObjectId);
250 $inlineStackProcessor->injectAjaxConfiguration($ajaxArguments['context']);
251
252 // Parent, this table embeds the child table
253 $parent = $inlineStackProcessor->getStructureLevel(-1);
254 $parentFieldName = $parent['field'];
255
256 $databaseRow = [
257 // TcaInlineExpandCollapseState needs this
258 'uid' => (int)$parent['uid'],
259 ];
260
261 $flexDataStructureIdentifier = $this->getFlexFormDataStructureIdentifierFromAjaxContext($ajaxArguments);
262 $processedTca = [];
263 if ($flexDataStructureIdentifier) {
264 $processedTca = $GLOBALS['TCA'][$parent['table']];
265 $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
266 $dataStructure = $flexFormTools->parseDataStructureByIdentifier($flexDataStructureIdentifier);
267 $processedTca['columns'][$parentFieldName]['config']['dataStructureIdentifier'] = $flexDataStructureIdentifier;
268 $processedTca['columns'][$parentFieldName]['config']['ds'] = $dataStructure;
269 }
270
271 $formDataCompilerInputForParent = [
272 'vanillaUid' => (int)$parent['uid'],
273 'command' => 'edit',
274 'tableName' => $parent['table'],
275 'databaseRow' => $databaseRow,
276 'processedTca' => $processedTca,
277 'inlineFirstPid' => $inlineFirstPid,
278 'columnsToProcess' => [
279 $parentFieldName
280 ],
281 // @todo: still needed?
282 'inlineStructure' => $inlineStackProcessor->getStructure(),
283 // Do not resolve existing children, we don't need them now
284 'inlineResolveExistingChildren' => false,
285 ];
286 /** @var TcaDatabaseRecord $formDataGroup */
287 $formDataGroup = GeneralUtility::makeInstance(InlineParentRecord::class);
288 /** @var FormDataCompiler $formDataCompiler */
289 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
290 $parentData = $formDataCompiler->compile($formDataCompilerInputForParent);
291 $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
292
293 if ($parentConfig['type'] === 'flex') {
294 $parentConfig = $this->getParentConfigFromFlexForm($parentConfig, $domObjectId);
295 $parentData['processedTca']['columns'][$parentFieldName]['config'] = $parentConfig;
296 }
297
298 // Set flag in config so that only the fields are rendered
299 // @todo: Solve differently / rename / whatever
300 $parentData['processedTca']['columns'][$parentFieldName]['config']['renderFieldsOnly'] = true;
301
302 // Child, a record from this table should be rendered
303 $child = $inlineStackProcessor->getUnstableStructure();
304
305 $childData = $this->compileChild($parentData, $parentFieldName, (int)$child['uid'], $inlineStackProcessor->getStructure());
306
307 $childData['inlineParentUid'] = (int)$parent['uid'];
308 $childData['renderType'] = 'inlineRecordContainer';
309 $nodeFactory = GeneralUtility::makeInstance(NodeFactory::class);
310 $childResult = $nodeFactory->create($childData)->render();
311
312 $jsonArray = [
313 'data' => '',
314 'stylesheetFiles' => [],
315 'scriptCall' => [],
316 ];
317
318 // The HTML-object-id's prefix of the dynamically created record
319 $objectPrefix = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($inlineFirstPid) . '-' . $child['table'];
320 $objectId = $objectPrefix . '-' . (int)$child['uid'];
321 $expandSingle = $parentConfig['appearance']['expandSingle'];
322 $jsonArray['scriptCall'][] = 'inline.domAddRecordDetails(' . GeneralUtility::quoteJSvalue($domObjectId) . ',' . GeneralUtility::quoteJSvalue($objectPrefix) . ',' . ($expandSingle ? '1' : '0') . ',json.data);';
323 if ($parentConfig['foreign_unique']) {
324 $jsonArray['scriptCall'][] = 'inline.removeUsed(' . GeneralUtility::quoteJSvalue($objectPrefix) . ',\'' . (int)$child['uid'] . '\');';
325 }
326 $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
327 if ($parentConfig['appearance']['useSortable']) {
328 $inlineObjectName = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($inlineFirstPid);
329 $jsonArray['scriptCall'][] = 'inline.createDragAndDropSorting(' . GeneralUtility::quoteJSvalue($inlineObjectName . '_records') . ');';
330 }
331 if (!$parentConfig['appearance']['collapseAll'] && $expandSingle) {
332 $jsonArray['scriptCall'][] = 'inline.collapseAllRecords(' . GeneralUtility::quoteJSvalue($objectId) . ',' . GeneralUtility::quoteJSvalue($objectPrefix) . ',\'' . (int)$child['uid'] . '\');';
333 }
334
335 $response->getBody()->write(json_encode($jsonArray));
336
337 return $response;
338 }
339
340 /**
341 * Adds localizations or synchronizes the locations of all child records.
342 * Handle AJAX calls to localize all records of a parent, localize a single record or to synchronize with the original language parent.
343 *
344 * @param ServerRequestInterface $request the incoming request
345 * @param ResponseInterface $response the empty response
346 * @return ResponseInterface the filled response
347 */
348 public function synchronizeLocalizeAction(ServerRequestInterface $request, ResponseInterface $response)
349 {
350 $ajaxArguments = isset($request->getParsedBody()['ajax']) ? $request->getParsedBody()['ajax'] : $request->getQueryParams()['ajax'];
351 $domObjectId = $ajaxArguments[0];
352 $type = $ajaxArguments[1];
353
354 /** @var InlineStackProcessor $inlineStackProcessor */
355 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
356 // Parse the DOM identifier (string), add the levels to the structure stack (array), load the TCA config:
357 $inlineStackProcessor->initializeByParsingDomObjectIdString($domObjectId);
358 $inlineStackProcessor->injectAjaxConfiguration($ajaxArguments['context']);
359 $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
360
361 $jsonArray = false;
362 if ($type === 'localize' || $type === 'synchronize' || MathUtility::canBeInterpretedAsInteger($type)) {
363 // Parent, this table embeds the child table
364 $parent = $inlineStackProcessor->getStructureLevel(-1);
365 $parentFieldName = $parent['field'];
366
367 // Child, a record from this table should be rendered
368 $child = $inlineStackProcessor->getUnstableStructure();
369
370 $formDataCompilerInputForParent = [
371 'vanillaUid' => (int)$parent['uid'],
372 'command' => 'edit',
373 'tableName' => $parent['table'],
374 'databaseRow' => [
375 // TcaInlineExpandCollapseState needs this
376 'uid' => (int)$parent['uid'],
377 ],
378 'inlineFirstPid' => $inlineFirstPid,
379 'columnsToProcess' => [
380 $parentFieldName
381 ],
382 // @todo: still needed? NO!
383 'inlineStructure' => $inlineStackProcessor->getStructure(),
384 // Do not compile existing children, we don't need them now
385 'inlineCompileExistingChildren' => false,
386 ];
387 // Full TcaDatabaseRecord is required here to have the list of connected uids $oldItemList
388 /** @var TcaDatabaseRecord $formDataGroup */
389 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
390 /** @var FormDataCompiler $formDataCompiler */
391 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
392 $parentData = $formDataCompiler->compile($formDataCompilerInputForParent);
393 $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
394 $parentLanguageField = $parentData['processedTca']['ctrl']['languageField'];
395 $parentLanguage = $parentData['databaseRow'][$parentLanguageField];
396 $oldItemList = $parentData['databaseRow'][$parentFieldName];
397
398 // DataHandler cannot handle arrays as field value
399 if (is_array($parentLanguage)) {
400 $parentLanguage = implode(',', $parentLanguage);
401 }
402
403 $cmd = [];
404 // Localize a single child element from default language of the parent element
405 if (MathUtility::canBeInterpretedAsInteger($type)) {
406 $cmd[$parent['table']][$parent['uid']]['inlineLocalizeSynchronize'] = [
407 'field' => $parent['field'],
408 'language' => $parentLanguage,
409 'ids' => [$type],
410 ];
411 // Either localize or synchronize all child elements from default language of the parent element
412 } else {
413 $cmd[$parent['table']][$parent['uid']]['inlineLocalizeSynchronize'] = [
414 'field' => $parent['field'],
415 'language' => $parentLanguage,
416 'action' => $type,
417 ];
418 }
419
420 /** @var $tce DataHandler */
421 $tce = GeneralUtility::makeInstance(DataHandler::class);
422 $tce->start([], $cmd);
423 $tce->process_cmdmap();
424
425 $newItemList = $tce->registerDBList[$parent['table']][$parent['uid']][$parentFieldName];
426
427 $jsonArray = [
428 'data' => '',
429 'stylesheetFiles' => [],
430 'scriptCall' => [],
431 ];
432 $nameObject = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($inlineFirstPid);
433 $nameObjectForeignTable = $nameObject . '-' . $child['table'];
434
435 $oldItems = $this->getInlineRelatedRecordsUidArray($oldItemList);
436 $newItems = $this->getInlineRelatedRecordsUidArray($newItemList);
437
438 // Set the items that should be removed in the forms view:
439 $removedItems = array_diff($oldItems, $newItems);
440 foreach ($removedItems as $childUid) {
441 $jsonArray['scriptCall'][] = 'inline.deleteRecord(' . GeneralUtility::quoteJSvalue($nameObjectForeignTable . '-' . $childUid) . ', {forceDirectRemoval: true});';
442 }
443
444 $localizedItems = array_diff($newItems, $oldItems);
445 foreach ($localizedItems as $childUid) {
446 $childData = $this->compileChild($parentData, $parentFieldName, (int)$childUid, $inlineStackProcessor->getStructure());
447
448 $childData['inlineParentUid'] = (int)$parent['uid'];
449 $childData['renderType'] = 'inlineRecordContainer';
450 $nodeFactory = GeneralUtility::makeInstance(NodeFactory::class);
451 $childResult = $nodeFactory->create($childData)->render();
452
453 $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
454
455 // Get the name of the field used as foreign selector (if any):
456 $foreignSelector = isset($parentConfig['foreign_selector']) && $parentConfig['foreign_selector'] ? $parentConfig['foreign_selector'] : false;
457 $selectedValue = $foreignSelector ? GeneralUtility::quoteJSvalue($childData['databaseRow'][$foreignSelector]) : 'null';
458 if (is_array($selectedValue)) {
459 $selectedValue = $selectedValue[0];
460 }
461 $jsonArray['scriptCall'][] = 'inline.memorizeAddRecord(' . GeneralUtility::quoteJSvalue($nameObjectForeignTable) . ', ' . GeneralUtility::quoteJSvalue($childUid) . ', null, ' . $selectedValue . ');';
462 // Remove possible virtual records in the form which showed that a child records could be localized:
463 $transOrigPointerFieldName = $childData['processedTca']['ctrl']['transOrigPointerField'];
464 if (isset($childData['databaseRow'][$transOrigPointerFieldName]) && $childData['databaseRow'][$transOrigPointerFieldName]) {
465 $transOrigPointerField = $childData['databaseRow'][$transOrigPointerFieldName];
466 if (is_array($transOrigPointerField)) {
467 $transOrigPointerField = $transOrigPointerField[0];
468 }
469 $jsonArray['scriptCall'][] = 'inline.fadeAndRemove(' . GeneralUtility::quoteJSvalue($nameObjectForeignTable . '-' . $transOrigPointerField . '_div') . ');';
470 }
471 }
472 // Tell JS to add new HTML of one or multiple (localize all) records to DOM
473 if (!empty($jsonArray['data'])) {
474 array_push(
475 $jsonArray['scriptCall'],
476 'inline.domAddNewRecord(\'bottom\', ' . GeneralUtility::quoteJSvalue($nameObject . '_records')
477 . ', ' . GeneralUtility::quoteJSvalue($nameObjectForeignTable)
478 . ', json.data);'
479 );
480 }
481 }
482
483 $response->getBody()->write(json_encode($jsonArray));
484
485 return $response;
486 }
487
488 /**
489 * Adds localizations or synchronizes the locations of all child records.
490 *
491 * @param ServerRequestInterface $request the incoming request
492 * @param ResponseInterface $response the empty response
493 * @return ResponseInterface the filled response
494 */
495 public function expandOrCollapseAction(ServerRequestInterface $request, ResponseInterface $response)
496 {
497 $ajaxArguments = isset($request->getParsedBody()['ajax']) ? $request->getParsedBody()['ajax'] : $request->getQueryParams()['ajax'];
498 $domObjectId = $ajaxArguments[0];
499
500 /** @var InlineStackProcessor $inlineStackProcessor */
501 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
502 // Parse the DOM identifier (string), add the levels to the structure stack (array), don't load TCA config
503 $inlineStackProcessor->initializeByParsingDomObjectIdString($domObjectId);
504 $expand = $ajaxArguments[1];
505 $collapse = $ajaxArguments[2];
506
507 $backendUser = $this->getBackendUserAuthentication();
508 // The current table - for this table we should add/import records
509 $currentTable = $inlineStackProcessor->getUnstableStructure();
510 $currentTable = $currentTable['table'];
511 // The top parent table - this table embeds the current table
512 $top = $inlineStackProcessor->getStructureLevel(0);
513 $topTable = $top['table'];
514 $topUid = $top['uid'];
515 $inlineView = $this->getInlineExpandCollapseStateArray();
516 // Only do some action if the top record and the current record were saved before
517 if (MathUtility::canBeInterpretedAsInteger($topUid)) {
518 $expandUids = GeneralUtility::trimExplode(',', $expand);
519 $collapseUids = GeneralUtility::trimExplode(',', $collapse);
520 // Set records to be expanded
521 foreach ($expandUids as $uid) {
522 $inlineView[$topTable][$topUid][$currentTable][] = $uid;
523 }
524 // Set records to be collapsed
525 foreach ($collapseUids as $uid) {
526 $inlineView[$topTable][$topUid][$currentTable] = $this->removeFromArray($uid, $inlineView[$topTable][$topUid][$currentTable]);
527 }
528 // Save states back to database
529 if (is_array($inlineView[$topTable][$topUid][$currentTable])) {
530 $inlineView[$topTable][$topUid][$currentTable] = array_unique($inlineView[$topTable][$topUid][$currentTable]);
531 $backendUser->uc['inlineView'] = serialize($inlineView);
532 $backendUser->writeUC();
533 }
534 }
535
536 $response->getBody()->write(json_encode([]));
537 return $response;
538 }
539
540 /**
541 * Compile a full child record
542 *
543 * @param array $parentData Result array of parent
544 * @param string $parentFieldName Name of parent field
545 * @param int $childUid Uid of child to compile
546 * @param array $inlineStructure Current inline structure
547 * @return array Full result array
548 *
549 * @todo: This clones methods compileChild from TcaInline Provider. Find a better abstraction
550 * @todo: to also encapsulate the more complex scenarios with combination child and friends.
551 */
552 protected function compileChild(array $parentData, $parentFieldName, $childUid, array $inlineStructure)
553 {
554 $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
555
556 /** @var InlineStackProcessor $inlineStackProcessor */
557 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
558 $inlineStackProcessor->initializeByGivenStructure($inlineStructure);
559 $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
560
561 // @todo: do not use stack processor here ...
562 $child = $inlineStackProcessor->getUnstableStructure();
563 $childTableName = $child['table'];
564
565 /** @var TcaDatabaseRecord $formDataGroup */
566 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
567 /** @var FormDataCompiler $formDataCompiler */
568 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
569 $formDataCompilerInput = [
570 'command' => 'edit',
571 'tableName' => $childTableName,
572 'vanillaUid' => (int)$childUid,
573 'isInlineChild' => true,
574 'inlineStructure' => $inlineStructure,
575 'inlineFirstPid' => $parentData['inlineFirstPid'],
576 'inlineParentConfig' => $parentConfig,
577 'isInlineAjaxOpeningContext' => true,
578
579 // values of the current parent element
580 // it is always a string either an id or new...
581 'inlineParentUid' => $parentData['databaseRow']['uid'],
582 'inlineParentTableName' => $parentData['tableName'],
583 'inlineParentFieldName' => $parentFieldName,
584
585 // values of the top most parent element set on first level and not overridden on following levels
586 'inlineTopMostParentUid' => $parentData['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'],
587 'inlineTopMostParentTableName' => $parentData['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'],
588 'inlineTopMostParentFieldName' => $parentData['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'],
589 ];
590 // For foreign_selector with useCombination $mainChild is the mm record
591 // and $combinationChild is the child-child. For "normal" relations, $mainChild
592 // is just the normal child record and $combinationChild is empty.
593 $mainChild = $formDataCompiler->compile($formDataCompilerInput);
594 if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
595 // This kicks in if opening an existing mainChild that has a child-child set
596 $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig, $inlineStructure);
597 }
598 return $mainChild;
599 }
600
601 /**
602 * With useCombination set, not only content of the intermediate table, but also
603 * the connected child should be rendered in one go. Prepare this here.
604 *
605 * @param array $child Full data array of "mm" record
606 * @param array $parentConfig TCA configuration of "parent"
607 * @param array $inlineStructure Current inline structure
608 * @return array Full data array of child
609 */
610 protected function compileChildChild(array $child, array $parentConfig, array $inlineStructure)
611 {
612 // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already
613 $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0];
614 // child-child table name is set in child tca "the selector field" foreign_table
615 $childChildTableName = $child['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table'];
616 /** @var TcaDatabaseRecord $formDataGroup */
617 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
618 /** @var FormDataCompiler $formDataCompiler */
619 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
620 $formDataCompilerInput = [
621 'command' => 'edit',
622 'tableName' => $childChildTableName,
623 'vanillaUid' => (int)$childChildUid,
624 'isInlineChild' => true,
625 'isInlineAjaxOpeningContext' => true,
626 // @todo: this is the wrong inline structure, isn't it? Shouldn't contain it the part from child child, too?
627 'inlineStructure' => $inlineStructure,
628 'inlineFirstPid' => $child['inlineFirstPid'],
629 // values of the top most parent element set on first level and not overridden on following levels
630 'inlineTopMostParentUid' => $child['inlineTopMostParentUid'],
631 'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'],
632 'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'],
633 ];
634 return $formDataCompiler->compile($formDataCompilerInput);
635 }
636
637 /**
638 * Merge stuff from child array into json array.
639 * This method is needed since ajax handling methods currently need to put scriptCalls before and after child code.
640 *
641 * @param array $jsonResult Given json result
642 * @param array $childResult Given child result
643 * @return array Merged json array
644 */
645 protected function mergeChildResultIntoJsonResult(array $jsonResult, array $childResult)
646 {
647 $jsonResult['data'] .= $childResult['html'];
648 $jsonResult['stylesheetFiles'] = $childResult['stylesheetFiles'];
649 if (!empty($childResult['inlineData'])) {
650 $jsonResult['scriptCall'][] = 'inline.addToDataArray(' . json_encode($childResult['inlineData']) . ');';
651 }
652 if (!empty($childResult['additionalJavaScriptSubmit'])) {
653 $additionalJavaScriptSubmit = implode('', $childResult['additionalJavaScriptSubmit']);
654 $additionalJavaScriptSubmit = str_replace([CR, LF], '', $additionalJavaScriptSubmit);
655 $jsonResult['scriptCall'][] = 'TBE_EDITOR.addActionChecks("submit", "' . addslashes($additionalJavaScriptSubmit) . '");';
656 }
657 foreach ($childResult['additionalJavaScriptPost'] as $singleAdditionalJavaScriptPost) {
658 $jsonResult['scriptCall'][] = $singleAdditionalJavaScriptPost;
659 }
660 if (!empty($childResult['additionalInlineLanguageLabelFiles'])) {
661 $labels = [];
662 foreach ($childResult['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) {
663 ArrayUtility::mergeRecursiveWithOverrule(
664 $labels,
665 $this->addInlineLanguageLabelFile($additionalInlineLanguageLabelFile)
666 );
667 }
668 $javaScriptCode = [];
669 $javaScriptCode[] = 'if (typeof TYPO3 === \'undefined\' || typeof TYPO3.lang === \'undefined\') {';
670 $javaScriptCode[] = ' TYPO3.lang = {}';
671 $javaScriptCode[] = '}';
672 $javaScriptCode[] = 'var additionalInlineLanguageLabels = ' . json_encode($labels) . ';';
673 $javaScriptCode[] = 'for (var attributeName in additionalInlineLanguageLabels) {';
674 $javaScriptCode[] = ' if (typeof TYPO3.lang[attributeName] === \'undefined\') {';
675 $javaScriptCode[] = ' TYPO3.lang[attributeName] = additionalInlineLanguageLabels[attributeName]';
676 $javaScriptCode[] = ' }';
677 $javaScriptCode[] = '}';
678
679 $jsonResult['scriptCall'][] = implode(LF, $javaScriptCode);
680 }
681 if (!empty($childResult['requireJsModules'])) {
682 foreach ($childResult['requireJsModules'] as $module) {
683 $moduleName = null;
684 $callback = null;
685 if (is_string($module)) {
686 // if $module is a string, no callback
687 $moduleName = $module;
688 $callback = null;
689 } elseif (is_array($module)) {
690 // if $module is an array, callback is possible
691 foreach ($module as $key => $value) {
692 $moduleName = $key;
693 $callback = $value;
694 break;
695 }
696 }
697 if ($moduleName !== null) {
698 $inlineCodeKey = $moduleName;
699 $javaScriptCode = 'require(["' . $moduleName . '"]';
700 if ($callback !== null) {
701 $inlineCodeKey .= sha1($callback);
702 $javaScriptCode .= ', ' . $callback;
703 }
704 $javaScriptCode .= ');';
705 $jsonResult['scriptCall'][] = '/*RequireJS-Module-' . $inlineCodeKey . '*/' . LF . $javaScriptCode;
706 }
707 }
708 }
709 return $jsonResult;
710 }
711
712 /**
713 * @param string $file
714 *
715 * @return array
716 */
717 protected function addInlineLanguageLabelFile($file)
718 {
719 /** @var $languageFactory LocalizationFactory */
720 $languageFactory = GeneralUtility::makeInstance(LocalizationFactory::class);
721 $language = $GLOBALS['LANG']->lang;
722 $localizationArray = $languageFactory->getParsedData(
723 $file,
724 $language,
725 'utf-8',
726 1
727 );
728 if (is_array($localizationArray) && !empty($localizationArray)) {
729 if (!empty($localizationArray[$language])) {
730 $xlfLabelArray = $localizationArray['default'];
731 ArrayUtility::mergeRecursiveWithOverrule($xlfLabelArray, $localizationArray[$language], true, false);
732 } else {
733 $xlfLabelArray = $localizationArray['default'];
734 }
735 } else {
736 $xlfLabelArray = [];
737 }
738 $labelArray = [];
739 foreach ($xlfLabelArray as $key => $value) {
740 if (isset($value[0]['target'])) {
741 $labelArray[$key] = $value[0]['target'];
742 } else {
743 $labelArray[$key] = '';
744 }
745 }
746 return $labelArray;
747 }
748
749 /**
750 * Gets an array with the uids of related records out of a list of items.
751 * This list could contain more information than required. This methods just
752 * extracts the uids.
753 *
754 * @param string $itemList The list of related child records
755 * @return array An array with uids
756 */
757 protected function getInlineRelatedRecordsUidArray($itemList)
758 {
759 $itemArray = GeneralUtility::trimExplode(',', $itemList, true);
760 // Perform modification of the selected items array:
761 foreach ($itemArray as &$value) {
762 $parts = explode('|', $value, 2);
763 $value = $parts[0];
764 }
765 unset($value);
766 return $itemArray;
767 }
768
769 /**
770 * Checks if a record selector may select a certain file type
771 *
772 * @param array $selectorConfiguration
773 * @param array $fileRecord
774 * @return bool
775 * @todo: check this ...
776 */
777 protected function checkInlineFileTypeAccessForField(array $selectorConfiguration, array $fileRecord)
778 {
779 if (!empty($selectorConfiguration['PA']['fieldConf']['config']['appearance']['elementBrowserAllowed'])) {
780 $allowedFileExtensions = GeneralUtility::trimExplode(
781 ',',
782 $selectorConfiguration['PA']['fieldConf']['config']['appearance']['elementBrowserAllowed'],
783 true
784 );
785 if (!in_array(strtolower($fileRecord['extension']), $allowedFileExtensions, true)) {
786 return false;
787 }
788 }
789 return true;
790 }
791
792 /**
793 * Return expand / collapse state array for a given table / uid combination
794 *
795 * @param string $table Handled table
796 * @param int $uid Handled uid
797 * @return array
798 */
799 protected function getInlineExpandCollapseStateArrayForTableUid($table, $uid)
800 {
801 $inlineView = $this->getInlineExpandCollapseStateArray();
802 $result = [];
803 if (MathUtility::canBeInterpretedAsInteger($uid)) {
804 if (!empty($inlineView[$table][$uid])) {
805 $result = $inlineView[$table][$uid];
806 }
807 }
808 return $result;
809 }
810
811 /**
812 * Get expand / collapse state of inline items
813 *
814 * @return array
815 */
816 protected function getInlineExpandCollapseStateArray()
817 {
818 $backendUser = $this->getBackendUserAuthentication();
819 if (!$this->backendUserHasUcInlineView($backendUser)) {
820 return [];
821 }
822
823 $inlineView = unserialize($backendUser->uc['inlineView']);
824 if (!is_array($inlineView)) {
825 $inlineView = [];
826 }
827
828 return $inlineView;
829 }
830
831 /**
832 * Method to check whether the backend user has the property inline view for the current IRRE item.
833 * In existing or old IRRE items the attribute may not exist, then the unserialize will fail.
834 *
835 * @param BackendUserAuthentication $backendUser
836 * @return bool
837 */
838 protected function backendUserHasUcInlineView(BackendUserAuthentication $backendUser)
839 {
840 return !empty($backendUser->uc['inlineView']);
841 }
842
843 /**
844 * Remove an element from an array.
845 *
846 * @param mixed $needle The element to be removed.
847 * @param array $haystack The array the element should be removed from.
848 * @param bool $strict Search elements strictly.
849 * @return array The array $haystack without the $needle
850 */
851 protected function removeFromArray($needle, $haystack, $strict = false)
852 {
853 $pos = array_search($needle, $haystack, $strict);
854 if ($pos !== false) {
855 unset($haystack[$pos]);
856 }
857 return $haystack;
858 }
859
860 /**
861 * Generates an error message that transferred as JSON for AJAX calls
862 *
863 * @param string $message The error message to be shown
864 * @return array The error message in a JSON array
865 */
866 protected function getErrorMessageForAJAX($message)
867 {
868 return [
869 'data' => $message,
870 'scriptCall' => [
871 'alert("' . $message . '");'
872 ],
873 ];
874 }
875
876 /**
877 * Get inlineFirstPid from a given objectId string
878 *
879 * @param string $domObjectId The id attribute of an element
880 * @return int|NULL Pid or null
881 */
882 protected function getInlineFirstPidFromDomObjectId($domObjectId)
883 {
884 // Substitute FlexForm addition and make parsing a bit easier
885 $domObjectId = str_replace('---', ':', $domObjectId);
886 // The starting pattern of an object identifier (e.g. "data-<firstPidValue>-<anything>)
887 $pattern = '/^data' . '-' . '(.+?)' . '-' . '(.+)$/';
888 if (preg_match($pattern, $domObjectId, $match)) {
889 return $match[1];
890 }
891 return null;
892 }
893
894 /**
895 * @return BackendUserAuthentication
896 */
897 protected function getBackendUserAuthentication()
898 {
899 return $GLOBALS['BE_USER'];
900 }
901
902 /**
903 * Extract the inline child table configuration from the flexform data structure
904 * using the the domObjectId to traverse the XML structure.
905 *
906 * domObjectId parsing has been copied from InlineStackProcessor::initializeByDomObjectId
907 *
908 * @param array $parentConfig
909 * @param string $domObjectId
910 * @return array
911 */
912 protected function getParentConfigFromFlexForm(array $parentConfig, string $domObjectId) : array
913 {
914 list($flexFormPath, $foreignTableName) = $this->splitDomObjectId($domObjectId);
915
916 $childConfig = $parentConfig['ds']['sheets'];
917 $flexFormPath = explode(':', $flexFormPath);
918 foreach ($flexFormPath as $flexFormNode) {
919 // We are dealing with configuration information from a flexform,
920 // not value storage, identifiers that reference language or
921 // value nodes must be skipped.
922 if (!isset($childConfig[$flexFormNode]) && preg_match('/^[lv][[:alpha:]]+$/', $flexFormNode)) {
923 continue;
924 }
925 $childConfig = $childConfig[$flexFormNode];
926
927 // Skip to the field configuration of a sheet
928 if (isset($childConfig['ROOT']) && $childConfig['ROOT']['type'] == 'array') {
929 $childConfig = $childConfig['ROOT']['el'];
930 }
931 }
932
933 if (!isset($childConfig['config'])
934 || !is_array($childConfig['config'])
935 || $childConfig['config']['type'] !== 'inline'
936 || $childConfig['config']['foreign_table'] !== $foreignTableName
937 ) {
938 throw new \UnexpectedValueException(
939 'Configuration retrieved from FlexForm is incomplete or not of type "inline".',
940 1446996319
941 );
942 }
943 return $childConfig['config'];
944 }
945
946 /**
947 * Inline fields within a flex form need the data structure identifier that
948 * specifies the specific flex form this inline element is in. Retrieve it from
949 * the context array.
950 *
951 * @param array $ajaxArguments The AJAX request arguments
952 * @return string Data structure identifier as json string
953 */
954 protected function getFlexFormDataStructureIdentifierFromAjaxContext(array $ajaxArguments)
955 {
956 if (!isset($ajaxArguments['context'])) {
957 return '';
958 }
959
960 $context = json_decode($ajaxArguments['context'], true);
961 if (GeneralUtility::hmac(serialize($context['config'])) !== $context['hmac']) {
962 return '';
963 }
964
965 return $context['config']['flexDataStructureIdentifier'] ?? '';
966 }
967
968 /**
969 * split the domObjectID and retrieve the needed parts
970 *
971 * @param string $domObjectId
972 *
973 * @return array
974 */
975 protected function splitDomObjectId(string $domObjectId) : array
976 {
977
978 // Substitute FlexForm addition and make parsing a bit easier
979 $domObjectId = str_replace('---', ':', $domObjectId);
980 $pattern = '/:data:(?<flexformPath>.*?)-(?<tableName>[^-]+)(?:-(?:NEW)?\w+)?$/';
981
982 /* EXPLANATION for the regex:
983 * according https://regex101.com/
984 *
985 * :data: matches the characters :data: literally (case sensitive)
986 * (?<flexformPath>.*?) Named capturing group flexformPath
987 * .*? matches any character (except newline)
988 * Quantifier: *? Between zero and unlimited times, as few times as possible, expanding as needed [lazy]
989 * - matches the character - literally
990 * (?<tableName>[^-]+) Named capturing group tableName
991 * [^-]+ match a single character not present in the list below
992 * Quantifier: + Between one and unlimited times, as many times as possible, giving back as needed [greedy]
993 * - the literal character -
994 * (?:-(?:NEW)?\w+)? Non-capturing group
995 * Quantifier: ? Between zero and one time, as many times as possible, giving back as needed [greedy]
996 * - matches the character - literally
997 * (?:NEW)? Non-capturing group
998 * Quantifier: ? Between zero and one time, as many times as possible, giving back as needed [greedy]
999 * NEW matches the characters NEW literally (case sensitive)
1000 * \w+ match any word character [a-zA-Z0-9_]
1001 * Quantifier: + Between one and unlimited times, as many times as possible, giving back as needed [greedy]
1002 * $ assert position at end of a line
1003 */
1004
1005 if (preg_match($pattern, $domObjectId, $match)) {
1006 return [$match['flexformPath'], $match['tableName']];
1007 }
1008
1009 return [];
1010 }
1011 }