Completed
Push — unit-tests-conditions ( a23049...dc9eee )
by Romain
02:30
created

FormViewHelper::initialize()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 26
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 26
rs 8.8571
c 0
b 0
f 0
cc 2
eloc 9
nc 2
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\Exceptions\ClassNotFoundException;
22
use Romm\Formz\Exceptions\EntryNotFoundException;
23
use Romm\Formz\Exceptions\InvalidOptionValueException;
24
use Romm\Formz\Form\FormInterface;
25
use Romm\Formz\Form\FormObject;
26
use Romm\Formz\Form\FormObjectFactory;
27
use Romm\Formz\Service\ContextService;
28
use Romm\Formz\Service\ExtensionService;
29
use Romm\Formz\Service\StringService;
30
use Romm\Formz\Service\TimeTrackerService;
31
use Romm\Formz\Service\ViewHelper\FormViewHelperService;
32
use TYPO3\CMS\Core\Page\PageRenderer;
33
use TYPO3\CMS\Core\Utility\GeneralUtility;
34
use TYPO3\CMS\Extbase\Error\Result;
35
use TYPO3\CMS\Extbase\Reflection\ReflectionService;
36
use TYPO3\CMS\Fluid\View\StandaloneView;
37
38
/**
39
 * This view helper overrides the default one from Extbase, to include
40
 * everything the extension needs to work properly.
41
 *
42
 * The only difference in Fluid is that the attribute "name" becomes mandatory,
43
 * and must be the exact same name as the form parameter in the controller
44
 * action called when the form is submitted. For instance, if your action looks
45
 * like this: `public function submitAction(ExampleForm $exampleForm) {...}`,
46
 * then the "name" attribute of this view helper must be "exampleForm".
47
 *
48
 * Thanks to the information of the form, the following things are automatically
49
 * handled in this view helper:
50
 *
51
 * - Class
52
 *   A custom class may be added to the form DOM element. If the TypoScript
53
 *   configuration "settings.defaultClass" is set for this form, then the given
54
 *   class will be added to the form element.
55
 *
56
 * - JavaScript
57
 *   A block of JavaScript is built from scratch, which will initialize the
58
 *   form, add validation rules to the fields, and handle activation of the
59
 *   fields validation.
60
 *
61
 * - Data attributes
62
 *   To help integrators customize every aspect they need in CSS, every useful
63
 *   information is put in data attributes in the form DOM element. For example,
64
 *   you can know in real time if the field "email" is valid if the form has the
65
 *   attribute "formz-valid-email"
66
 *
67
 * - CSS
68
 *   A block of CSS is built from scratch, which will handle the fields display,
69
 *   depending on their activation property.
70
 */
