*/ protected $javaScriptModules = []; /** * @var array Default wizards */ protected $defaultFieldWizard = [ 'localizationStateSelector' => [ 'renderType' => 'localizationStateSelector', ], ]; public function __construct( private readonly IconFactory $iconFactory, private readonly InlineStackProcessor $inlineStackProcessor, private readonly HashService $hashService, ) {} /** * Entry method * * @return array As defined in initializeResultArray() of AbstractNode */ public function render(): array { $languageService = $this->getLanguageService(); $this->inlineData = $this->data['inlineData']; $inlineStructure = $this->data['inlineStructure']; $table = $this->data['tableName']; $row = $this->data['databaseRow']; $field = $this->data['fieldName']; $parameterArray = $this->data['parameterArray']; $resultArray = $this->initializeResultArray(); $config = $parameterArray['fieldConf']['config']; $foreign_table = $config['foreign_table']; $isReadOnly = isset($config['readOnly']) && $config['readOnly']; $language = 0; if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) { $languageFieldName = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); $language = isset($row[$languageFieldName][0]) ? (int)$row[$languageFieldName][0] : (int)($row[$languageFieldName] ?? 0); } // Add the current inline job to the structure stack $newStructureItem = [ 'table' => $table, 'uid' => $row['uid'], 'field' => $field, 'config' => $config, ]; // Extract FlexForm parts (if any) from element name, e.g. array('vDEF', 'lDEF', 'FlexField', 'vDEF') if (!empty($parameterArray['itemFormElName'])) { $flexFormParts = $this->extractFlexFormParts($parameterArray['itemFormElName']); if ($flexFormParts !== null) { $newStructureItem['flexform'] = $flexFormParts; } } $inlineStructure['stable'][] = $newStructureItem; // Transport the flexform DS identifier fields to the FormInlineAjaxController if (!empty($newStructureItem['flexform']) && isset($this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier']) ) { $config['dataStructureIdentifier'] = $this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier']; } // Hand over original returnUrl to FormInlineAjaxController. Needed if opening for instance a // nested element in a new view to then go back to the original returnUrl and not the url of // the inline ajax controller $config['originalReturnUrl'] = $this->data['returnUrl']; // e.g. data[][][] $nameForm = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure); // e.g. data------- $nameObject = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']); $inlineChildren = $parameterArray['fieldConf']['children'] ?? []; $config['inline']['first'] = $config['inline']['last'] = false; if (is_array($inlineChildren) && $inlineChildren !== []) { $firstChild = array_first($inlineChildren); if (isset($firstChild['databaseRow']['uid'])) { $config['inline']['first'] = $firstChild['databaseRow']['uid']; } $lastChild = array_last($inlineChildren); if (isset($lastChild['databaseRow']['uid'])) { $config['inline']['last'] = $lastChild['databaseRow']['uid']; } } $top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0); $this->inlineData['config'][$nameObject] = [ 'table' => $foreign_table, ]; $configJson = (string)json_encode($config); $this->inlineData['config'][$nameObject . '-' . $foreign_table] = [ 'top' => [ 'table' => $top['table'], 'uid' => $top['uid'], ], 'context' => [ 'config' => $configJson, 'hmac' => $this->hashService->hmac($configJson, 'InlineContext'), ], ]; $this->inlineData['nested'][$nameObject] = $this->data['tabAndInlineStack']; $uniqueMax = 0; $uniqueIds = []; if ($config['foreign_unique'] ?? false) { // Add inlineData['unique'] with JS unique configuration // @todo: Improve validation and throw an exception if type is neither select nor group here $type = ($config['selectorOrUniqueConfiguration']['config']['type'] ?? '') === 'select' ? 'select' : 'groupdb'; foreach ($inlineChildren as $child) { // Determine used unique ids, skip not localized records if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) { $value = $child['databaseRow'][$config['foreign_unique']]; // We're assuming there is only one connected value here for both select and group if ($type === 'select') { // A select field is an array of uids. See TcaSelectItems data provider for details. // Pick first entry, ends up as eg. $value = 42. $value = $value['0'] ?? []; } else { // A group field is an array of arrays containing uid + table + title + row. // See TcaGroup data provider for details. // Pick the first one (always on 0), and use uid + table only. Exclude title + row // since the entire inlineData['unique'] array ends up in JavaScript in the end // and we don't need and want the title and the entire row data in the frontend. // Ends up as $value = [ 'uid' => '42', 'table' => 'tx_my_table' ] $value = [ 'uid' => $value[0]['uid'], 'table' => $value[0]['table'], ]; } // Note structure of $value is different in select vs. group: It's a uid for select, but an // array with uid + table for group. if (isset($child['databaseRow']['uid'])) { $uniqueIds[$child['databaseRow']['uid']] = $value; } } } $possibleRecords = $config['selectorOrUniquePossibleRecords'] ?? []; $possibleRecordsUidToTitle = []; foreach ($possibleRecords as $possibleRecord) { $possibleRecordsUidToTitle[$possibleRecord['value']] = $possibleRecord['label']; } $uniqueMax = ($config['appearance']['useCombination'] ?? false) || empty($possibleRecords) ? -1 : count($possibleRecords); $this->inlineData['unique'][$nameObject . '-' . $foreign_table] = [ 'max' => $uniqueMax, 'used' => $uniqueIds, 'type' => $type, 'table' => $foreign_table, 'elTable' => $config['selectorOrUniqueConfiguration']['foreignTable'] ?? '', 'field' => $config['foreign_unique'] ?? '', 'selector' => ($config['selectorOrUniqueConfiguration']['isSelector'] ?? false) ? $type : false, 'possible' => $possibleRecordsUidToTitle, ]; } $resultArray['inlineData'] = $this->inlineData; // @todo: It might be a good idea to have something like "isLocalizedRecord" or similar set by a data provider $uidOfDefaultRecord = 0; if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) { $originPointerField = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); $uidOfDefaultRecord = $row[$originPointerField] ?? 0; } $isLocalizedParent = $language > 0 && ($uidOfDefaultRecord[0] ?? $uidOfDefaultRecord) > 0 && MathUtility::canBeInterpretedAsInteger($row['uid']); $numberOfFullLocalizedChildren = 0; $numberOfNotYetLocalizedChildren = 0; foreach ($inlineChildren as $child) { if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) { $numberOfFullLocalizedChildren++; } if ($isLocalizedParent && $child['isInlineDefaultLanguageRecordInLocalizedParentContext']) { $numberOfNotYetLocalizedChildren++; } } // Render the localization buttons if needed $localizationButtons = ''; if ($numberOfNotYetLocalizedChildren) { // Add the "Localize all records" button before all child records: if (!empty($config['appearance']['showAllLocalizationLink'])) { $localizationButtons = ' ' . $this->getLevelInteractionButton('localize', $config); } // Add the "Synchronize with default language" button before all child records: if (!empty($config['appearance']['showSynchronizationLink'])) { $localizationButtons .= ' ' . $this->getLevelInteractionButton('synchronize', $config); } } // Hide the "Create new record" button if there are more than maxitems or the field is read-only if ($isReadOnly || $numberOfFullLocalizedChildren >= ($config['maxitems'] ?? 0) || ($uniqueMax > 0 && $numberOfFullLocalizedChildren >= $uniqueMax)) { $config['inline']['hideNewButton'] = true; } // Render the "new record" level button: $newRecordButton = ''; // For b/w compatibility, "showNewRecordLink" - in contrast to the other show* options - defaults to TRUE if (!isset($config['appearance']['showNewRecordLink']) || $config['appearance']['showNewRecordLink']) { $newRecordButton = $this->getLevelInteractionButton('newRecord', $config); } $fieldInformationResult = $this->renderFieldInformation(); $html = $fieldInformationResult['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); // Wrap all inline fields of a record with a custom element (container) $formGroupAttributes = [ 'id' => $nameObject, 'data-type' => 'record', 'data-object-group' => $nameObject . '-' . $foreign_table, 'data-form-field' => $nameForm, 'data-expand-single' => (bool)($config['appearance']['expandSingle'] ?? false) ? 'true' : 'false', 'data-sortable' => (bool)($config['appearance']['useSortable'] ?? false) ? 'true' : 'false', 'data-min' => (int)($config['minitems'] ?? 0), 'data-max' => (int)($config['maxitems'] ?? 0), ]; $html .= ''; // Add the level buttons before all child records: if (in_array($config['appearance']['levelLinksPosition'], ['both', 'top'], true)) { $html .= '
' . $newRecordButton . $localizationButtons . '
'; } // If it's required to select from possible child records (reusable children), add a selector box if (!$isReadOnly && ($config['foreign_selector'] ?? false) && ($config['appearance']['showPossibleRecordsSelector'] ?? true) !== false) { if (($config['selectorOrUniqueConfiguration']['config']['type'] ?? false) === 'select') { $selectorBox = $this->renderPossibleRecordsSelectorTypeSelect($inlineStructure, $config, $uniqueIds); } else { $selectorBox = $this->renderPossibleRecordsSelectorTypeGroupDB($inlineStructure, $config); } $html .= $selectorBox . $localizationButtons; } $title = $languageService->sL(trim($parameterArray['fieldConf']['label'] ?? '')); $html .= '
'; $sortableRecordUids = []; foreach ($inlineChildren as $options) { $options['inlineParentUid'] = $row['uid']; $options['inlineFirstPid'] = $this->data['inlineFirstPid']; // @todo: this can be removed if this container no longer sets additional info to $config $options['inlineParentConfig'] = $config; $options['inlineData'] = $this->inlineData; $options['inlineStructure'] = $inlineStructure; $options['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray']; $options['renderType'] = 'inlineRecordContainer'; $childResult = $this->nodeFactory->create($options)->render(); $html .= $childResult['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childResult, false); if (!$options['isInlineDefaultLanguageRecordInLocalizedParentContext'] && isset($options['databaseRow']['uid'])) { // Don't add record to list of "valid" uids if it is only the default // language record of a not yet localized child $sortableRecordUids[] = $options['databaseRow']['uid']; } } $html .= '
'; $fieldWizardResult = $this->renderFieldWizard(); $fieldWizardHtml = $fieldWizardResult['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); $html .= $fieldWizardHtml; // Add the level buttons after all child records: if (in_array($config['appearance']['levelLinksPosition'], ['both', 'bottom'], true)) { $html .= '
' . $newRecordButton . $localizationButtons . '
'; } if (is_array($config['customControls'] ?? false)) { $html .= '
'; foreach ($config['customControls'] as $customControlConfig) { if (!isset($customControlConfig['userFunc'])) { throw new \RuntimeException('Support for customControl without a userFunc key in TCA type inline is not supported.', 1548052629); } $parameters = [ 'table' => $table, 'field' => $field, 'row' => $row, 'nameObject' => $nameObject, 'nameForm' => $nameForm, 'config' => $config, 'customControlConfig' => $customControlConfig, // Warning: By reference should be used with care here and exists mostly to allow additional $resultArray['javaScriptModules'] 'resultArray' => &$resultArray, ]; $html .= GeneralUtility::callUserFunction($customControlConfig['userFunc'], $parameters, $this); } $html .= '
'; } $resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $this->javaScriptModules); $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create( '@typo3/backend/form-engine/container/inline-control-container.js' ); // Publish the uids of the child records in the given order to the browser $html .= ''; // Close the wrap for all inline fields (container) $html .= '
'; $resultArray['html'] = $this->wrapWithFieldsetAndLegend($html); return $resultArray; } /** * Creates the HTML code of a general button to be used on a level of inline children. * The possible keys for the parameter $type are 'newRecord', 'localize' and 'synchronize'. * * @param string $type The button type, values are 'newRecord', 'localize' and 'synchronize'. * @param array $conf TCA configuration of the parent(!) field * @return string The HTML code of the new button, wrapped in a div */ protected function getLevelInteractionButton(string $type, array $conf = []): string { $languageService = $this->getLanguageService(); $attributes = []; switch ($type) { case 'newRecord': $title = htmlspecialchars($languageService->sL('core.core:cm.createnew')); $icon = 'actions-plus'; $attributes['class'] = 'btn btn-default t3js-create-new-button'; $attributes['data-type'] = 'newRecord'; if (!empty($conf['inline']['hideNewButton'])) { $attributes['hidden'] = 'hidden'; } if (!empty($conf['appearance']['newRecordLinkAddTitle'])) { $title = htmlspecialchars(sprintf( $languageService->sL('core.core:cm.createnew.link'), $languageService->sL($this->data['tcaSchemata']->get($conf['foreign_table'])->getTitle()), )); } elseif (isset($conf['appearance']['newRecordLinkTitle']) && $conf['appearance']['newRecordLinkTitle'] !== '') { $title = htmlspecialchars($languageService->sL($conf['appearance']['newRecordLinkTitle'])); } break; case 'localize': $title = htmlspecialchars($languageService->sL('core.misc:localizeAllRecords')); $icon = 'actions-document-localize'; $attributes['class'] = 'btn btn-default t3js-synchronizelocalize-button'; $attributes['data-type'] = 'localize'; break; case 'synchronize': $title = htmlspecialchars($languageService->sL('core.misc:synchronizeWithOriginalLanguage')); $icon = 'actions-document-synchronize'; $attributes['class'] = 'btn btn-default t3js-synchronizelocalize-button'; $attributes['data-type'] = 'synchronize'; break; default: $title = ''; $icon = ''; } // Create the button: $icon = $icon ? $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() : ''; $attributes['title'] = $title; return ' '; } /** * Generate a button that opens an element browser in a new window. * For group/db there is no way to use a "selector" like a -box. * * @param array $inlineConfiguration TCA inline configuration of the parent(!) field * @return string A HTML button that opens an element browser in a new window */ protected function renderPossibleRecordsSelectorTypeGroupDB(array $inlineStructure, array $inlineConfiguration): string { $languageService = $this->getLanguageService(); $groupFieldConfiguration = $inlineConfiguration['selectorOrUniqueConfiguration']['config']; $objectPrefix = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']) . '-' . $inlineConfiguration['foreign_table']; $elementBrowserEnabled = (bool)($inlineConfiguration['appearance']['elementBrowserEnabled'] ?? true); // Remove any white-spaces from the allowed extension lists $allowed = GeneralUtility::trimExplode(',', (string)($groupFieldConfiguration['allowed'] ?? ''), true); $item = ''; if ($elementBrowserEnabled) { if (!empty($inlineConfiguration['appearance']['createNewRelationLinkTitle'])) { $createNewRelationText = htmlspecialchars($languageService->sL($inlineConfiguration['appearance']['createNewRelationLinkTitle'])); } else { $createNewRelationText = htmlspecialchars($languageService->sL('core.core:cm.createNewRelation')); } $item .= ' '; } $item = '
' . $item . '
'; if (!empty($allowed)) { $item .= '
' . htmlspecialchars($languageService->sL('core.core:cm.allowedRelations')) . '
    ' . implode(' ', array_map(static fn(string $item): string => '
  • ' . strtoupper($item) . '
  • ', $allowed)) . '
