getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax']; $parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']); $domObjectId = $ajaxArguments[0] ?? ''; $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId); if (!MathUtility::canBeInterpretedAsInteger($inlineFirstPid) && !str_starts_with((string)$inlineFirstPid, 'NEW') ) { throw new \RuntimeException( 'inlineFirstPid should either be an integer or a "NEW..." string', 1521220491 ); } $childChildUid = null; if (isset($ajaxArguments[1]) && MathUtility::canBeInterpretedAsInteger($ajaxArguments[1])) { $childChildUid = (int)$ajaxArguments[1]; } // Parse the DOM identifier, add the levels to the structure stack $inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all()); $inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig); $inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0); // Parent, this table embeds the child table $inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1); // Child, a record from this table should be rendered $child = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure); if (isset($child['uid']) && MathUtility::canBeInterpretedAsInteger($child['uid'])) { // If uid comes in, it is the id of the record neighbor record "create after" $childVanillaUid = -1 * abs((int)$child['uid']); } else { // Else inline first Pid is the storage pid of new inline records $childVanillaUid = $inlineFirstPid; } $childTableName = $parentConfig['foreign_table']; $formDataCompilerInput = [ 'request' => $request, 'command' => 'new', 'tableName' => $childTableName, 'vanillaUid' => $childVanillaUid, 'isInlineChild' => true, 'inlineStructure' => $inlineStructure, 'inlineFirstPid' => $inlineFirstPid, 'inlineParentUid' => $inlineParent['uid'], 'inlineParentTableName' => $inlineParent['table'], 'inlineParentFieldName' => $inlineParent['field'], 'inlineParentConfig' => $parentConfig, 'inlineTopMostParentUid' => $inlineTopMostParent['uid'], 'inlineTopMostParentTableName' => $inlineTopMostParent['table'], 'inlineTopMostParentFieldName' => $inlineTopMostParent['field'], ]; if ($childChildUid) { $formDataCompilerInput['inlineChildChildUid'] = $childChildUid; } $childData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) { // We have a foreign_selector. So, we just created a new record on an intermediate table in $childData. // Now, if a valid id is given as second ajax parameter, the intermediate row should be connected to an // existing record of the child-child table specified by the given uid. If there is no such id, user // clicked on "created new" and a new child-child should be created, too. if ($childChildUid) { // Fetch existing child child $childData['databaseRow'][$parentConfig['foreign_selector']] = [ $childChildUid, ]; $childData['combinationChild'] = $this->compileChildChild($request, $childData, $parentConfig, $inlineStructure); } else { $formDataCompilerInput = [ 'request' => $request, 'command' => 'new', 'tableName' => $this->getChildChildTableName($parentConfig['foreign_selector'], $childData), 'vanillaUid' => $inlineFirstPid, 'isInlineChild' => true, 'isInlineAjaxOpeningContext' => true, 'inlineStructure' => $inlineStructure, 'inlineFirstPid' => $inlineFirstPid, ]; $childData['combinationChild'] = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); } } $childData['inlineParentUid'] = $inlineParent['uid']; $childData['renderType'] = 'inlineRecordContainer'; $childResult = $this->nodeFactory->create($childData)->render(); $jsonArray = [ 'data' => '', 'stylesheetFiles' => [], 'scriptItems' => new JavaScriptItems(), 'compilerInput' => [ 'uid' => $childData['databaseRow']['uid'], 'childChildUid' => $childChildUid, ], ]; $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult); return new JsonResponse($jsonArray); } /** * Show the details of a child record. */ public function detailsAction(ServerRequestInterface $request): ResponseInterface { $ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax']; $domObjectId = $ajaxArguments[0] ?? ''; $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId); $parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']); // Parse the DOM identifier, add the levels to the structure stack $inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all()); $inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig); // Parent, this table embeds the child table $inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1); $parentFieldName = $inlineParent['field']; // Set flag in config so that only the fields are rendered // @todo: Solve differently / rename / whatever $parentConfig['renderFieldsOnly'] = true; $parentData = [ 'processedTca' => [ 'columns' => [ $parentFieldName => [ 'config' => $parentConfig, ], ], ], 'uid' => $inlineParent['uid'], 'tableName' => $inlineParent['table'], 'inlineFirstPid' => $inlineFirstPid, // Hand over given original return url to compile stack. Needed if inline children compile links to // another view (eg. edit metadata in a nested inline situation like news with inline content element image), // so the back link is still the link from the original request. See issue #82525. This is additionally // given down in TcaInline data provider to compiled children data. 'returnUrl' => $parentConfig['originalReturnUrl'], ]; // Child, a record from this table should be rendered $child = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure); $childData = $this->compileChild($request, $parentData, $parentFieldName, (int)$child['uid'], $inlineStructure); $childData['inlineParentUid'] = (int)$inlineParent['uid']; $childData['renderType'] = 'inlineRecordContainer'; $childResult = $this->nodeFactory->create($childData)->render(); $jsonArray = [ 'data' => '', 'stylesheetFiles' => [], 'scriptItems' => new JavaScriptItems(), ]; $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult); return new JsonResponse($jsonArray); } /** * Adds localizations or synchronizes the locations of all child records. * Handle AJAX calls to localize all records of a parent, localize a single record or to synchronize with the original language parent. * * @param ServerRequestInterface $request the incoming request * @return ResponseInterface the filled response */ public function synchronizeLocalizeAction(ServerRequestInterface $request): ResponseInterface { $ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax']; $domObjectId = $ajaxArguments[0] ?? ''; $type = $ajaxArguments[1] ?? null; $parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']); // Parse the DOM identifier (string), add the levels to the structure stack (array), load the TCA config: $inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all()); $inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig); $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId); $jsonArray = [ 'data' => '', 'stylesheetFiles' => [], 'scriptItems' => new JavaScriptItems(), 'compilerInput' => [ 'localize' => [], ], ]; if ($type === 'localize' || $type === 'synchronize' || MathUtility::canBeInterpretedAsInteger($type)) { // Parent, this table embeds the child table $parent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1); $parentFieldName = $parent['field']; $processedTca = $GLOBALS['TCA'][$parent['table']]; $processedTca['columns'][$parentFieldName]['config'] = $parentConfig; $formDataCompilerInputForParent = [ 'request' => $request, 'vanillaUid' => (int)$parent['uid'], 'command' => 'edit', 'tableName' => $parent['table'], 'processedTca' => $processedTca, 'inlineFirstPid' => $inlineFirstPid, 'columnsToProcess' => [ $parentFieldName, ], // @todo: still needed? NO! 'inlineStructure' => $inlineStructure, // Do not compile existing children, we don't need them now 'inlineCompileExistingChildren' => false, ]; // Full TcaDatabaseRecord is required here to have the list of connected uids $oldItemList $parentData = $this->formDataCompiler->compile($formDataCompilerInputForParent, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config']; $parentLanguageField = $parentData['processedTca']['ctrl']['languageField']; $parentLanguage = $parentData['databaseRow'][$parentLanguageField]; $oldItemList = $parentData['databaseRow'][$parentFieldName]; // DataHandler cannot handle arrays as field value if (is_array($parentLanguage)) { $parentLanguage = implode(',', $parentLanguage); } $cmd = []; // Localize a single child element from default language of the parent element if (MathUtility::canBeInterpretedAsInteger($type)) { $cmd[$parent['table']][$parent['uid']]['inlineLocalizeSynchronize'] = [ 'field' => $parent['field'], 'language' => $parentLanguage, 'ids' => [$type], ]; } else { // Either localize or synchronize all child elements from default language of the parent element $cmd[$parent['table']][$parent['uid']]['inlineLocalizeSynchronize'] = [ 'field' => $parent['field'], 'language' => $parentLanguage, 'action' => $type, ]; } $tce = GeneralUtility::makeInstance(DataHandler::class); $tce->start([], $cmd); $tce->process_cmdmap(); $newItemList = $tce->registerDBList[$parent['table']][$parent['uid']][$parentFieldName]; $oldItems = $this->getInlineRelatedRecordsUidArray($oldItemList); $newItems = $this->getInlineRelatedRecordsUidArray($newItemList); // Render error messages from DataHandler $tce->printLogErrorMessages(); $messages = $this->flashMessageService->getMessageQueueByIdentifier()->getAllMessagesAndFlush(); if (!empty($messages)) { foreach ($messages as $message) { $jsonArray['messages'][] = [ 'title' => $message->getTitle(), 'message' => $message->getMessage(), 'severity' => $message->getSeverity(), ]; if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) { $jsonArray['hasErrors'] = true; } } } // Set the items that should be removed in the forms view: $removedItems = array_diff($oldItems, $newItems); $jsonArray['compilerInput']['delete'] = $removedItems; $localizedItems = array_diff($newItems, $oldItems); foreach ($localizedItems as $i => $childUid) { $childData = $this->compileChild($request, $parentData, $parentFieldName, (int)$childUid, $inlineStructure); $childData['inlineParentUid'] = (int)$parent['uid']; $childData['renderType'] = 'inlineRecordContainer'; $childResult = $this->nodeFactory->create($childData)->render(); $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult); // Get the name of the field used as foreign selector (if any): $foreignSelector = isset($parentConfig['foreign_selector']) && $parentConfig['foreign_selector'] ? $parentConfig['foreign_selector'] : false; $selectedValue = $foreignSelector ? $childData['databaseRow'][$foreignSelector] : null; if (is_array($selectedValue)) { $selectedValue = $selectedValue[0]; } $jsonArray['compilerInput']['localize'][$i] = [ 'uid' => $childUid, 'selectedValue' => $selectedValue, ]; // Remove possible virtual records in the form which showed that a child records could be localized: $transOrigPointerFieldName = $childData['processedTca']['ctrl']['transOrigPointerField']; if (isset($childData['databaseRow'][$transOrigPointerFieldName]) && $childData['databaseRow'][$transOrigPointerFieldName]) { $transOrigPointerFieldValue = $childData['databaseRow'][$transOrigPointerFieldName]; if (is_array($transOrigPointerFieldValue)) { $transOrigPointerFieldValue = $transOrigPointerFieldValue[0]; if (is_array($transOrigPointerFieldValue) && ($transOrigPointerFieldValue['uid'] ?? false)) { // With nested inline containers (eg. fal sys_file_reference), row[l10n_parent][0] is sometimes // a table / row combination again. See tx_styleguide_file file_5. If this happens we // pick the uid field from the array ... Basically, we need the uid of the 'default language' record, // since this is used in JS to locate and remove the 'shadowed' container. // @todo: Find out if this is really necessary that sometimes ['databaseRow']['l10n_parent'][0] // is resolved to a direct uid, and sometimes it's an array with items. Could this be harmonized? $transOrigPointerFieldValue = $transOrigPointerFieldValue['uid']; } } $jsonArray['compilerInput']['localize'][$i]['remove'] = $transOrigPointerFieldValue; } } } return new JsonResponse($jsonArray); } /** * Store status of inline children expand / collapse state in backend user uC. * * @param ServerRequestInterface $request the incoming request * @return ResponseInterface the filled response */ public function expandOrCollapseAction(ServerRequestInterface $request): ResponseInterface { $ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax']; [$domObjectId, $expand, $collapse] = $ajaxArguments; // Parse the DOM identifier (string), add the levels to the structure stack (array), don't load TCA config $inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all()); $backendUser = $this->getBackendUserAuthentication(); // The current table - for this table we should add/import records $currentTable = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure); $currentTable = $currentTable['table']; // The top parent table - this table embeds the current table $top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0); $topTable = $top['table']; $topUid = $top['uid']; $inlineView = $this->getInlineExpandCollapseStateArray(); // Only do some action if the top record and the current record were saved before if (MathUtility::canBeInterpretedAsInteger($topUid)) { $expandUids = GeneralUtility::trimExplode(',', $expand); $collapseUids = GeneralUtility::trimExplode(',', $collapse); // Set records to be expanded foreach ($expandUids as $uid) { $inlineView[$topTable][$topUid][$currentTable][] = $uid; } // Set records to be collapsed foreach ($collapseUids as $uid) { $inlineView[$topTable][$topUid][$currentTable] = $this->removeFromArray($uid, $inlineView[$topTable][$topUid][$currentTable]); } // Save states back to database if (is_array($inlineView[$topTable][$topUid][$currentTable])) { $inlineView[$topTable][$topUid][$currentTable] = array_unique($inlineView[$topTable][$topUid][$currentTable]); $backendUser->uc['inlineView'] = json_encode($inlineView); $backendUser->writeUC(); } } return new JsonResponse([]); } /** * Compile a full child record * * @param array $parentData Result array of parent * @param string $parentFieldName Name of parent field * @param int $childUid Uid of child to compile * @param array $inlineStructure Current inline structure * @return array Full result array * * @todo: This clones methods compileChild from TcaInline Provider. Find a better abstraction * @todo: to also encapsulate the more complex scenarios with combination child and friends. */ protected function compileChild(ServerRequestInterface $request, array $parentData, $parentFieldName, $childUid, array $inlineStructure) { $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config']; $inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0); $childTableName = $inlineStructure['unstable']['table'] ?? null; if (!$childTableName) { throw new \RuntimeException('No unstable inline structure found', 1733754245); } $formDataCompilerInput = [ 'request' => $request, 'command' => 'edit', 'tableName' => $childTableName, 'vanillaUid' => (int)$childUid, 'returnUrl' => $parentData['returnUrl'], 'isInlineChild' => true, 'inlineStructure' => $inlineStructure, 'inlineFirstPid' => $parentData['inlineFirstPid'], 'inlineParentConfig' => $parentConfig, 'isInlineAjaxOpeningContext' => true, // values of the current parent element // it is always a string either an id or new... 'inlineParentUid' => $parentData['databaseRow']['uid'] ?? $parentData['uid'], 'inlineParentTableName' => $parentData['tableName'], 'inlineParentFieldName' => $parentFieldName, // values of the top most parent element set on first level and not overridden on following levels 'inlineTopMostParentUid' => $inlineTopMostParent['uid'], 'inlineTopMostParentTableName' => $inlineTopMostParent['table'], 'inlineTopMostParentFieldName' => $inlineTopMostParent['field'], ]; // For foreign_selector with useCombination $mainChild is the mm record // and $combinationChild is the child-child. For "normal" relations, $mainChild // is just the normal child record and $combinationChild is empty. $mainChild = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) { // This kicks in if opening an existing mainChild that has a child-child set $mainChild['combinationChild'] = $this->compileChildChild($request, $mainChild, $parentConfig, $inlineStructure); } return $mainChild; } /** * With useCombination set, not only content of the intermediate table, but also * the connected child should be rendered in one go. Prepare this here. * * @param array $child Full data array of "mm" record * @param array $parentConfig TCA configuration of "parent" * @param array $inlineStructure Current inline structure * @return array Full data array of child */ protected function compileChildChild(ServerRequestInterface $request, array $child, array $parentConfig, array $inlineStructure) { // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0]; $formDataCompilerInput = [ 'request' => $request, 'command' => 'edit', 'tableName' => $this->getChildChildTableName($parentConfig['foreign_selector'] ?? '', $child), 'vanillaUid' => (int)$childChildUid, 'isInlineChild' => true, 'isInlineAjaxOpeningContext' => true, // @todo: this is the wrong inline structure, isn't it? Shouldn't contain it the part from child child, too? 'inlineStructure' => $inlineStructure, 'inlineFirstPid' => $child['inlineFirstPid'], // values of the top most parent element set on first level and not overridden on following levels 'inlineTopMostParentUid' => $child['inlineTopMostParentUid'], 'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'], 'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'], ]; return $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); } /** * Merge stuff from child array into json array. * This method is needed since ajax handling methods currently need to put scriptCalls before and after child code. * * @param array $jsonResult Given json result * @param array $childResult Given child result * @return array Merged json array */ protected function mergeChildResultIntoJsonResult(array $jsonResult, array $childResult) { /** @var JavaScriptItems $scriptItems */ $scriptItems = $jsonResult['scriptItems']; $jsonResult['data'] .= $childResult['html']; $jsonResult['stylesheetFiles'] = []; foreach ($childResult['stylesheetFiles'] as $stylesheetFile) { $jsonResult['stylesheetFiles'][] = $this->getRelativePathToStylesheetFile($stylesheetFile); } if (!empty($childResult['inlineData'])) { $jsonResult['inlineData'] = $childResult['inlineData']; } if (!empty($childResult['additionalInlineLanguageLabelFiles'])) { $labels = []; foreach ($childResult['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) { ArrayUtility::mergeRecursiveWithOverrule( $labels, $this->getLabelsFromLocalizationFile($additionalInlineLanguageLabelFile) ); } $scriptItems->addGlobalAssignment(['TYPO3' => ['lang' => $labels]]); } $this->addJavaScriptModulesToJavaScriptItems($childResult['javaScriptModules'] ?? [], $scriptItems); return $jsonResult; } /** * Gets an array with the uids of related records out of a list of items. * This list could contain more information than required. This methods just * extracts the uids. * * @param string $itemList The list of related child records * @return array An array with uids */ protected function getInlineRelatedRecordsUidArray($itemList) { $itemArray = GeneralUtility::trimExplode(',', $itemList, true); // Perform modification of the selected items array: foreach ($itemArray as &$value) { $parts = explode('|', $value, 2); $value = $parts[0]; } unset($value); return $itemArray; } /** * Get expand / collapse state of inline items * * @return array */ protected function getInlineExpandCollapseStateArray() { $backendUser = $this->getBackendUserAuthentication(); if (!$this->backendUserHasUcInlineView($backendUser)) { return []; } $inlineView = json_decode($backendUser->uc['inlineView'], true); if (!is_array($inlineView)) { $inlineView = []; } return $inlineView; } /** * Method to check whether the backend user has the property inline view for the current IRRE item. * In existing or old IRRE items the attribute may not exist, then the json_decode will fail. * * @return bool */ protected function backendUserHasUcInlineView(BackendUserAuthentication $backendUser) { return !empty($backendUser->uc['inlineView']); } /** * Remove an element from an array. * * @param mixed $needle The element to be removed. * @param array $haystack The array the element should be removed from. * @param bool $strict Search elements strictly. * @return array The array $haystack without the $needle */ protected function removeFromArray($needle, $haystack, $strict = false) { $pos = array_search($needle, $haystack, $strict); if ($pos !== false) { unset($haystack[$pos]); } return $haystack; } /** * Get inlineFirstPid from a given objectId string * * @param string $domObjectId The id attribute of an element * @return int|string|null Pid or null */ protected function getInlineFirstPidFromDomObjectId(string $domObjectId) { // Substitute FlexForm addition and make parsing a bit easier $domObjectId = str_replace('---', ':', $domObjectId); // The starting pattern of an object identifier (e.g. "data--) $pattern = '/^data-(.+?)-(.+)$/'; if (preg_match($pattern, $domObjectId, $match)) { return $match[1]; } return null; } /** * Validates the config that is transferred over the wire to provide the * correct TCA config for the parent table * * @throws \RuntimeException */ protected function extractSignedParentConfigFromRequest(string $contextString): array { if ($contextString === '') { throw new \RuntimeException('Empty context string given', 1489751361); } $context = json_decode($contextString, true); if (empty($context['config'])) { throw new \RuntimeException('Empty context config section given', 1489751362); } if (!hash_equals($this->hashService->hmac((string)$context['config'], 'InlineContext'), (string)$context['hmac'])) { throw new \RuntimeException('Hash does not validate', 1489751363); } return json_decode($context['config'], true); } /** * The child-child table name is set in the child TCA "the selector field" and is depending on * the TCA type (select or group) either the "foreign_table" or the (first) "allowed" table. */ protected function getChildChildTableName(string $foreignSelector, array $childConfiguration): string { $config = $childConfiguration['processedTca']['columns'][$foreignSelector]['config'] ?? []; $type = $config['type'] ?? ''; return match ($type) { 'select' => $config['foreign_table'] ?? '', 'group' => GeneralUtility::trimExplode(',', $config['allowed'] ?? '', true)[0] ?? '', default => '', }; } protected function getBackendUserAuthentication(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } }