getParsedBody(); $queryParams = $request->getQueryParams(); // Setting internal vars: $this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0); $this->sys_language = (int)($parsedBody['language_tag'] ?? $queryParams['language_tag'] ?? 0); $this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request); $colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null; $this->colPos = $colPos === null ? null : (int)$colPos; $this->uid_pid = (int)($parsedBody['uid_pid'] ?? $queryParams['uid_pid'] ?? 0); // Getting the current page and receiving access information $this->pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: []; $action = (string)($parsedBody['action'] ?? $queryParams['action'] ?? 'wizard'); if ($action === 'wizard') { return $this->wizardAction($request); } if ($action === 'positionMap') { return $this->positionMapAction($request); } return new HtmlResponse('Action not allowed', 400); } /** * Renders the wizard */ protected function wizardAction(ServerRequestInterface $request): ResponseInterface { if (!$this->id || $this->pageInfo === []) { // No pageId or no access. return new HtmlResponse('No Access'); } // Whether position selection must be performed (no colPos was yet defined) $positionSelection = $this->colPos === null; // Get processed and modified wizard items $wizardItems = $this->eventDispatcher->dispatch( new ModifyNewContentElementWizardItemsEvent( $this->getWizards($request), $this->pageInfo, $this->colPos, $this->sys_language, $this->uid_pid, $request, ) )->getWizardItems(); $key = 'common'; $categories = []; foreach ($wizardItems as $wizardKey => $wizardItem) { // An item is either a header or an item rendered with title/description and icon: if (isset($wizardItem['header'])) { $key = $wizardKey; $categories[$key] = [ 'identifier' => $key, 'label' => $wizardItem['header'] ?: '-', 'items' => [], ]; } else { // Get default values for the wizard item $defaultValues = (array)($wizardItem['defaultValues'] ?? []); // Initialize the view variables for the item $item = [ 'identifier' => $wizardKey, 'icon' => $wizardItem['iconIdentifier'] ?? '', 'iconOverlay' => $wizardItem['iconOverlay'] ?? '', 'label' => $wizardItem['title'] ?? '', 'description' => $wizardItem['description'] ?? '', 'defaultValues' => $defaultValues, ]; // If the URL was already created (e.g. via the PSR-14 event) this needs to be // kept and not overwritten if (isset($wizardItem['url'])) { $item['url'] = $wizardItem['url']; if ($positionSelection) { $item['requestType'] = 'ajax'; $item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false); } } elseif ($positionSelection) { $item['url'] = (string)$this->uriBuilder ->buildUriFromRoute( 'new_content_element_wizard', [ 'action' => 'positionMap', 'id' => $this->id, 'sys_language_uid' => $this->sys_language, 'returnUrl' => $this->returnUrl, ] ); $item['requestType'] = 'ajax'; $item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false); } else { // In case no position has to be selected, we can just add the target if ($wizardItem['saveAndClose'] ?? false) { // Go to DataHandler directly instead of FormEngine $item['url'] = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [ 'data' => [ 'tt_content' => [ StringUtility::getUniqueId('NEW') => array_replace($defaultValues, [ 'colPos' => $this->colPos, 'pid' => $this->uid_pid, 'sys_language_uid' => $this->sys_language, ]), ], ], 'redirect' => $this->returnUrl, ]); } else { $item['url'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [ 'edit' => [ 'tt_content' => [ $this->uid_pid => 'new', ], ], 'module' => '_CURRENT_MODULE_', 'returnUrl' => $this->returnUrl, 'defVals' => [ 'tt_content' => array_replace($defaultValues, [ 'colPos' => $this->colPos, 'sys_language_uid' => $this->sys_language, ]), ], ]); } } $categories[$key]['items'][] = $item; } } // Unset empty categories foreach ($categories as $key => $category) { if ($category['items'] === []) { unset($categories[$key]); } } $view = $this->backendViewFactory->create($request); $view->assignMultiple([ 'positionSelection' => $positionSelection, 'categoriesJson' => GeneralUtility::jsonEncodeForHtmlAttribute($categories, false), ]); return new HtmlResponse($view->render('NewContentElement/Wizard')); } /** * Renders the position map */ protected function positionMapAction(ServerRequestInterface $request): ResponseInterface { $pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)); $posMap = GeneralUtility::makeInstance(ContentCreationPagePositionMap::class); $posMap->cur_sys_language = $this->sys_language; $posMap->defVals = (array)($request->getParsedBody()['defVals'] ?? []); $posMap->saveAndClose = (bool)($request->getParsedBody()['saveAndClose'] ?? false); $posMap->R_URI = $this->returnUrl; $view = $this->backendViewFactory->create($request); $view->assign('posMap', $posMap->printContentElementColumns($this->id, $pageInfo, $request)); return new HtmlResponse($view->render('NewContentElement/PositionMap')); } /** * Returns the array of elements in the wizard display. * For the plugin section there is support for adding elements there from a global variable. */ protected function getWizards(ServerRequestInterface $request): array { $wizards = $this->loadAvailableWizards(); $newContentElementWizardTsConfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['wizards.']['newContentElement.'] ?? []; $wizardsFromPageTSConfig = $this->migrateCommonGroupToDefault($newContentElementWizardTsConfig['wizardItems.'] ?? []); $wizardsFromPageTSConfig = $this->migratePositionalCommonGroupToDefault($wizardsFromPageTSConfig); $wizards = $this->mergeContentElementWizardsWithPageTSConfigWizards($wizards, $wizardsFromPageTSConfig); $wizards = $this->removeWizardsByPageTs($wizards, $newContentElementWizardTsConfig); $wizards = $this->removeWizardsByBackendLayoutColPosRestriction($wizards, $this->pageInfo, $this->colPos, $request); if ($wizards === []) { return []; } $wizardItems = []; foreach ($wizards as $groupKey => $wizardGroup) { $wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'before'); $wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'after'); } $orderedWizards = $this->orderWizards($wizards); foreach ($orderedWizards as $groupKey => $wizardGroup) { $groupKey = rtrim($groupKey, '.'); $groupItems = []; $wizardElements = $wizardGroup['elements.'] ?? []; if (is_array($wizardElements)) { $wizardElements = $this->orderElements($wizardElements); foreach ($wizardElements as $itemKey => $itemConf) { $itemKey = rtrim($itemKey, '.'); if ($itemConf !== []) { $groupItems[$groupKey . '_' . $itemKey] = $this->prepareWizardItem($itemConf); } } } if (!empty($groupItems)) { $wizardItems[$groupKey]['header'] = $this->getLanguageService()->sL($wizardGroup['header'] ?? ''); $wizardItems = array_merge($wizardItems, $groupItems); } } // Remove elements where preset values are not allowed: return $this->removeInvalidWizardItems($wizardItems); } protected function loadAvailableWizards(): array { $schema = $this->tcaSchemaFactory->get('tt_content'); // Foreign table support for TypeInformation is not supported in tt_content $typeField = $schema->getSubSchemaTypeInformation()->getFieldName(); $fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : []; $items = $fieldConfig['items'] ?? []; $itemGroups = $fieldConfig['itemGroups'] ?? []; $groupedWizardItems = []; foreach (array_keys($itemGroups) as $groupIdentifier) { $groupedWizardItems[$groupIdentifier . '.']['header'] = $itemGroups[$groupIdentifier]; } foreach ($items as $item) { $selectItem = SelectItem::fromTcaItemArray($item); if ($selectItem->isDivider()) { continue; } $recordType = $selectItem->getValue(); $groupIdentifier = $selectItem->getGroup(); $groupedWizardItems[$groupIdentifier . '.']['elements.'] ??= []; // In case this group is not defined in itemGroups, use the group identifier as label. $groupedWizardItems[$groupIdentifier . '.']['header'] ??= $groupIdentifier; $itemDescription = $selectItem->getDescription(); $wizardEntry = [ 'iconIdentifier' => $selectItem->getIcon(), 'iconOverlay' => $selectItem->getIconOverlay(), 'title' => $selectItem->getLabel(), 'description' => $itemDescription['description'] ?? ($itemDescription ?? ''), 'defaultValues' => [ 'CType' => $recordType, ], ]; if ($schema->hasSubSchema($recordType)) { $wizardEntry = array_replace_recursive($wizardEntry, $schema->getSubSchema($recordType)->getRawConfiguration()['creationOptions'] ?? []); } $groupedWizardItems[$groupIdentifier . '.']['elements.'][$recordType . '.'] = $wizardEntry; } return $groupedWizardItems; } /** * This method merges Content Element wizards defined by TCA with wizards defined in PageTSConfig. * PageTS has precedence. * It might happen that both TCA and PageTS define an entry with exactly the same default values. * In such a case, the automatically added TCA entry is dropped. */ protected function mergeContentElementWizardsWithPageTSConfigWizards(array $contentElementWizards, array $pageTsConfigWizards): array { $uniqueDefaultValuesInPageTsWizards = []; foreach ($pageTsConfigWizards as $wizard) { foreach ($wizard['elements.'] ?? [] as $elementConfig) { $defaultValues = $elementConfig['tt_content_defValues.'] ?? []; if ($defaultValues === []) { continue; } ksort($defaultValues); $uniqueDefaultValuesInPageTsWizards[] = $defaultValues; } } foreach ($contentElementWizards as $group => $wizard) { foreach ($wizard['elements.'] ?? [] as $key => $elementConfig) { // Remove duplicated entry. $defaultValues = $elementConfig['defaultValues']; ksort($defaultValues); if (in_array($defaultValues, $uniqueDefaultValuesInPageTsWizards, true)) { unset($contentElementWizards[$group]['elements.'][$key]); } } } $mergedWizards = array_replace_recursive($contentElementWizards, $pageTsConfigWizards); return $mergedWizards; } /** * Orders elements within a wizard group using before/after configuration. * Similar to orderWizards() but for individual content elements. */ protected function orderElements(array $elements): array { // Check if any element has before/after configuration // and return early if no reordering is required. if (!$this->hasPositionalArguments($elements)) { return $elements; } // Prepare elements for dependency ordering. // Create implicit chain based on initial order for consecutive elements // without explicit dependencies, preserving relative order while allowing // explicit positioning. $preparedElements = []; // First pass: prepare all elements with their explicit dependencies foreach ($elements as $elementKey => $element) { $preparedElement = $element; // Prepare before/after values (they might be comma-separated strings) $preparedElement = $this->prepareDependencyOrdering($preparedElement, 'before'); $preparedElement = $this->prepareDependencyOrdering($preparedElement, 'after'); $preparedElements[$elementKey] = $preparedElement; } // Second pass: add implicit chain for consecutive elements without explicit dependencies // This preserves relative order within blocks, while explicit dependencies can reorder them $previousIndependentElementKey = null; foreach ($elements as $elementKey => $element) { $isIndependent = empty($element['before']) && empty($element['after']); if ($isIndependent) { // Element without explicit dependency: chain with previous independent element if ($previousIndependentElementKey !== null) { $existingAfter = $preparedElements[$elementKey]['after'] ?? []; if (!in_array($previousIndependentElementKey, $existingAfter, true)) { $preparedElements[$elementKey]['after'] = array_merge($existingAfter, [$previousIndependentElementKey]); } } $previousIndependentElementKey = $elementKey; } } // Use dependency ordering service to order elements return $this->dependencyOrderingService->orderByDependencies($preparedElements); } protected function hasPositionalArguments(array $elements): bool { foreach ($elements as $element) { if (!empty($element['before']) || !empty($element['after'])) { return true; } } return false; } /** * There are two separate ordering systems for wizard groups: * 1. TCA itemGroup sorting by associative array item order. * 2. PageTS defined order by "before" and "after". * * System 1. has a well-defined order, where every item defines "after" (linked list). * Due to this, the two system cannot be combined. * As soon as system 2 defines at least one "before" or "after" it takes over. */ protected function orderWizards(array $wizards): array { // First round: Order by TCA defined sorting. $hasAtLeastOnePositionalArgument = false; foreach ($wizards as $group => $wizard) { if (isset($wizard['before'])) { $hasAtLeastOnePositionalArgument = true; $wizards[$group]['pageTsBefore'] = $wizard['before']; unset($wizards[$group]['before']); } if (isset($wizard['after'])) { $hasAtLeastOnePositionalArgument = true; $wizards[$group]['pageTsAfter'] = $wizard['after']; unset($wizards[$group]['after']); } } // No order defined by pageTS. Use TCA sorting. if (!$hasAtLeastOnePositionalArgument) { $schema = $this->tcaSchemaFactory->get('tt_content'); // Foreign table support for TypeInformation is not supported in tt_content $typeField = $schema->getSubSchemaTypeInformation()->getFieldName(); $fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : []; $itemGroups = $fieldConfig['itemGroups'] ?? []; // Auto-set positional information based on TCA itemGroups sorting. $lastGroup = null; foreach (array_keys($itemGroups) as $groupIdentifier) { if (!array_key_exists($groupIdentifier . '.', $wizards)) { continue; } if ($lastGroup !== null) { $wizards[$groupIdentifier . '.']['after'] = [$lastGroup . '.']; } $lastGroup = $groupIdentifier; } return $this->dependencyOrderingService->orderByDependencies($wizards); } // Override order by pageTsConfig. foreach ($wizards as $group => $wizard) { // Unset "after" previously set by Content Element wizards. unset($wizards[$group]['after']); if (isset($wizard['pageTsBefore'])) { $wizards[$group]['before'] = $wizard['pageTsBefore']; unset($wizards[$group]['pageTsBefore']); } if (isset($wizard['pageTsAfter'])) { $wizards[$group]['after'] = $wizard['pageTsAfter']; unset($wizards[$group]['pageTsAfter']); } } return $this->dependencyOrderingService->orderByDependencies($wizards); } /** * This method returns the wizard items, defined in Page TSconfig for b/w * compatibility. * * Additionally, it migrates previously defined wizard items in the * `common` group to the new `default` group, which is defined in TCA. * * @param array $wizardsFromPageTs * @return array */ protected function migrateCommonGroupToDefault(array $wizardsFromPageTs): array { if (!array_key_exists('common.', $wizardsFromPageTs)) { // In case "common." is not defined, just return the wizards, which are still defined via Page TSconfig return $wizardsFromPageTs; } // Prepare "removeItems" to be merged if ($wizardsFromPageTs['default.']['elements.']['removeItems'] ?? false) { $wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['elements.']['removeItems'] ?? '', true); } elseif ($wizardsFromPageTs['default.']['removeItems'] ?? false) { $wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['removeItems'], true); } if ($wizardsFromPageTs['common.']['elements.']['removeItems'] ?? false) { $wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['elements.']['removeItems'] ?? '', true); } elseif ($wizardsFromPageTs['common.']['removeItems'] ?? false) { $wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['removeItems'], true); } $defaultItems = array_merge_recursive($wizardsFromPageTs['default.'] ?? [], $wizardsFromPageTs['common.']); unset($wizardsFromPageTs['common.']); if ($defaultItems !== []) { $wizardsFromPageTs['default.'] = $defaultItems; } return $wizardsFromPageTs; } protected function migratePositionalCommonGroupToDefault(array $wizards): array { foreach ($wizards as $group => $wizard) { if (($wizard['before'] ?? '') === 'common') { $wizards[$group]['before'] = 'default'; } if (($wizard['after'] ?? '') === 'common') { $wizards[$group]['after'] = 'default'; } } return $wizards; } protected function prepareWizardItem(array $itemConf): array { // Just replace the "known" keys of $itemConf. This way extensions are able to set custom keys, which are not // used by the controller, but might be evaluated by listeners of the ModifyNewContentElementWizardItemsEvent. $itemConf = array_replace_recursive( $itemConf, [ 'title' => trim($this->getLanguageService()->sL($itemConf['title'] ?? '')), 'description' => trim($this->getLanguageService()->sL($itemConf['description'] ?? '')), 'iconIdentifier' => $itemConf['iconIdentifier'] ?? null, 'saveAndClose' => (bool)($itemConf['saveAndClose'] ?? false), 'defaultValues' => array_replace_recursive( $itemConf['tt_content_defValues'] ?? [], $itemConf['tt_content_defValues.'] ?? [], $itemConf['defaultValues'] ?? [] ), ] ); unset($itemConf['tt_content_defValues'], $itemConf['tt_content_defValues.']); return $itemConf; } protected function removeWizardsByPageTs(array $wizards, mixed $wizardsItemsPageTs): array { $removeWizardItems = $wizardsItemsPageTs['wizardItems.']['removeItems'] ?? []; if (is_string($removeWizardItems)) { $removeWizardItems = GeneralUtility::trimExplode(',', $removeWizardItems, true); } foreach ($wizards as $key => &$wizard) { // Leave out removeItems etc. if (is_string($wizard)) { unset($wizards[$key]); continue; } if (in_array(rtrim((string)$key, '.'), $removeWizardItems, true)) { unset($wizards[$key]); continue; } $removeWizardElements = $wizardsItemsPageTs['wizardItems.'][$key]['removeItems'] ?? []; if (is_string($removeWizardElements)) { $removeWizardElements = GeneralUtility::trimExplode(',', $removeWizardElements, true); } foreach ($wizard['elements.'] ?? [] as $identifier => $element) { if (in_array(rtrim((string)$identifier, '.'), $removeWizardElements, true)) { unset($wizard['elements.'][$identifier]); } } } return $wizards; } protected function removeWizardsByBackendLayoutColPosRestriction(array $wizardGroups, array $pageInfo, ?int $colPos, ServerRequestInterface $request): array { // Force colPos to 0 if null to apply restrictions for 0 by default. $colPos = (int)$colPos; // This is the page uid of a workspace overlay already so backend layouts of workspace // changed or moved pages should be considered correctly. $pid = (int)$pageInfo['uid']; $backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pid); $columnConfiguration = $this->backendLayoutView->getColPosConfigurationForPage($backendLayout, $colPos, $pid, $request); if (!empty($columnConfiguration['allowedContentTypes'])) { $allowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'], true); foreach ($wizardGroups as $wizardGroupName => $wizards) { foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) { $cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? ''; if (empty($cType)) { continue; } if (!in_array(trim($cType), $allowedContentTypes, true)) { unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]); } } } } if (!empty($columnConfiguration['disallowedContentTypes'])) { $disAllowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'], true); foreach ($wizardGroups as $wizardGroupName => $wizards) { foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) { $cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? ''; if (empty($cType)) { continue; } if (in_array(trim($cType), $disAllowedContentTypes, true)) { unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]); } } } } return $wizardGroups; } /** * Checks the array for elements which might contain invalid default values and will unset them! * Looks for the "defaultValues" key in each element and if found it will traverse that array * as fieldname / value pairs and check. */ protected function removeInvalidWizardItems(array $wizardItems): array { $schema = $this->tcaSchemaFactory->get('tt_content'); $removeItems = []; $keepItems = []; // Get TCEFORM from TSconfig of current page $TCEFORM_TSconfig = FormEngineUtility::getTCEFORM_TSconfig('tt_content', ['pid' => $this->id]); $backendUser = $this->getBackendUser(); // Traverse wizard items: foreach ($wizardItems as $key => $cfg) { if (!is_array($cfg['defaultValues'] ?? false)) { continue; } // This is not a group; this is likely broken configuration if ($cfg['defaultValues'] === []) { unset($wizardItems[$key]); } // If defaultValues are defined, check access by traversing all fields with default values: foreach ($cfg['defaultValues'] as $fieldName => $value) { if (!$schema->hasField($fieldName)) { continue; } // Get information about if the field value is OK: $config = $schema->getField($fieldName)->getConfiguration(); $userNotAllowedToAccess = ($config['type'] ?? '') === 'select' && ($config['authMode'] ?? false) && !$backendUser->checkAuthMode('tt_content', $fieldName, $value); // Check removeItems if (!isset($removeItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['removeItems'] ?? false)) { $removeItems[$fieldName] = array_flip(GeneralUtility::trimExplode( ',', $TCEFORM_TSconfig[$fieldName]['removeItems'], true )); } // Check keepItems if (!isset($keepItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['keepItems'] ?? false)) { $keepItems[$fieldName] = array_flip(GeneralUtility::trimExplode( ',', $TCEFORM_TSconfig[$fieldName]['keepItems'], true )); } $isNotInKeepItems = !empty($keepItems[$fieldName]) && !isset($keepItems[$fieldName][$value]); if ($userNotAllowedToAccess || ($fieldName === 'CType' && (isset($removeItems[$fieldName][$value]) || $isNotInKeepItems))) { // Remove element all together: unset($wizardItems[$key]); break; } // Add the parameter: $wizardItems[$key]['defaultValues'][$fieldName] = $this->getLanguageService()->sL($value); } } return $wizardItems; } /** * Prepare a wizard tab configuration for sorting. */ protected function prepareDependencyOrdering(array $wizardGroup, string $key): array { if (is_string($wizardGroup[$key] ?? null)) { $wizardGroup[$key] = GeneralUtility::trimExplode(',', $wizardGroup[$key], true); } if (is_array($wizardGroup[$key] ?? null)) { $wizardGroup[$key] = array_map( static fn(string $s): string => rtrim($s, '.') . '.', $wizardGroup[$key] ); } return $wizardGroup; } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } }