Completed
Push — master ( cd1754...73aca6 )
by Rudie
12s queued 10s
created

FormHelper   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 485
Duplicated Lines 0 %

Coupling/Cohesion

Components 5
Dependencies 12

Test Coverage

Coverage 98.45%

Importance

Changes 0
Metric Value
dl 0
loc 485
ccs 127
cts 129
cp 0.9845
rs 3.36
c 0
b 0
f 0
wmc 63
lcom 5
cbo 12

24 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A getConfig() 0 10 2
A getView() 0 4 1
A mergeOptions() 0 4 1
A getFieldType() 0 26 5
A prepareAttributes() 0 17 5
A addCustomField() 0 8 2
A loadCustomTypes() 0 10 3
A hasCustomField() 0 4 1
A convertModelToArray() 0 16 4
A formatLabel() 0 16 4
A createRulesParser() 0 4 1
A getFieldValidationRules() 0 13 2
A mergeFieldsRules() 0 10 2
A mergeAttributes() 0 9 2
A getBoolableFields() 0 11 4
A alterFieldValuesBools() 0 11 3
A alterFieldValues() 0 18 3
A alterValid() 0 18 4
A appendMessagesWithPrefix() 0 12 4
A transformToDotSyntax() 0 4 1
A transformToBracketSyntax() 0 10 3
A getTranslator() 0 4 1
A checkFieldName() 0 17 4

How to fix   Complexity   

Complex Class

Complex classes like FormHelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FormHelper, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Kris\LaravelFormBuilder;
4
5
use Illuminate\Contracts\Support\MessageBag;
6
use Illuminate\Contracts\View\Factory as View;
7
use Illuminate\Database\Eloquent\Model;
8
use Illuminate\Support\Arr;
9
use Illuminate\Support\Collection;
10
use Illuminate\Translation\Translator;
11
use Kris\LaravelFormBuilder\Events\AfterCollectingFieldRules;
12
use Kris\LaravelFormBuilder\Fields\CheckableType;
13
use Kris\LaravelFormBuilder\Fields\FormField;
14
use Kris\LaravelFormBuilder\Form;
15
use Kris\LaravelFormBuilder\RulesParser;
16
17
class FormHelper
18
{
19
20
    /**
21
     * @var View
22
     */
23
    protected $view;
24
25
    /**
26
     * @var TranslatorInterface
27
     */
28
    protected $translator;
29
30
    /**
31
     * @var array
32
     */
33
    protected $config;
34
35
    /**
36
     * @var FormBuilder
37
     */
38
    protected $formBuilder;
39
40
    /**
41
     * @var array
42
     */
43
    protected static $reservedFieldNames = [
44
        'save'
45
    ];
46
47
    /**
48
     * All available field types
49
     *
50
     * @var array
51
     */
52
    protected static $availableFieldTypes = [
53
        'text'           => 'InputType',
54
        'email'          => 'InputType',
55
        'url'            => 'InputType',
56
        'tel'            => 'InputType',
57
        'search'         => 'InputType',
58
        'password'       => 'InputType',
59
        'hidden'         => 'InputType',
60
        'number'         => 'InputType',
61
        'date'           => 'InputType',
62
        'file'           => 'InputType',
63
        'image'          => 'InputType',
64
        'color'          => 'InputType',
65
        'datetime-local' => 'InputType',
66
        'month'          => 'InputType',
67
        'range'          => 'InputType',
68
        'time'           => 'InputType',
69
        'week'           => 'InputType',
70
        'select'         => 'SelectType',
71
        'textarea'       => 'TextareaType',
72
        'button'         => 'ButtonType',
73
        'buttongroup'    => 'ButtonGroupType',
74
        'submit'         => 'ButtonType',
75
        'reset'          => 'ButtonType',
76
        'radio'          => 'CheckableType',
77
        'checkbox'       => 'CheckableType',
78
        'choice'         => 'ChoiceType',
79
        'form'           => 'ChildFormType',
80
        'entity'         => 'EntityType',
81
        'collection'     => 'CollectionType',
82
        'repeated'       => 'RepeatedType',
83
        'static'         => 'StaticType'
84
    ];
85
86
    /**
87
     * Custom types
88
     *
89
     * @var array
90
     */
91
    private $customTypes = [];
92
93
    /**
94
     * @param View    $view
95
     * @param Translator $translator
96
     * @param array   $config
97 134
     */
98
    public function __construct(View $view, Translator $translator, array $config = [])
99 134
    {
100 134
        $this->view = $view;
101 134
        $this->translator = $translator;
0 ignored issues
show
Documentation Bug introduced by
It seems like $translator of type object<Illuminate\Translation\Translator> is incompatible with the declared type object<Kris\LaravelFormB...er\TranslatorInterface> of property $translator.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
102 134
        $this->config = $config;
103 134
        $this->loadCustomTypes();
104
    }
105
106
    /**
107
     * @param string $key
108
     * @param string $default
109
     * @param array $customConfig
110
     * @return mixed
111 134
     */
112
    public function getConfig($key = null, $default = null, $customConfig = [])
113 134
    {
114
        $config = array_replace_recursive($this->config, $customConfig);
115 134
116 134
        if ($key) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $key of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
117
            return Arr::get($config, $key, $default);
118
        }
119
120
        return $config;
121
    }
