Completed
Push — unit-test-form-view-helper ( 80118a...83100a )
by Romain
02:13
created

FormViewHelper::setUpFormService()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 31
rs 8.5806
c 0
b 0
f 0
cc 4
eloc 19
nc 3
nop 0
1
<?php
2
/*
3
 * 2017 Romain CANON <[email protected]>
4
 *
5
 * This file is part of the TYPO3 Formz project.
6
 * It is free software; you can redistribute it and/or modify it
7
 * under the terms of the GNU General Public License, either
8
 * version 3 of the License, or any later version.
9
 *
10
 * For the full copyright and license information, see:
11
 * http://www.gnu.org/licenses/gpl-3.0.html
12
 */
13
14
namespace Romm\Formz\ViewHelpers;
15
16
use Romm\Formz\AssetHandler\AssetHandlerFactory;
17
use Romm\Formz\AssetHandler\Connector\AssetHandlerConnectorManager;
18
use Romm\Formz\AssetHandler\Html\DataAttributesAssetHandler;
19
use Romm\Formz\Behaviours\BehavioursManager;
20
use Romm\Formz\Core\Core;
21
use Romm\Formz\Form\FormInterface;
22
use Romm\Formz\Form\FormObject;
23
use Romm\Formz\Form\FormObjectFactory;
24
use Romm\Formz\Service\ContextService;
25
use Romm\Formz\Service\ExtensionService;
26
use Romm\Formz\Service\StringService;
27
use Romm\Formz\Service\TimeTrackerService;
28
use Romm\Formz\Validation\Validator\Form\DefaultFormValidator;
29
use Romm\Formz\ViewHelpers\Service\FormService;
30
use TYPO3\CMS\Core\Page\PageRenderer;
31
use TYPO3\CMS\Core\Utility\GeneralUtility;
32
use TYPO3\CMS\Extbase\Error\Result;
33
use TYPO3\CMS\Extbase\Reflection\ReflectionService;
34
use TYPO3\CMS\Fluid\View\StandaloneView;
35
36
/**
37
 * This view helper overrides the default one from Extbase, to include
38
 * everything the extension needs to work properly.
39
 *
40
 * The only difference in Fluid is that the attribute "name" becomes mandatory,
41
 * and must be the exact same name as the form parameter in the controller
42
 * action called when the form is submitted. For instance, if your action looks
43
 * like this: `public function submitAction(ExampleForm $exampleForm) {...}`,
44
 * then the "name" attribute of this view helper must be "exampleForm".
45
 *
46
 * Thanks to the information of the form, the following things are automatically
47
 * handled in this view helper:
48
 *
49
 * - Class
50
 *   A custom class may be added to the form DOM element. If the TypoScript
51
 *   configuration "settings.defaultClass" is set for this form, then the given
52
 *   class will be added to the form element.
53
 *
54
 * - JavaScript
55
 *   A block of JavaScript is built from scratch, which will initialize the
56
 *   form, add validation rules to the fields, and handle activation of the
57
 *   fields validation.
58
 *
59
 * - Data attributes
60
 *   To help integrators customize every aspect they need in CSS, every useful
61
 *   information is put in data attributes in the form DOM element. For example,
62
 *   you can know in real time if the field "email" is valid if the form has the
63
 *   attribute "formz-valid-email"
64
 *
65
 * - CSS
66
 *   A block of CSS is built from scratch, which will handle the fields display,
67
 *   depending on their activation property.
68
 */
