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