coreUriBuilder->buildUriFromRoute('form_manager')); } $formSettings = $this->getFormSettings(); if (!$this->formPersistenceManager->isAllowedPersistenceIdentifier($formPersistenceIdentifier)) { throw new PersistenceManagerException(sprintf('Read "%s" is not allowed', $formPersistenceIdentifier), 1614500662); } if (PathUtility::isExtensionPath($formPersistenceIdentifier) && !PersistenceManagerConfiguration::fromArray($formSettings['persistenceManager'] ?? [])->allowSaveToExtensionPaths ) { throw new PersistenceManagerException('Edit an extension formDefinition is not allowed.', 1478265661); } $formDefinition = $this->formPersistenceManager->load($formPersistenceIdentifier); if ($prototypeName === null) { $prototypeName = $formDefinition['prototypeName'] ?? 'standard'; } else { // Loading a form definition with another prototype is currently not implemented but is planned in the future. // This safety check is a preventive measure. $selectablePrototypeNames = $this->configurationService->getSelectablePrototypeNamesDefinedInFormEditorSetup(); if (!in_array($prototypeName, $selectablePrototypeNames, true)) { throw new Exception(sprintf('The prototype name "%s" is not configured within "formManager.selectablePrototypesConfiguration" ', $prototypeName), 1528625039); } } $formDefinition['prototypeName'] = $prototypeName; $prototypeConfiguration = $this->configurationService->getPrototypeConfiguration($prototypeName); $formDefinition = $this->transformFormDefinitionForFormEditor($prototypeConfiguration, $formDefinition, $formPersistenceIdentifier); $formEditorDefinitions = $this->getFormEditorDefinitions($prototypeConfiguration); $additionalViewModelJavaScriptModules = array_map( static fn(string $name) => JavaScriptModuleInstruction::create($name), $prototypeConfiguration['formEditor']['dynamicJavaScriptModules']['additionalViewModelModules'] ?? [] ); array_map($this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(...), $additionalViewModelJavaScriptModules); $formEditorAppInitialData = [ 'formEditorDefinitions' => $formEditorDefinitions, 'formDefinition' => $formDefinition, 'formPersistenceIdentifier' => $formPersistenceIdentifier, 'prototypeName' => $prototypeName, 'endpoints' => [ 'formPageRenderer' => $this->uriBuilder->uriFor('renderFormPage'), 'saveForm' => $this->uriBuilder->uriFor('saveForm'), ], 'additionalViewModelModules' => $additionalViewModelJavaScriptModules, 'maximumUndoSteps' => $prototypeConfiguration['formEditor']['maximumUndoSteps'], ]; $moduleTemplate = $this->initializeModuleTemplate($this->request, $returnUrl); $moduleTemplate->assign('formEditorTemplates', $this->renderFormEditorTemplates($prototypeConfiguration, $formEditorDefinitions)); $moduleTemplate->getDocHeaderComponent()->addBreadcrumbSuffixNode(new BreadcrumbNode( identifier: $formPersistenceIdentifier, label: $formDefinition['label'], icon: 'content-form', )); $addInlineSettings = [ 'FormEditor' => [ 'typo3WinBrowserUrl' => (string)$this->coreUriBuilder->buildUriFromRoute('wizard_element_browser'), 'dateEditor' => [ 'absolutePattern' => DateRangeValidatorPatterns::RFC3339_FULL_DATE, ], ], ]; $addInlineSettings = array_replace_recursive( $addInlineSettings, $prototypeConfiguration['formEditor']['addInlineSettings'] ); if (json_encode($formEditorAppInitialData) === false) { throw new Exception('The form editor app data could not be encoded', 1628677079); } $javaScriptModules = array_map( static fn(string $name) => JavaScriptModuleInstruction::create($name), array_filter( $prototypeConfiguration['formEditor']['dynamicJavaScriptModules'] ?? [], fn(string $name) => in_array($name, self::JS_MODULE_NAMES, true), ARRAY_FILTER_USE_KEY ) ); $pageRenderer = $this->pageRenderer; $pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction( JavaScriptModuleInstruction::create('@typo3/form/backend/helper.js', 'Helper') ->invoke('dispatchFormEditor', $javaScriptModules, $formEditorAppInitialData) ); array_map($pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(...), $javaScriptModules); $pageRenderer->addInlineSettingArray('', $addInlineSettings); $stylesheets = $prototypeConfiguration['formEditor']['stylesheets']; foreach ($stylesheets as $stylesheet) { $pageRenderer->addCssFile($stylesheet); } $moduleTemplate->setModuleClass($this->request->getPluginName() . '_' . $this->request->getControllerName()); $moduleTemplate->setFlashMessageQueue($this->getFlashMessageQueue()); $moduleTemplate->setTitle( $this->getLanguageService()->translate('title', 'form.module'), $formDefinition['label'] ); return $moduleTemplate->renderResponse('Backend/FormEditor/Index'); } /** * Initialize the save action. * This action uses the Fluid JsonView::class as view. */ protected function initializeSaveFormAction(): void { $this->assertAllowedHttpMethod($this->request, 'POST'); $this->defaultViewObjectName = JsonView::class; } /** * Save a formDefinition which was build by the form editor. */ protected function saveFormAction(string $formPersistenceIdentifier, FormDefinitionArray $formDefinition): ResponseInterface { $formDefinition = $formDefinition->getArrayCopy(); $event = $this->eventDispatcher->dispatch( new BeforeFormIsSavedEvent($formPersistenceIdentifier, $formDefinition, $this->request), ); $formPersistenceIdentifier = $event->formPersistenceIdentifier; $formDefinition = $event->form; $response = [ 'status' => 'success', ]; try { if (!$this->formPersistenceManager->isAllowedPersistenceIdentifier($formPersistenceIdentifier)) { throw new PersistenceManagerException(sprintf('Save "%s" is not allowed', $formPersistenceIdentifier), 1614500663); } $this->formPersistenceManager->save($formPersistenceIdentifier, $formDefinition, []); $this->flushPageCache($formPersistenceIdentifier); $prototypeConfiguration = $this->configurationService->getPrototypeConfiguration($formDefinition['prototypeName']); $formDefinition = $this->transformFormDefinitionForFormEditor($prototypeConfiguration, $formDefinition, $formPersistenceIdentifier); $response['formDefinition'] = $formDefinition; } catch (PersistenceManagerException $e) { $response = [ 'status' => 'error', 'message' => $e->getMessage(), 'code' => $e->getCode(), ]; } // saveFormAction uses the extbase JsonView::class. // That's why we have to set the view variables in this way. /** @var JsonView $view */ $view = $this->view; $view->assign('response', $response); $view->setVariablesToRender([ 'response', ]); return $this->jsonResponse(); } /** * Render a page from the formDefinition which was build by the form editor. * Use the frontend rendering and set the form framework to preview mode. */ protected function renderFormPageAction( FormDefinitionArray $formDefinition, int $pageIndex, ?string $prototypeName = null, ?string $formPersistenceIdentifier = null ): ResponseInterface { $prototypeName = $prototypeName ?: $formDefinition['prototypeName'] ?? 'standard'; $formDefinition = $formDefinition->getArrayCopy(); $formDefinition['renderingOptions']['previewMode'] = true; $formDefinition = $this->arrayFormFactory->build($formDefinition, $prototypeName, $this->request); if ($formPersistenceIdentifier !== null) { $formDefinition->setRenderingOption('formPersistenceIdentifier', $formPersistenceIdentifier); } $form = $formDefinition->bind($this->request); $form->setCurrentSiteLanguage($this->buildFakeSiteLanguage(0, 0)); $form->overrideCurrentPage($pageIndex); return $this->htmlResponse($form->render()); } protected function getFormSettings(): array { $typoScriptSettings = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_SETTINGS, 'form'); $formSettings = $this->extFormConfigurationManager->getYamlConfiguration($typoScriptSettings, false); if (!isset($formSettings['formManager'])) { // Config sub array formManager is crucial and should always exist. If it does // not, this indicates an issue in config loading logic. Except in this case. throw new \LogicException('Configuration could not be loaded', 1681549038); } return $formSettings; } /** * Build a SiteLanguage object to render the form preview with a * specific language. */ protected function buildFakeSiteLanguage(int $pageId, int $languageId): SiteLanguage { $fakeSiteConfiguration = [ 'languages' => [ [ 'languageId' => $languageId, 'title' => 'Dummy', 'navigationTitle' => '', 'flag' => '', 'locale' => '', ], ], ]; return GeneralUtility::makeInstance(Site::class, 'form-dummy', $pageId, $fakeSiteConfiguration)->getLanguageById($languageId); } /** * Prepare the formElements.*.formEditor section from the YAML settings. * Sort all formElements into groups and add additional data. */ protected function getInsertRenderablesPanelConfiguration(array $prototypeConfiguration, array $formElementsDefinition, bool $isInsertPages = false): array { /** @var array>> $formElementsByGroup */ $formElementsByGroup = []; foreach ($formElementsDefinition as $formElementName => $formElementConfiguration) { if (!isset($formElementConfiguration['group']) || ($isInsertPages && $formElementConfiguration['group'] !== 'page') || (!$isInsertPages && $formElementConfiguration['group'] === 'page')) { continue; } if (!isset($formElementsByGroup[$formElementConfiguration['group']])) { $formElementsByGroup[$formElementConfiguration['group']] = []; } $formElementConfiguration = $this->translationService->translateValuesRecursive( $formElementConfiguration, $prototypeConfiguration['formEditor']['translationFiles'] ?? [] ); $formElementsByGroup[$formElementConfiguration['group']][] = [ 'identifier' => $formElementName, 'label' => $formElementConfiguration['label'], 'description' => $formElementConfiguration['description'] ?? '', 'requestType' => 'event', 'event' => 'typo3:form:insert-element-click', 'sorting' => $formElementConfiguration['groupSorting'], 'icon' => $formElementConfiguration['iconIdentifier'], ]; } $formGroups = []; foreach ($prototypeConfiguration['formEditor']['formElementGroups'] ?? [] as $groupName => $groupConfiguration) { if (!isset($formElementsByGroup[$groupName])) { continue; } usort($formElementsByGroup[$groupName], static function ($a, $b) { return $a['sorting'] - $b['sorting']; }); $groupConfiguration = $this->translationService->translateValuesRecursive( $groupConfiguration, $prototypeConfiguration['formEditor']['translationFiles'] ?? [] ); $formGroups[$groupName] = [ 'identifier' => $groupName, 'items' => $formElementsByGroup[$groupName], 'label' => $groupConfiguration['label'], ]; } return $formGroups; } /** * Reduce the YAML settings by the 'formEditor' keyword. */ protected function getFormEditorDefinitions(array $prototypeConfiguration): array { $formEditorDefinitions = []; foreach ([$prototypeConfiguration, $prototypeConfiguration['formEditor']] as $configuration) { foreach ($configuration as $firstLevelItemKey => $firstLevelItemValue) { if (!str_ends_with($firstLevelItemKey, 'Definition')) { continue; } $reducedKey = substr($firstLevelItemKey, 0, -10); foreach ($firstLevelItemValue as $formEditorDefinitionKey => $formEditorDefinitionValue) { if (isset($formEditorDefinitionValue['formEditor'])) { $formEditorDefinitionValue = array_intersect_key($formEditorDefinitionValue, array_flip(['formEditor'])); $formEditorDefinitions[$reducedKey][$formEditorDefinitionKey] = $formEditorDefinitionValue['formEditor']; } else { $formEditorDefinitions[$reducedKey][$formEditorDefinitionKey] = $formEditorDefinitionValue; } } } } $formEditorDefinitions = ArrayUtility::reIndexNumericArrayKeysRecursive($formEditorDefinitions); $formEditorDefinitions = $this->formEditorEnrichmentService->enrichFormEditorDefinitions($formEditorDefinitions); return $this->translationService->translateValuesRecursive( $formEditorDefinitions, $prototypeConfiguration['formEditor']['translationFiles'] ?? [] ); } /** * Initialize ModuleTemplate and register docheader icons. */ protected function initializeModuleTemplate(RequestInterface $request, string $returnUrl = ''): ModuleTemplate { $moduleTemplate = $this->moduleTemplateFactory->create($request); $getVars = $request->getArguments(); if (isset($getVars['action']) && $getVars['action'] === 'index') { $closeUrl = $returnUrl !== '' ? $returnUrl : (string)$this->coreUriBuilder->buildUriFromRoute('web_FormFormbuilder'); $closeButton = $this->componentFactory->createCloseButton($closeUrl) ->setDataAttributes(['identifier' => 'closeButton']) ->setClasses('formeditor-element-close-form-button hidden'); $moduleTemplate->addButtonToButtonBar($closeButton, ButtonBar::BUTTON_POSITION_LEFT, 2); $saveButton = $this->componentFactory->createInputButton() ->setDataAttributes(['identifier' => 'saveButton']) ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.save_button')) ->setName('formeditor-save-form') ->setValue('save') ->setClasses('formeditor-element-save-form-button hidden') ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL)) ->setShowLabelText(true); $moduleTemplate->addButtonToButtonBar($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 3); $undoButton = $this->componentFactory->createInputButton() ->setDataAttributes(['identifier' => 'undoButton']) ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.undo_button')) ->setName('formeditor-undo-form') ->setValue('undo') ->setClasses('formeditor-element-undo-form-button hidden disabled') ->setIcon($this->iconFactory->getIcon('actions-edit-undo', IconSize::SMALL)); $moduleTemplate->addButtonToButtonBar($undoButton, ButtonBar::BUTTON_POSITION_LEFT, 5); $redoButton = $this->componentFactory->createInputButton() ->setDataAttributes(['identifier' => 'redoButton']) ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.redo_button')) ->setName('formeditor-redo-form') ->setValue('redo') ->setClasses('formeditor-element-redo-form-button hidden disabled') ->setIcon($this->iconFactory->getIcon('actions-edit-redo', IconSize::SMALL)); $moduleTemplate->addButtonToButtonBar($redoButton, ButtonBar::BUTTON_POSITION_LEFT, 5); } return $moduleTemplate; } /** * Render the form editor templates. */ protected function renderFormEditorTemplates(array $prototypeConfiguration, array $formEditorDefinitions): string { $fluidConfiguration = $prototypeConfiguration['formEditor']['formEditorFluidConfiguration'] ?? null; $formEditorPartials = $prototypeConfiguration['formEditor']['formEditorPartials'] ?? null; if (!isset($fluidConfiguration['templatePathAndFilename'])) { throw new RenderingException('The option templatePathAndFilename must be set.', 1485636499); } if (!isset($fluidConfiguration['layoutRootPaths']) || !is_array($fluidConfiguration['layoutRootPaths'])) { throw new RenderingException('The option layoutRootPaths must be set.', 1480294721); } if (!isset($fluidConfiguration['partialRootPaths']) || !is_array($fluidConfiguration['partialRootPaths'])) { throw new RenderingException('The option partialRootPaths must be set.', 1480294722); } $elementsCategories = $this->getInsertRenderablesPanelConfiguration($prototypeConfiguration, $formEditorDefinitions['formElements']); $pagesCategories = $this->getInsertRenderablesPanelConfiguration($prototypeConfiguration, $formEditorDefinitions['formElements'], true); $viewFactoryData = new ViewFactoryData( templatePathAndFilename: $fluidConfiguration['templatePathAndFilename'], partialRootPaths: $fluidConfiguration['partialRootPaths'], layoutRootPaths: $fluidConfiguration['layoutRootPaths'], request: $this->request, ); $view = $this->viewFactory->create($viewFactoryData); $view->assignMultiple([ 'elementsCategoriesJson' => GeneralUtility::jsonEncodeForHtmlAttribute($elementsCategories, false), 'pagesCategoriesJson' => GeneralUtility::jsonEncodeForHtmlAttribute($pagesCategories, false), 'formEditorPartials' => $formEditorPartials, ]); return $view->render(); } /** * @todo move this to FormDefinitionConversionService */ protected function transformFormDefinitionForFormEditor(array $prototypeConfiguration, array $formDefinition, string $formPersistenceIdentifier): array { /** @var array> $multiValueFormElementProperties */ $multiValueFormElementProperties = []; /** @var array> $multiValueFinisherProperties */ $multiValueFinisherProperties = []; foreach ($prototypeConfiguration['formElementsDefinition'] as $type => $configuration) { if (!isset($configuration['formEditor']['editors'])) { continue; } foreach ($configuration['formEditor']['editors'] as $editorConfiguration) { if (($editorConfiguration['templateName'] ?? '') === 'Inspector-PropertyGridEditor') { $multiValueFormElementProperties[$type][] = $editorConfiguration['propertyPath']; } } } foreach ($prototypeConfiguration['formElementsDefinition']['Form']['formEditor']['propertyCollections']['finishers'] ?? [] as $configuration) { if (!isset($configuration['editors'])) { continue; } foreach ($configuration['editors'] as $editorConfiguration) { if (($editorConfiguration['templateName'] ?? '') === 'Inspector-PropertyGridEditor') { $multiValueFinisherProperties[$configuration['identifier']][] = $editorConfiguration['propertyPath']; } } } $formDefinition = $this->filterEmptyArrays($formDefinition); $formDefinition = $this->migrateEmailFinisherRecipients($formDefinition); $formDefinition = $this->transformMultiValuePropertiesForFormEditor( $formDefinition, 'type', $multiValueFormElementProperties ); $formDefinition = $this->transformMultiValuePropertiesForFormEditor( $formDefinition, 'identifier', $multiValueFinisherProperties ); $rtePropertyPaths = $this->formDefinitionConversionService->extractRtePropertyPaths($prototypeConfiguration); if ($rtePropertyPaths !== []) { $formDefinition = $this->formDefinitionConversionService->transformRteContentForRichTextEditor( $formDefinition, $rtePropertyPaths ); } $formDefinition = $this->formDefinitionConversionService->sanitizeHtml($formDefinition, $rtePropertyPaths); $formDefinition = $this->formDefinitionConversionService->addHmacData($formDefinition, $formPersistenceIdentifier); return $this->formDefinitionConversionService->migrateFinisherConfiguration($formDefinition); } /** * Some data needs a transformation before it can be used by the * form editor. This rules for multivalue elements like select * elements. To ensure the right sorting if the data goes into * javascript, we need to do transformations: * * [ * '5' => '5', * '4' => '4', * '3' => '3' * ] * * * This method transform this into: * * [ * [ * _label => '5' * _value => 5 * ], * [ * _label => '4' * _value => 4 * ], * [ * _label => '3' * _value => 3 * ], * ] * * @param array> $multiValueProperties */ protected function transformMultiValuePropertiesForFormEditor( array $formDefinition, string $identifierProperty, array $multiValueProperties ): array { $output = $formDefinition; foreach ($formDefinition as $key => $value) { $identifier = $value[$identifierProperty] ?? null; if (is_string($identifier) && array_key_exists($identifier, $multiValueProperties)) { $multiValuePropertiesForIdentifier = $multiValueProperties[$identifier]; foreach ($multiValuePropertiesForIdentifier as $multiValueProperty) { if (!ArrayUtility::isValidPath($value, $multiValueProperty, '.')) { continue; } $multiValuePropertyData = ArrayUtility::getValueByPath($value, $multiValueProperty, '.'); if (!is_array($multiValuePropertyData)) { continue; } $newMultiValuePropertyData = []; foreach ($multiValuePropertyData as $k => $v) { $newMultiValuePropertyData[] = [ '_label' => $v, '_value' => $k, ]; } $value = ArrayUtility::setValueByPath($value, $multiValueProperty, $newMultiValuePropertyData, '.'); } } $output[$key] = $value; if (is_array($value)) { $output[$key] = $this->transformMultiValuePropertiesForFormEditor( $value, $identifierProperty, $multiValueProperties ); } } return $output; } /** * Remove keys from an array if the key value is an empty array */ protected function filterEmptyArrays(array $array): array { foreach ($array as $key => $value) { if (!is_array($value)) { continue; } if (empty($value)) { unset($array[$key]); continue; } $array[$key] = $this->filterEmptyArrays($value); if (empty($array[$key])) { unset($array[$key]); } } return $array; } /** * Migrate single recipient options to their list successors */ protected function migrateEmailFinisherRecipients(array $formDefinition): array { foreach ($formDefinition['finishers'] ?? [] as $i => $finisherConfiguration) { if (!in_array($finisherConfiguration['identifier'], ['EmailToSender', 'EmailToReceiver'], true)) { continue; } $recipientAddress = $finisherConfiguration['options']['recipientAddress'] ?? ''; $recipientName = $finisherConfiguration['options']['recipientName'] ?? ''; $carbonCopyAddress = $finisherConfiguration['options']['carbonCopyAddress'] ?? ''; $blindCarbonCopyAddress = $finisherConfiguration['options']['blindCarbonCopyAddress'] ?? ''; $replyToAddress = $finisherConfiguration['options']['replyToAddress'] ?? ''; if (!empty($recipientAddress)) { $finisherConfiguration['options']['recipients'][$recipientAddress] = $recipientName; } if (!empty($carbonCopyAddress)) { $finisherConfiguration['options']['carbonCopyRecipients'][$carbonCopyAddress] = ''; } if (!empty($blindCarbonCopyAddress)) { $finisherConfiguration['options']['blindCarbonCopyRecipients'][$blindCarbonCopyAddress] = ''; } if (!empty($replyToAddress)) { $finisherConfiguration['options']['replyToRecipients'][$replyToAddress] = ''; } unset( $finisherConfiguration['options']['recipientAddress'], $finisherConfiguration['options']['recipientName'], $finisherConfiguration['options']['carbonCopyAddress'], $finisherConfiguration['options']['blindCarbonCopyAddress'], $finisherConfiguration['options']['replyToAddress'] ); $formDefinition['finishers'][$i] = $finisherConfiguration; } return $formDefinition; } protected function flushPageCache(string $formPersistenceIdentifier): void { $pageIdList = []; $referenceRows = $this->databaseService->getReferencesByPersistenceIdentifier($formPersistenceIdentifier); foreach ($referenceRows as $referenceRow) { $record = BackendUtility::getRecord($referenceRow['tablename'], $referenceRow['recuid']); if (!$record) { continue; } $pageIdList[] = $record['pid']; } foreach (array_unique($pageIdList) as $pageId) { $this->cacheManager->flushCachesInGroupByTag('pages', 'pageId_' . $pageId); } } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } }