Completed
Push — unit-test-form-view-helper ( 21ce8b )
by Romain
02:37 queued 23s
created

FormViewHelper::renderForm()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 21
rs 9.3142
c 0
b 0
f 0
cc 1
eloc 13
nc 1
nop 1
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\Form\FormInterface;
21
use Romm\Formz\Form\FormObjectFactory;
22
use Romm\Formz\Service\ContextService;
23
use Romm\Formz\Service\ExtensionService;
24
use Romm\Formz\Service\StringService;
25
use Romm\Formz\Service\TimeTrackerService;
26
use Romm\Formz\Validation\Validator\Form\AbstractFormValidator;
27
use Romm\Formz\Validation\Validator\Form\DefaultFormValidator;
28
use Romm\Formz\ViewHelpers\Service\FormService;
29
use TYPO3\CMS\Core\Page\PageRenderer;
30
use TYPO3\CMS\Core\Utility\GeneralUtility;
31
use TYPO3\CMS\Extbase\Error\Result;
32
use TYPO3\CMS\Extbase\Reflection\ReflectionService;
33
use TYPO3\CMS\Fluid\View\StandaloneView;
34
35
/**
36
 * This view helper overrides the default one from Extbase, to include
37
 * everything the extension needs to work properly.
38
 *
39
 * The only difference in Fluid is that the attribute "name" becomes mandatory,
40
 * and must be the exact same name as the form parameter in the controller
41
 * action called when the form is submitted. For instance, if your action looks
42
 * like this: `public function submitAction(ExampleForm $exampleForm) {...}`,
43
 * then the "name" attribute of this view helper must be "exampleForm".
44
 *
45
 * Thanks to the information of the form, the following things are automatically
46
 * handled in this view helper:
47
 *
48
 * - Class
49
 *   A custom class may be added to the form DOM element. If the TypoScript
50
 *   configuration "settings.defaultClass" is set for this form, then the given
51
 *   class will be added to the form element.
52
 *
53
 * - JavaScript
54
 *   A block of JavaScript is built from scratch, which will initialize the
55
 *   form, add validation rules to the fields, and handle activation of the
56
 *   fields validation.
57
 *
58
 * - Data attributes
59
 *   To help integrators customize every aspect they need in CSS, every useful
60
 *   information is put in data attributes in the form DOM element. For example,
61
 *   you can know in real time if the field "email" is valid if the form has the
62
 *   attribute "formz-valid-email"
63
 *
64
 * - CSS
65
 *   A block of CSS is built from scratch, which will handle the fields display,
66
 *   depending on their activation property.
67
 */
