Completed
Push — unit-test-form-view-helper ( d2fd33...868503 )
by Romain
02:29
created

FormViewHelper::getDataAttributesAssetHandler()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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