11d6050c77c593e0bd873036149829fdac7eaabf
[Packages/TYPO3.CMS.git] / typo3 / sysext / form / Classes / Domain / Model / FormDefinition.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Form\Domain\Model;
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\Core\Utility\ArrayUtility;
19 use TYPO3\CMS\Extbase\Mvc\Web\Request;
20 use TYPO3\CMS\Extbase\Mvc\Web\Response;
21 use TYPO3\CMS\Extbase\Reflection\ObjectAccess;
22 use TYPO3\CMS\Form\Domain\Exception\IdentifierNotValidException;
23 use TYPO3\CMS\Form\Domain\Exception\TypeDefinitionNotFoundException;
24 use TYPO3\CMS\Form\Domain\Finishers\FinisherInterface;
25 use TYPO3\CMS\Form\Domain\Model\Exception\DuplicateFormElementException;
26 use TYPO3\CMS\Form\Domain\Model\Exception\FinisherPresetNotFoundException;
27 use TYPO3\CMS\Form\Domain\Model\Exception\FormDefinitionConsistencyException;
28 use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
29 use TYPO3\CMS\Form\Domain\Model\FormElements\Page;
30 use TYPO3\CMS\Form\Domain\Model\Renderable\AbstractCompositeRenderable;
31 use TYPO3\CMS\Form\Domain\Model\Renderable\RenderableInterface;
32 use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
33 use TYPO3\CMS\Form\Exception as FormException;
34 use TYPO3\CMS\Form\Mvc\ProcessingRule;
35
36 /**
37 * This class encapsulates a complete *Form Definition*, with all of its pages,
38 * form elements, validation rules which apply and finishers which should be
39 * executed when the form is completely filled in.
40 *
41 * It is *not modified* when the form executes.
42 *
43 * The Anatomy Of A Form
44 * =====================
45 *
46 * A FormDefinition consists of multiple *Page* ({@link Page}) objects. When a
47 * form is displayed to the user, only one *Page* is visible at any given time,
48 * and there is a navigation to go back and forth between the pages.
49 *
50 * A *Page* consists of multiple *FormElements* ({@link FormElementInterface}, {@link AbstractFormElement}),
51 * which represent the input fields, textareas, checkboxes shown inside the page.
52 *
53 * *FormDefinition*, *Page* and *FormElement* have *identifier* properties, which
54 * must be unique for each given type (i.e. it is allowed that the FormDefinition and
55 * a FormElement have the *same* identifier, but two FormElements are not allowed to
56 * have the same identifier.
57 *
58 * Simple Example
59 * --------------
60 *
61 * Generally, you can create a FormDefinition manually by just calling the API
62 * methods on it, or you use a *Form Definition Factory* to build the form from
63 * another representation format such as YAML.
64 *
65 * /---code php
66 * $formDefinition = $this->objectManager->get(FormDefinition::class, 'myForm');
67 *
68 * $page1 = $this->objectManager->get(Page::class, 'page1');
69 * $formDefinition->addPage($page);
70 *
71 * $element1 = $this->objectManager->get(GenericFormElement::class, 'title', 'Textfield'); # the second argument is the type of the form element
72 * $page1->addElement($element1);
73 * \---
74 *
75 * Creating a Form, Using Abstract Form Element Types
76 * =====================================================
77 *
78 * While you can use the {@link FormDefinition::addPage} or {@link Page::addElement}
79 * methods and create the Page and FormElement objects manually, it is often better
80 * to use the corresponding create* methods ({@link FormDefinition::createPage}
81 * and {@link Page::createElement}), as you pass them an abstract *Form Element Type*
82 * such as *Text* or *Page*, and the system **automatically
83 * resolves the implementation class name and sets default values**.
84 *
85 * So the simple example from above should be rewritten as follows:
86 *
87 * /---code php
88 * $prototypeConfiguration = []; // We'll talk about this later
89 *
90 * $formDefinition = $this->objectManager->get(FormDefinition::class, 'myForm', $prototypeConfiguration);
91 * $page1 = $formDefinition->createPage('page1');
92 * $element1 = $page1->addElement('title', 'Textfield');
93 * \---
94 *
95 * Now, you might wonder how the system knows that the element *Textfield*
96 * is implemented using a GenericFormElement: **This is configured in the $prototypeConfiguration**.
97 *
98 * To make the example from above actually work, we need to add some sensible
99 * values to *$prototypeConfiguration*:
100 *
101 * <pre>
102 * $prototypeConfiguration = [
103 * 'formElementsDefinition' => [
104 * 'Page' => [
105 * 'implementationClassName' => 'TYPO3\CMS\Form\Domain\Model\FormElements\Page'
106 * ],
107 * 'Textfield' => [
108 * 'implementationClassName' => 'TYPO3\CMS\Form\Domain\Model\FormElements\GenericFormElement'
109 * ]
110 * ]
111 * ]
112 * </pre>
113 *
114 * For each abstract *Form Element Type* we add some configuration; in the above
115 * case only the *implementation class name*. Still, it is possible to set defaults
116 * for *all* configuration options of such an element, as the following example
117 * shows:
118 *
119 * <pre>
120 * $prototypeConfiguration = [
121 * 'formElementsDefinition' => [
122 * 'Page' => [
123 * 'implementationClassName' => 'TYPO3\CMS\Form\Domain\Model\FormElements\Page',
124 * 'label' => 'this is the label of the page if nothing is specified'
125 * ],
126 * 'Textfield' => [
127 * 'implementationClassName' => 'TYPO3\CMS\Form\Domain\Model\FormElements\GenericFormElement',
128 * 'label' = >'Default Label',
129 * 'defaultValue' => 'Default form element value',
130 * 'properties' => [
131 * 'placeholder' => 'Text which is shown if element is empty'
132 * ]
133 * ]
134 * ]
135 * ]
136 * </pre>
137 *
138 * Using Preconfigured $prototypeConfiguration
139 * ---------------------------------
140 *
141 * Often, it is not really useful to manually create the $prototypeConfiguration array.
142 *
143 * Most of it comes pre-configured inside the extensions's yaml settings,
144 * and the {@link \TYPO3\CMS\Form\Domain\Configuration\ConfigurationService} contains helper methods
145 * which return the ready-to-use *$prototypeConfiguration*.
146 *
147 * Property Mapping and Validation Rules
148 * =====================================
149 *
150 * Besides Pages and FormElements, the FormDefinition can contain information
151 * about the *format of the data* which is inputted into the form. This generally means:
152 *
153 * - expected Data Types
154 * - Property Mapping Configuration to be used
155 * - Validation Rules which should apply
156 *
157 * Background Info
158 * ---------------
159 * You might wonder why Data Types and Validation Rules are *not attached
160 * to each FormElement itself*.
161 *
162 * If the form should create a *hierarchical output structure* such as a multi-
163 * dimensional array or a PHP object, your expected data structure might look as follows:
164 * <pre>
165 * - person
166 * -- firstName
167 * -- lastName
168 * -- address
169 * --- street
170 * --- city
171 * </pre>
172 *
173 * Now, let's imagine you want to edit *person.address.street* and *person.address.city*,
174 * but want to validate that the *combination* of *street* and *city* is valid
175 * according to some address database.
176 *
177 * In this case, the form elements would be configured to fill *street* and *city*,
178 * but the *validator* needs to be attached to the *compound object* *address*,
179 * as both parts need to be validated together.
180 *
181 * Connecting FormElements to the output data structure
182 * ====================================================
183 *
184 * The *identifier* of the *FormElement* is most important, as it determines
185 * where in the output structure the value which is entered by the user is placed,
186 * and thus also determines which validation rules need to apply.
187 *
188 * Using the above example, if you want to create a FormElement for the *street*,
189 * you should use the identifier *person.address.street*.
190 *
191 * Rendering a FormDefinition
192 * ==========================
193 *
194 * In order to trigger *rendering* on a FormDefinition,
195 * the current {@link \TYPO3\CMS\Extbase\Mvc\Web\Request} needs to be bound to the FormDefinition,
196 * resulting in a {@link \TYPO3\CMS\Form\Domain\Runtime\FormRuntime} object which contains the *Runtime State* of the form
197 * (such as the currently inserted values).
198 *
199 * /---code php
200 * # $currentRequest and $currentResponse need to be available, f.e. inside a controller you would
201 * # use $this->request and $this->response; inside a ViewHelper you would use $this->controllerContext->getRequest()
202 * # and $this->controllerContext->getResponse()
203 * $form = $formDefinition->bind($currentRequest, $currentResponse);
204 *
205 * # now, you can use the $form object to get information about the currently
206 * # entered values into the form, etc.
207 * \---
208 *
209 * Refer to the {@link \TYPO3\CMS\Form\Domain\Runtime\FormRuntime} API doc for further information.
210 *
211 * Scope: frontend
212 * **This class is NOT meant to be sub classed by developers.**
213 */
214 class FormDefinition extends AbstractCompositeRenderable
215 {
216
217 /**
218 * @var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface
219 */
220 protected $objectManager;
221
222 /**
223 * The finishers for this form
224 *
225 * @var \TYPO3\CMS\Form\Domain\Finishers\FinisherInterface[]
226 */
227 protected $finishers = [];
228
229 /**
230 * Property Mapping Rules, indexed by element identifier
231 *
232 * @var \TYPO3\CMS\Form\Mvc\ProcessingRule[]
233 */
234 protected $processingRules = [];
235
236 /**
237 * Contains all elements of the form, indexed by identifier.
238 * Is used as internal cache as we need this really often.
239 *
240 * @var \TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface[]
241 */
242 protected $elementsByIdentifier = [];
243
244 /**
245 * Form element default values in the format ['elementIdentifier' => 'default value']
246 *
247 * @var array
248 */
249 protected $elementDefaultValues = [];
250
251 /**
252 * @var array
253 */
254 protected $typeDefinitions;
255
256 /**
257 * @var array
258 */
259 protected $validatorsDefinition;
260
261 /**
262 * @var array
263 */
264 protected $finishersDefinition;
265
266 /**
267 * The persistence identifier of the form
268 *
269 * @var string
270 */
271 protected $persistenceIdentifier = null;
272
273 /**
274 * @param \TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager
275 * @internal
276 */
277 public function injectObjectManager(\TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager)
278 {
279 $this->objectManager = $objectManager;
280 }
281
282 /**
283 * Constructor. Creates a new FormDefinition with the given identifier.
284 *
285 * @param string $identifier The Form Definition's identifier, must be a non-empty string.
286 * @param array $prototypeConfiguration overrides form defaults of this definition
287 * @param string $type element type of this form
288 * @param string $persistenceIdentifier the persistence identifier of the form
289 * @throws IdentifierNotValidException if the identifier was not valid
290 * @api
291 */
292 public function __construct(
293 string $identifier,
294 array $prototypeConfiguration = [],
295 string $type = 'Form',
296 string $persistenceIdentifier = null
297 ) {
298 $this->typeDefinitions = isset($prototypeConfiguration['formElementsDefinition']) ? $prototypeConfiguration['formElementsDefinition'] : [];
299 $this->validatorsDefinition = isset($prototypeConfiguration['validatorsDefinition']) ? $prototypeConfiguration['validatorsDefinition'] : [];
300 $this->finishersDefinition = isset($prototypeConfiguration['finishersDefinition']) ? $prototypeConfiguration['finishersDefinition'] : [];
301
302 if (!is_string($identifier) || strlen($identifier) === 0) {
303 throw new IdentifierNotValidException('The given identifier was not a string or the string was empty.', 1477082503);
304 }
305
306 $this->identifier = $identifier;
307 $this->type = $type;
308 $this->persistenceIdentifier = $persistenceIdentifier;
309
310 if ($prototypeConfiguration !== []) {
311 $this->initializeFromFormDefaults();
312 }
313 }
314
315 /**
316 * Initialize the form defaults of the current type
317 *
318 * @return void
319 * @throws TypeDefinitionNotFoundException
320 * @internal
321 */
322 protected function initializeFromFormDefaults()
323 {
324 if (!isset($this->typeDefinitions[$this->type])) {
325 throw new TypeDefinitionNotFoundException(sprintf('Type "%s" not found. Probably some configuration is missing.', $this->type), 1474905835);
326 }
327 $typeDefinition = $this->typeDefinitions[$this->type];
328 $this->setOptions($typeDefinition);
329 }
330
331 /**
332 * Set multiple properties of this object at once.
333 * Every property which has a corresponding set* method can be set using
334 * the passed $options array.
335 *
336 * @param array $options
337 * @return void
338 * @internal
339 */
340 public function setOptions(array $options)
341 {
342 if (isset($options['rendererClassName'])) {
343 $this->setRendererClassName($options['rendererClassName']);
344 }
345 if (isset($options['renderingOptions'])) {
346 foreach ($options['renderingOptions'] as $key => $value) {
347 if (is_array($value)) {
348 $currentValue = isset($this->getRenderingOptions()[$key]) ? $this->getRenderingOptions()[$key] : [];
349 ArrayUtility::mergeRecursiveWithOverrule($currentValue, $value);
350 $this->setRenderingOption($key, $currentValue);
351 } else {
352 $this->setRenderingOption($key, $value);
353 }
354 }
355 }
356 if (isset($options['finishers'])) {
357 foreach ($options['finishers'] as $finisherConfiguration) {
358 $this->createFinisher($finisherConfiguration['identifier'], isset($finisherConfiguration['options']) ? $finisherConfiguration['options'] : []);
359 }
360 }
361
362 ArrayUtility::assertAllArrayKeysAreValid(
363 $options,
364 ['rendererClassName', 'renderingOptions', 'finishers', 'formEditor']
365 );
366 }
367
368 /**
369 * Create a page with the given $identifier and attach this page to the form.
370 *
371 * - Create Page object based on the given $typeName
372 * - set defaults inside the Page object
373 * - attach Page object to this form
374 * - return the newly created Page object
375 *
376 * @param string $identifier Identifier of the new page
377 * @param string $typeName Type of the new page
378 * @return Page the newly created page
379 * @throws TypeDefinitionNotFoundException
380 * @api
381 */
382 public function createPage(string $identifier, string $typeName = 'Page'): Page
383 {
384 if (!isset($this->typeDefinitions[$typeName])) {
385 throw new TypeDefinitionNotFoundException(sprintf('Type "%s" not found. Probably some configuration is missing.', $typeName), 1474905953);
386 }
387
388 $typeDefinition = $this->typeDefinitions[$typeName];
389
390 if (!isset($typeDefinition['implementationClassName'])) {
391 throw new TypeDefinitionNotFoundException(sprintf('The "implementationClassName" was not set in type definition "%s".', $typeName), 1477083126);
392 }
393 $implementationClassName = $typeDefinition['implementationClassName'];
394 $page = $this->objectManager->get($implementationClassName, $identifier, $typeName);
395
396 if (isset($typeDefinition['label'])) {
397 $page->setLabel($typeDefinition['label']);
398 }
399
400 if (isset($typeDefinition['rendererClassName'])) {
401 $page->setRendererClassName($typeDefinition['rendererClassName']);
402 }
403
404 if (isset($typeDefinition['renderingOptions'])) {
405 foreach ($typeDefinition['renderingOptions'] as $key => $value) {
406 $page->setRenderingOption($key, $value);
407 }
408 }
409
410 ArrayUtility::assertAllArrayKeysAreValid(
411 $typeDefinition,
412 ['implementationClassName', 'label', 'rendererClassName', 'renderingOptions', 'formEditor']
413 );
414
415 $this->addPage($page);
416 return $page;
417 }
418
419 /**
420 * Add a new page at the end of the form.
421 *
422 * Instead of this method, you should often use {@link createPage} instead.
423 *
424 * @param Page $page
425 * @return void
426 * @throws FormDefinitionConsistencyException if Page is already added to a FormDefinition
427 * @see createPage
428 * @api
429 */
430 public function addPage(Page $page)
431 {
432 $this->addRenderable($page);
433 }
434
435 /**
436 * Get the Form's pages
437 *
438 * @return array<Page> The Form's pages in the correct order
439 * @api
440 */
441 public function getPages(): array
442 {
443 return $this->renderables;
444 }
445
446 /**
447 * Check whether a page with the given $index exists
448 *
449 * @param int $index
450 * @return bool TRUE if a page with the given $index exists, otherwise FALSE
451 * @api
452 */
453 public function hasPageWithIndex(int $index): bool
454 {
455 return isset($this->renderables[$index]);
456 }
457
458 /**
459 * Get the page with the passed index. The first page has index zero.
460 *
461 * If page at $index does not exist, an exception is thrown. @see hasPageWithIndex()
462 *
463 * @param int $index
464 * @return Page the page, or NULL if none found.
465 * @throws FormException if the specified index does not exist
466 * @api
467 */
468 public function getPageByIndex(int $index)
469 {
470 if (!$this->hasPageWithIndex($index)) {
471 throw new FormException(sprintf('There is no page with an index of %d', $index), 1329233627);
472 }
473 return $this->renderables[$index];
474 }
475
476 /**
477 * Adds the specified finisher to this form
478 *
479 * @param FinisherInterface $finisher
480 * @return void
481 * @api
482 */
483 public function addFinisher(FinisherInterface $finisher)
484 {
485 $this->finishers[] = $finisher;
486 }
487
488 /**
489 * @param string $finisherIdentifier identifier of the finisher as registered in the current form (for example: "Redirect")
490 * @param array $options options for this finisher in the format ['option1' => 'value1', 'option2' => 'value2', ...]
491 * @return FinisherInterface
492 * @throws FinisherPresetNotFoundException
493 * @api
494 */
495 public function createFinisher(string $finisherIdentifier, array $options = []): FinisherInterface
496 {
497 if (isset($this->finishersDefinition[$finisherIdentifier]) && is_array($this->finishersDefinition[$finisherIdentifier]) && isset($this->finishersDefinition[$finisherIdentifier]['implementationClassName'])) {
498 $implementationClassName = $this->finishersDefinition[$finisherIdentifier]['implementationClassName'];
499 $defaultOptions = isset($this->finishersDefinition[$finisherIdentifier]['options']) ? $this->finishersDefinition[$finisherIdentifier]['options'] : [];
500 ArrayUtility::mergeRecursiveWithOverrule($defaultOptions, $options);
501
502 $finisher = $this->objectManager->get($implementationClassName);
503 $finisher->setOptions($defaultOptions);
504 $this->addFinisher($finisher);
505 return $finisher;
506 } else {
507 throw new FinisherPresetNotFoundException('The finisher preset identified by "' . $finisherIdentifier . '" could not be found, or the implementationClassName was not specified.', 1328709784);
508 }
509 }
510
511 /**
512 * Gets all finishers of this form
513 *
514 * @return \TYPO3\CMS\Form\Domain\Finishers\FinisherInterface[]
515 * @api
516 */
517 public function getFinishers(): array
518 {
519 return $this->finishers;
520 }
521
522 /**
523 * Add an element to the ElementsByIdentifier Cache.
524 *
525 * @param RenderableInterface $renderable
526 * @return void
527 * @throws DuplicateFormElementException
528 * @internal
529 */
530 public function registerRenderable(RenderableInterface $renderable)
531 {
532 if ($renderable instanceof FormElementInterface) {
533 if (isset($this->elementsByIdentifier[$renderable->getIdentifier()])) {
534 throw new DuplicateFormElementException(sprintf('A form element with identifier "%s" is already part of the form.', $renderable->getIdentifier()), 1325663761);
535 }
536 $this->elementsByIdentifier[$renderable->getIdentifier()] = $renderable;
537 }
538 }
539
540 /**
541 * Remove an element from the ElementsByIdentifier cache
542 *
543 * @param RenderableInterface $renderable
544 * @return void
545 * @internal
546 */
547 public function unregisterRenderable(RenderableInterface $renderable)
548 {
549 if ($renderable instanceof FormElementInterface) {
550 unset($this->elementsByIdentifier[$renderable->getIdentifier()]);
551 }
552 }
553
554 /**
555 * Get a Form Element by its identifier
556 *
557 * If identifier does not exist, returns NULL.
558 *
559 * @param string $elementIdentifier
560 * @return FormElementInterface The element with the given $elementIdentifier or NULL if none found
561 * @api
562 */
563 public function getElementByIdentifier(string $elementIdentifier)
564 {
565 return isset($this->elementsByIdentifier[$elementIdentifier]) ? $this->elementsByIdentifier[$elementIdentifier] : null;
566 }
567
568 /**
569 * Sets the default value of a form element
570 *
571 * @param string $elementIdentifier identifier of the form element. This supports property paths!
572 * @param mixed $defaultValue
573 * @return void
574 * @internal
575 */
576 public function addElementDefaultValue(string $elementIdentifier, $defaultValue)
577 {
578 $this->elementDefaultValues = ArrayUtility::setValueByPath($this->elementDefaultValues, $elementIdentifier, $defaultValue);
579 }
580
581 /**
582 * returns the default value of the specified form element
583 * or NULL if no default value was set
584 *
585 * @param string $elementIdentifier identifier of the form element. This supports property paths!
586 * @return mixed The elements default value
587 * @internal
588 */
589 public function getElementDefaultValueByIdentifier(string $elementIdentifier)
590 {
591 return ObjectAccess::getPropertyPath($this->elementDefaultValues, $elementIdentifier);
592 }
593
594 /**
595 * Move $pageToMove before $referencePage
596 *
597 * @param Page $pageToMove
598 * @param Page $referencePage
599 * @return void
600 * @api
601 */
602 public function movePageBefore(Page $pageToMove, Page $referencePage)
603 {
604 $this->moveRenderableBefore($pageToMove, $referencePage);
605 }
606
607 /**
608 * Move $pageToMove after $referencePage
609 *
610 * @param Page $pageToMove
611 * @param Page $referencePage
612 * @return void
613 * @api
614 */
615 public function movePageAfter(Page $pageToMove, Page $referencePage)
616 {
617 $this->moveRenderableAfter($pageToMove, $referencePage);
618 }
619
620 /**
621 * Remove $pageToRemove from form
622 *
623 * @param Page $pageToRemove
624 * @return void
625 * @api
626 */
627 public function removePage(Page $pageToRemove)
628 {
629 $this->removeRenderable($pageToRemove);
630 }
631
632 /**
633 * Bind the current request & response to this form instance, effectively creating
634 * a new "instance" of the Form.
635 *
636 * @param Request $request
637 * @param Response $response
638 * @return FormRuntime
639 * @api
640 */
641 public function bind(Request $request, Response $response): FormRuntime
642 {
643 return $this->objectManager->get(FormRuntime::class, $this, $request, $response);
644 }
645
646 /**
647 * @param string $propertyPath
648 * @return ProcessingRule
649 * @api
650 */
651 public function getProcessingRule(string $propertyPath): ProcessingRule
652 {
653 if (!isset($this->processingRules[$propertyPath])) {
654 $this->processingRules[$propertyPath] = $this->objectManager->get(ProcessingRule::class);
655 }
656 return $this->processingRules[$propertyPath];
657 }
658
659 /**
660 * Get all mapping rules
661 *
662 * @return \TYPO3\CMS\Form\Mvc\ProcessingRule[]
663 * @internal
664 */
665 public function getProcessingRules(): array
666 {
667 return $this->processingRules;
668 }
669
670 /**
671 * @return array
672 * @internal
673 */
674 public function getTypeDefinitions(): array
675 {
676 return $this->typeDefinitions;
677 }
678
679 /**
680 * @return array
681 * @internal
682 */
683 public function getValidatorsDefinition(): array
684 {
685 return $this->validatorsDefinition;
686 }
687
688 /**
689 * Get the persistence identifier of the form
690 *
691 * @return string
692 * @internal
693 */
694 public function getPersistenceIdentifier(): string
695 {
696 return $this->persistenceIdentifier;
697 }
698 }