71
class FormViewHelper extends \TYPO3\CMS\Fluid\ViewHelpers\FormViewHelper
72
{
73
    /**
74
     * @var bool
75
     */
76
    protected $escapeOutput = false;
77
78
    /**
79
     * @var PageRenderer
80
     */
81
    protected $pageRenderer;
82
83
    /**
84
     * @var FormObject
85
     */
86
    protected $formObject;
87
88
    /**
89
     * @var FormViewHelperService
90
     */
91
    protected $formService;
92
93
    /**
94
     * @var string
95
     */
96
    protected $formObjectClassName;
97
98
    /**
99
     * @var AssetHandlerFactory
100
     */
101
    protected $assetHandlerFactory;
102
103
    /**
104
     * @var TimeTrackerService
105
     */
106
    protected $timeTracker;
107
108
    /**
109
     * @var bool
110
     */
111
    protected $typoScriptIncluded = false;
112
113
    /**
114
     * @inheritdoc
115
     */
116
    public function initialize()
117
    {
118
        parent::initialize();
119
120
        $this->typoScriptIncluded = ContextService::get()->isTypoScriptIncluded();
121
122
        if (true === $this->typoScriptIncluded) {
123
            $this->formObjectClassName = $this->getFormClassName();
124
            $this->formObject = $this->getFormObject();
125
            $this->formService->setFormObject($this->formObject);
126
            $this->assetHandlerFactory = AssetHandlerFactory::get($this->formObject, $this->controllerContext);
127
        }
128
129
        /*
130
         * Important: we need to instantiate the page renderer with this instead
131
         * of Extbase object manager (or with an inject function).
132
         *
133
         * This is due to some TYPO3 low level behaviour which overrides the
134
         * page renderer singleton instance, whenever a new request is used. The
135
         * problem is that the instance is not updated on Extbase side.
136
         *
137
         * Using Extbase injection can lead to old page renderer instance being
138
         * used, resulting in a leak of assets inclusion, and maybe more issues.
139
         */
140
        $this->pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
141
    }
142
143
    /**
144
     * @inheritdoc
145
     */
146
    public function initializeArguments()
147
    {
148
        parent::initializeArguments();
149
150
        // The name attribute becomes mandatory.
151
        $this->overrideArgument('name', 'string', 'Name of the form.', true);
152
        $this->registerArgument('formClassName', 'string', 'Class name of the form.', false);
153
    }
154
155
    /**
156
     * @return string
157
     */
158
    protected function renderViewHelper()
159
    {
160
        $this->timeTracker = TimeTrackerService::getAndStart();
161
162
        if (false === $this->typoScriptIncluded) {
163
            return (ExtensionService::get()->isInDebugMode())
164
                ? ContextService::get()->translate('form.typoscript_not_included.error_message')
165
                : '';
166
        }
167
168
        $formzValidationResult = $this->formObject->getConfigurationValidationResult();
169
        $this->timeTracker->logTime('post-config');
170
171
        $result = ($formzValidationResult->hasErrors())
172
            // If the form configuration is not valid, we display the errors list.
173
            ? $this->getErrorText($formzValidationResult)
174
            // Everything is ok, we render the form.
175
            : $this->renderForm(func_get_args());
176
177
        $this->timeTracker->logTime('final');
178
179
        if (ExtensionService::get()->isInDebugMode()) {
180
            $result = $this->timeTracker->getHTMLCommentLogs() . LF . $result;
181
        }
182
183
        $this->formService->resetState();
184
185
        return $result;
186
    }
187
188
    /**
189
     * Will render the whole form and return the HTML result.
190
     *
191
     * @param array $arguments
192
     * @return string
193
     */
194
    final protected function renderForm(array $arguments)
195
    {
196
        /*
197
         * We begin by setting up the form service: request results and form
198
         * instance are inserted in the service, and are used afterwards.
199
         *
200
         * There are only two ways to be sure the values injected are correct:
201
         * when the form was actually submitted by the user, or when the
202
         * argument `object` of the view helper is filled with a form instance.
203
         */
204
        $this->formService
205
            ->activateFormContext()
206
            ->setUpData(
207
                $this->getFormObjectName(),
208
                $this->controllerContext->getRequest()->getOriginalRequest(),
209
                $this->arguments['object']
210
            );
211
212
        /*
213
         * Adding the default class configured in TypoScript configuration to
214
         * the form HTML tag.
215
         */
216
        $this->addDefaultClass();
217
218
        /*
219
         * If the form was submitted, applying custom behaviours on its fields.
220
         */
221
        $this->applyBehavioursOnSubmittedForm();
222
223
        /*
224
         * Handling data attributes that are added to the form HTML tag,
225
         * depending on several parameters.
226
         */
227
        $this->handleDataAttributes();
228
229
        /*
230
         * Including JavaScript and CSS assets in the page renderer.
231
         */
232
        $this->handleAssets();
233
234
        $this->timeTracker->logTime('pre-render');
235
236
        /*
237
         * Getting the result of the original Fluid `FormViewHelper` rendering.
238
         */
239
        $result = $this->getParentRenderResult($arguments);
240
241
        /*
242
         * Language files need to be included at the end, because they depend on
243
         * what was used by previous assets.
244
         */
245
        $this->getAssetHandlerConnectorManager()
246
            ->getJavaScriptAssetHandlerConnector()
247
            ->includeLanguageJavaScriptFiles();
248
249
        return $result;
250
    }
251
252
    /**
253
     * Will loop on the submitted form fields and apply behaviours if their
254
     * configuration contains.
255
     */
256
    protected function applyBehavioursOnSubmittedForm()
257
    {
258
        if ($this->formService->formWasSubmitted()) {
259
            /** @var BehavioursManager $behavioursManager */
260
            $behavioursManager = GeneralUtility::makeInstance(BehavioursManager::class);
261
262
            $formProperties = $behavioursManager->applyBehaviourOnPropertiesArray(
263
                $this->formService->getFormInstance(),
0 ignored issues
show
Bug introduced by
It seems like $this->formService->getFormInstance() targeting Romm\Formz\Service\ViewH...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...
264
                $this->formObject->getConfiguration()
265
            );
266
267
            $this->controllerContext
268
                ->getRequest()
269
                ->getOriginalRequest()
270
                ->setArgument($this->getFormObjectName(), $formProperties);
271
        }
272
    }
273
274
    /**
275
     * Will add a default class to the form element.
276
     *
277
     * To customize the class, take a look at `settings.defaultClass` in the
278
     * form TypoScript configuration.
279
     */
280
    protected function addDefaultClass()
281
    {
282
        $formDefaultClass = $this->formObject
283
            ->getConfiguration()
284
            ->getSettings()
285
            ->getDefaultClass();
286
287
        $class = $this->tag->getAttribute('class');
288
289
        if (false === empty($formDefaultClass)) {
290
            $class = (!empty($class) ? $class . ' ' : '') . $formDefaultClass;
291
            $this->tag->addAttribute('class', $class);
292
        }
293
    }
294
295
    /**
296
     * Adds custom data attributes to the form element, based on the
297
     * submitted form values and results.
298
     */
299
    protected function handleDataAttributes()
300
    {
301
        $dataAttributes = [];
302
        $object = $this->formService->getFormInstance();
303
        $formResult = $this->formService->getFormResult();
304
        $dataAttributesAssetHandler = $this->getDataAttributesAssetHandler();
305
306
        if ($object && $formResult) {
307
            $dataAttributes += $dataAttributesAssetHandler->getFieldsValuesDataAttributes($object, $formResult);
308
        }
309
310
        if ($formResult
311
            && true === $this->formService->formWasSubmitted()
312
        ) {
313
            $dataAttributes += [DataAttributesAssetHandler::getFieldSubmissionDone() => '1'];
314
            $dataAttributes += $dataAttributesAssetHandler->getFieldsValidDataAttributes($formResult);
315
            $dataAttributes += $dataAttributesAssetHandler->getFieldsMessagesDataAttributes($formResult);
316
        }
317
318
        $this->tag->addAttributes($dataAttributes);
319
    }
320
321
    /**
322
     * Will include all JavaScript and CSS assets needed for this form.
323
     */
324
    protected function handleAssets()
325
    {
326
        $assetHandlerConnectorManager = $this->getAssetHandlerConnectorManager();
327
328
        // Default Formz assets.
329
        $assetHandlerConnectorManager->includeDefaultAssets();
330
331
        // JavaScript assets.
332
        $assetHandlerConnectorManager->getJavaScriptAssetHandlerConnector()
333
            ->generateAndIncludeFormzConfigurationJavaScript()
334
            ->generateAndIncludeJavaScript()
335
            ->generateAndIncludeInlineJavaScript()
336
            ->includeJavaScriptValidationAndConditionFiles();
337
338
        // CSS assets.
339
        $assetHandlerConnectorManager->getCssAssetHandlerConnector()
340
            ->includeGeneratedCss();
341
    }
342
343
    /**
344
     * Will return an error text from a Fluid view.
345
     *
346
     * @param Result $result
347
     * @return string
348
     */
349
    protected function getErrorText(Result $result)
350
    {
351
        /** @var $view StandaloneView */
352
        $view = Core::instantiate(StandaloneView::class);
353
        $view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:' . ExtensionService::get()->getExtensionKey() . '/Resources/Private/Templates/Error/ConfigurationErrorBlock.html'));
354
        $layoutRootPath = StringService::get()->getExtensionRelativePath('Resources/Private/Layouts');
355
        $view->setLayoutRootPaths([$layoutRootPath]);
356
        $view->assign('result', $result);
357
358
        $templatePath = GeneralUtility::getFileAbsFileName('EXT:' . ExtensionService::get()->getExtensionKey() . '/Resources/Public/StyleSheets/Form.ErrorBlock.css');
359
        $this->pageRenderer->addCssFile(StringService::get()->getResourceRelativePath($templatePath));
360
361
        return $view->render();
362
    }
363
364
    /**
365
     * Returns the class name of the form object: it is fetched from the action
366
     * of the controller which will be called when submitting this form. It
367
     * means two things:
368
     * - The action must have a parameter which has the exact same name as the
369
     *   form;
370
     * - The parameter must indicate its type.
371
     *
372
     * @return string
373
     * @throws ClassNotFoundException
374
     * @throws InvalidOptionValueException
375
     */
376
    protected function getFormClassName()
377
    {
378
        $formClassName = ($this->hasArgument('formClassName'))
379
            ? $this->arguments['formClassName']
380
            : $this->getFormClassNameFromControllerAction();
381
382
        if (false === class_exists($formClassName)) {
383
            throw new ClassNotFoundException(
384
                vsprintf(
385
                    '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()".',
386
                    [
387
                        $formClassName,
388
                        $this->getFormObjectName(),
389
                        $this->getControllerName(),
390
                        $this->getControllerActionName()
391
                    ]
392
                ),
393
                1457442014
394
            );
395
        }
396
397
        if (false === in_array(FormInterface::class, class_implements($formClassName))) {
398
            throw new InvalidOptionValueException(
399
                'Invalid value for the form class name (current value: "' . $formClassName . '"); it must be an instance of "' . FormInterface::class . '".',
400
                1457442462
401
            );
402
        }
403
404
        return $formClassName;
405
    }
406
407
    /**
408
     * Will fetch the name of the controller action argument bound to this
409
     * request.
410
     *
411
     * @return string
412
     * @throws EntryNotFoundException
413
     */
414
    protected function getFormClassNameFromControllerAction()
415
    {
416
        $controllerObjectName = $this->getControllerName();
417
        $actionName = $this->getControllerActionName();
418
419
        /** @var ReflectionService $reflectionService */
420
        $reflectionService = Core::instantiate(ReflectionService::class);
421
        $methodParameters = $reflectionService->getMethodParameters($controllerObjectName, $actionName);
422
423
        if (false === isset($methodParameters[$this->getFormObjectName()])) {
424
            throw new EntryNotFoundException(
425
                vsprintf(
426
                    'The method "%s::%s()" must have a parameter "$%s". Note that you can also change the parameter "name" of the form view helper.',
427
                    [
428
                        $controllerObjectName,
429
                        $actionName,
430
                        $this->getFormObjectName()
431
                    ]
432
                ),
433
                1457441846
434
            );
435
        }
436
437
        return $methodParameters[$this->getFormObjectName()]['type'];
438
    }
439
440
    /**
441
     * Renders the whole Fluid template.
442
     *
443
     * @param array $arguments
444
     * @return string
445
     */
446
    protected function getParentRenderResult(array $arguments)
447
    {
448
        return call_user_func_array([get_parent_class(), 'render'], $arguments);
449
    }
450
451
    /**
452
     * @return string
453
     */
454
    protected function getControllerName()
455
    {
456
        return $this->controllerContext
457
            ->getRequest()
458
            ->getControllerObjectName();
459
    }
460
461
    /**
462
     * @return string
463
     */
464
    protected function getControllerActionName()
465
    {
466
        $actionName = ($this->arguments['action'])
467
            ?: $this->controllerContext
468
                ->getRequest()
469
                ->getControllerActionName();
470
471
        return $actionName . 'Action';
472
    }
473
474
    /**
475
     * @return AssetHandlerConnectorManager
476
     */
477
    protected function getAssetHandlerConnectorManager()
478
    {
479
        return AssetHandlerConnectorManager::get($this->pageRenderer, $this->assetHandlerFactory);
480
    }
481
482
    /**
483
     * @return DataAttributesAssetHandler
484
     */
485
    protected function getDataAttributesAssetHandler()
486
    {
487
        return $this->assetHandlerFactory->getAssetHandler(DataAttributesAssetHandler::class);
488
    }
489
490
    /**
491
     * @return FormObject
492
     */
493
    protected function getFormObject()
494
    {
495
        /** @var FormObjectFactory $formObjectFactory */
496
        $formObjectFactory = Core::instantiate(FormObjectFactory::class);
497
498
        return $formObjectFactory->getInstanceFromClassName($this->formObjectClassName, $this->getFormObjectName());
499
    }
500
501
    /**
502
     * @param FormViewHelperService $service
503
     */
504
    public function injectFormService(FormViewHelperService $service)
505
    {
506
        $this->formService = $service;
507
    }
508
}
509