68
class FormViewHelper extends \TYPO3\CMS\Fluid\ViewHelpers\FormViewHelper
69
{
70
    /**
71
     * @var PageRenderer
72
     */
73
    protected $pageRenderer;
74
75
    /**
76
     * @var FormObjectFactory
77
     */
78
    protected $formObjectFactory;
79
80
    /**
81
     * @var FormService
82
     */
83
    protected $formService;
84
85
    /**
86
     * @var string
87
     */
88
    protected $formObjectClassName;
89
90
    /**
91
     * @var AssetHandlerFactory
92
     */
93
    protected $assetHandlerFactory;
94
95
    /**
96
     * @var TimeTrackerService
97
     */
98
    protected $timeTracker;
99
100
    /**
101
     * @inheritdoc
102
     */
103
    public function initialize()
104
    {
105
        parent::initialize();
106
107
        /*
108
         * Important: we need to instantiate the page renderer with this instead
109
         * of Extbase object manager (or with an inject function).
110
         *
111
         * This is due to some TYPO3 low level behaviour which overrides the
112
         * page renderer singleton instance, whenever a new request is used. The
113
         * problem is that the instance is not updated on Extbase side.
114
         *
115
         * Using Extbase injection can lead to old page renderer instance being
116
         * used, resulting in a leak of assets inclusion, and maybe more issues.
117
         */
118
        $this->pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
119
    }
120
121
    /**
122
     * @inheritdoc
123
     */
124
    public function initializeArguments()
125
    {
126
        parent::initializeArguments();
127
128
        // The name attribute becomes mandatory.
129
        $this->overrideArgument('name', 'string', 'Name of the form.', true);
130
        $this->registerArgument('formClassName', 'string', 'Class name of the form.', false);
131
    }
132
133
    /**
134
     * @return string
135
     */
136
    protected function renderViewHelper()
137
    {
138
        $this->timeTracker = TimeTrackerService::getAndStart();
139
140
        if (false === ContextService::get()->isTypoScriptIncluded()) {
141
            $result = (ExtensionService::get()->isInDebugMode())
142
                ? ContextService::get()->translate('form.typoscript_not_included.error_message')
143
                : '';
144
        } else {
145
            $formObject = $this->formObjectFactory
146
                ->getInstanceFromClassName($this->getFormObjectClassName(), $this->getFormObjectName());
147
            $this->formService->setFormObject($formObject);
148
            $this->assetHandlerFactory = AssetHandlerFactory::get($formObject, $this->controllerContext);
149
150
            $formzValidationResult = $formObject->getConfigurationValidationResult();
151
            $result = ($formzValidationResult->hasErrors())
152
                // If the form configuration is not valid, we display the errors list.
153
                ? $this->getErrorText($formzValidationResult)
154
                // Everything is ok, we render the form.
155
                : $this->renderForm(func_get_args());
156
        }
157
158
        $this->timeTracker->logTime('final');
159
        $result = $this->timeTracker->getHTMLCommentLogs() . LF . $result;
160
161
        $this->formService->resetState();
162
163
        return $result;
164
    }
165
166
    /**
167
     * Will render the whole form and return the HTML result.
168
     *
169
     * @param array $arguments
170
     * @return string
171
     */
172
    final protected function renderForm(array $arguments)
173
    {
174
        $this->timeTracker->logTime('post-config');
175
176
        $this->setObjectAndRequestResult()
177
            ->applyBehavioursOnSubmittedForm()
178
            ->addDefaultClass()
179
            ->handleDataAttributes()
180
            ->handleAssets();
181
182
        $this->timeTracker->logTime('pre-render');
183
184
        // Renders the whole Fluid template.
185
        $result = call_user_func_array([get_parent_class(), 'render'], $arguments);
186
187
        $this->getAssetHandlerConnectorManager()
188
            ->getJavaScriptAssetHandlerConnector()
189
            ->includeLanguageJavaScriptFiles();
190
191
        return $result;
192
    }
193
194
    /**
195
     * This function will inject in the variable container the instance of form
196
     * and its submission result. There are only two ways to be sure the values
197
     * injected are correct: when the form has actually been submitted by the
198
     * user, or when the view helper argument `object` is filled.
199
     *
200
     * @return $this
201
     */
202
    protected function setObjectAndRequestResult()
203
    {
204
        $this->formService->activateFormContext();
205
206
        $originalRequest = $this->controllerContext
207
            ->getRequest()
208
            ->getOriginalRequest();
209
210
        if (null !== $originalRequest
211
            && $originalRequest->hasArgument($this->getFormObjectName())
212
        ) {
213
            /** @var array $formInstance */
214
            $formInstance = $originalRequest->getArgument($this->getFormObjectName());
215
216
            $formRequestResult = AbstractFormValidator::getFormValidationResult(
217
                $this->getFormObjectClassName(),
218
                $this->getFormObjectName()
219
            );
220
221
            $this->formService->setFormInstance($formInstance);
222
            $this->formService->setFormResult($formRequestResult);
0 ignored issues
show
Bug introduced by
It seems like $formRequestResult defined by \Romm\Formz\Validation\V...s->getFormObjectName()) on line 216 can be null; however, Romm\Formz\ViewHelpers\S...ervice::setFormResult() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
223
            $this->formService->markFormAsSubmitted();
224
        } elseif (null !== $this->arguments['object']) {
225
            $formInstance = $this->arguments['object'];
226
227
            /*
228
             * @todo: pas forcément un DefaultFormValidator: comment je gère ça?
229
             * + ça prend quand même un peu de temps cette manière. Peut-on faire autrement ?
230
             */
231
            /** @var DefaultFormValidator $formValidator */
232
            $formValidator = GeneralUtility::makeInstance(
233
                DefaultFormValidator::class,
234
                ['name' => $this->getFormObjectName()]
235
            );
236
            $formRequestResult = $formValidator->validate($formInstance);
237
238
            $this->formService->setFormInstance($formInstance);
239
            $this->formService->setFormResult($formRequestResult);
240
        }
241
242
        return $this;
243
    }