122
123
    /**
124
     * @return View
125 38
     */
126
    public function getView()
127 38
    {
128
        return $this->view;
129
    }
130
131
    /**
132
     * Merge options array.
133
     *
134
     * @param array $targetOptions
135
     * @param array $sourceOptions
136
     * @return array
137 134
     */
138
    public function mergeOptions(array $targetOptions, array $sourceOptions)
139 134
    {
140
        return array_replace_recursive($targetOptions, $sourceOptions);
141
    }
142
143
144
    /**
145
     * Get proper class for field type.
146
     *
147
     * @param $type
148
     * @return string
149 91
     */
150
    public function getFieldType($type)
151 91
    {
152
        $types = array_keys(static::$availableFieldTypes);
153 91
154 1
        if (!$type || trim($type) == '') {
155
            throw new \InvalidArgumentException('Field type must be provided.');
156
        }
157 90
158 3
        if ($this->hasCustomField($type)) {
159
            return $this->customTypes[$type];
160
        }
161 87
162 2
        if (!in_array($type, $types)) {
163 2
            throw new \InvalidArgumentException(
164 2
                sprintf(
165 2
                    'Unsupported field type [%s]. Available types are: %s',
166 2
                    $type,
167
                    join(', ', array_merge($types, array_keys($this->customTypes)))
168
                )
169
            );
170
        }
171 85
172
        $namespace = __NAMESPACE__.'\\Fields\\';
173 85
174
        return $namespace . static::$availableFieldTypes[$type];
175
    }
176
177
    /**
178
     * Convert array of attributes to html attributes.
179
     *
180
     * @param $options
181
     * @return string
182 107
     */
183
    public function prepareAttributes($options)
184 107
    {
185 13
        if (!$options) {
186
            return null;
187
        }
188 107
189
        $attributes = [];
190 107
191 107
        foreach ($options as $name => $option) {
192 107
            if ($option !== null) {
193 107
                $name = is_numeric($name) ? $option : $name;
194
                $attributes[] = $name.'="'.$option.'" ';
195
            }
196
        }
197 107
198
        return join('', $attributes);
199
    }
200
201
    /**
202
     * Add custom field.
203
     *
204
     * @param $name
205
     * @param $class
206 4
     */
207
    public function addCustomField($name, $class)
208 4
    {
209 4
        if (!$this->hasCustomField($name)) {
210
            return $this->customTypes[$name] = $class;
211
        }
212 1
213
        throw new \InvalidArgumentException('Custom field ['.$name.'] already exists on this form object.');
214
    }
215
216
    /**
217
     * Load custom field types from config file.
218 134
     */
219
    private function loadCustomTypes()
220 134
    {
221
        $customFields = (array) $this->getConfig('custom_fields');
222 134
223 1
        if (!empty($customFields)) {
224 1
            foreach ($customFields as $fieldName => $fieldClass) {
225
                $this->addCustomField($fieldName, $fieldClass);
226
            }
227 134
        }
228
    }
229
230
    /**
231
     * Check if custom field with provided name exists
232
     * @param string $name
233
     * @return boolean
234 91
     */