69
class FormViewHelper extends \TYPO3\CMS\Fluid\ViewHelpers\FormViewHelper
70
{
71
    /**
72
     * @var bool
73
     */
74
    protected $escapeOutput = false;
75
76
    /**
77
     * @var PageRenderer
78
     */
79
    protected $pageRenderer;
80
81
    /**
82
     * @var FormObject
83
     */
84
    protected $formObject;
85
86
    /**
87
     * @var FormService
88
     */
89
    protected $formService;
90
91
    /**
92
     * @var string
93
     */
94
    protected $formObjectClassName;
95
96
    /**
97
     * @var AssetHandlerFactory
98
     */
99
    protected $assetHandlerFactory;
100
101
    /**
102
     * @var TimeTrackerService
103
     */
104
    protected $timeTracker;
105
106
    /**
107
     * @var bool
108
     */
109
    protected $typoScriptIncluded = false;
110
111
    /**
112
     * @inheritdoc
113
     */
114
    public function initialize()
115
    {
116
        parent::initialize();
117
118
        $this->typoScriptIncluded = ContextService::get()->isTypoScriptIncluded();
119
120
        if (true === $this->typoScriptIncluded) {
121
            $this->formObject = $this->getFormObject();
122
            $this->formService->setFormObject($this->formObject);
123
            $this->assetHandlerFactory = AssetHandlerFactory::get($this->formObject, $this->controllerContext);
124
        }
125
126
        /*
127
         * Important: we need to instantiate the page renderer with this instead
128
         * of Extbase object manager (or with an inject function).
129
         *
130
         * This is due to some TYPO3 low level behaviour which overrides the
131
         * page renderer singleton instance, whenever a new request is used. The
132
         * problem is that the instance is not updated on Extbase side.
133
         *
134
         * Using Extbase injection can lead to old page renderer instance being
135
         * used, resulting in a leak of assets inclusion, and maybe more issues.
136
         */
137
        $this->pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
138
    }
139
140
    /**
141
     * @inheritdoc
142
     */
143
    public function initializeArguments()
144
    {
145
        parent::initializeArguments();
146
147
        // The name attribute becomes mandatory.
148
        $this->overrideArgument('name', 'string', 'Name of the form.', true);
149
        $this->registerArgument('formClassName', 'string', 'Class name of the form.', false);
150
    }
151
152
    /**
153
     * @return string
154
     */
155
    protected function renderViewHelper()
156
    {
157
        $this->timeTracker = TimeTrackerService::getAndStart();
158
159
        if (false === $this->typoScriptIncluded) {
160
            return (ExtensionService::get()->isInDebugMode())
161
                ? ContextService::get()->translate('form.typoscript_not_included.error_message')
162
                : '';
163
        }
164
165
        $formzValidationResult = $this->formObject->getConfigurationValidationResult();
166
        $this->timeTracker->logTime('post-config');
167
168
        $result = ($formzValidationResult->hasErrors())
169
            // If the form configuration is not valid, we display the errors list.
170
            ? $this->getErrorText($formzValidationResult)
171
            // Everything is ok, we render the form.
172
            : $this->renderForm(func_get_args());
173
174
        $this->timeTracker->logTime('final');
175
176
        if (ExtensionService::get()->isInDebugMode()) {
177
            $result = $this->timeTracker->getHTMLCommentLogs() . LF . $result;
178
        }
179
180
        $this->formService->resetState();
181
182
        return $result;
183
    }
184
185
    /**
186
     * Will render the whole form and return the HTML result.
187
     *
188
     * @param array $arguments
189
     * @return string
190
     */
191
    final protected function renderForm(array $arguments)
192
    {
193
        /*
194
         * We begin by setting up the form service: request results and form
195
         * instance are inserted in the service, and are used afterwards.
196
         */
197
        $this->setUpFormService();
198
199
        /*
200
         * Adding the default class configured in TypoScript configuration to
201
         * the form HTML tag.
202
         */
203
        $this->addDefaultClass();
204
205
        /*
206
         * If the form was submitted, applying custom behaviours on its fields.
207
         */
208
        $this->applyBehavioursOnSubmittedForm();
209
210
        /*
211
         * Handling data attributes that are added to the form HTML tag,
212
         * depending on several parameters.
213
         */
214
        $this->handleDataAttributes();
215
216
        /*
217
         * Including JavaScript and CSS assets in the page renderer.
218
         */
219
        $this->handleAssets();
220
221
        $this->timeTracker->logTime('pre-render');
222
        $result = $this->getParentRenderResult($arguments);
223
224
        /*
225
         * Language files need to be included at the end, because they depend on
226
         * what was used by previous assets.
227
         */
228
        $this->getAssetHandlerConnectorManager()
229
            ->getJavaScriptAssetHandlerConnector()
230
            ->includeLanguageJavaScriptFiles();
231
232
        return $result;
233
    }
234
235
    /**
236
     * This function will inject in the form service the form instance and its
237
     * submission result. There are only two ways to be sure the values injected
238
     * are correct: when the form was actually submitted by the user, or when
239
     * the argument `object` of the view helper is filled with a form instance.
240
     */
241
    protected function setUpFormService()
242
    {
243
        $this->formService->activateFormContext();
244
245
        $originalRequest = $this->controllerContext
246
            ->getRequest()
247
            ->getOriginalRequest();
248
249
        if (null !== $originalRequest
250
            && $originalRequest->hasArgument($this->getFormObjectName())
251
        ) {
252
            /** @var array $formInstance */
253
            $formInstance = $originalRequest->getArgument($this->getFormObjectName());
254
255
            $this->formService->setFormInstance($formInstance);
256
            $this->formService->setFormResult($this->formObject->getLastValidationResult());
257
            $this->formService->markFormAsSubmitted();
258
        } elseif (null !== $this->arguments['object']) {
259
            /** @var DefaultFormValidator $formValidator */
260
            $formValidator = Core::instantiate(
261
                DefaultFormValidator::class,
262
                ['name' => $this->getFormObjectName()]
263
            );
264
265
            $formInstance = $this->arguments['object'];
266
            $formRequestResult = $formValidator->validateWithoutSavingResults($formInstance);
267
268
            $this->formService->setFormInstance($formInstance);
269
            $this->formService->setFormResult($formRequestResult);
270
        }
271
    }
272
273
    /**
274
     * Will loop on the submitted form fields and apply behaviours if their
275
     * configuration contains.
276
     */
277
    protected function applyBehavioursOnSubmittedForm()
278
    {
279
        if ($this->formService->formWasSubmitted()) {
280
            /** @var BehavioursManager $behavioursManager */
281
            $behavioursManager = GeneralUtility::makeInstance(BehavioursManager::class);
282
283
            $formProperties = $behavioursManager->applyBehaviourOnPropertiesArray(
284
                $this->formService->getFormInstance(),
0 ignored issues
show
Bug introduced by
It seems like $this->formService->getFormInstance() targeting Romm\Formz\ViewHelpers\S...vice::getFormInstance() can also be of type object<Romm\Formz\Form\FormInterface>; however, Romm\Formz\Behaviours\Be...iourOnPropertiesArray() does only seem to accept array, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
285
                $this->formObject->getConfiguration()
286
            );
287
288
            $this->controllerContext
289
                ->getRequest()
290
                ->getOriginalRequest()
291
                ->setArgument($this->getFormObjectName(), $formProperties);
292
        }
293
    }
294
295
    /**
296
     * Will add a default class to the form element.
297
     *
298
     * To customize the class, take a look at `settings.defaultClass` in the
299
     * form TypoScript configuration.
300
     */
301
    protected function addDefaultClass()
302
    {
303
        $formDefaultClass = $this->formObject
304
            ->getConfiguration()
305
            ->getSettings()
306
            ->getDefaultClass();
307
308
        $class = $this->tag->getAttribute('class');
309
310
        if (false === empty($formDefaultClass)) {
311
            $class = (!empty($class) ? $class . ' ' : '') . $formDefaultClass;
312
            $this->tag->addAttribute('class', $class);
313
        }
314
    }
315
316
    /**
317
     * Adds custom data attributes to the form element, based on the
318
     * submitted form values and results.
319
     */
320
    protected function handleDataAttributes()
321
    {
322
        $dataAttributes = [];
323
        $object = $this->formService->getFormInstance();
324
        $formResult = $this->formService->getFormResult();
325
326
        /** @var DataAttributesAssetHandler $dataAttributesAssetHandler */
327
        $dataAttributesAssetHandler = $this->assetHandlerFactory->getAssetHandler(DataAttributesAssetHandler::class);
328
329
        if ($object) {
330
            $dataAttributes += $dataAttributesAssetHandler->getFieldsValuesDataAttributes($object, $formResult);
331
        }
332
333
        if ($formResult
334
            && true === $this->formService->formWasSubmitted()
335
        ) {
336
            $dataAttributes += ['formz-submission-done' => '1'];
337
            $dataAttributes += $dataAttributesAssetHandler->getFieldsValidDataAttributes($formResult);
338
            $dataAttributes += $dataAttributesAssetHandler->getFieldsMessagesDataAttributes($formResult);
339
        }
340
341
        foreach ($dataAttributes as $attributeName => $attributeValue) {
342
            $this->tag->addAttribute($attributeName, $attributeValue);
343
        }
344
    }
345
346
    /**
347
     * Will include all JavaScript and CSS assets needed for this form.
348
     */
349
    protected function handleAssets()
350
    {
351
        $assetHandlerConnectorManager = $this->getAssetHandlerConnectorManager();
352
353
        // Default Formz assets.
354
        $assetHandlerConnectorManager->includeDefaultAssets();
355
356
        // JavaScript assets.
357
        $assetHandlerConnectorManager->getJavaScriptAssetHandlerConnector()
358
            ->generateAndIncludeFormzConfigurationJavaScript()
359
            ->generateAndIncludeJavaScript()
360
            ->generateAndIncludeInlineJavaScript()
361
            ->includeJavaScriptValidationAndConditionFiles();
362
363
        // CSS assets.
364
        $assetHandlerConnectorManager->getCssAssetHandlerConnector()
365
            ->includeGeneratedCss();
366
    }
367
368
    /**
369
     * Will return an error text from a Fluid view.
370
     *
371
     * @param Result $result
372
     * @return string
373
     */
374
    protected function getErrorText(Result $result)
375
    {
376
        /** @var $view \TYPO3\CMS\Fluid\View\StandaloneView */
377
        $view = $this->objectManager->get(StandaloneView::class);
378
        $view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:' . ExtensionService::get()->getExtensionKey() . '/Resources/Private/Templates/Error/ConfigurationErrorBlock.html'));
379
        $layoutRootPath = StringService::get()->getExtensionRelativePath('Resources/Private/Layouts');
380
        $view->setLayoutRootPaths([$layoutRootPath]);
381
        $view->assign('result', $result);
382
383
        $templatePath = GeneralUtility::getFileAbsFileName('EXT:' . ExtensionService::get()->getExtensionKey() . '/Resources/Public/StyleSheets/Form.ErrorBlock.css');
384
        $this->pageRenderer->addCssFile(StringService::get()->getResourceRelativePath($templatePath));
385
386
        return $view->render();
387
    }
388
389
    /**
390
     * Returns the class name of the form object: it is fetched from the action
391
     * of the controller which will be called when submitting this form. It
392
     * means two things:
393
     * - The action must have a parameter which has the exact same name as the
394
     *   form;
395
     * - The parameter must indicate its type.
396
     *
397
     * @return string
398
     * @throws \Exception
399
     */
400
    protected function getFormObjectClassName()
401
    {
402
        if (null === $this->formObjectClassName) {
403
            $formClassName = ($this->hasArgument('formClassName'))
404
                ? $this->arguments['formClassName']
405
                : $this->getFormObjectClassNameFromControllerAction();
406
407
            if (false === class_exists($formClassName)) {
408
                throw new \Exception(
409
                    vsprintf(
410
                        'Invalid value for the form class name (current value: "%s"). You need to either fill the parameter "formClassName" in the view helper, or specify the type of the parameter "$%s" for the method "%s::%s()".',
411
                        [
412
                            $formClassName,
413
                            $this->getFormObjectName(),
414
                            $this->getControllerName(),
415
                            $this->getControllerActionName()
416
                        ]
417
                    ),
418
                    1457442014
419
                );
420
            }
421
422
            if (false === in_array(FormInterface::class, class_implements($formClassName))) {
423
                throw new \Exception(
424
                    'Invalid value for the form class name (current value: "' . $formClassName . '"); it must be an instance of "' . FormInterface::class . '".',
425
                    1457442462
426
                );
427
            }
428
429
            $this->formObjectClassName = $formClassName;
430
        }
431
432
        return $this->formObjectClassName;
433
    }
434
435
    /**
436
     * Will fetch the name of the controller action argument bound to this
437
     * request.
438
     *
439
     * @return string
440
     * @throws \Exception
441
     */
442
    protected function getFormObjectClassNameFromControllerAction()
443
    {
444
        $controllerObjectName = $this->getControllerName();
445
        $actionName = $this->getControllerActionName();
446
447
        /** @var ReflectionService $reflectionService */
448
        $reflectionService = $this->objectManager->get(ReflectionService::class);
449
        $methodParameters = $reflectionService->getMethodParameters($controllerObjectName, $actionName);
450
451
        if (false === isset($methodParameters[$this->getFormObjectName()])) {
452
            throw new \Exception(
453
                'The method "' . $controllerObjectName . '::' . $actionName . '()" must have a parameter "$' . $this->getFormObjectName() . '". Note that you can also change the parameter "name" of the form view helper.',
454
                1457441846
455
            );
456
        }
457
458
        return $methodParameters[$this->getFormObjectName()]['type'];
459
    }
460
461
    /**
462
     * Renders the whole Fluid template.
463
     *
464
     * @param array $arguments
465
     * @return string
466
     */
467
    protected function getParentRenderResult(array $arguments)
468
    {
469
        return call_user_func_array([get_parent_class(), 'render'], $arguments);
470
    }
471
472
    /**
473
     * @return string
474
     */
475
    protected function getControllerName()
476
    {
477
        return $this->controllerContext
478
            ->getRequest()
479
            ->getControllerObjectName();
480
    }
481
482
    /**
483
     * @return string
484
     */
485
    protected function getControllerActionName()
486
    {
487
        $actionName = ($this->arguments['action'])
488
            ?: $this->controllerContext
489
                ->getRequest()
490
                ->getControllerActionName();
491
492
        return $actionName . 'Action';
493
    }
494
495
    /**
496
     * @return AssetHandlerConnectorManager
497
     */
498
    protected function getAssetHandlerConnectorManager()
499
    {
500
        return AssetHandlerConnectorManager::get($this->pageRenderer, $this->assetHandlerFactory);
501
    }
502
503
    /**
504
     * @return FormObject
505
     */
506
    protected function getFormObject()
507
    {
508
        /** @var FormObjectFactory $formObjectFactory */
509
        $formObjectFactory = Core::instantiate(FormObjectFactory::class);
510
511
        return $formObjectFactory->getInstanceFromClassName($this->getFormObjectClassName(), $this->getFormObjectName());
512
    }
513
514
    /**
515
     * @param FormService $service
516
     */
517
    public function injectFormService(FormService $service)
518
    {
519
        $this->formService = $service;
520
    }
521
}
522