[TASK] Performance optimizations for the form manager module
[Packages/TYPO3.CMS.git] / typo3 / sysext / form / Classes / Controller / FormManagerController.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 Symfony\Component\Yaml\Yaml;
19 use TYPO3\CMS\Backend\Template\Components\ButtonBar;
20 use TYPO3\CMS\Backend\Utility\BackendUtility;
21 use TYPO3\CMS\Backend\View\BackendTemplateView;
22 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
23 use TYPO3\CMS\Core\Imaging\Icon;
24 use TYPO3\CMS\Core\Imaging\IconFactory;
25 use TYPO3\CMS\Core\Localization\LanguageService;
26 use TYPO3\CMS\Core\Messaging\AbstractMessage;
27 use TYPO3\CMS\Core\Page\PageRenderer;
28 use TYPO3\CMS\Core\Utility\ArrayUtility;
29 use TYPO3\CMS\Core\Utility\GeneralUtility;
30 use TYPO3\CMS\Extbase\Mvc\View\JsonView;
31 use TYPO3\CMS\Form\Exception as FormException;
32 use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
33 use TYPO3\CMS\Form\Service\DatabaseService;
34 use TYPO3\CMS\Form\Service\TranslationService;
35
36 /**
37 * The form manager controller
38 *
39 * Scope: backend
40 */
41 class FormManagerController extends AbstractBackendController
42 {
43
44 /**
45 * @var DatabaseService
46 */
47 protected $databaseService;
48
49 /**
50 * @param \TYPO3\CMS\Form\Service\DatabaseService $databaseService
51 * @internal
52 */
53 public function injectDatabaseService(\TYPO3\CMS\Form\Service\DatabaseService $databaseService)
54 {
55 $this->databaseService = $databaseService;
56 }
57
58 /**
59 * Default View Container
60 *
61 * @var BackendTemplateView
62 */
63 protected $defaultViewObjectName = BackendTemplateView::class;
64
65 /**
66 * Displays the Form Manager
67 *
68 * @internal
69 */
70 public function indexAction()
71 {
72 $this->registerDocheaderButtons();
73 $this->view->getModuleTemplate()->setModuleName($this->request->getPluginName() . '_' . $this->request->getControllerName());
74 $this->view->getModuleTemplate()->setFlashMessageQueue($this->controllerContext->getFlashMessageQueue());
75
76 $this->view->assign('forms', $this->getAvailableFormDefinitions());
77 $this->view->assign('stylesheets', $this->resolveResourcePaths($this->formSettings['formManager']['stylesheets']));
78 $this->view->assign('dynamicRequireJsModules', $this->formSettings['formManager']['dynamicRequireJsModules']);
79 $this->view->assign('formManagerAppInitialData', $this->getFormManagerAppInitialData());
80 if (!empty($this->formSettings['formManager']['javaScriptTranslationFile'])) {
81 $this->getPageRenderer()->addInlineLanguageLabelFile($this->formSettings['formManager']['javaScriptTranslationFile']);
82 }
83 }
84
85 /**
86 * Initialize the create action.
87 * This action uses the Fluid JsonView::class as view.
88 *
89 * @internal
90 */
91 public function initializeCreateAction()
92 {
93 $this->defaultViewObjectName = JsonView::class;
94 }
95
96 /**
97 * Creates a new Form and redirects to the Form Editor
98 *
99 * @param string $formName
100 * @param string $templatePath
101 * @param string $prototypeName
102 * @param string $savePath
103 * @throws FormException
104 * @internal
105 */
106 public function createAction(string $formName, string $templatePath, string $prototypeName, string $savePath)
107 {
108 if (!$this->isValidTemplatePath($prototypeName, $templatePath)) {
109 throw new FormException(sprintf('The template path "%s" is not allowed', $templatePath), 1329233410);
110 }
111 if (empty($formName)) {
112 throw new FormException(sprintf('No form name', $templatePath), 1472312204);
113 }
114
115 $templatePath = GeneralUtility::getFileAbsFileName($templatePath);
116 $form = Yaml::parse(file_get_contents($templatePath));
117 $form['label'] = $formName;
118 $form['identifier'] = $this->formPersistenceManager->getUniqueIdentifier($this->convertFormNameToIdentifier($formName));
119 $form['prototypeName'] = $prototypeName;
120
121 $formPersistenceIdentifier = $this->formPersistenceManager->getUniquePersistenceIdentifier($form['identifier'], $savePath);
122
123 foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormCreate'] ?? [] as $className) {
124 $hookObj = GeneralUtility::makeInstance($className);
125 if (method_exists($hookObj, 'beforeFormCreate')) {
126 $form = $hookObj->beforeFormCreate(
127 $formPersistenceIdentifier,
128 $form
129 );
130 }
131 }
132
133 $response = [
134 'status' => 'success',
135 'url' => $this->controllerContext->getUriBuilder()->uriFor('index', ['formPersistenceIdentifier' => $formPersistenceIdentifier], 'FormEditor')
136 ];
137
138 try {
139 $this->formPersistenceManager->save($formPersistenceIdentifier, $form);
140 } catch (PersistenceManagerException $e) {
141 $response = [
142 'status' => 'error',
143 'message' => $e->getMessage(),
144 'code' => $e->getCode(),
145 ];
146 }
147
148 $this->view->assign('response', $response);
149 // createAction uses the Extbase JsonView::class.
150 // That's why we have to set the view variables in this way.
151 $this->view->setVariablesToRender([
152 'response',
153 ]);
154 }
155
156 /**
157 * Initialize the duplicate action.
158 * This action uses the Fluid JsonView::class as view.
159 *
160 * @internal
161 */
162 public function initializeDuplicateAction()
163 {
164 $this->defaultViewObjectName = JsonView::class;
165 }
166
167 /**
168 * Duplicates a given formDefinition and redirects to the Form Editor
169 *
170 * @param string $formName
171 * @param string $formPersistenceIdentifier persistence identifier of the form to duplicate
172 * @param string $savePath
173 * @internal
174 */
175 public function duplicateAction(string $formName, string $formPersistenceIdentifier, string $savePath)
176 {
177 $formToDuplicate = $this->formPersistenceManager->load($formPersistenceIdentifier);
178 $formToDuplicate['label'] = $formName;
179 $formToDuplicate['identifier'] = $this->formPersistenceManager->getUniqueIdentifier($this->convertFormNameToIdentifier($formName));
180
181 $formPersistenceIdentifier = $this->formPersistenceManager->getUniquePersistenceIdentifier($formToDuplicate['identifier'], $savePath);
182
183 foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormDuplicate'] ?? [] as $className) {
184 $hookObj = GeneralUtility::makeInstance($className);
185 if (method_exists($hookObj, 'beforeFormDuplicate')) {
186 $formToDuplicate = $hookObj->beforeFormDuplicate(
187 $formPersistenceIdentifier,
188 $formToDuplicate
189 );
190 }
191 }
192
193 $response = [
194 'status' => 'success',
195 'url' => $this->controllerContext->getUriBuilder()->uriFor('index', ['formPersistenceIdentifier' => $formPersistenceIdentifier], 'FormEditor')
196 ];
197
198 try {
199 $this->formPersistenceManager->save($formPersistenceIdentifier, $formToDuplicate);
200 } catch (PersistenceManagerException $e) {
201 $response = [
202 'status' => 'error',
203 'message' => $e->getMessage(),
204 'code' => $e->getCode(),
205 ];
206 }
207
208 $this->view->assign('response', $response);
209 // createAction uses the Extbase JsonView::class.
210 // That's why we have to set the view variables in this way.
211 $this->view->setVariablesToRender([
212 'response',
213 ]);
214 }
215
216 /**
217 * Initialize the references action.
218 * This action uses the Fluid JsonView::class as view.
219 *
220 * @internal
221 */
222 public function initializeReferencesAction()
223 {
224 $this->defaultViewObjectName = JsonView::class;
225 }
226
227 /**
228 * Show references to this persistence identifier
229 *
230 * @param string $formPersistenceIdentifier persistence identifier of the form to duplicate
231 * @internal
232 */
233 public function referencesAction(string $formPersistenceIdentifier)
234 {
235 $this->view->assign('references', $this->getProcessedReferencesRows($formPersistenceIdentifier));
236 $this->view->assign('formPersistenceIdentifier', $formPersistenceIdentifier);
237 // referencesAction uses the extbase JsonView::class.
238 // That's why we have to set the view variables in this way.
239 $this->view->setVariablesToRender([
240 'references',
241 'formPersistenceIdentifier'
242 ]);
243 }
244
245 /**
246 * Delete a formDefinition identified by the $formPersistenceIdentifier.
247 *
248 * @param string $formPersistenceIdentifier persistence identifier to delete
249 * @internal
250 */
251 public function deleteAction(string $formPersistenceIdentifier)
252 {
253 if (empty($this->databaseService->getReferencesByPersistenceIdentifier($formPersistenceIdentifier))) {
254 foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormDelete'] ?? [] as $className) {
255 $hookObj = GeneralUtility::makeInstance($className);
256 if (method_exists($hookObj, 'beforeFormDelete')) {
257 $hookObj->beforeFormDelete(
258 $formPersistenceIdentifier
259 );
260 }
261 }
262
263 $this->formPersistenceManager->delete($formPersistenceIdentifier);
264 } else {
265 $controllerConfiguration = TranslationService::getInstance()->translateValuesRecursive(
266 $this->formSettings['formManager']['controller'],
267 $this->formSettings['formManager']['translationFile']
268 );
269
270 $this->addFlashMessage(
271 sprintf($controllerConfiguration['deleteAction']['errorMessage'], $formPersistenceIdentifier),
272 $controllerConfiguration['deleteAction']['errorTitle'],
273 AbstractMessage::ERROR,
274 true
275 );
276 }
277 $this->redirect('index');
278 }
279
280 /**
281 * Return a list of all accessible file mountpoints.
282 *
283 * Only registered mountpoints from
284 * TYPO3.CMS.Form.persistenceManager.allowedFileMounts
285 * are listet. This is list will be reduced by the configured
286 * mountpoints for the current backend user.
287 *
288 * @return array
289 */
290 protected function getAccessibleFormStorageFolders(): array
291 {
292 $preparedAccessibleFormStorageFolders = [];
293 foreach ($this->formPersistenceManager->getAccessibleFormStorageFolders() as $identifier => $folder) {
294 // TODO: deprecated since TYPO3 v9, will be removed in TYPO3 v10
295 if ($folder->getCombinedIdentifier() === '1:/user_upload/') {
296 continue;
297 }
298
299 $preparedAccessibleFormStorageFolders[] = [
300 'label' => $folder->getName(),
301 'value' => $identifier
302 ];
303 }
304
305 if ($this->formSettings['persistenceManager']['allowSaveToExtensionPaths']) {
306 foreach ($this->formPersistenceManager->getAccessibleExtensionFolders() as $relativePath => $fullPath) {
307 $preparedAccessibleFormStorageFolders[] = [
308 'label' => $relativePath,
309 'value' => $relativePath
310 ];
311 }
312 }
313
314 return $preparedAccessibleFormStorageFolders;
315 }
316
317 /**
318 * Returns the json encoded data which is used by the form editor
319 * JavaScript app.
320 *
321 * @return string
322 */
323 protected function getFormManagerAppInitialData(): string
324 {
325 $formManagerAppInitialData = [
326 'selectablePrototypesConfiguration' => $this->formSettings['formManager']['selectablePrototypesConfiguration'],
327 'accessibleFormStorageFolders' => $this->getAccessibleFormStorageFolders(),
328 'endpoints' => [
329 'create' => $this->controllerContext->getUriBuilder()->uriFor('create'),
330 'duplicate' => $this->controllerContext->getUriBuilder()->uriFor('duplicate'),
331 'delete' => $this->controllerContext->getUriBuilder()->uriFor('delete'),
332 'references' => $this->controllerContext->getUriBuilder()->uriFor('references')
333 ],
334 ];
335
336 $formManagerAppInitialData = ArrayUtility::reIndexNumericArrayKeysRecursive($formManagerAppInitialData);
337 $formManagerAppInitialData = TranslationService::getInstance()->translateValuesRecursive(
338 $formManagerAppInitialData,
339 $this->formSettings['formManager']['translationFile'] ?? null
340 );
341 return json_encode($formManagerAppInitialData);
342 }
343
344 /**
345 * List all formDefinitions which can be loaded through t form persistence
346 * manager. Enrich this data by a reference counter.
347 * @return array
348 */
349 protected function getAvailableFormDefinitions(): array
350 {
351 $allReferencesForFileUid = $this->databaseService->getAllReferencesForFileUid();
352 $allReferencesForPersistenceIdentifier = $this->databaseService->getAllReferencesForPersistenceIdentifier();
353
354 $availableFormDefinitions = [];
355 foreach ($this->formPersistenceManager->listForms() as $formDefinition) {
356 $referenceCount = 0;
357 if (
358 isset($formDefinition['fileUid'])
359 && array_key_exists($formDefinition['fileUid'], $allReferencesForFileUid)
360 ) {
361 $referenceCount = $allReferencesForFileUid[$formDefinition['fileUid']];
362 } elseif (array_key_exists($formDefinition['persistenceIdentifier'], $allReferencesForPersistenceIdentifier)) {
363 $referenceCount = $allReferencesForPersistenceIdentifier[$formDefinition['persistenceIdentifier']];
364 }
365
366 $formDefinition['referenceCount'] = $referenceCount;
367 $availableFormDefinitions[] = $formDefinition;
368 }
369
370 return $availableFormDefinitions;
371 }
372
373 /**
374 * Returns an array with informations about the references for a
375 * formDefinition identified by $persistenceIdentifier.
376 *
377 * @param string $persistenceIdentifier
378 * @return array
379 * @throws \InvalidArgumentException
380 */
381 protected function getProcessedReferencesRows(string $persistenceIdentifier): array
382 {
383 if (empty($persistenceIdentifier)) {
384 throw new \InvalidArgumentException('$persistenceIdentifier must not be empty.', 1477071939);
385 }
386
387 $references = [];
388 $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
389
390 $referenceRows = $this->databaseService->getReferencesByPersistenceIdentifier($persistenceIdentifier);
391 foreach ($referenceRows as &$referenceRow) {
392 $record = $this->getRecord($referenceRow['tablename'], $referenceRow['recuid']);
393 if (!$record) {
394 continue;
395 }
396 $pageRecord = $this->getRecord('pages', $record['pid']);
397 $urlParameters = [
398 'edit' => [
399 $referenceRow['tablename'] => [
400 $referenceRow['recuid'] => 'edit'
401 ]
402 ],
403 'returnUrl' => $this->getModuleUrl('web_FormFormbuilder')
404 ];
405
406 $references[] = [
407 'recordPageTitle' => is_array($pageRecord) ? $this->getRecordTitle('pages', $pageRecord) : '',
408 'recordTitle' => $this->getRecordTitle($referenceRow['tablename'], $record, true),
409 'recordIcon' => $iconFactory->getIconForRecord($referenceRow['tablename'], $record, Icon::SIZE_SMALL)->render(),
410 'recordUid' => $referenceRow['recuid'],
411 'recordEditUrl' => $this->getModuleUrl('record_edit', $urlParameters),
412 ];
413 }
414 return $references;
415 }
416
417 /**
418 * Check if a given $templatePath for a given $prototypeName is valid
419 * and accessible.
420 *
421 * Valid template paths has to be configured within
422 * TYPO3.CMS.Form.formManager.selectablePrototypesConfiguration.[('identifier': $prototypeName)].newFormTemplates.[('templatePath': $templatePath)]
423 *
424 * @param string $prototypeName
425 * @param string $templatePath
426 * @return bool
427 */
428 protected function isValidTemplatePath(string $prototypeName, string $templatePath): bool
429 {
430 $isValid = false;
431 foreach ($this->formSettings['formManager']['selectablePrototypesConfiguration'] as $prototypesConfiguration) {
432 if ($prototypesConfiguration['identifier'] !== $prototypeName) {
433 continue;
434 }
435 foreach ($prototypesConfiguration['newFormTemplates'] as $templatesConfiguration) {
436 if ($templatesConfiguration['templatePath'] !== $templatePath) {
437 continue;
438 }
439 $isValid = true;
440 break;
441 }
442 }
443
444 $templatePath = GeneralUtility::getFileAbsFileName($templatePath);
445 if (!is_file($templatePath)) {
446 $isValid = false;
447 }
448
449 return $isValid;
450 }
451
452 /**
453 * Register document header buttons
454 *
455 * @throws \InvalidArgumentException
456 */
457 protected function registerDocheaderButtons()
458 {
459 /** @var ButtonBar $buttonBar */
460 $buttonBar = $this->view->getModuleTemplate()->getDocHeaderComponent()->getButtonBar();
461 $currentRequest = $this->request;
462 $moduleName = $currentRequest->getPluginName();
463 $getVars = $this->request->getArguments();
464
465 // Create new
466 $addFormButton = $buttonBar->makeLinkButton()
467 ->setDataAttributes(['identifier' => 'newForm'])
468 ->setHref('#')
469 ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formManager.create_new_form'))
470 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-add', Icon::SIZE_SMALL));
471 $buttonBar->addButton($addFormButton, ButtonBar::BUTTON_POSITION_LEFT);
472
473 // Reload
474 $reloadButton = $buttonBar->makeLinkButton()
475 ->setHref(GeneralUtility::getIndpEnv('REQUEST_URI'))
476 ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload'))
477 ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-refresh', Icon::SIZE_SMALL));
478 $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT);
479
480 // Shortcut
481 $mayMakeShortcut = $this->getBackendUser()->mayMakeShortcut();
482 if ($mayMakeShortcut) {
483 $extensionName = $currentRequest->getControllerExtensionName();
484 if (count($getVars) === 0) {
485 $modulePrefix = strtolower('tx_' . $extensionName . '_' . $moduleName);
486 $getVars = ['id', 'route', $modulePrefix];
487 }
488
489 $shortcutButton = $buttonBar->makeShortcutButton()
490 ->setModuleName($moduleName)
491 ->setDisplayName($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:module.shortcut_name'))
492 ->setGetVariables($getVars);
493 $buttonBar->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT);
494 }
495 }
496
497 /**
498 * Returns a form identifier which is the lower cased form name.
499 *
500 * @param string $formName
501 * @return string
502 */
503 protected function convertFormNameToIdentifier(string $formName): string
504 {
505 $formIdentifier = preg_replace('/[^a-zA-Z0-9-_]/', '', $formName);
506 $formIdentifier = lcfirst($formIdentifier);
507 return $formIdentifier;
508 }
509
510 /**
511 * Wrapper used for unit testing.
512 *
513 * @param string $table
514 * @param int $uid
515 * @return array|null
516 */
517 protected function getRecord(string $table, int $uid)
518 {
519 return BackendUtility::getRecord($table, $uid);
520 }
521
522 /**
523 * Wrapper used for unit testing.
524 *
525 * @param string $table
526 * @param array $row
527 * @param bool $prep
528 * @return string
529 */
530 protected function getRecordTitle(string $table, array $row, bool $prep = false): string
531 {
532 return BackendUtility::getRecordTitle($table, $row, $prep);
533 }
534
535 /**
536 * Wrapper used for unit testing.
537 *
538 * @param string $moduleName
539 * @param array $urlParameters
540 * @return string
541 */
542 protected function getModuleUrl(string $moduleName, array $urlParameters = []): string
543 {
544 /** @var \TYPO3\CMS\Backend\Routing\UriBuilder $uriBuilder */
545 $uriBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Routing\UriBuilder::class);
546 return (string)$uriBuilder->buildUriFromRoute($moduleName, $urlParameters);
547 }
548
549 /**
550 * Returns the current BE user.
551 *
552 * @return BackendUserAuthentication
553 */
554 protected function getBackendUser(): BackendUserAuthentication
555 {
556 return $GLOBALS['BE_USER'];
557 }
558
559 /**
560 * Returns the Language Service
561 *
562 * @return LanguageService
563 */
564 protected function getLanguageService(): LanguageService
565 {
566 return $GLOBALS['LANG'];
567 }
568
569 /**
570 * Returns the page renderer
571 *
572 * @return PageRenderer
573 */
574 protected function getPageRenderer(): PageRenderer
575 {
576 return GeneralUtility::makeInstance(PageRenderer::class);
577 }
578 }