235
    public function hasCustomField($name)
236 91
    {
237
        return array_key_exists($name, $this->customTypes);
238
    }
239
240
    /**
241
     * @param object $model
242
     * @return object|null
243 5
     */
244
    public function convertModelToArray($model)
245 5
    {
246 1
        if (!$model) {
247
            return null;
248
        }
249 5
250 3
        if ($model instanceof Model) {
251
            return $model->toArray();
252
        }
253 3
254 2
        if ($model instanceof Collection) {
255
            return $model->all();
256
        }
257 2
258
        return $model;
259
    }
260
261
    /**
262
     * Format the label to the proper format.
263
     *
264
     * @param $name
265
     * @return string
266 105
     */
267
    public function formatLabel($name)
268 105
    {
269 1
        if (!$name) {
270
            return null;
271
        }
272 105
273 2
        if ($this->translator->has($name)) {
274
            $translatedName = $this->translator->get($name);
275 2
276 2
            if (is_string($translatedName)) {
277
                return $translatedName;
278
            }
279
        }
280 103
281
        return ucfirst(str_replace('_', ' ', $name));
282
    }
283
284
    /**
285
     * @param FormField $field
286
     * @return RulesParser
287 106
     */
288
    public function createRulesParser(FormField $field)
289 106
    {
290
        return new RulesParser($field);
291
    }
292
293
    /**
294
     * @param FormField $field
295
     * @return array
296 10
     */
297
    public function getFieldValidationRules(FormField $field)
298 10
    {
299
        $fieldRules = $field->getValidationRules();
300 10
301
        if (is_array($fieldRules)) {
302
          $fieldRules = Rules::fromArray($fieldRules)->setFieldName($field->getNameKey());
303
        }
304 10
305 10
        $formBuilder = $field->getParent()->getFormBuilder();
306
        $formBuilder->fireEvent(new AfterCollectingFieldRules($field, $fieldRules));
307 10
308
        return $fieldRules;
309
    }
310
311
    /**
312
     * @param FormField[] $fields
313
     * @return array
314 10
     */
315
    public function mergeFieldsRules($fields)
316 10
    {
317
        $rules = new Rules([]);
318 10
319 10
        foreach ($fields as $field) {
320
            $rules->append($this->getFieldValidationRules($field));
0 ignored issues
show
Documentation introduced by
$this->getFieldValidationRules($field) is of type object<Kris\LaravelFormBuilder\Rules>, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
321
        }
322 10
323
        return $rules;
324
    }
325
326
    /**
327
     * @param array $fields
328
     * @return array
329 3
     */
330
    public function mergeAttributes(array $fields)
331 3
    {
332 3
        $attributes = [];
333 3
        foreach ($fields as $field) {
334
            $attributes = array_merge($attributes, $field->getAllAttributes());
335
        }
336 3
337
        return $attributes;
338
    }
339
340
    /**
341
     * Get a form's checkbox fields' names.
342
     *
343
     * @param  Form  $form
344
     * @return array
345
     */
346 3
    public function getBoolableFields(Form $form)
347
    {
348
        $fields = [];
349 3
        foreach ($form->getFields() as $name => $field) {
350 3
            if ($field instanceof CheckableType && $field->getOption('value') == CheckableType::DEFAULT_VALUE) {
351 2
                $fields[] = $this->transformToDotSyntax($name);
352
            }
353 2
        }
354 2
355 3
        return $fields;
356
    }
357
358
    /**
359
     * Turn checkbox fields into bools.
360 3
     *
361 3
     * @param  Form  $form
362
     * @param  array $values
363
     * @return void
364
     */
365
    public function alterFieldValuesBools(Form $form, array &$values)
366
    {
367
        $fields = $this->getBoolableFields($form);
368 10
369
        foreach ($fields as $name) {
370
          $value = Arr::get($values, $name, -1);
371 10
          if ($value !== -1) {
372
            Arr::set($values, $name, (int) (bool) $value);
373
          }
374 10
        }
375 1
    }
376 1
377
    /**
378
     * Alter a form's values recursively according to its fields.
379
     *
380 10
     * @param  Form  $form
381 10
     * @param  array $values
382 10
     * @return void
383
     */
384
    public function alterFieldValues(Form $form, array &$values)
