> */ protected array $javaScriptModules = []; protected $defaultFieldWizard = [ 'localizationStateSelector' => [ 'renderType' => 'localizationStateSelector', ], ]; public function __construct( private readonly IconFactory $iconFactory, private readonly InlineStackProcessor $inlineStackProcessor, private readonly EventDispatcherInterface $eventDispatcher, private readonly OnlineMediaHelperRegistry $onlineMediaHelperRegistry, private readonly DefaultUploadFolderResolver $defaultUploadFolderResolver, private readonly HashService $hashService, ) {} /** * Entry method * * @return array As defined in initializeResultArray() of AbstractNode */ public function render(): array { $languageService = $this->getLanguageService(); $this->fileReferenceData = $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']; $isReadOnly = (bool)($config['readOnly'] ?? false); $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]; } // 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') $itemName = (string)$parameterArray['itemFormElName']; if ($itemName !== '') { $flexFormParts = $this->extractFlexFormParts($itemName); if ($flexFormParts !== null) { $newStructureItem['flexform'] = $flexFormParts; if ($flexFormParts !== [] && isset($this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier']) ) { // Transport the flexform DS identifier fields to the FormFilesAjaxController $config['dataStructureIdentifier'] = $this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier']; } } } $inlineStructure['stable'][] = $newStructureItem; // Hand over original returnUrl to FormFilesAjaxController. 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[][][] $formFieldName = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure); // e.g. data------- $formFieldIdentifier = $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->fileReferenceData['config'][$formFieldIdentifier] = [ 'table' => self::FILE_REFERENCE_TABLE, ]; $configJson = (string)json_encode($config); $this->fileReferenceData['config'][$formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE] = [ 'min' => $config['minitems'] ?? null, 'max' => $config['maxitems'] ?? null, 'sortable' => $config['appearance']['useSortable'] ?? false, 'top' => [ 'table' => $top['table'], 'uid' => $top['uid'], ], 'context' => [ 'config' => $configJson, 'hmac' => $this->hashService->hmac($configJson, 'FilesContext'), ], ]; $this->fileReferenceData['nested'][$formFieldIdentifier] = $this->data['tabAndInlineStack']; $resultArray['inlineData'] = $this->fileReferenceData; // @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++; } } if ($isReadOnly || $numberOfFullLocalizedChildren >= ($config['maxitems'] ?? 0)) { $config['inline']['showNewFileReferenceButton'] = false; $config['inline']['showCreateNewRelationButton'] = false; $config['inline']['showOnlineMediaAddButtonStyle'] = false; } $fieldInformationResult = $this->renderFieldInformation(); $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); $fieldWizardResult = $this->renderFieldWizard(); $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); $sortableRecordUids = $fileReferencesHtml = []; foreach ($inlineChildren as $options) { $options['inlineParentUid'] = $row['uid']; $options['inlineFirstPid'] = $this->data['inlineFirstPid']; $options['inlineParentConfig'] = $config; $options['inlineData'] = $this->fileReferenceData; $options['inlineStructure'] = $inlineStructure; $options['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray']; $options['renderType'] = 'fileReferenceContainer'; $fileReference = $this->nodeFactory->create($options)->render(); $fileReferencesHtml[] = $fileReference['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fileReference, 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']; } } $view = $this->backendViewFactory->create($this->data['request']); $view->assignMultiple([ 'formFieldIdentifier' => $formFieldIdentifier, 'formFieldName' => $formFieldName, 'webComponentAttributes' => GeneralUtility::implodeAttributes([ 'id' => $formFieldIdentifier, 'data-type' => 'file', 'data-object-group' => $formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE, 'data-form-field' => $formFieldName, '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), ], true), 'fieldInformation' => $fieldInformationResult['html'], 'fieldWizard' => $fieldWizardResult['html'], 'fileReferences' => [ 'id' => $formFieldIdentifier . '_records', 'title' => $languageService->sL(trim($parameterArray['fieldConf']['label'] ?? '')), 'records' => implode(LF, $fileReferencesHtml), ], 'sortableRecordUids' => implode(',', $sortableRecordUids), 'validationRules' => $this->getValidationDataAsJsonString([ 'type' => 'inline', 'minitems' => $config['minitems'] ?? null, 'maxitems' => $config['maxitems'] ?? null, ]), ]); if (!$isReadOnly && ($config['appearance']['showFileSelectors'] ?? true) !== false) { /** @var FileExtensionFilter $fileExtensionFilter */ $fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class); $fileExtensionFilter->setAllowedFileExtensions($config['allowed'] ?? null); $fileExtensionFilter->setDisallowedFileExtensions($config['disallowed'] ?? null); $view->assign('fileSelectors', $this->getFileSelectors($inlineStructure, $config, $fileExtensionFilter)); $filteredFileExtensions = $fileExtensionFilter->getFilteredFileExtensions(); // Do not display "allowed file extensions" if all extensions are allowed (indicated by ['*']) if (($filteredFileExtensions['allowedFileExtensions'] ?? null) === ['*']) { $filteredFileExtensions = []; } $view->assignMultiple($filteredFileExtensions); // Render the localization buttons if needed if ($numberOfNotYetLocalizedChildren) { $view->assignMultiple([ 'showAllLocalizationLink' => !empty($config['appearance']['showAllLocalizationLink']), 'showSynchronizationLink' => !empty($config['appearance']['showSynchronizationLink']), ]); } } $event = $this->eventDispatcher->dispatch( new CustomFileControlsEvent($resultArray, $table, $field, $row, $config, $formFieldIdentifier, $formFieldName) ); $resultArray = $event->getResultArray(); $controls = $event->getControls(); if ($controls !== []) { $view->assign('customControls', [ 'id' => $formFieldIdentifier . '_customControls', 'controls' => implode("\n", $controls), ]); } $resultArray['javaScriptModules'] = array_merge( $resultArray['javaScriptModules'], $this->javaScriptModules, [JavaScriptModuleInstruction::create('@typo3/backend/form-engine/container/inline-control-container.js')] ); $resultArray['html'] = $this->wrapWithFieldsetAndLegend($view->render('Form/FilesControlContainer')); return $resultArray; } /** * Generate buttons to select, reference and upload files. */ protected function getFileSelectors(array $inlineStructure, array $inlineConfiguration, FileExtensionFilter $fileExtensionFilter): array { $languageService = $this->getLanguageService(); $backendUser = $this->getBackendUserAuthentication(); $currentStructureDomObjectIdPrefix = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']); $objectPrefix = $currentStructureDomObjectIdPrefix . '-' . self::FILE_REFERENCE_TABLE; $controls = []; if ($inlineConfiguration['appearance']['elementBrowserEnabled'] ?? true) { if ($inlineConfiguration['appearance']['createNewRelationLinkTitle'] ?? false) { $buttonText = $inlineConfiguration['appearance']['createNewRelationLinkTitle']; } else { $buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.createNewRelation'; } $buttonText = $languageService->sL($buttonText); $attributes = [ 'type' => 'button', 'class' => 'btn btn-default t3js-element-browser', 'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null, 'title' => $buttonText, 'data-mode' => 'file', 'data-allowed-types' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []), 'data-disallowed-types' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []), 'data-irre-object-id' => $objectPrefix, ]; $controls[] = ' '; } $onlineMediaAllowed = []; foreach ($this->onlineMediaHelperRegistry->getSupportedFileExtensions() as $supportedFileExtension) { if ($fileExtensionFilter->isAllowed($supportedFileExtension)) { $onlineMediaAllowed[] = $supportedFileExtension; } } $showUpload = (bool)($inlineConfiguration['appearance']['fileUploadAllowed'] ?? true); $showByUrl = ($inlineConfiguration['appearance']['fileByUrlAllowed'] ?? true) && $onlineMediaAllowed !== []; if (($showUpload || $showByUrl) && $backendUser->getUserSettings()->isUploadFieldsInTopOfEBEnabled()) { $folder = $this->defaultUploadFolderResolver->resolve( $backendUser, $this->data['tableName'] === 'pages' ? $this->data['vanillaUid'] : ($this->data['parentPageRow']['uid'] ?? 0), $this->data['tableName'], $this->data['fieldName'] ); if ( $folder instanceof Folder && $folder->getStorage()->checkUserActionPermission('add', 'File') ) { if ($showUpload) { if ($inlineConfiguration['appearance']['uploadFilesLinkTitle'] ?? false) { $buttonText = $inlineConfiguration['appearance']['uploadFilesLinkTitle']; } else { $buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:file_upload.select-and-submit'; } $buttonText = $languageService->sL($buttonText); $attributes = [ 'type' => 'button', 'class' => 'btn btn-default t3js-drag-uploader', 'title' => $buttonText, 'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null, 'data-dropzone-target' => '#' . StringUtility::escapeCssSelector($currentStructureDomObjectIdPrefix), 'data-insert-dropzone-before' => '1', 'data-file-irre-object' => $objectPrefix, 'data-file-allowed' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []), 'data-file-disallowed' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []), 'data-target-folder' => $folder->getCombinedIdentifier(), 'data-max-file-size' => (string)(GeneralUtility::getMaxUploadFileSize() * 1024), ]; $controls[] = ' '; $this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/drag-uploader.js'); } if ($showByUrl) { if ($inlineConfiguration['appearance']['addMediaLinkTitle'] ?? false) { $buttonText = $inlineConfiguration['appearance']['addMediaLinkTitle']; } else { $buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.button'; } $buttonText = $languageService->sL($buttonText); $attributes = [ 'type' => 'button', 'class' => 'btn btn-default t3js-online-media-add-btn', 'title' => $buttonText, 'hidden' => !($inlineConfiguration['inline']['showOnlineMediaAddButtonStyle'] ?? true) ? 'hidden' : null, 'data-target-folder' => $folder->getCombinedIdentifier(), 'data-file-irre-object' => $objectPrefix, 'data-online-media-allowed' => implode(',', $onlineMediaAllowed), 'data-btn-submit' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'), 'data-placeholder' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'), 'data-online-media-allowed-help-text' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.allowEmbedSources'), ]; // @todo Should be implemented as web component $controls[] = ' '; $this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/online-media.js'); } } } $event = $this->eventDispatcher->dispatch( new CustomFileSelectorsEvent($controls, $this->javaScriptModules, $this->data['tableName'], $this->data['fieldName'], $this->data['databaseRow'], $inlineConfiguration, $fileExtensionFilter, $objectPrefix) ); $this->javaScriptModules = $event->getJavaScriptModules(); return $event->getSelectors(); } /** * Extracts FlexForm parts of a form element name like * data[table][uid][field][sDEF][lDEF][FlexForm][vDEF] */ protected function extractFlexFormParts(string $formElementName): ?array { $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']; } }