244
245
    /**
246
     * Will loop on the submitted form fields and apply behaviours if their
247
     * configuration contains.
248
     *
249
     * @return $this
250
     */
251
    protected function applyBehavioursOnSubmittedForm()
252
    {
253
        $originalRequest = $this->controllerContext
254
            ->getRequest()
255
            ->getOriginalRequest();
256
257
        if ($this->formService->formWasSubmitted()) {
258
            /** @var BehavioursManager $behavioursManager */
259
            $behavioursManager = GeneralUtility::makeInstance(BehavioursManager::class);
260
261
            $formProperties = $behavioursManager->applyBehaviourOnPropertiesArray(
262
                $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...
263
                $this->formService->getFormObject()->getConfiguration()
264
            );
265
266
            $originalRequest->setArgument($this->getFormObjectName(), $formProperties);
267
        }
268
269
        return $this;
270
    }
271
272
    /**
273
     * Will add a default class to the form element.
274
     *
275
     * To customize the class, take a look at `settings.defaultClass` in the
276
     * form TypoScript configuration.
277
     *
278
     * @return $this
279
     */
280
    protected function addDefaultClass()
281
    {
282
        $formDefaultClass = $this->formService
283
            ->getFormObject()
284
            ->getConfiguration()
285
            ->getSettings()
286
            ->getDefaultClass();
287
288
        $class = $this->tag->getAttribute('class');
289
290
        if (false === empty($formDefaultClass)) {
291
            $class = (!empty($class) ? $class . ' ' : '') . $formDefaultClass;
292
            $this->tag->addAttribute('class', $class);
293
        }
294
295
        return $this;
296
    }
297
298
    /**
299
     * Adds custom data attributes to the form element, based on the
300
     * submitted form values and results.
301
     *
302
     * @return $this
303
     */
304
    protected function handleDataAttributes()
305
    {
306
        $dataAttributes = [];
307
        $object = $this->formService->getFormInstance();
308
        $formResult = $this->formService->getFormResult();
309
310
        /** @var DataAttributesAssetHandler $dataAttributesAssetHandler */
311
        $dataAttributesAssetHandler = $this->assetHandlerFactory->getAssetHandler(DataAttributesAssetHandler::class);
312
313
        if ($object) {
314
            $dataAttributes += $dataAttributesAssetHandler->getFieldsValuesDataAttributes($object, $formResult);
315
        }
316
317
        if ($formResult) {
318
            $dataAttributes += $dataAttributesAssetHandler->getFieldsValidDataAttributes($formResult);
319
320
            if (true === $this->formService->formWasSubmitted()) {
321
                $dataAttributes += ['formz-submission-done' => '1'];
322
                $dataAttributes += $dataAttributesAssetHandler->getFieldsErrorsDataAttributes($formResult);
323
            }
324
        }
325
326
        foreach ($dataAttributes as $attributeName => $attributeValue) {
327
            $this->tag->addAttribute($attributeName, $attributeValue);
328
        }
329
330
        return $this;
331
    }
332
333
    /**
334
     * @return $this
335
     */
336
    protected function handleAssets()
337
    {
338
        $assetHandlerConnectorManager = $this->getAssetHandlerConnectorManager();
339
340
        // Default Formz assets.
341
        $assetHandlerConnectorManager->includeDefaultAssets();
342
343
        // JavaScript assets.
344
        $assetHandlerConnectorManager->getJavaScriptAssetHandlerConnector()
345
            ->generateAndIncludeFormzConfigurationJavaScript()
346
            ->generateAndIncludeJavaScript()
347
            ->generateAndIncludeInlineJavaScript()
348
            ->includeJavaScriptValidationAndConditionFiles();
349
350
        // CSS assets.
351
        $assetHandlerConnectorManager->getCssAssetHandlerConnector()
352
            ->includeGeneratedCss();
353
354
        return $this;
355
    }