385 10
    {
386
        $this->alterFieldValuesBools($form, $values);
387
388
        // Alter the form's child forms recursively
389
        foreach ($form->getFields() as $name => $field) {
390
            if (method_exists($field, 'alterFieldValues')) {
391
                $fullName = $this->transformToDotSyntax($name);
392 1
393
                $subValues = (array) Arr::get($values, $fullName);
394 1
                $field->alterFieldValues($subValues);
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Kris\LaravelFormBuilder\Fields\FormField as the method alterFieldValues() does only exist in the following sub-classes of Kris\LaravelFormBuilder\Fields\FormField: Kris\LaravelFormBuilder\Fields\ChildFormType. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
395 1
                Arr::set($values, $fullName, $subValues);
396 1
            }
397
        }
398
399 1
        // Alter the form itself
400 1
        $form->alterFieldValues($values);
401
    }
402
403 1
    /**
404
     * Alter a form's validity recursively, and add messages with nested form prefix.
405
     *
406
     * @return void
407
     */
408
    public function alterValid(Form $form, Form $mainForm, &$isValid)
409 106
    {
410
        // Alter the form itself
411 106
        $messages = $form->alterValid($mainForm, $isValid);
412
413
        // Add messages to the existing Bag
414
        if ($messages) {
415
            $messageBag = $mainForm->getValidator()->getMessageBag();
416
            $this->appendMessagesWithPrefix($messageBag, $form->getName(), $messages);
417
        }
418 8
419
        // Alter the form's child forms recursively
420 8
        foreach ($form->getFields() as $name => $field) {
421 8
            if (method_exists($field, 'alterValid')) {
422 1
                $field->alterValid($mainForm, $isValid);
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Kris\LaravelFormBuilder\Fields\FormField as the method alterValid() does only exist in the following sub-classes of Kris\LaravelFormBuilder\Fields\FormField: Kris\LaravelFormBuilder\Fields\ChildFormType. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
423
            }
424
        }
425 7
    }
426 7
427
    /**
428
     * Add unprefixed messages with prefix to a MessageBag.
429
     *
430
     * @return void
431
     */
432 3
    public function appendMessagesWithPrefix(MessageBag $messageBag, $prefix, array $keyedMessages)
433
    {
434 3
        foreach ($keyedMessages as $key => $messages) {
435
            if ($prefix) {
436
                $key = $this->transformToDotSyntax($prefix . '[' . $key . ']');
437
            }
438
439
            foreach ((array) $messages as $message) {
440
                $messageBag->add($key, $message);
441
            }
442
        }
443
    }
444 74
445
    /**
446 74
     * @param string $string
447 2
     * @return string
448 2
     */
449
    public function transformToDotSyntax($string)
450
    {
451
        return str_replace(['.', '[]', '[', ']'], ['_', '', '.', ''], $string);
452 72
    }
453 2
454 2
    /**
455 2
     * @param string $string
456
     * @return string
457
     */
458
    public function transformToBracketSyntax($string)
459 70
    {
460
        $name = explode('.', $string);
461
        if ($name && count($name) == 1) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $name of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
462
            return $name[0];
463
        }
464
465
        $first = array_shift($name);
466
        return $first . '[' . implode('][', $name) . ']';
467
    }
468
469
    /**
470
     * @return TranslatorInterface
471
     */
472
    public function getTranslator()
473
    {
474
        return $this->translator;
475
    }
476
477
    /**
478
     * Check if field name is valid and not reserved.
479
     *
480
     * @throws \InvalidArgumentException
481
     * @param string $name
482
     * @param string $className
483
     */
484
    public function checkFieldName($name, $className)
485
    {
486
        if (!$name || trim($name) == '') {
487
            throw new \InvalidArgumentException(
488
                "Please provide valid field name for class [{$className}]"
489
            );
490
        }
491
492
        if (in_array($name, static::$reservedFieldNames)) {
493
            throw new \InvalidArgumentException(
494
                "Field name [{$name}] in form [{$className}] is a reserved word. Please use a different field name." .
495
                "\nList of all reserved words: " . join(', ', static::$reservedFieldNames)
496
            );
497
        }
498
499
        return true;
500
    }
501
}
502