addInlineFirstPid($result); foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) { if (!$this->isInlineField($fieldConfig)) { continue; } $result['processedTca']['columns'][$fieldName]['children'] = []; if (!$this->isUserAllowedToModify($fieldConfig)) { continue; } if ($result['inlineResolveExistingChildren']) { $result = $this->resolveRelatedRecords($result, $fieldName); $result = $this->addForeignSelectorAndUniquePossibleRecords($result, $fieldName); } } return $result; } /** * Is column of type "inline" * * @param array $fieldConfig * @return bool */ protected function isInlineField($fieldConfig) { return !empty($fieldConfig['config']['type']) && $fieldConfig['config']['type'] === 'inline'; } /** * Is user allowed to modify child elements * * @param array $fieldConfig * @return bool */ protected function isUserAllowedToModify($fieldConfig) { return $this->getBackendUser()->check('tables_modify', $fieldConfig['config']['foreign_table']); } /** * The "entry" pid for inline records. Nested inline records can potentially hang around on different * pid's, but the entry pid is needed for AJAX calls, so that they would know where the action takes place on the page structure. * * @param array $result Incoming result * @return array Modified result * @todo: Find out when and if this is different from 'effectivePid' */ protected function addInlineFirstPid(array $result) { if ($result['inlineFirstPid'] === null) { $table = $result['tableName']; $row = $result['databaseRow']; // If the parent is a page, use the uid(!) of the (new?) page as pid for the child records: if ($table === 'pages') { $liveVersionId = BackendUtility::getLiveVersionIdOfRecord('pages', $row['uid']); $pid = $liveVersionId ?? $row['uid']; } elseif (($row['pid'] ?? 0) < 0) { $prevRec = BackendUtility::getRecord($table, (int)abs($row['pid'])); $pid = $prevRec['pid']; } else { $pid = $row['pid'] ?? 0; } if (MathUtility::canBeInterpretedAsInteger($pid)) { $pageRecord = BackendUtility::getRecord('pages', (int)$pid); $pageSchema = $result['tcaSchemata']->get('pages'); if ($pageSchema->hasCapability(TcaSchemaCapability::Language) && ($pageRecord[$pageSchema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()] ?? 0) > 0) { $pid = (int)$pageRecord[$pageSchema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()]; } } elseif (!str_starts_with($pid, 'NEW')) { throw new \RuntimeException( 'inlineFirstPid should either be an integer or a "NEW..." string', 1521220142 ); } $result['inlineFirstPid'] = $pid; } return $result; } /** * Substitute the value in databaseRow of this inline field with an array * that contains the databaseRows of currently connected records and some meta information. * * @param array $result Result array * @param string $fieldName Current handle field name * @return array Modified item array */ protected function resolveRelatedRecordsOverlays(array $result, $fieldName) { $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table']; $connectedUidsOfLocalizedOverlay = []; if ($result['command'] === 'edit') { $connectedUidsOfLocalizedOverlay = $this->resolveConnectedRecordUids( $result['processedTca']['columns'][$fieldName]['config'], $result['tableName'], $result['databaseRow'], $result['databaseRow'][$fieldName] ); } $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfLocalizedOverlay); $connectedUidsOfLocalizedOverlay = $this->getSubstitutedWorkspacedUids($result, $connectedUidsOfLocalizedOverlay, $childTableName); if ($result['inlineCompileExistingChildren']) { $tableNameWithDefaultRecords = $result['tableName']; $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids( $result['processedTca']['columns'][$fieldName]['config'], $tableNameWithDefaultRecords, $result['defaultLanguageRow'], (string)($result['defaultLanguageRow'][$fieldName] ?? '') ); $connectedUidsOfDefaultLanguageRecord = $this->getSubstitutedWorkspacedUids($result, $connectedUidsOfDefaultLanguageRecord, $childTableName); $showPossibleLocalizationRecords = $result['processedTca']['columns'][$fieldName]['config']['appearance']['showPossibleLocalizationRecords'] ?? false; // Find which records are localized, which records are not localized and which are // localized but miss default language record $childTableSchema = $result['tcaSchemata']->has($childTableName) ? $result['tcaSchemata']->get($childTableName) : null; $fieldNameWithDefaultLanguageUid = $childTableSchema?->hasCapability(TcaSchemaCapability::Language) ? $childTableSchema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName() : ''; foreach ($connectedUidsOfLocalizedOverlay as $localizedUid) { try { $localizedRecord = $this->getRecordFromDatabase($childTableName, $localizedUid); } catch (DatabaseRecordException $e) { // The child could not be compiled, probably it was deleted and a dangling mm record exists $this->logger->warning( $e->getMessage(), [ 'table' => $childTableName, 'uid' => $localizedUid, 'exception' => $e, ] ); continue; } // Compile localized record $compiledChild = $this->compileChild($result, $fieldName, $localizedUid); $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild; // If that relation is configured to "showPossibleLocalizationRecords", this localized record // needs to be removed from the list of records that are pending to be localized. if ($fieldNameWithDefaultLanguageUid && $showPossibleLocalizationRecords) { $uidOfDefaultLanguageRecord = (int)$localizedRecord[$fieldNameWithDefaultLanguageUid]; if (in_array($uidOfDefaultLanguageRecord, $connectedUidsOfDefaultLanguageRecord, true)) { // This localized child has a default language record. Remove this record from list of default language records $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecord]); } $uidOfDefaultLanguageRecordWorkspaceVersionArray = $this->getSubstitutedWorkspacedUids($result, [$uidOfDefaultLanguageRecord], $childTableName); if (!empty($uidOfDefaultLanguageRecordWorkspaceVersionArray) && in_array($uidOfDefaultLanguageRecordWorkspaceVersionArray[0], $connectedUidsOfDefaultLanguageRecord, true) ) { // In some situations 'l10n_parent' of a localized workspace record points to the live version // of the default language record, and not to the workspace version, even though it exists. // Filter those as well, since the interface would otherwise show the item as "can be localized/synchronized". $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecordWorkspaceVersionArray[0]]); } } } if ($showPossibleLocalizationRecords) { foreach ($connectedUidsOfDefaultLanguageRecord as $defaultLanguageUid) { // If there are still uids in $connectedUidsOfDefaultLanguageRecord, these are records that // exist in default language, but are not localized yet. Compile and mark those try { $compiledChild = $this->compileChild($result, $fieldName, $defaultLanguageUid, true); } catch (DatabaseRecordException $e) { // The child could not be compiled, probably it was deleted and a dangling mm record exists $this->logger->warning( $e->getMessage(), [ 'table' => $childTableName, 'uid' => $defaultLanguageUid, 'exception' => $e, ] ); continue; } $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild; } } } return $result; } /** * Substitute the value in databaseRow of this inline field with an array * that contains the databaseRows of currently connected records and some meta information. * * @param array $result Result array * @param string $fieldName Current handle field name * @return array Modified item array */ protected function resolveRelatedRecords(array $result, $fieldName) { if ($result['defaultLanguageRow'] !== null) { return $this->resolveRelatedRecordsOverlays($result, $fieldName); } $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table']; $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids( $result['processedTca']['columns'][$fieldName]['config'], $result['tableName'], $result['databaseRow'], $result['databaseRow'][$fieldName] ); $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfDefaultLanguageRecord); $connectedUidsOfDefaultLanguageRecord = $this->getSubstitutedWorkspacedUids($result, $connectedUidsOfDefaultLanguageRecord, $childTableName); if ($result['inlineCompileExistingChildren']) { foreach ($connectedUidsOfDefaultLanguageRecord as $uid) { try { $compiledChild = $this->compileChild($result, $fieldName, $uid); $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild; } catch (DatabaseRecordException $e) { // Nothing to do here, missing child is just not being rendered. } } } return $result; } /** * If there is a foreign_selector or foreign_unique configuration, fetch * the list of possible records that can be connected and attach the to the * inline configuration. * * @param array $result Result array * @param string $fieldName Current handle field name * @return array Modified item array */ protected function addForeignSelectorAndUniquePossibleRecords(array $result, $fieldName) { if (!is_array($result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'] ?? null)) { return $result; } $selectorOrUniqueConfiguration = $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration']; $foreignFieldName = $selectorOrUniqueConfiguration['fieldName']; $selectorOrUniquePossibleRecords = []; if ($selectorOrUniqueConfiguration['config']['type'] === 'select') { // Compile child table data for this field only $selectDataInput = [ 'request' => $result['request'], 'tableName' => $result['processedTca']['columns'][$fieldName]['config']['foreign_table'], 'command' => 'new', // Since there is no existing record that may have a type, it does not make sense to // do extra handling of pageTsConfig merged here. Just provide "parent" pageTS as is 'pageTsConfig' => $result['pageTsConfig'], 'userTsConfig' => $result['userTsConfig'], 'databaseRow' => $result['databaseRow'], 'processedTca' => [ 'ctrl' => [], 'columns' => [ $foreignFieldName => [ 'config' => $selectorOrUniqueConfiguration['config'], ], ], ], 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'], 'site' => $result['site'], // pass through schemata as they are immutable once they are set 'tcaSchemata' => $result['tcaSchemata'], // pass through fullTca as it is immutable once set 'fullTca' => $result['fullTca'], ]; $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class); $formDataGroup->setProviderList([TcaSelectItems::class]); $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class); $compilerResult = $formDataCompiler->compile($selectDataInput, $formDataGroup); $selectorOrUniquePossibleRecords = $compilerResult['processedTca']['columns'][$foreignFieldName]['config']['items']; } $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniquePossibleRecords'] = $selectorOrUniquePossibleRecords; return $result; } /** * Compile a full child record * * @param array $result Result array of parent * @param string $parentFieldName Name of parent field * @param int $childUid Uid of child to compile * @return array Full result array */ protected function compileChild(array $result, $parentFieldName, $childUid, $isInlineDefaultLanguageRecordInLocalizedParentContext = false) { $parentConfig = $result['processedTca']['columns'][$parentFieldName]['config']; $childTableName = $parentConfig['foreign_table']; $inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($result['inlineStructure'], 0) ?: []; $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class); $formDataCompilerInput = [ 'request' => $result['request'], 'command' => 'edit', 'tableName' => $childTableName, 'vanillaUid' => (int)$childUid, // Give incoming returnUrl down to children so they generate a returnUrl back to // the originally opening record, also see "originalReturnUrl" in inline container // and FormInlineAjaxController 'returnUrl' => $result['returnUrl'], 'isInlineChild' => true, 'isInlineDefaultLanguageRecordInLocalizedParentContext' => $isInlineDefaultLanguageRecordInLocalizedParentContext, 'inlineStructure' => $result['inlineStructure'], 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'], 'inlineFirstPid' => $result['inlineFirstPid'], 'inlineParentConfig' => $parentConfig, // values of the current parent element // it is always a string either an id or new... 'inlineParentUid' => $result['databaseRow']['uid'], 'inlineParentTableName' => $result['tableName'], 'inlineParentFieldName' => $parentFieldName, // values of the top most parent element set on first level and not overridden on following levels 'inlineTopMostParentUid' => $result['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'] ?? '', 'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'] ?? '', 'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'] ?? '', // pass through schemata as they are immutable once they are set 'tcaSchemata' => $result['tcaSchemata'], // pass through fullTca as it is immutable once set 'fullTca' => $result['fullTca'], ]; // For foreign_selector with useCombination $mainChild is the mm record // and $combinationChild is the child-child. For 1:n "normal" relations, // $mainChild is just the normal child record and $combinationChild is empty. $mainChild = $formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) { try { $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig); } catch (DatabaseRecordException $e) { // The child could not be compiled, probably it was deleted and a dangling mm record // exists. This is a data inconsistency, we catch this exception and create a flash message $message = vsprintf( $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:formEngine.databaseRecordErrorInlineChildChild'), [$e->getTableName(), $e->getUid(), $childTableName, (int)$childUid] ); $flashMessage = new FlashMessage( $message, '', ContextualFeedbackSeverity::ERROR ); $this->flashMessageService->getMessageQueueByIdentifier()->enqueue($flashMessage); } } 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" * @return array Full data array of child */ protected function compileChildChild(array $child, array $parentConfig) { // foreign_selector on intermediate is type=select (resolved to array of uids) // or type=group (resolved to array of associative arrays with 'table', 'uid', etc.) $childChildValue = $child['databaseRow'][$parentConfig['foreign_selector']][0]; $childChildUid = is_array($childChildValue) ? $childChildValue['uid'] : $childChildValue; $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class); $formDataCompilerInput = [ 'request' => $child['request'], 'command' => 'edit', 'tableName' => $this->getChildChildTableName($parentConfig['foreign_selector'] ?? '', $child), 'vanillaUid' => (int)$childChildUid, 'isInlineChild' => true, 'isInlineChildExpanded' => $child['isInlineChildExpanded'], // @todo: this is the wrong inline structure, isn't it? Shouldn't it contain the part from child child, too? 'inlineStructure' => $child['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'], // pass through schemata as they are immutable once they are set 'tcaSchemata' => $child['tcaSchemata'], // pass through fullTca as it is immutable once set 'fullTca' => $child['fullTca'], ]; $childChild = $formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class)); return $childChild; } /** * Substitute given list of uids in child table with workspace uid if needed * * @param array $result Result array * @param array $connectedUids List of connected uids * @param string $childTableName Name of child table * @return int[] List of substituted uids */ protected function getSubstitutedWorkspacedUids(array $result, array $connectedUids, string $childTableName): array { $backendUser = $this->getBackendUser(); $newConnectedUids = []; foreach ($connectedUids as $uid) { // Fetch workspace version of a record (if any): if ($backendUser->workspace !== 0 && $result['tcaSchemata']->has($childTableName) && $result['tcaSchemata']->get($childTableName)->hasCapability(TcaSchemaCapability::Workspace) ) { $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $childTableName, $uid, 'uid,t3ver_state'); if (!empty($workspaceVersion)) { $versionState = VersionState::tryFrom($workspaceVersion['t3ver_state'] ?? 0); if ($versionState === VersionState::DELETE_PLACEHOLDER) { continue; } $uid = $workspaceVersion['uid']; } } $newConnectedUids[] = (int)$uid; } return $newConnectedUids; } /** * Use RelationHandler to resolve connected uids. * * @param array $parentConfig TCA config section of parent * @param string $parentTableName Name of parent table * @param array $parentRecord Full parent record * @param string $parentFieldValue Database value of parent record of this inline field * @return array Array with connected uids * @todo: Cover with unit tests */ protected function resolveConnectedRecordUids(array $parentConfig, string $parentTableName, array $parentRecord, string $parentFieldValue): array { $directlyConnectedIds = GeneralUtility::trimExplode(',', $parentFieldValue); $parentUid = (int)$parentRecord['uid']; // Relations to non-MM tables point to the LIVE version, so we need to ensure // we use the live version that is sent to RelationHandler if (empty($parentConfig['MM']) && (int)($parentRecord['t3ver_oid'] ?? 0) > 0) { $parentUid = (int)$parentRecord['t3ver_oid']; } $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); $relationHandler->start($parentFieldValue, $parentConfig['foreign_table'] ?? '', $parentConfig['MM'] ?? '', $parentUid, $parentTableName, $parentConfig); $foreignRecordUids = $relationHandler->getValueArray(); $resolvedForeignRecordUids = []; foreach ($foreignRecordUids as $aForeignRecordUid) { if ($parentConfig['MM'] ?? $parentConfig['foreign_field'] ?? false) { $resolvedForeignRecordUids[] = (int)$aForeignRecordUid; } else { foreach ($directlyConnectedIds as $id) { if ((int)$aForeignRecordUid === (int)$id) { $resolvedForeignRecordUids[] = (int)$aForeignRecordUid; } } } } return $resolvedForeignRecordUids; } /** * 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 getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } }