[FEATURE] Introduce conditional variants for form elements
[Packages/TYPO3.CMS.git] / typo3 / sysext / form / Classes / Controller / FormEditorController.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Form\Controller;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use TYPO3\CMS\Backend\Routing\UriBuilder;
19 use TYPO3\CMS\Backend\Template\Components\ButtonBar;
20 use TYPO3\CMS\Backend\View\BackendTemplateView;
21 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
22 use TYPO3\CMS\Core\Imaging\Icon;
23 use TYPO3\CMS\Core\Localization\LanguageService;
24 use TYPO3\CMS\Core\Site\Entity\Site;
25 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
26 use TYPO3\CMS\Core\Utility\ArrayUtility;
27 use TYPO3\CMS\Core\Utility\GeneralUtility;
28 use TYPO3\CMS\Extbase\Mvc\View\JsonView;
29 use TYPO3\CMS\Fluid\View\TemplateView;
30 use TYPO3\CMS\Form\Domain\Configuration\ConfigurationService;
31 use TYPO3\CMS\Form\Domain\Exception\RenderingException;
32 use TYPO3\CMS\Form\Domain\Factory\ArrayFormFactory;
33 use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
34 use TYPO3\CMS\Form\Service\TranslationService;
35 use TYPO3\CMS\Form\Type\FormDefinitionArray;
36
37 /**
38 * The form editor controller
39 *
40 * Scope: backend
41 */
42 class FormEditorController extends AbstractBackendController
43 {
44
45 /**
46 * Default View Container
47 *
48 * @var BackendTemplateView
49 */
50 protected $defaultViewObjectName = BackendTemplateView::class;
51
52 /**
53 * @var array
54 */
55 protected $prototypeConfiguration;
56
57 /**
58 * Displays the form editor
59 *
60 * @param string $formPersistenceIdentifier
61 * @param string $prototypeName
62 * @throws PersistenceManagerException
63 * @internal
64 */
65 public function indexAction(string $formPersistenceIdentifier, string $prototypeName = null)
66 {
67 $this->registerDocheaderButtons();
68 $this->view->getModuleTemplate()->setModuleName($this->request->getPluginName() . '_' . $this->request->getControllerName());
69 $this->view->getModuleTemplate()->setFlashMessageQueue($this->controllerContext->getFlashMessageQueue());
70
71 if (
72 strpos($formPersistenceIdentifier, 'EXT:') === 0
73 && !$this->formSettings['persistenceManager']['allowSaveToExtensionPaths']
74 ) {
75 throw new PersistenceManagerException('Edit a extension formDefinition is not allowed.', 1478265661);
76 }
77
78 $formDefinition = $this->formPersistenceManager->load($formPersistenceIdentifier);
79 $formDefinition = ArrayUtility::stripTagsFromValuesRecursive($formDefinition);
80 $prototypeName = $prototypeName ?: $formDefinition['prototypeName'] ?? 'standard';
81
82 $formDefinition['prototypeName'] = $prototypeName;
83 $formDefinition = $this->filterEmptyArrays($formDefinition);
84
85 $configurationService = $this->objectManager->get(ConfigurationService::class);
86 $this->prototypeConfiguration = $configurationService->getPrototypeConfiguration($prototypeName);
87
88 $formDefinition = $this->transformFormDefinitionForFormEditor($formDefinition);
89 $formEditorDefinitions = $this->getFormEditorDefinitions();
90
91 $formEditorAppInitialData = [
92 'formEditorDefinitions' => $formEditorDefinitions,
93 'formDefinition' => $formDefinition,
94 'formPersistenceIdentifier' => $formPersistenceIdentifier,
95 'prototypeName' => $prototypeName,
96 'endpoints' => [
97 'formPageRenderer' => $this->controllerContext->getUriBuilder()->uriFor('renderFormPage'),
98 'saveForm' => $this->controllerContext->getUriBuilder()->uriFor('saveForm')
99 ],
100 'additionalViewModelModules' => $this->prototypeConfiguration['formEditor']['dynamicRequireJsModules']['additionalViewModelModules'],
101 'maximumUndoSteps' => $this->prototypeConfiguration['formEditor']['maximumUndoSteps'],
102 ];
103
104 $this->view->assign('formEditorAppInitialData', json_encode($formEditorAppInitialData));
105 $this->view->assign('stylesheets', $this->resolveResourcePaths($this->prototypeConfiguration['formEditor']['stylesheets']));
106 $this->view->assign('formEditorTemplates', $this->renderFormEditorTemplates($formEditorDefinitions));
107 $this->view->assign('dynamicRequireJsModules', $this->prototypeConfiguration['formEditor']['dynamicRequireJsModules']);
108
109 $popupWindowWidth = 700;
110 $popupWindowHeight = 750;
111 $popupWindowSize = \trim($this->getBackendUser()->getTSConfig()['options.']['popupWindowSize'] ?? '');
112 if (!empty($popupWindowSize)) {
113 list($popupWindowWidth, $popupWindowHeight) = GeneralUtility::intExplode('x', $popupWindowSize);
114 }
115 $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
116 $addInlineSettings = [
117 'FormEditor' => [
118 'typo3WinBrowserUrl' => (string)$uriBuilder->buildUriFromRoute('wizard_element_browser'),
119 ],
120 'Popup' => [
121 'PopupWindow' => [
122 'width' => $popupWindowWidth,
123 'height' => $popupWindowHeight
124 ],
125 ]
126 ];
127
128 $addInlineSettings = array_replace_recursive(
129 $addInlineSettings,
130 $this->prototypeConfiguration['formEditor']['addInlineSettings']
131 );
132 $this->view->assign('addInlineSettings', $addInlineSettings);
133 }
134
135 /**
136 * Initialize the save action.
137 * This action uses the Fluid JsonView::class as view.
138 *
139 * @internal
140 */
141 public function initializeSaveFormAction()
142 {
143 $this->defaultViewObjectName = JsonView::class;
144 }
145
146 /**
147 * Save a formDefinition which was build by the form editor.
148 *
149 * @param string $formPersistenceIdentifier
150 * @param FormDefinitionArray $formDefinition
151 * @internal
152 */
153 public function saveFormAction(string $formPersistenceIdentifier, FormDefinitionArray $formDefinition)
154 {
155 $formDefinition = $formDefinition->getArrayCopy();
156 $formDefinition = $this->filterEmptyArrays($formDefinition);
157 foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormSave'] ?? [] as $className) {
158 $hookObj = GeneralUtility::makeInstance($className);
159 if (method_exists($hookObj, 'beforeFormSave')) {
160 $formDefinition = $hookObj->beforeFormSave(
161 $formPersistenceIdentifier,
162 $formDefinition
163 );
164 }
165 }
166
167 $response = [
168 'status' => 'success',
169 ];
170
171 try {
172 $this->formPersistenceManager->save($formPersistenceIdentifier, $formDefinition);
173 } catch (PersistenceManagerException $e) {
174 $response = [
175 'status' => 'error',
176 'message' => $e->getMessage(),
177 'code' => $e->getCode(),
178 ];
179 }
180
181 $response['formDefinition'] = $formDefinition;
182
183 $this->view->assign('response', $response);
184 // saveFormAction uses the extbase JsonView::class.
185 // That's why we have to set the view variables in this way.
186 $this->view->setVariablesToRender([
187 'response',
188 ]);
189 }
190
191 /**
192 * Render a page from the formDefinition which was build by the form editor.
193 * Use the frontend rendering and set the form framework to preview mode.
194 *
195 * @param FormDefinitionArray $formDefinition
196 * @param int $pageIndex
197 * @param string $prototypeName
198 * @return string
199 * @internal
200 */
201 public function renderFormPageAction(FormDefinitionArray $formDefinition, int $pageIndex, string $prototypeName = null): string
202 {
203 $prototypeName = $prototypeName ?: $formDefinition['prototypeName'] ?? 'standard';
204
205 $formFactory = $this->objectManager->get(ArrayFormFactory::class);
206 $formDefinition = $formFactory->build($formDefinition->getArrayCopy(), $prototypeName);
207 $formDefinition->setRenderingOption('previewMode', true);
208 $form = $formDefinition->bind($this->request, $this->response);
209 $form->setCurrentSiteLanguage($this->buildFakeSiteLanguage(0, 0));
210 $form->overrideCurrentPage($pageIndex);
211
212 return $form->render();
213 }
214
215 /**
216 * Build a SiteLanguage object to render the form preview with a
217 * specific language.
218 *
219 * @param int $pageId
220 * @param int $languageId
221 * @return SiteLanguage
222 */
223 protected function buildFakeSiteLanguage(int $pageId, int $languageId): SiteLanguage
224 {
225 $fakeSiteConfiguration = [
226 'languages' => [
227 [
228 'languageId' => $languageId,
229 'title' => 'Dummy',
230 'navigationTitle' => '',
231 'typo3Language' => '',
232 'flag' => '',
233 'locale' => '',
234 'iso-639-1' => '',
235 'hreflang' => '',
236 'direction' => '',
237 ],
238 ],
239 ];
240
241 /** @var \TYPO3\CMS\Core\Site\Entity\SiteLanguage $currentSiteLanguage */
242 $currentSiteLanguage = GeneralUtility::makeInstance(Site::class, 'form-dummy', $pageId, $fakeSiteConfiguration)
243 ->getLanguageById($languageId);
244 return $currentSiteLanguage;
245 }
246
247 /**
248 * Prepare the formElements.*.formEditor section from the YAML settings.
249 * Sort all formElements into groups and add additional data.
250 *
251 * @param array $formElementsDefinition
252 * @return array
253 */
254 protected function getInsertRenderablesPanelConfiguration(array $formElementsDefinition): array
255 {
256 $formElementsByGroup = [];
257
258 foreach ($formElementsDefinition as $formElementName => $formElementConfiguration) {
259 if (!isset($formElementConfiguration['group'])) {
260 continue;
261 }
262 if (!isset($formElementsByGroup[$formElementConfiguration['group']])) {
263 $formElementsByGroup[$formElementConfiguration['group']] = [];
264 }
265
266 $formElementConfiguration = TranslationService::getInstance()->translateValuesRecursive(
267 $formElementConfiguration,
268 $this->prototypeConfiguration['formEditor']['translationFile'] ?? null
269 );
270
271 $formElementsByGroup[$formElementConfiguration['group']][] = [
272 'key' => $formElementName,
273 'cssKey' => preg_replace('/[^a-z0-9]/', '-', strtolower($formElementName)),
274 'label' => $formElementConfiguration['label'],
275 'sorting' => $formElementConfiguration['groupSorting'],
276 'iconIdentifier' => $formElementConfiguration['iconIdentifier'],
277 ];
278 }
279
280 $formGroups = [];
281 foreach ($this->prototypeConfiguration['formEditor']['formElementGroups'] ?? [] as $groupName => $groupConfiguration) {
282 if (!isset($formElementsByGroup[$groupName])) {
283 continue;
284 }
285
286 usort($formElementsByGroup[$groupName], function ($a, $b) {
287 return $a['sorting'] - $b['sorting'];
288 });
289 unset($formElementsByGroup[$groupName]['sorting']);
290
291 $groupConfiguration = TranslationService::getInstance()->translateValuesRecursive(
292 $groupConfiguration,
293 $this->prototypeConfiguration['formEditor']['translationFile'] ?? null
294 );
295
296 $formGroups[] = [
297 'key' => $groupName,
298 'elements' => $formElementsByGroup[$groupName],
299 'label' => $groupConfiguration['label'],
300 ];
301 }
302
303 return $formGroups;
304 }
305
306 /**
307 * Reduce the YAML settings by the 'formEditor' keyword.
308 *
309 * @return array
310 */
311 protected function getFormEditorDefinitions(): array
312 {
313 $formEditorDefinitions = [];
314 foreach ([$this->prototypeConfiguration, $this->prototypeConfiguration['formEditor']] as $configuration) {
315 foreach ($configuration as $firstLevelItemKey => $firstLevelItemValue) {
316 if (substr($firstLevelItemKey, -10) !== 'Definition') {
317 continue;
318 }
319 $reducedKey = substr($firstLevelItemKey, 0, -10);
320 foreach ($configuration[$firstLevelItemKey] as $formEditorDefinitionKey => $formEditorDefinitionValue) {
321 if (isset($formEditorDefinitionValue['formEditor'])) {
322 $formEditorDefinitionValue = array_intersect_key($formEditorDefinitionValue, array_flip(['formEditor']));
323 $formEditorDefinitions[$reducedKey][$formEditorDefinitionKey] = $formEditorDefinitionValue['formEditor'];
324 } else {
325 $formEditorDefinitions[$reducedKey][$formEditorDefinitionKey] = $formEditorDefinitionValue;
326 }
327 }
328 }
329 }
330 $formEditorDefinitions = ArrayUtility::reIndexNumericArrayKeysRecursive($formEditorDefinitions);
331 $formEditorDefinitions = TranslationService::getInstance()->translateValuesRecursive(
332 $formEditorDefinitions,
333 $this->prototypeConfiguration['formEditor']['translationFile'] ?? null
334 );
335 return $formEditorDefinitions;
336 }
337
338 /**
339 * Registers the Icons into the docheader
340 *
341 * @throws \InvalidArgumentException
342 */
343 protected function registerDocheaderButtons()
344 {
345 /** @var ButtonBar $buttonBar */
346 $buttonBar = $this->view->getModuleTemplate()->getDocHeaderComponent()->getButtonBar();
347 $getVars = $this->request->getArguments();
348
349 if (isset($getVars['action']) && $getVars['action'] === 'index') {
350 $newPageButton = $buttonBar->makeInputButton()
351 ->setDataAttributes(['action' => 'formeditor-new-page', 'identifier' => 'headerNewPage'])
352 ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.new_page_button'))
353 ->setName('formeditor-new-page')
354 ->setValue('new-page')
355 ->setClasses('t3-form-element-new-page-button hidden')
356 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-page-new', Icon::SIZE_SMALL));
357 /** @var UriBuilder $uriBuilder */
358 $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
359
360 $closeButton = $buttonBar->makeLinkButton()
361 ->setDataAttributes(['identifier' => 'closeButton'])
362 ->setHref((string)$uriBuilder->buildUriFromRoute('web_FormFormbuilder'))
363 ->setClasses('t3-form-element-close-form-button hidden')
364 ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc'))
365 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-close', Icon::SIZE_SMALL));
366
367 $saveButton = $buttonBar->makeInputButton()
368 ->setDataAttributes(['identifier' => 'saveButton'])
369 ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.save_button'))
370 ->setName('formeditor-save-form')
371 ->setValue('save')
372 ->setClasses('t3-form-element-save-form-button hidden')
373 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-document-save', Icon::SIZE_SMALL))
374 ->setShowLabelText(true);
375
376 $formSettingsButton = $buttonBar->makeInputButton()
377 ->setDataAttributes(['identifier' => 'formSettingsButton'])
378 ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.form_settings_button'))
379 ->setName('formeditor-form-settings')
380 ->setValue('settings')
381 ->setClasses('t3-form-element-form-settings-button hidden')
382 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-system-extension-configure', Icon::SIZE_SMALL))
383 ->setShowLabelText(true);
384
385 $undoButton = $buttonBar->makeInputButton()
386 ->setDataAttributes(['identifier' => 'undoButton'])
387 ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.undo_button'))
388 ->setName('formeditor-undo-form')
389 ->setValue('undo')
390 ->setClasses('t3-form-element-undo-form-button hidden disabled')
391 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-edit-undo', Icon::SIZE_SMALL));
392
393 $redoButton = $buttonBar->makeInputButton()
394 ->setDataAttributes(['identifier' => 'redoButton'])
395 ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.redo_button'))
396 ->setName('formeditor-redo-form')
397 ->setValue('redo')
398 ->setClasses('t3-form-element-redo-form-button hidden disabled')
399 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-edit-redo', Icon::SIZE_SMALL));
400
401 $buttonBar->addButton($newPageButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
402 $buttonBar->addButton($closeButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
403 $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 3);
404 $buttonBar->addButton($formSettingsButton, ButtonBar::BUTTON_POSITION_LEFT, 4);
405 $buttonBar->addButton($undoButton, ButtonBar::BUTTON_POSITION_LEFT, 5);
406 $buttonBar->addButton($redoButton, ButtonBar::BUTTON_POSITION_LEFT, 5);
407 }
408 }
409
410 /**
411 * Render the "text/x-formeditor-template" templates.
412 *
413 * @param array $formEditorDefinitions
414 * @return string
415 */
416 protected function renderFormEditorTemplates(array $formEditorDefinitions): string
417 {
418 $fluidConfiguration = $this->prototypeConfiguration['formEditor']['formEditorFluidConfiguration'] ?? null;
419 $formEditorPartials = $this->prototypeConfiguration['formEditor']['formEditorPartials'] ?? null;
420
421 if (!isset($fluidConfiguration['templatePathAndFilename'])) {
422 throw new RenderingException(
423 'The option templatePathAndFilename must be set.',
424 1485636499
425 );
426 }
427 if (
428 !isset($fluidConfiguration['layoutRootPaths'])
429 || !is_array($fluidConfiguration['layoutRootPaths'])
430 ) {
431 throw new RenderingException(
432 'The option layoutRootPaths must be set.',
433 1480294721
434 );
435 }
436 if (
437 !isset($fluidConfiguration['partialRootPaths'])
438 || !is_array($fluidConfiguration['partialRootPaths'])
439 ) {
440 throw new RenderingException(
441 'The option partialRootPaths must be set.',
442 1480294722
443 );
444 }
445
446 $insertRenderablesPanelConfiguration = $this->getInsertRenderablesPanelConfiguration($formEditorDefinitions['formElements']);
447
448 $view = $this->objectManager->get(TemplateView::class);
449 $view->setControllerContext(clone $this->controllerContext);
450 $view->getRenderingContext()->getTemplatePaths()->fillFromConfigurationArray($fluidConfiguration);
451 $view->setTemplatePathAndFilename($fluidConfiguration['templatePathAndFilename']);
452 $view->assignMultiple([
453 'insertRenderablesPanelConfiguration' => $insertRenderablesPanelConfiguration,
454 'formEditorPartials' => $formEditorPartials,
455 ]);
456
457 return $view->render();
458 }
459
460 /**
461 * @param array $formDefinition
462 * @return array
463 */
464 protected function transformFormDefinitionForFormEditor(array $formDefinition): array
465 {
466 $multiValueProperties = [];
467 foreach ($this->prototypeConfiguration['formElementsDefinition'] as $type => $configuration) {
468 if (!isset($configuration['formEditor']['editors'])) {
469 continue;
470 }
471 foreach ($configuration['formEditor']['editors'] as $editorConfiguration) {
472 if ($editorConfiguration['templateName'] === 'Inspector-PropertyGridEditor') {
473 $multiValueProperties[$type][] = $editorConfiguration['propertyPath'];
474 }
475 }
476 }
477
478 return $this->transformMultiValueElementsForFormEditor($formDefinition, $multiValueProperties);
479 }
480
481 /**
482 * Some data needs a transformation before it can be used by the
483 * form editor. This rules for multivalue elements like select
484 * elements. To ensure the right sorting if the data goes into
485 * javascript, we need to do transformations:
486 *
487 * [
488 * '5' => '5',
489 * '4' => '4',
490 * '3' => '3'
491 * ]
492 *
493 *
494 * This method transform this into:
495 *
496 * [
497 * [
498 * _label => '5'
499 * _value => 5
500 * ],
501 * [
502 * _label => '4'
503 * _value => 4
504 * ],
505 * [
506 * _label => '3'
507 * _value => 3
508 * ],
509 * ]
510 *
511 * @param array $formDefinition
512 * @param array $multiValueProperties
513 * @return array
514 */
515 protected function transformMultiValueElementsForFormEditor(
516 array $formDefinition,
517 array $multiValueProperties
518 ): array {
519 $output = $formDefinition;
520 foreach ($formDefinition as $key => $value) {
521 if (isset($value['type']) && array_key_exists($value['type'], $multiValueProperties)) {
522 $multiValuePropertiesForType = $multiValueProperties[$value['type']];
523 foreach ($multiValuePropertiesForType as $multiValueProperty) {
524 if (!ArrayUtility::isValidPath($value, $multiValueProperty, '.')) {
525 continue;
526 }
527 $multiValuePropertyData = ArrayUtility::getValueByPath($value, $multiValueProperty, '.');
528 if (!is_array($multiValuePropertyData)) {
529 continue;
530 }
531 $newMultiValuePropertyData = [];
532 foreach ($multiValuePropertyData as $k => $v) {
533 $newMultiValuePropertyData[] = [
534 '_label' => $v,
535 '_value' => $k
536 ];
537 }
538 $value = ArrayUtility::setValueByPath($value, $multiValueProperty, $newMultiValuePropertyData, '.');
539 }
540 }
541
542 $output[$key] = $value;
543 if (is_array($value)) {
544 $output[$key] = $this->transformMultiValueElementsForFormEditor($value, $multiValueProperties);
545 }
546 }
547
548 return $output;
549 }
550
551 /**
552 * Remove keys from an array if the key value is an empty array
553 *
554 * @param array $array
555 * @return array
556 */
557 protected function filterEmptyArrays(array $array): array
558 {
559 foreach ($array as $key => $value) {
560 if (!is_array($value)) {
561 continue;
562 }
563 if (empty($value)) {
564 unset($array[$key]);
565 continue;
566 }
567 $array[$key] = $this->filterEmptyArrays($value);
568 if (empty($array[$key])) {
569 unset($array[$key]);
570 }
571 }
572
573 return $array;
574 }
575
576 /**
577 * Returns the current BE user.
578 *
579 * @return BackendUserAuthentication
580 */
581 protected function getBackendUser(): BackendUserAuthentication
582 {
583 return $GLOBALS['BE_USER'];
584 }
585
586 /**
587 * Returns the language service
588 *
589 * @return LanguageService
590 */
591 protected function getLanguageService(): LanguageService
592 {
593 return $GLOBALS['LANG'];
594 }
595 }