'; } return '
' . $item . '
'; } /** * Get a selector as used for the select type, to select from all available * records and to create a relation to the embedding record (e.g. like MM). * * @param array $config TCA inline configuration of the parent(!) field * @param array $uniqueIds The uids that have already been used and should be unique * @return string A HTML ' . implode('', $opt) . ' '; if ($size <= 1) { // Add a "Create new relation" button for adding new relations // This is necessary, if the size of the selector is "1" or if // there is only one record item in the select-box, that is selected by default // The selector-box creates a new relation on using an onChange event (see some line above) if (!empty($config['appearance']['createNewRelationLinkTitle'])) { $createNewRelationText = htmlspecialchars($this->getLanguageService()->sL($config['appearance']['createNewRelationLinkTitle'])); } else { $createNewRelationText = htmlspecialchars($this->getLanguageService()->sL('core.core:cm.createNewRelation')); } $item .= ' '; } // Wrap the selector and add a spacer to the bottom $item = '
' . $item . '
'; return $item; } /** * Extracts FlexForm parts of a form element name like * data[table][uid][field][sDEF][lDEF][FlexForm][vDEF] * Helper method used in inline * * @param string $formElementName The form element name * @return array|null */ protected function extractFlexFormParts($formElementName) { $flexFormParts = null; $matches = []; if (preg_match('#^data(?:\[[^]]+\]){3}(\[data\](?:\[[^]]+\]){4,})$#', $formElementName, $matches)) { $flexFormParts = GeneralUtility::trimExplode( '][', trim($matches[1], '[]') ); } return $flexFormParts; } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } }