*/
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 = '
';
}
/**
* 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