Completed
Pull Request — master (#601)
by Tortue
02:15
created

Checkable::unique()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 9.7
c 0
b 0
f 0
cc 3
nc 2
nop 1
1
<?php
2
namespace Former\Traits;
3
4
use Former\Helpers;
5
use HtmlObject\Element;
6
use HtmlObject\Input;
7
use Illuminate\Container\Container;
8
use Illuminate\Support\Arr;
9
use Illuminate\Support\Collection;
10
use Illuminate\Support\Str;
11
12
/**
13
 * Abstract methods inherited by Checkbox and Radio
14
 */
15
abstract class Checkable extends Field
16
{
17
	/**
18
	 * Renders the checkables as inline
19
	 *
20
	 * @var boolean
21
	 */
22
	protected $inline = false;
23
24
	/**
25
	 * Add a text to a single element
26
	 *
27
	 * @var string
28
	 */
29
	protected $text = null;
30
31
	/**
32
	 * Renders the checkables as grouped
33
	 *
34
	 * @var boolean
35
	 */
36
	protected $grouped = false;
37
38
	/**
39
	 * The checkable items currently stored
40
	 *
41
	 * @var array
42
	 */
43
	protected $items = array();
44
45
	/**
46
	 * The type of checkable item
47
	 *
48
	 * @var string
49
	 */
50
	protected $checkable = null;
51
52
	/**
53
	 * An array of checked items
54
	 *
55
	 * @var array
56
	 */
57
	protected $checked = array();
58
59
	/**
60
	 * The checkable currently being focused on
61
	 *
62
	 * @var integer
63
	 */
64
	protected $focus = null;
65
66
	/**
67
	 * Whether this particular checkable is to be pushed
68
	 *
69
	 * @var boolean
70
	 */
71
	protected $isPushed = null;
72
73
	////////////////////////////////////////////////////////////////////
74
	//////////////////////////// CORE METHODS //////////////////////////
75
	////////////////////////////////////////////////////////////////////
76
77
	/**
78
	 * Build a new checkable
79
	 *
80
	 * @param Container $app
81
	 * @param string    $type
82
	 * @param array     $name
83
	 * @param           $label
84
	 * @param           $value
85
	 * @param           $attributes
86
	 */
87
	public function __construct(Container $app, $type, $name, $label, $value, $attributes)
88
	{
89
		// Unify auto and chained methods of grouping checkboxes
90
		if (Str::endsWith($name, '[]')) {
0 ignored issues
show
Documentation introduced by
$name is of type array, but the function expects a string.

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...
91
			$name = substr($name, 0, -2);
92
			$this->grouped();
93
		}
94
		parent::__construct($app, $type, $name, $label, $value, $attributes);
95
96
		if (is_array($this->value)) {
97
			$this->items($this->value);
98
		}
99
	}
100
101
	/**
102
	 * Apply methods to focused checkable
103
	 *
104
	 * @param string $method
105
	 * @param array  $parameters
106
	 *
107
	 * @return $this
108
	 */
109
	public function __call($method, $parameters)
110
	{
111
		$focused = $this->setOnFocused('attributes.'.$method, Arr::get($parameters, 0));
112
		if ($focused) {
113
			return $this;
114
		}
115
116
		return parent::__call($method, $parameters);
117
	}
118
119
	/**
120
	 * Prints out the currently stored checkables
121
	 */
122
	public function render()
123
	{
124
		$html = null;
125
126
		$this->setFieldClasses();
127
128
		// Multiple items
129
		if ($this->items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->items 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...
130
			unset($this->app['former']->labels[array_search($this->name, $this->app['former']->labels)]);
131
			foreach ($this->items as $key => $item) {
132
				$value = $this->isCheckbox() && !$this->isGrouped() ? 1 : $key;
133
				$html .= $this->createCheckable($item, $value);
134
			}
135
136
			return $html;
137
		}
138
139
		// Single item
140
		return $this->createCheckable(array(
141
			'name'  => $this->name,
142
			'label' => $this->text,
143
			'value' => $this->value,
144
			'attributes' => $this->attributes,
145
		));
146
	}
147
148
	////////////////////////////////////////////////////////////////////
149
	////////////////////////// FIELD METHODS ///////////////////////////
150
	////////////////////////////////////////////////////////////////////
151
152
	/**
153
	 * Focus on a particular checkable
154
	 *
155
	 * @param integer $on The checkable to focus on
156
	 *
157
	 * @return $this
158
	 */
159
	public function on($on)
160
	{
161
		if (!isset($this->items[$on])) {
162
			return $this;
163
		} else {
164
			$this->focus = $on;
165
		}
166
167
		return $this;
168
	}
169
170
	/**
171
	 * Set the checkables as inline
172
	 */
173
	public function inline($isInline = true)
174
	{
175
		$this->inline = $isInline;
176
177
		return $this;
178
	}
179
180
	/**
181
	 * Set the checkables as stacked
182
	 */
183
	public function stacked($isStacked = true)
184
	{
185
		$this->inline = !$isStacked;
186
187
		return $this;
188
	}
189
190
	/**
191
	 * Set the checkables as grouped
192
	 */
193
	public function grouped($isGrouped = true)
194
	{
195
		$this->grouped = $isGrouped;
196
197
		return $this;
198
	}
199
200
	/**
201
	 * Add text to a single checkable
202
	 *
203
	 * @param  string $text The checkable label
204
	 *
205
	 * @return $this
206
	 */
207
	public function text($text)
208
	{
209
		// Translate and format
210
		$text = Helpers::translate($text);
211
212
		// Apply on focused if any
213
		$focused = $this->setOnFocused('label', $text);
214
		if ($focused) {
215
			return $this;
216
		}
217
218
		$this->text = $text;
219
220
		return $this;
221
	}
222
223
	/**
224
	 * Push this particular checkbox
225
	 *
226
	 * @param boolean $pushed
227
	 *
228
	 * @return $this
229
	 */
230
	public function push($pushed = true)
231
	{
232
		$this->isPushed = $pushed;
233
234
		return $this;
235
	}
236
237
	/**
238
	 * Check a specific item
239
	 *
240
	 * @param bool|string $checked The checkable to check, or an array of checked items
241
	 *
242
	 * @return $this
243
	 */
244
	public function check($checked = true)
245
	{
246
		// If we're setting all the checked items at once
247
		if (is_array($checked)) {
248
			$this->checked = $checked;
249
			// Checking an item in particular
250
		} elseif (is_string($checked) or is_int($checked)) {
251
			$this->checked[$checked] = true;
252
			// Only setting a single item
253
		} else {
254
			$this->checked[$this->name] = (bool) $checked;
255
		}
256
257
		return $this;
258
	}
259
260
261
	/**
262
	 * Check if the checkables are inline
263
	 *
264
	 * @return boolean
265
	 */
266
	public function isInline()
267
	{
268
		return $this->inline;
269
	}
270
271
	////////////////////////////////////////////////////////////////////
272
	////////////////////////// INTERNAL METHODS ////////////////////////
273
	////////////////////////////////////////////////////////////////////
274
275
	/**
276
	 * Creates a series of checkable items
277
	 *
278
	 * @param array $_items Items to create
279
	 */
280
	protected function items($_items)
281
	{
282
		// If passing an array
283
		if (sizeof($_items) == 1 and
284
			isset($_items[0]) and
285
			is_array($_items[0])
286
		) {
287
			$_items = $_items[0];
288
		}
289
290
		// Fetch models if that's what we were passed
291
		if (isset($_items[0]) and is_object($_items[0])) {
292
			$_items = Helpers::queryToArray($_items);
293
			$_items = array_flip($_items);
294
		}
295
296
		// Iterate through items, assign a name and a label to each
297
		$count = 0;
298
		foreach ($_items as $label => $name) {
299
300
			// Define a fallback name in case none is found
301
			$fallback = $this->isCheckbox()
302
				? $this->name.'_'.$count
303
				: $this->name;
304
305
			// Grouped fields
306
			if ($this->isGrouped()) {
307
				$attributes['id'] = str_replace('[]', null, $fallback);
0 ignored issues
show
Coding Style Comprehensibility introduced by
$attributes was never initialized. Although not strictly required by PHP, it is generally a good practice to add $attributes = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
308
				$fallback         = str_replace('[]', null, $this->name).'[]';
309
			}
310
311
			// If we haven't any name defined for the checkable, try to compute some
312
			if (!is_string($label) and !is_array($name)) {
313
				$label = $name;
314
				$name  = $fallback;
315
			}
316
317
			// If we gave custom information on the item, add them
318
			if (is_array($name)) {
319
				$attributes = $name;
320
				$name       = Arr::get($attributes, 'name', $fallback);
321
				unset($attributes['name']);
322
			}
323
324
			// Store all informations we have in an array
325
			$item = array(
326
				'name'  => $name,
327
				'label' => Helpers::translate($label),
328
				'count' => $count,
329
			);
330
			if (isset($attributes)) {
331
				$item['attributes'] = $attributes;
332
			}
333
334
			$this->items[] = $item;
335
			$count++;
336
		}
337
	}
338
339
	/**
340
	 * Renders a checkable
341
	 *
342
	 * @param string|array $item          A checkable item
343
	 * @param integer      $fallbackValue A fallback value if none is set
344
	 *
345
	 * @return string
346
	 */
347
	protected function createCheckable($item, $fallbackValue = 1)
348
	{
349
		// Extract informations
350
		extract($item);
351
352
		// Set default values
353
		if (!isset($attributes)) {
354
			$attributes = array();
355
		}
356
		if (isset($attributes['value'])) {
357
			$value = $attributes['value'];
358
		}
359
		if (!isset($value) or $value === $this->app['former']->getOption('unchecked_value')) {
360
			$value = $fallbackValue;
361
		}
362
363
		// If inline items, add class
364
		$isInline = $this->inline ? ' '.$this->app['former.framework']->getInlineLabelClass($this) : null;
365
366
		// In Bootsrap 3 or 4, don't append the the checkable type (radio/checkbox) as a class if
367
		// rendering inline.
368
		$class =  ($this->app['former']->framework() == 'TwitterBootstrap3' ||
369
			$this->app['former']->framework() == 'TwitterBootstrap4') ? trim($isInline) : $this->checkable.$isInline;
370
371
		// Merge custom attributes with global attributes
372
		$attributes = array_merge($this->attributes, $attributes);
373
		if (!isset($attributes['id'])) {
374
			$attributes['id'] = $name.$this->unique($name);
375
		}
376
377
		// Create field
378
		$field = Input::create($this->checkable, $name, Helpers::encode($value), $attributes);
379
		if ($this->isChecked($item, $value)) {
380
			$field->checked('checked');
381
		}
382
383
		// Add hidden checkbox if requested
384
		if ($this->isOfType('checkbox', 'checkboxes')) {
385
			if ($this->isPushed or ($this->app['former']->getOption('push_checkboxes') and $this->isPushed !== false)) {
386
				$field = $this->app['former']->hidden($name)->forceValue($this->app['former']->getOption('unchecked_value')).$field->render();
387
388
				// app['former.field'] was overwritten by Former::hidden() call in the line above, so here
389
				// we reset it to $this to enable $this->app['former']->getErrors() to retrieve the correct object
390
				$this->app->instance('former.field', $this);
391
			}
392
		}
393
394
		// If no label to wrap, return plain checkable
395
		if (!$label) {
396
			$element = (is_object($field)) ? $field->render() : $field;
397
		} elseif ($this->app['former']->framework() == 'TwitterBootstrap4') {
398
			// Revised for Bootstrap 4, move the 'input' outside of the 'label'
399
			$labelClass = 'form-check-label';
400
			$element = $field . Element::create('label', $label)->for($attributes['id'])->class($labelClass)->render();
0 ignored issues
show
Documentation Bug introduced by
The method for does not exist on object<Former\Traits\Checkable>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
401
402
			$wrapper_class = $this->inline ? 'form-check form-check-inline' : 'form-check';
403
404
			$element = Element::create('div', $element)->class($wrapper_class)->render();
0 ignored issues
show
Documentation Bug introduced by
The method class does not exist on object<Former\Traits\Checkable>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
405
		} else {
406
			// Original way is to add the 'input' inside the 'label'
407
			$element = Element::create('label', $field.$label)->for($attributes['id'])->class($class)->render();
0 ignored issues
show
Documentation Bug introduced by
The method for does not exist on object<Former\Traits\Checkable>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
408
		}
409
410
		// If BS3, if checkables are stacked, wrap them in a div with the checkable type
411
		if (!$isInline && $this->app['former']->framework() == 'TwitterBootstrap3') {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isInline of type string|null is loosely compared to false; 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...
412
			$wrapper = Element::create('div', $element)->class($this->checkable);
0 ignored issues
show
Documentation Bug introduced by
The method class does not exist on object<Former\Traits\Checkable>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
413
			if ($this->getAttribute('disabled')) {
414
				$wrapper->addClass('disabled');
415
			}
416
			$element = $wrapper->render();
417
		}
418
419
		// Return the field
420
		return $element;
421
	}
422
423
	////////////////////////////////////////////////////////////////////
424
	///////////////////////////// HELPERS //////////////////////////////
425
	////////////////////////////////////////////////////////////////////
426
427
	/**
428
	 * Generate an unique ID for a field
429
	 *
430
	 * @param string $name The field's name
431
	 *
432
	 * @return string A field number to use
433
	 */
434
	protected function unique($name)
435
	{
436
		$this->app['former']->labels[] = $name;
437
438
		// Count number of fields with the same ID
439
		$where  = array_filter($this->app['former']->labels, function ($label) use ($name) {
440
			return $label == $name;
441
		});
442
		$unique = sizeof($where);
443
444
		// In case the field doesn't need to be numbered
445
		if ($unique < 2 or empty($this->items)) {
446
			return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type documented by Former\Traits\Checkable::unique of type string.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
447
		}
448
449
		return $unique;
450
	}
451
452
	/**
453
	 * Set something on the currently focused checkable
454
	 *
455
	 * @param string $attribute The key to set
456
	 * @param string $value     Its value
457
	 *
458
	 * @return $this|bool
459
	 */
460
	protected function setOnFocused($attribute, $value)
461
	{
462
		if (is_null($this->focus)) {
463
			return false;
464
		}
465
466
		$this->items[$this->focus] = Arr::set($this->items[$this->focus], $attribute, $value);
467
468
		return $this;
469
	}
470
471
	/**
472
	 * Check if a checkable is checked
473
	 *
474
	 * @return boolean Checked or not
475
	 */
476
	protected function isChecked($item = null, $value = null)
477
	{
478
		if (isset($item['name'])) {
479
			$name = $item['name'];
480
		}
481
		if (empty($name)) {
482
			$name = $this->name;
483
		}
484
485
		// If it's a checkbox, see if we marqued that one as checked in the array
486
		// Or if it's a single radio, simply see if we called check
487
		if ($this->isCheckbox() or
488
			!$this->isCheckbox() and !$this->items
489
		) {
490
			$checked = Arr::get($this->checked, $name, false);
491
492
			// If there are multiple, search for the value
493
			// as the name are the same between radios
494
		} else {
495
			$checked = Arr::get($this->checked, $value, false);
496
		}
497
498
		// Check the values and POST array
499
		if ($this->isGrouped()) {
500
			// The group index. (e.g. 'bar' if the item name is foo[bar], or the item index for foo[])
501
			$groupIndex = self::getGroupIndexFromItem($item);
502
503
			// Search using the bare name, not the individual item name
504
			$post   = $this->app['former']->getPost($this->name);
505
			$static = $this->app['former']->getValue($this->bind ?: $this->name);
506
507
			if (isset($post[$groupIndex])) {
508
				$post = $post[$groupIndex];
509
			}
510
511
			/**
512
			 * Support for Laravel Collection repopulating for grouped checkboxes. Note that the groupIndex must
513
			 * match the value in order for the checkbox to be considered checked, e.g.:
514
			 *
515
			 *  array(
516
			 *    'name' = 'roles[foo]',
517
			 *    'value' => 'foo',
518
			 *  )
519
			 */
520
			if ($static instanceof Collection) {
521
				// If the repopulate value is a collection, search for an item matching the $groupIndex
522
				foreach ($static as $staticItem) {
523
					$staticItemValue = method_exists($staticItem, 'getKey') ? $staticItem->getKey() : $staticItem;
524
					if ($staticItemValue == $groupIndex) {
525
						$static = $staticItemValue;
526
						break;
527
					}
528
				}
529
			} else if (isset($static[$groupIndex])) {
530
				$static = $static[$groupIndex];
531
			}
532
		} else {
533
			$post   = $this->app['former']->getPost($name);
534
			$static = $this->app['former']->getValue($this->bind ?: $name);
535
		}
536
537
		if (!is_null($post) and $post !== $this->app['former']->getOption('unchecked_value')) {
538
			$isChecked = ($post == $value);
539
		} elseif (!is_null($static)) {
540
			$isChecked = ($static == $value);
541
		} else {
542
			$isChecked = $checked;
543
		}
544
545
		return $isChecked ? true : false;
546
	}
547
548
	/**
549
	 * Check if the current element is a checkbox
550
	 *
551
	 * @return boolean Checkbox or radio
552
	 */
553
	protected function isCheckbox()
554
	{
555
		return $this->checkable == 'checkbox';
556
	}
557
558
	/**
559
	 * Check if the checkables are grouped or not
560
	 *
561
	 * @return boolean
562
	 */
563
	protected function isGrouped()
564
	{
565
		return
566
			$this->grouped == true or
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
567
			strpos($this->name, '[]') !== false;
568
	}
569
570
	/**
571
	 * @param array $item The item array, containing at least name and count keys.
572
	 *
573
	 * @return mixed The group index. (e.g. returns bar if the item name is foo[bar], or the item count for foo[])
574
	 */
575
	public static function getGroupIndexFromItem($item)
576
	{
577
		$groupIndex = preg_replace('/^.*?\[(.*)\]$/', '$1', $item['name']);
578
		if (empty($groupIndex) or $groupIndex == $item['name']) {
579
			return $item['count'];
580
		}
581
582
		return $groupIndex;
583
	}
584
}
585