Completed
Pull Request — master (#577)
by
unknown
02:17
created

FormHelper::mergeOptions()   B

Complexity

Conditions 7
Paths 32

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
nc 32
nop 2
dl 0
loc 30
ccs 13
cts 13
cp 1
crap 7
rs 8.5066
c 0
b 0
f 0
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\FormField;
13
use Kris\LaravelFormBuilder\Form;
14
use Kris\LaravelFormBuilder\RulesParser;
15
16
class FormHelper
17
{
18
19
    /**
20
     * @var View
21
     */
22
    protected $view;
23
24
    /**
25
     * @var TranslatorInterface
26
     */
27
    protected $translator;
28
29
    /**
30
     * @var array
31
     */
32
    protected $config;
33
34
    /**
35
     * @var FormBuilder
36
     */
37
    protected $formBuilder;
38
39
    /**
40
     * @var array
41
     */
42
    protected static $reservedFieldNames = [
43
        'save'
44
    ];
45
46
    /**
47
     * All available field types
48
     *
49
     * @var array
50
     */
51
    protected static $availableFieldTypes = [
52
        'text'           => 'InputType',
53
        'email'          => 'InputType',
54
        'url'            => 'InputType',
55
        'tel'            => 'InputType',
56
        'search'         => 'InputType',
57
        'password'       => 'InputType',
58
        'hidden'         => 'InputType',
59
        'number'         => 'InputType',
60
        'date'           => 'InputType',
61
        'file'           => 'InputType',
62
        'image'          => 'InputType',
63
        'color'          => 'InputType',
64
        'datetime-local' => 'InputType',
65
        'month'          => 'InputType',
66
        'range'          => 'InputType',
67
        'time'           => 'InputType',
68
        'week'           => 'InputType',
69
        'select'         => 'SelectType',
70
        'textarea'       => 'TextareaType',
71
        'button'         => 'ButtonType',
72
        'buttongroup'    => 'ButtonGroupType',
73
        'submit'         => 'ButtonType',
74
        'reset'          => 'ButtonType',
75
        'radio'          => 'CheckableType',
76
        'checkbox'       => 'CheckableType',
77
        'choice'         => 'ChoiceType',
78
        'form'           => 'ChildFormType',
79
        'entity'         => 'EntityType',
80
        'collection'     => 'CollectionType',
81
        'repeated'       => 'RepeatedType',
82
        'static'         => 'StaticType'
83
    ];
84
85
    /**
86
     * Custom types
87
     *
88
     * @var array
89
     */
90
    private $customTypes = [];
91
92
    /**
93
     * @param View    $view
94
     * @param Translator $translator
95
     * @param array   $config
96
     */
97 131
    public function __construct(View $view, Translator $translator, array $config = [])
98
    {
99 131
        $this->view = $view;
100 131
        $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...
101 131
        $this->config = $config;
102 131
        $this->loadCustomTypes();
103 131
    }
104
105
    /**
106
     * @param string $key
107
     * @param string $default
108
     * @param array $customConfig
109
     * @return mixed
110
     */
111 131
    public function getConfig($key = null, $default = null, $customConfig = [])
112
    {
113 131
        $config = array_replace_recursive($this->config, $customConfig);
114
115 131
        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...
116 131
            return Arr::get($config, $key, $default);
117
        }
118
119
        return $config;
120
    }
121
122
    /**
123
     * @return View
124
     */
125 38
    public function getView()
126
    {
127 38
        return $this->view;
128
    }
129
130
    /**
131
     * Merge options array.
132
     *
133
     * @param array $targetOptions
134
     * @param array $sourceOptions
135
     * @return array
136
     */
137 131
    public function mergeOptions(array $targetOptions, array $sourceOptions)
138
    {
139
        // Normalize rules
140 131
        if (array_key_exists('+rules', $sourceOptions)) {
141 1
            $sourceOptions['+rules'] = $this->normalizeRules($sourceOptions['+rules']);
142
        }
143
144 131
        if (array_key_exists('rules', $sourceOptions)) {
145 26
            $sourceOptions['rules'] = $this->normalizeRules($sourceOptions['rules']);
146
        }
147
148 131
        if (array_key_exists('rules', $targetOptions)) {
149 102
            $targetOptions['rules'] = $this->normalizeRules($targetOptions['rules']);
150
        }
151
152
153
        // Append rules
154 131
        if ($rulesToBeAppended = Arr::pull($sourceOptions, '+rules')) {
155 1
            $mergedRules = array_values(array_unique(array_merge($targetOptions['rules'], $rulesToBeAppended)));
156 1
            $targetOptions['rules'] = $mergedRules;
157
        }
158
159
160
        // Replace rules
161 131
        if (array_key_exists('rules', $targetOptions) && array_key_exists('rules', $sourceOptions)) {
162 26
            unset($targetOptions['rules']);
163
        }
164
165 131
        return array_replace_recursive($targetOptions, $sourceOptions);
166
    }
167
168
    /**
169
     * Normalize the the given rule expression to an array.
170
     * @param mixed $rules
171
     * @return array
172
     */
173 103
    public function normalizeRules($rules)
174
    {
175 103
        if (empty($rules)) {
176 101
            return [];
177
        }
178
179 15
        if (is_string($rules)) {
180 15
            return explode('|', $rules);
181
        }
182
183 15
        if (is_array($rules)) {
184 15
            $normalizedRules = [];
185 15
            foreach ($rules as $rule) {
186 15
                $normalizedRules[] = $this->normalizeRules($rule);
187
            }
188
189 15
            return array_values(array_unique(Arr::flatten($normalizedRules)));
190
        }
191
192
        return $rules;
193
    }
194
195
    /**
196
     * Get proper class for field type.
197
     *
198
     * @param $type
199
     * @return string
200
     */
201 86
    public function getFieldType($type)
202
    {
203 86
        $types = array_keys(static::$availableFieldTypes);
204
205 86
        if (!$type || trim($type) == '') {
206 1
            throw new \InvalidArgumentException('Field type must be provided.');
207
        }
208
209 85
        if ($this->hasCustomField($type)) {
210 2
            return $this->customTypes[$type];
211
        }
212
213 83
        if (!in_array($type, $types)) {
214 2
            throw new \InvalidArgumentException(
215 2
                sprintf(
216 2
                    'Unsupported field type [%s]. Available types are: %s',
217 2
                    $type,
218 2
                    join(', ', array_merge($types, array_keys($this->customTypes)))
219
                )
220
            );
221
        }
222
223 81
        $namespace = __NAMESPACE__.'\\Fields\\';
224
225 81
        return $namespace . static::$availableFieldTypes[$type];
226
    }
227
228
    /**
229
     * Convert array of attributes to html attributes.
230
     *
231
     * @param $options
232
     * @return string
233
     */
234 102
    public function prepareAttributes($options)
235
    {
236 102
        if (!$options) {
237 13
            return null;
238
        }
239
240 102
        $attributes = [];
241
242 102
        foreach ($options as $name => $option) {
243 102
            if ($option !== null) {
244 102
                $name = is_numeric($name) ? $option : $name;
245 102
                $attributes[] = $name.'="'.$option.'" ';
246
            }
247
        }
248
249 102
        return join('', $attributes);
250
    }
251
252
    /**
253
     * Add custom field.
254
     *
255
     * @param $name
256
     * @param $class
257
     */
258 3
    public function addCustomField($name, $class)
259
    {
260 3
        if (!$this->hasCustomField($name)) {
261 3
            return $this->customTypes[$name] = $class;
262
        }
263
264 1
        throw new \InvalidArgumentException('Custom field ['.$name.'] already exists on this form object.');
265
    }
266
267
    /**
268
     * Load custom field types from config file.
269
     */
270 131
    private function loadCustomTypes()
271
    {
272 131
        $customFields = (array) $this->getConfig('custom_fields');
273
274 131
        if (!empty($customFields)) {
275 1
            foreach ($customFields as $fieldName => $fieldClass) {
276 1
                $this->addCustomField($fieldName, $fieldClass);
277
            }
278
        }
279 131
    }
280
281
    /**
282
     * Check if custom field with provided name exists
283
     * @param string $name
284
     * @return boolean
285
     */
286 86
    public function hasCustomField($name)
287
    {
288 86
        return array_key_exists($name, $this->customTypes);
289
    }
290
291
    /**
292
     * @param object $model
293
     * @return object|null
294
     */
295 4
    public function convertModelToArray($model)
296
    {
297 4
        if (!$model) {
298 1
            return null;
299
        }
300
301 4
        if ($model instanceof Model) {
302 2
            return $model->toArray();
303
        }
304
305 3
        if ($model instanceof Collection) {
306 2
            return $model->all();
307
        }
308
309 2
        return $model;
310
    }
311
312
    /**
313
     * Format the label to the proper format.
314
     *
315
     * @param $name
316
     * @return string
317
     */
318 100
    public function formatLabel($name)
319
    {
320 100
        if (!$name) {
321 1
            return null;
322
        }
323
324 100
        if ($this->translator->has($name)) {
325 2
            $translatedName = $this->translator->get($name);
326
327 2
            if (is_string($translatedName)) {
328 2
                return $translatedName;
329
            }
330
        }
331
332 98
        return ucfirst(str_replace('_', ' ', $name));
333
    }
334
335
    /**
336
     * @param FormField $field
337
     * @return RulesParser
338
     */
339 101
    public function createRulesParser(FormField $field)
340
    {
341 101
        return new RulesParser($field);
342
    }
343
344
    /**
345
     * @param FormField $field
346
     * @return array
347
     */
348 9
    public function getFieldValidationRules(FormField $field)
349
    {
350 9
        $fieldRules = $field->getValidationRules();
351
352 9
        if (is_array($fieldRules)) {
353
          $fieldRules = Rules::fromArray($fieldRules)->setFieldName($field->getNameKey());
354
        }
355
356 9
        $formBuilder = $field->getParent()->getFormBuilder();
357 9
        $formBuilder->fireEvent(new AfterCollectingFieldRules($field, $fieldRules));
358
359 9
        return $fieldRules;
360
    }
361
362
    /**
363
     * @param FormField[] $fields
364
     * @return array
365
     */
366 9
    public function mergeFieldsRules($fields)
367
    {
368 9
        $rules = new Rules([]);
369
370 9
        foreach ($fields as $field) {
371 9
            $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...
372
        }
373
374 9
        return $rules;
375
    }
376
377
    /**
378
     * @param array $fields
379
     * @return array
380
     */
381 3
    public function mergeAttributes(array $fields)
382
    {
383 3
        $attributes = [];
384 3
        foreach ($fields as $field) {
385 3
            $attributes = array_merge($attributes, $field->getAllAttributes());
386
        }
387
388 3
        return $attributes;
389
    }
390
391
    /**
392
     * Alter a form's values recursively according to its fields.
393
     *
394
     * @param  Form  $form
395
     * @param  array $values
396
     * @return void
397
     */
398 3
    public function alterFieldValues(Form $form, array &$values)
399
    {
400
        // Alter the form's child forms recursively
401 3
        foreach ($form->getFields() as $name => $field) {
402 3
            if (method_exists($field, 'alterFieldValues')) {
403 2
                $fullName = $this->transformToDotSyntax($name);
404
405 2
                $subValues = (array) Arr::get($values, $fullName);
406 2
                $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...
407 3
                Arr::set($values, $fullName, $subValues);
408
            }
409
        }
410
411
        // Alter the form itself
412 3
        $form->alterFieldValues($values);
413 3
    }
414
415
    /**
416
     * Alter a form's validity recursively, and add messages with nested form prefix.
417
     *
418
     * @return void
419
     */
420 9
    public function alterValid(Form $form, Form $mainForm, &$isValid)
421
    {
422
        // Alter the form itself
423 9
        $messages = $form->alterValid($mainForm, $isValid);
424
425
        // Add messages to the existing Bag
426 9
        if ($messages) {
427 1
            $messageBag = $mainForm->getValidator()->getMessageBag();
428 1
            $this->appendMessagesWithPrefix($messageBag, $form->getName(), $messages);
429
        }
430
431
        // Alter the form's child forms recursively
432 9
        foreach ($form->getFields() as $name => $field) {
433 9
            if (method_exists($field, 'alterValid')) {
434 9
                $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...
435
            }
436
        }
437 9
    }
438
439
    /**
440
     * Add unprefixed messages with prefix to a MessageBag.
441
     *
442
     * @return void
443
     */
444 1
    public function appendMessagesWithPrefix(MessageBag $messageBag, $prefix, array $keyedMessages)
445
    {
446 1
        foreach ($keyedMessages as $key => $messages) {
447 1
            if ($prefix) {
448 1
                $key = $this->transformToDotSyntax($prefix . '[' . $key . ']');
449
            }
450
451 1
            foreach ((array) $messages as $message) {
452 1
                $messageBag->add($key, $message);
453
            }
454
        }
455 1
    }
456
457
    /**
458
     * @param string $string
459
     * @return string
460
     */
461 101
    public function transformToDotSyntax($string)
462
    {
463 101
        return str_replace(['.', '[]', '[', ']'], ['_', '', '.', ''], $string);
464
    }
465
466
    /**
467
     * @param string $string
468
     * @return string
469
     */
470 7
    public function transformToBracketSyntax($string)
471
    {
472 7
        $name = explode('.', $string);
473 7
        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...
474 1
            return $name[0];
475
        }
476
477 6
        $first = array_shift($name);
478 6
        return $first . '[' . implode('][', $name) . ']';
479
    }
480
481
    /**
482
     * @return TranslatorInterface
483
     */
484 3
    public function getTranslator()
485
    {
486 3
        return $this->translator;
487
    }
488
489
    /**
490
     * Check if field name is valid and not reserved.
491
     *
492
     * @throws \InvalidArgumentException
493
     * @param string $name
494
     * @param string $className
495
     */
496 69
    public function checkFieldName($name, $className)
497
    {
498 69
        if (!$name || trim($name) == '') {
499 2
            throw new \InvalidArgumentException(
500 2
                "Please provide valid field name for class [{$className}]"
501
            );
502
        }
503
504 67
        if (in_array($name, static::$reservedFieldNames)) {
505 2
            throw new \InvalidArgumentException(
506 2
                "Field name [{$name}] in form [{$className}] is a reserved word. Please use a different field name." .
507 2
                "\nList of all reserved words: " . join(', ', static::$reservedFieldNames)
508
            );
509
        }
510
511 65
        return true;
512
    }
513
}
514