iconFactory = GeneralUtility::makeInstance(IconFactory::class); $this->inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class); $this->initHookObjects(); } /** * Entry method * * @return array As defined in initializeResultArray() of AbstractNode * @throws AccessDeniedContentEditException */ public function render() { $data = $this->data; $this->inlineData = $data['inlineData']; $inlineStackProcessor = $this->inlineStackProcessor; $inlineStackProcessor->initializeByGivenStructure($data['inlineStructure']); $record = $data['databaseRow']; $inlineConfig = $data['inlineParentConfig']; $foreignTable = $inlineConfig['foreign_table']; $resultArray = $this->initializeResultArray(); // Send a mapping information to the browser via JSON: // e.g. data[][][] => data------- $formPrefix = $inlineStackProcessor->getCurrentStructureFormPrefix(); $domObjectId = $inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($data['inlineFirstPid']); $this->inlineData['map'][$formPrefix] = $domObjectId; $resultArray['inlineData'] = $this->inlineData; // If there is a selector field, normalize it: if (!empty($inlineConfig['foreign_selector'])) { $foreign_selector = $inlineConfig['foreign_selector']; $valueToNormalize = $record[$foreign_selector]; if (is_array($record[$foreign_selector])) { // @todo: this can be kicked again if always prepared rows are handled here $valueToNormalize = implode(',', $record[$foreign_selector]); } $record[$foreign_selector] = $this->normalizeUid($valueToNormalize); } // Get the current naming scheme for DOM name/id attributes: $appendFormFieldNames = '[' . $foreignTable . '][' . $record['uid'] . ']'; $objectId = $domObjectId . '-' . $foreignTable . '-' . $record['uid']; $classes = []; $html = ''; $combinationHtml = ''; $isNewRecord = $data['command'] === 'new'; $hiddenField = ''; if (isset($data['processedTca']['ctrl']['enablecolumns']['disabled'])) { $hiddenField = $data['processedTca']['ctrl']['enablecolumns']['disabled']; } if (!$data['isInlineDefaultLanguageRecordInLocalizedParentContext']) { if ($isNewRecord || $data['isInlineChildExpanded']) { // Render full content ONLY IF this is an AJAX request, a new record, or the record is not collapsed $combinationHtml = ''; if (isset($data['combinationChild'])) { $combinationChild = $this->renderCombinationChild($data, $appendFormFieldNames); $combinationHtml = $combinationChild['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $combinationChild, false); } $childArray = $this->renderChild($data); $html = $childArray['html']; $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray, false); } else { // This class is the marker for the JS-function to check if the full content has already been loaded $classes[] = 't3js-not-loaded'; } if ($isNewRecord) { // Add pid of record as hidden field $html .= ''; // Tell DataHandler this record is expanded $ucFieldName = 'uc[inlineView]' . '[' . $data['inlineTopMostParentTableName'] . ']' . '[' . $data['inlineTopMostParentUid'] . ']' . $appendFormFieldNames; $html .= ''; } else { // Set additional field for processing for saving $html .= ''; if (!$data['isInlineChildExpanded'] && !empty($hiddenField)) { $checked = !empty($record[$hiddenField]) ? ' checked="checked"' : ''; $html .= ''; $html .= ''; } } // If this record should be shown collapsed $classes[] = $data['isInlineChildExpanded'] ? 'panel-visible' : 'panel-collapsed'; } $hiddenFieldHtml = implode(LF, $resultArray['additionalHiddenFields'] ?? []); if ($inlineConfig['renderFieldsOnly']) { // Render "body" part only $html .= $combinationHtml; } else { // Render header row and content (if expanded) if ($data['isInlineDefaultLanguageRecordInLocalizedParentContext']) { $classes[] = 't3-form-field-container-inline-placeHolder'; } if (!empty($hiddenField) && isset($record[$hiddenField]) && (int)$record[$hiddenField]) { $classes[] = 't3-form-field-container-inline-hidden'; } if ($isNewRecord) { $classes[] = 'inlineIsNewRecord'; } $originalUniqueValue = ''; if (isset($data['inlineData']['unique'][$domObjectId . '-' . $foreignTable]['used'][$record['uid']])) { $uniqueValueValues = $data['inlineData']['unique'][$domObjectId . '-' . $foreignTable]['used'][$record['uid']]; // in case of site_language we don't have the full form engine options, so fallbacks need to be taken into account $originalUniqueValue = ($uniqueValueValues['table'] ?? $foreignTable) . '_' . ($uniqueValueValues['uid'] ?? $uniqueValueValues); } // The hashed object id needs a non-numeric prefix, the value is used as ID selector in JavaScript $hashedObjectId = 'hash-' . md5($objectId); $containerAttributes = [ 'id' => $objectId . '_div', 'class' => 'form-irre-object panel panel-default panel-condensed ' . trim(implode(' ', $classes)), 'data-object-uid' => $record['uid'], 'data-object-id' => $objectId, 'data-object-id-hash' => $hashedObjectId, 'data-field-name' => $appendFormFieldNames, 'data-topmost-parent-table' => $data['inlineTopMostParentTableName'], 'data-topmost-parent-uid' => $data['inlineTopMostParentUid'], 'data-table-unique-original-value' => $originalUniqueValue, ]; $html = '
' . $this->renderForeignRecordHeader($data) . '
' . $html . $hiddenFieldHtml . $combinationHtml . '
'; } $resultArray['html'] = $html; return $resultArray; } /** * Render inner child * * @param array $data * @return array Result array */ protected function renderChild(array $data) { $domObjectId = $this->inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($data['inlineFirstPid']); $data['tabAndInlineStack'][] = [ 'inline', $domObjectId . '-' . $data['tableName'] . '-' . $data['databaseRow']['uid'], ]; // @todo: ugly construct ... $data['inlineData'] = $this->inlineData; $data['renderType'] = 'fullRecordContainer'; return $this->nodeFactory->create($data)->render(); } /** * Render child child * * Render a table with FormEngine, that occurs on an intermediate table but should be editable directly, * so two tables are combined (the intermediate table with attributes and the sub-embedded table). * -> This is a direct embedding over two levels! * * @param array $data * @param string $appendFormFieldNames The [][] of the parent record (the intermediate table) * @return array Result array */ protected function renderCombinationChild(array $data, $appendFormFieldNames) { $childData = $data['combinationChild']; $parentConfig = $data['inlineParentConfig']; // If field is set to readOnly, set all fields of the relation to readOnly as well if (isset($parentConfig['readOnly']) && $parentConfig['readOnly']) { foreach ($childData['processedTca']['columns'] as $columnName => $columnConfiguration) { $childData['processedTca']['columns'][$columnName]['config']['readOnly'] = true; } } $resultArray = $this->initializeResultArray(); // Display Warning FlashMessage if it is not suppressed if (!isset($parentConfig['appearance']['suppressCombinationWarning']) || empty($parentConfig['appearance']['suppressCombinationWarning'])) { $combinationWarningMessage = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:warning.inline_use_combination'; if (!empty($parentConfig['appearance']['overwriteCombinationWarningMessage'])) { $combinationWarningMessage = $parentConfig['appearance']['overwriteCombinationWarningMessage']; } $message = $this->getLanguageService()->sL($combinationWarningMessage); $markup = []; // @TODO: This is not a FlashMessage! The markup must be changed and special CSS // @TODO: should be created, in order to prevent confusion. $markup[] = '
'; $markup[] = '
'; $markup[] = '
'; $markup[] = ' '; $markup[] = ' '; $markup[] = ' '; $markup[] = ' '; $markup[] = '
'; $markup[] = '
'; $markup[] = '
' . htmlspecialchars($message) . '
'; $markup[] = '
'; $markup[] = '
'; $markup[] = '
'; $resultArray['html'] = implode(LF, $markup); } $childArray = $this->renderChild($childData); $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childArray); // If this is a new record, add a pid value to store this record and the pointer value for the intermediate table if ($childData['command'] === 'new') { $comboFormFieldName = 'data[' . $childData['tableName'] . '][' . $childData['databaseRow']['uid'] . '][pid]'; $resultArray['html'] .= ''; } // If the foreign_selector field is also responsible for uniqueness, tell the browser the uid of the "other" side of the relation if ($childData['command'] === 'new' || $parentConfig['foreign_unique'] === $parentConfig['foreign_selector']) { $parentFormFieldName = 'data' . $appendFormFieldNames . '[' . $parentConfig['foreign_selector'] . ']'; $resultArray['html'] .= ''; } return $resultArray; } /** * Renders the HTML header for a foreign record, such as the title, toggle-function, drag'n'drop, etc. * Later on the command-icons are inserted here. * * @param array $data Current data * @return string The HTML code of the header */ protected function renderForeignRecordHeader(array $data) { $languageService = $this->getLanguageService(); $inlineConfig = $data['inlineParentConfig']; $foreignTable = $inlineConfig['foreign_table']; $rec = $data['databaseRow']; // Init: $domObjectId = $this->inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($data['inlineFirstPid']); $objectId = $domObjectId . '-' . $foreignTable . '-' . $rec['uid']; $recordTitle = $data['recordTitle']; if (!empty($recordTitle)) { // The user function may return HTML, therefore we can't escape it if (empty($data['processedTca']['ctrl']['formattedLabel_userFunc'])) { $recordTitle = BackendUtility::getRecordTitlePrep($recordTitle); } } else { $recordTitle = '[' . htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title')) . ']'; } $altText = BackendUtility::getRecordIconAltText($rec, $foreignTable); $iconImg = '' . $this->iconFactory->getIconForRecord($foreignTable, $rec, Icon::SIZE_SMALL)->render() . ''; $label = '' . $recordTitle . ''; $ctrl = $this->renderForeignRecordHeaderControl($data); $thumbnail = false; // Renders a thumbnail for the header if ($GLOBALS['TYPO3_CONF_VARS']['GFX']['thumbnails'] && !empty($inlineConfig['appearance']['headerThumbnail']['field'])) { $fieldValue = $rec[$inlineConfig['appearance']['headerThumbnail']['field']]; $fileUid = $fieldValue[0]['uid']; if (!empty($fileUid)) { try { $fileObject = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid); } catch (\InvalidArgumentException $e) { $fileObject = null; } if ($fileObject && $fileObject->isMissing()) { $thumbnail .= '' . htmlspecialchars(static::getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:warning.file_missing')) . ' ' . htmlspecialchars($fileObject->getName()) . '
'; } elseif ($fileObject) { $imageSetup = $inlineConfig['appearance']['headerThumbnail']; unset($imageSetup['field']); $cropVariantCollection = CropVariantCollection::create($rec['crop'] ?? ''); if (!$cropVariantCollection->getCropArea()->isEmpty()) { $imageSetup['crop'] = $cropVariantCollection->getCropArea()->makeAbsoluteBasedOnFile($fileObject); } $imageSetup = array_merge(['maxWidth' => '145', 'maxHeight' => '45'], $imageSetup); if ($GLOBALS['TYPO3_CONF_VARS']['GFX']['thumbnails'] && $fileObject->isImage()) { $processedImage = $fileObject->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $imageSetup); // Only use a thumbnail if the processing process was successful by checking if image width is set if ($processedImage->getProperty('width')) { $imageUrl = $processedImage->getPublicUrl(true); $thumbnail = ''; } } else { $thumbnail = ''; } } } } if (!empty($inlineConfig['appearance']['headerThumbnail']['field']) && $thumbnail) { $mediaContainer = '
' . $thumbnail . '
'; } else { $mediaContainer = '
' . $iconImg . '
'; } $header = $mediaContainer . '
' . $label . '
' . $ctrl . '
'; return $header; } /** * Render the control-icons for a record header (create new, sorting, delete, disable/enable). * Most of the parts are copy&paste from TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList and * modified for the JavaScript calls here * * @param array $data Current data * @return string The HTML code with the control-icons */ protected function renderForeignRecordHeaderControl(array $data) { $rec = $data['databaseRow']; $inlineConfig = $data['inlineParentConfig']; $foreignTable = $inlineConfig['foreign_table']; $languageService = $this->getLanguageService(); $backendUser = $this->getBackendUserAuthentication(); // Initialize: $cells = [ 'edit' => '', 'hide' => '', 'delete' => '', 'info' => '', 'new' => '', 'sort.up' => '', 'sort.down' => '', 'dragdrop' => '', 'localize' => '', 'locked' => '', ]; $isNewItem = strpos($rec['uid'], 'NEW') === 0; $isParentReadOnly = isset($inlineConfig['readOnly']) && $inlineConfig['readOnly']; $isParentExisting = MathUtility::canBeInterpretedAsInteger($data['inlineParentUid']); $tcaTableCtrl = $GLOBALS['TCA'][$foreignTable]['ctrl']; $tcaTableCols = $GLOBALS['TCA'][$foreignTable]['columns']; $isPagesTable = $foreignTable === 'pages'; $isSysFileReferenceTable = $foreignTable === 'sys_file_reference'; $enableManualSorting = $tcaTableCtrl['sortby'] || $inlineConfig['MM'] || !$data['isOnSymmetricSide'] && $inlineConfig['foreign_sortby'] || $data['isOnSymmetricSide'] && $inlineConfig['symmetric_sortby']; $calcPerms = $backendUser->calcPerms(BackendUtility::readPageAccess($rec['pid'], $backendUser->getPagePermsClause(Permission::PAGE_SHOW))); // If the listed table is 'pages' we have to request the permission settings for each page: $localCalcPerms = false; if ($isPagesTable) { $localCalcPerms = $backendUser->calcPerms(BackendUtility::getRecord('pages', $rec['uid'])); } // This expresses the edit permissions for this particular element: $permsEdit = $isPagesTable && $localCalcPerms & Permission::PAGE_EDIT || !$isPagesTable && $calcPerms & Permission::CONTENT_EDIT; // Controls: Defines which controls should be shown $enabledControls = $inlineConfig['appearance']['enabledControls']; // Hook: Can disable/enable single controls for specific child records: foreach ($this->hookObjects as $hookObj) { /** @var InlineElementHookInterface $hookObj */ $hookObj->renderForeignRecordHeaderControl_preProcess($data['inlineParentUid'], $foreignTable, $rec, $inlineConfig, $data['isInlineDefaultLanguageRecordInLocalizedParentContext'], $enabledControls); } if ($data['isInlineDefaultLanguageRecordInLocalizedParentContext']) { $cells['localize'] = ' ' . $this->iconFactory->getIcon('actions-edit-localize-status-low', Icon::SIZE_SMALL)->render() . ' '; } // "Info": (All records) // @todo: hardcoded sys_file! if ($rec['table_local'] === 'sys_file') { $uid = $rec['uid_local'][0]['uid']; $table = '_FILE'; } else { $uid = $rec['uid']; $table = $foreignTable; } if ($enabledControls['info']) { if ($isNewItem) { $cells['info'] = '' . $this->iconFactory->getIcon('empty-empty', Icon::SIZE_SMALL)->render() . ''; } else { $cells['info'] = ' ' . $this->iconFactory->getIcon('actions-document-info', Icon::SIZE_SMALL)->render() . ' '; } } // If the table is NOT a read-only table, then show these links: if (!$isParentReadOnly && !$tcaTableCtrl['readOnly'] && !$data['isInlineDefaultLanguageRecordInLocalizedParentContext']) { // "New record after" link (ONLY if the records in the table are sorted by a "sortby"-row or if default values can depend on previous record): if ($enabledControls['new'] && ($enableManualSorting || $tcaTableCtrl['useColumnsForDefaultValues'])) { if (!$isPagesTable && $calcPerms & Permission::CONTENT_EDIT || $isPagesTable && $calcPerms & Permission::PAGE_NEW) { $style = ''; if ($inlineConfig['inline']['inlineNewButtonStyle']) { $style = ' style="' . $inlineConfig['inline']['inlineNewButtonStyle'] . '"'; } $cells['new'] = ' ' . $this->iconFactory->getIcon('actions-' . ($isPagesTable ? 'page-new' : 'add'), Icon::SIZE_SMALL)->render() . ' '; } } // "Up/Down" links if ($enabledControls['sort'] && $permsEdit && $enableManualSorting) { // Up $icon = 'actions-move-up'; $class = ''; if ($inlineConfig['inline']['first'] == $rec['uid']) { $class = ' disabled'; $icon = 'empty-empty'; } $cells['sort.up'] = ' ' . $this->iconFactory->getIcon($icon, Icon::SIZE_SMALL)->render() . ' '; // Down $icon = 'actions-move-down'; $class = ''; if ($inlineConfig['inline']['last'] == $rec['uid']) { $class = ' disabled'; $icon = 'empty-empty'; } $cells['sort.down'] = ' ' . $this->iconFactory->getIcon($icon, Icon::SIZE_SMALL)->render() . ' '; } // "Edit" link: if (($rec['table_local'] === 'sys_file') && !$isNewItem && $backendUser->check('tables_modify', 'sys_file_metadata')) { $sys_language_uid = 0; if (!empty($rec['sys_language_uid'])) { $sys_language_uid = $rec['sys_language_uid'][0]; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_metadata'); $recordInDatabase = $queryBuilder ->select('uid') ->from('sys_file_metadata') ->where( $queryBuilder->expr()->eq( 'file', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT) ), $queryBuilder->expr()->eq( 'sys_language_uid', $queryBuilder->createNamedParameter($sys_language_uid, \PDO::PARAM_INT) ) ) ->setMaxResults(1) ->execute() ->fetch(); if (!empty($recordInDatabase)) { $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); $url = (string)$uriBuilder->buildUriFromRoute('record_edit', [ 'edit[sys_file_metadata][' . (int)$recordInDatabase['uid'] . ']' => 'edit', 'returnUrl' => $this->data['returnUrl'] ]); $title = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.editMetadata'); $cells['edit'] = ' ' . $this->iconFactory->getIcon('actions-open', Icon::SIZE_SMALL)->render() . ' '; } } // "Delete" link: if ($enabledControls['delete'] && ($isPagesTable && $localCalcPerms & Permission::PAGE_DELETE || !$isPagesTable && $calcPerms & Permission::CONTENT_EDIT || $isSysFileReferenceTable && $calcPerms & Permission::PAGE_EDIT) ) { $title = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')); $icon = $this->iconFactory->getIcon('actions-edit-delete', Icon::SIZE_SMALL)->render(); $cells['delete'] = '' . $icon . ''; } // "Hide/Unhide" links: $hiddenField = $tcaTableCtrl['enablecolumns']['disabled']; if ($enabledControls['hide'] && $permsEdit && $hiddenField && $tcaTableCols[$hiddenField] && (!$tcaTableCols[$hiddenField]['exclude'] || $backendUser->check('non_exclude_fields', $foreignTable . ':' . $hiddenField))) { if ($rec[$hiddenField]) { $title = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide' . ($isPagesTable ? 'Page' : ''))); $cells['hide'] = ' ' . $this->iconFactory->getIcon('actions-edit-unhide', Icon::SIZE_SMALL)->render() . ' '; } else { $title = htmlspecialchars($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide' . ($isPagesTable ? 'Page' : ''))); $cells['hide'] = ' ' . $this->iconFactory->getIcon('actions-edit-hide', Icon::SIZE_SMALL)->render() . ' '; } } // Drag&Drop Sorting: Sortable handler for script.aculo.us if ($enabledControls['dragdrop'] && $permsEdit && $enableManualSorting && $inlineConfig['appearance']['useSortable']) { $cells['dragdrop'] = ' ' . $this->iconFactory->getIcon('actions-move-move', Icon::SIZE_SMALL)->render() . ' '; } } elseif ($data['isInlineDefaultLanguageRecordInLocalizedParentContext'] && $isParentExisting) { if ($enabledControls['localize'] && $data['isInlineDefaultLanguageRecordInLocalizedParentContext']) { $cells['localize'] = ' ' . $this->iconFactory->getIcon('actions-document-localize', Icon::SIZE_SMALL)->render() . ' '; } } // If the record is edit-locked by another user, we will show a little warning sign: if ($lockInfo = BackendUtility::isRecordLocked($foreignTable, $rec['uid'])) { $cells['locked'] = ' ' . $this->iconFactory->getIcon('warning-in-use', Icon::SIZE_SMALL)->render() . ' '; } // Hook: Post-processing of single controls for specific child records: foreach ($this->hookObjects as $hookObj) { $hookObj->renderForeignRecordHeaderControl_postProcess($data['inlineParentUid'], $foreignTable, $rec, $inlineConfig, $data['isInlineDefaultLanguageRecordInLocalizedParentContext'], $cells); } $out = ''; if (!empty($cells['edit']) || !empty($cells['hide']) || !empty($cells['delete'])) { $out .= '
' . $cells['edit'] . $cells['hide'] . $cells['delete'] . '
'; unset($cells['edit'], $cells['hide'], $cells['delete']); } if (!empty($cells['info']) || !empty($cells['new']) || !empty($cells['sort.up']) || !empty($cells['sort.down']) || !empty($cells['dragdrop'])) { $out .= '
' . $cells['info'] . $cells['new'] . $cells['sort.up'] . $cells['sort.down'] . $cells['dragdrop'] . '
'; unset($cells['info'], $cells['new'], $cells['sort.up'], $cells['sort.down'], $cells['dragdrop']); } if (!empty($cells['localize'])) { $out .= '
' . $cells['localize'] . '
'; unset($cells['localize']); } if (!empty($cells)) { $out .= '
' . implode('', $cells) . '
'; } return $out; } /** * Normalize a relation "uid" published by transferData, like "1|Company%201" * * @param string $string A transferData reference string, containing the uid * @return string The normalized uid */ protected function normalizeUid($string) { $parts = explode('|', $string); return $parts[0]; } /** * Initialized the hook objects for this class. * Each hook object has to implement the interface * \TYPO3\CMS\Backend\Form\Element\InlineElementHookInterface * * @throws \UnexpectedValueException */ protected function initHookObjects() { $this->hookObjects = []; foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tceforms_inline.php']['tceformsInlineHook'] ?? [] as $className) { $processObject = GeneralUtility::makeInstance($className); if (!$processObject instanceof InlineElementHookInterface) { throw new \UnexpectedValueException($className . ' must implement interface ' . InlineElementHookInterface::class, 1202072000); } $this->hookObjects[] = $processObject; } } /** * @return BackendUserAuthentication */ protected function getBackendUserAuthentication() { return $GLOBALS['BE_USER']; } /** * @return LanguageService */ protected function getLanguageService() { return $GLOBALS['LANG']; } }