356
357
    /**
358
     * Will return an error text from a Fluid view.
359
     *
360
     * @param Result $result
361
     * @return string
362
     */
363
    protected function getErrorText(Result $result)
364
    {
365
        /** @var $view \TYPO3\CMS\Fluid\View\StandaloneView */
366
        $view = $this->objectManager->get(StandaloneView::class);
367
        $view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:' . ExtensionService::get()->getExtensionKey() . '/Resources/Private/Templates/Error/ConfigurationErrorBlock.html'));
368
        $layoutRootPath = StringService::get()->getExtensionRelativePath('Resources/Private/Layouts');
369
        $view->setLayoutRootPaths([$layoutRootPath]);
370
        $view->assign('result', $result);
371
372
        $templatePath = GeneralUtility::getFileAbsFileName('EXT:' . ExtensionService::get()->getExtensionKey() . '/Resources/Public/StyleSheets/Form.ErrorBlock.css');
373
        $this->pageRenderer->addCssFile(StringService::get()->getResourceRelativePath($templatePath));
374
375
        return $view->render();
376
    }
377
378
    /**
379
     * Returns the class name of the form object: it is fetched from the action
380
     * of the controller which will be called when submitting this form. It
381
     * means two things:
382
     * - The action must have a parameter which has the exact same name as the
383
     *   form.
384
     * - The parameter must indicate its type.
385
     *
386
     * @return null|string
387
     * @throws \Exception
388
     */
389
    protected function getFormObjectClassName()
390
    {
391
        if (null === $this->formObjectClassName) {
392
            $request = $this->controllerContext->getRequest();
393
            $controllerObjectName = $request->getControllerObjectName();
394
            $actionName = ($this->arguments['action']) ?: $request->getControllerActionName();
395
            $actionName = $actionName . 'Action';
396
397
            if ($this->hasArgument('formClassName')) {
398
                $formClassName = $this->arguments['formClassName'];
399
            } else {
400
                /** @var ReflectionService $reflectionService */
401
                $reflectionService = $this->objectManager->get(ReflectionService::class);
402
                $methodParameters = $reflectionService->getMethodParameters($controllerObjectName, $actionName);
403
404
                if (false === isset($methodParameters[$this->getFormObjectName()])) {
405
                    throw new \Exception(
406
                        'The method "' . $controllerObjectName . '::' . $actionName . '()" must have a parameter "$' . $this->getFormObjectName() . '". Note that you can also change the parameter "name" of the form view helper.',
407
                        1457441846
408
                    );
409
                }
410
411
                $formClassName = $methodParameters[$this->getFormObjectName()]['type'];
412
            }
413
414
            if (false === class_exists($formClassName)) {
415
                throw new \Exception(
416
                    'Invalid value for the form class name (current value: "' . $formClassName . '"). You need to either fill the parameter "formClassName" in the view helper, or specify the type of the parameter "$' . $this->getFormObjectName() . '" for the method "' . $controllerObjectName . '::' . $actionName . '()".',
417
                    1457442014
418
                );
419
            }
420
421
            if (false === in_array(FormInterface::class, class_implements($formClassName))) {
422
                throw new \Exception(
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
            $this->formObjectClassName = $formClassName;
429
        }
430
431
        return $this->formObjectClassName;
432
    }
433
434
    /**
435
     * @param PageRenderer $pageRenderer
436
     */
437
    public function injectPageRenderer(PageRenderer $pageRenderer)
438
    {
439
        $this->pageRenderer = $pageRenderer;
440
    }
441
442
    /**
443
     * @param FormObjectFactory $formObjectFactory
444
     */
445
    public function injectFormObjectFactory(FormObjectFactory $formObjectFactory)
446
    {
447
        $this->formObjectFactory = $formObjectFactory;
448
    }
449
450
    /**
451
     * @param FormService $service
452
     */
453
    public function injectFormService(FormService $service)
454
    {
455
        $this->formService = $service;
456
    }
457
458
    /**
459
     * @return AssetHandlerConnectorManager
460
     */
461
    public function getAssetHandlerConnectorManager()
462
    {
463
        return AssetHandlerConnectorManager::get($this->pageRenderer, $this->assetHandlerFactory);
464
    }
465
}
466