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