Completed
Push — master ( 82be35...1537c0 )
by Song
02:28
created

src/Form/Field/HasMany.php (4 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace Encore\Admin\Form\Field;
4
5
use Encore\Admin\Admin;
6
use Encore\Admin\Form;
7
use Encore\Admin\Form\Field;
8
use Encore\Admin\Form\NestedForm;
9
use Illuminate\Database\Eloquent\Relations\HasMany as Relation;
10
use Illuminate\Database\Eloquent\Relations\MorphMany;
11
use Illuminate\Support\Arr;
12
use Illuminate\Support\Str;
13
14
/**
15
 * Class HasMany.
16
 */
17
class HasMany extends Field
18
{
19
    /**
20
     * Relation name.
21
     *
22
     * @var string
23
     */
24
    protected $relationName = '';
25
26
    /**
27
     * Form builder.
28
     *
29
     * @var \Closure
30
     */
31
    protected $builder = null;
32
33
    /**
34
     * Form data.
35
     *
36
     * @var array
37
     */
38
    protected $value = [];
39
40
    /**
41
     * View Mode.
42
     *
43
     * Supports `default` and `tab` currently.
44
     *
45
     * @var string
46
     */
47
    protected $viewMode = 'default';
48
49
    /**
50
     * Available views for HasMany field.
51
     *
52
     * @var array
53
     */
54
    protected $views = [
55
        'default' => 'admin::form.hasmany',
56
        'tab'     => 'admin::form.hasmanytab',
57
        'table'   => 'admin::form.hasmanytable',
58
    ];
59
60
    /**
61
     * Options for template.
62
     *
63
     * @var array
64
     */
65
    protected $options = [
66
        'allowCreate' => true,
67
        'allowDelete' => true,
68
    ];
69
70
    /**
71
     * Distinct fields.
72
     *
73
     * @var array
74
     */
75
    protected $distinctFields = [];
76
77
    /**
78
     * Create a new HasMany field instance.
79
     *
80
     * @param $relationName
81
     * @param array $arguments
82
     */
83 View Code Duplication
    public function __construct($relationName, $arguments = [])
84
    {
85
        $this->relationName = $relationName;
86
87
        $this->column = $relationName;
88
89
        if (count($arguments) == 1) {
90
            $this->label = $this->formatLabel();
91
            $this->builder = $arguments[0];
92
        }
93
94
        if (count($arguments) == 2) {
95
            list($this->label, $this->builder) = $arguments;
96
        }
97
    }
98
99
    /**
100
     * Get validator for this field.
101
     *
102
     * @param array $input
103
     *
104
     * @return bool|\Illuminate\Contracts\Validation\Validator
105
     */
106
    public function getValidator(array $input)
107
    {
108
        if (!array_key_exists($this->column, $input)) {
109
            return false;
110
        }
111
112
        $input = Arr::only($input, $this->column);
113
114
        /** unset item that contains remove flag */
115
        foreach ($input[$this->column] as $key => $value) {
116
            if ($value[NestedForm::REMOVE_FLAG_NAME]) {
117
                unset($input[$this->column][$key]);
118
            }
119
        }
120
121
        $form = $this->buildNestedForm($this->column, $this->builder);
122
123
        $rules = $attributes = [];
124
125
        /* @var Field $field */
126
        foreach ($form->fields() as $field) {
127
            if (!$fieldRules = $field->getRules()) {
128
                continue;
129
            }
130
131
            $column = $field->column();
132
133
            if (is_array($column)) {
134
                foreach ($column as $key => $name) {
135
                    $rules[$name.$key] = $fieldRules;
136
                }
137
138
                $this->resetInputKey($input, $column);
139
            } else {
140
                $rules[$column] = $fieldRules;
141
            }
142
143
            $attributes = array_merge(
144
                $attributes,
145
                $this->formatValidationAttribute($input, $field->label(), $column)
146
            );
147
        }
148
149
        Arr::forget($rules, NestedForm::REMOVE_FLAG_NAME);
150
151
        if (empty($rules)) {
152
            return false;
153
        }
154
155
        $newRules = [];
156
        $newInput = [];
157
158
        foreach ($rules as $column => $rule) {
159
            foreach (array_keys($input[$this->column]) as $key) {
160
                $newRules["{$this->column}.$key.$column"] = $rule;
161
                if (isset($input[$this->column][$key][$column]) &&
162
                    is_array($input[$this->column][$key][$column])) {
163
                    foreach ($input[$this->column][$key][$column] as $vkey => $value) {
164
                        $newInput["{$this->column}.$key.{$column}$vkey"] = $value;
165
                    }
166
                }
167
            }
168
        }
169
170
        if (empty($newInput)) {
171
            $newInput = $input;
172
        }
173
174
        $this->appendDistinctRules($newRules);
175
176
        return \validator($newInput, $newRules, $this->getValidationMessages(), $attributes);
177
    }
178
179
    /**
180
     * Set distinct fields.
181
     *
182
     * @param array $fields
183
     *
184
     * @return $this
185
     */
186
    public function distinctFields(array $fields)
187
    {
188
        $this->distinctFields = $fields;
189
190
        return $this;
191
    }
192
193
    /**
194
     * Append distinct rules.
195
     *
196
     * @param array $rules
197
     */
198
    protected function appendDistinctRules(array &$rules)
199
    {
200
        foreach ($this->distinctFields as $field) {
201
            $rules["{$this->column}.*.$field"] = 'distinct';
202
        }
203
    }
204
205
    /**
206
     * Format validation attributes.
207
     *
208
     * @param array  $input
209
     * @param string $label
210
     * @param string $column
211
     *
212
     * @return array
213
     */
214 View Code Duplication
    protected function formatValidationAttribute($input, $label, $column)
215
    {
216
        $new = $attributes = [];
217
218
        if (is_array($column)) {
219
            foreach ($column as $index => $col) {
220
                $new[$col.$index] = $col;
221
            }
222
        }
223
224
        foreach (array_keys(Arr::dot($input)) as $key) {
225
            if (is_string($column)) {
226
                if (Str::endsWith($key, ".$column")) {
227
                    $attributes[$key] = $label;
228
                }
229
            } else {
230
                foreach ($new as $k => $val) {
231
                    if (Str::endsWith($key, ".$k")) {
232
                        $attributes[$key] = $label."[$val]";
233
                    }
234
                }
235
            }
236
        }
237
238
        return $attributes;
239
    }
240
241
    /**
242
     * Reset input key for validation.
243
     *
244
     * @param array $input
245
     * @param array $column $column is the column name array set
246
     *
247
     * @return void.
248
     */
249
    protected function resetInputKey(array &$input, array $column)
250
    {
251
        /**
252
         * flip the column name array set.
253
         *
254
         * for example, for the DateRange, the column like as below
255
         *
256
         * ["start" => "created_at", "end" => "updated_at"]
257
         *
258
         * to:
259
         *
260
         * [ "created_at" => "start", "updated_at" => "end" ]
261
         */
262
        $column = array_flip($column);
263
264
        /**
265
         * $this->column is the inputs array's node name, default is the relation name.
266
         *
267
         * So... $input[$this->column] is the data of this column's inputs data
268
         *
269
         * in the HasMany relation, has many data/field set, $set is field set in the below
270
         */
271
        foreach ($input[$this->column] as $index => $set) {
272
273
            /*
274
             * foreach the field set to find the corresponding $column
275
             */
276 View Code Duplication
            foreach ($set as $name => $value) {
277
                /*
278
                 * if doesn't have column name, continue to the next loop
279
                 */
280
                if (!array_key_exists($name, $column)) {
281
                    continue;
282
                }
283
284
                /**
285
                 * example:  $newKey = created_atstart.
286
                 *
287
                 * Σ( ° △ °|||)︴
288
                 *
289
                 * I don't know why a form need range input? Only can imagine is for range search....
290
                 */
291
                $newKey = $name.$column[$name];
292
293
                /*
294
                 * set new key
295
                 */
296
                Arr::set($input, "{$this->column}.$index.$newKey", $value);
297
                /*
298
                 * forget the old key and value
299
                 */
300
                Arr::forget($input, "{$this->column}.$index.$name");
301
            }
302
        }
303
    }
304
305
    /**
306
     * Prepare input data for insert or update.
307
     *
308
     * @param array $input
309
     *
310
     * @return array
311
     */
312
    public function prepare($input)
313
    {
314
        $form = $this->buildNestedForm($this->column, $this->builder);
315
316
        return $form->setOriginal($this->original, $this->getKeyName())->prepare($input);
317
    }
318
319
    /**
320
     * Build a Nested form.
321
     *
322
     * @param string   $column
323
     * @param \Closure $builder
324
     * @param null     $model
325
     *
326
     * @return NestedForm
327
     */
328 View Code Duplication
    protected function buildNestedForm($column, \Closure $builder, $model = null)
329
    {
330
        $form = new Form\NestedForm($column, $model);
331
332
        $form->setForm($this->form);
333
334
        call_user_func($builder, $form);
335
336
        $form->hidden($this->getKeyName());
337
338
        $form->hidden(NestedForm::REMOVE_FLAG_NAME)->default(0)->addElementClass(NestedForm::REMOVE_FLAG_CLASS);
339
340
        return $form;
341
    }
342
343
    /**
344
     * Get the HasMany relation key name.
345
     *
346
     * @return string
347
     */
348
    protected function getKeyName()
349
    {
350
        if (is_null($this->form)) {
351
            return;
352
        }
353
354
        return $this->form->model()->{$this->relationName}()->getRelated()->getKeyName();
355
    }
356
357
    /**
358
     * Set view mode.
359
     *
360
     * @param string $mode currently support `tab` mode.
361
     *
362
     * @return $this
363
     *
364
     * @author Edwin Hui
365
     */
366
    public function mode($mode)
367
    {
368
        $this->viewMode = $mode;
369
370
        return $this;
371
    }
372
373
    /**
374
     * Use tab mode to showing hasmany field.
375
     *
376
     * @return HasMany
377
     */
378
    public function useTab()
379
    {
380
        return $this->mode('tab');
381
    }
382
383
    /**
384
     * Use table mode to showing hasmany field.
385
     *
386
     * @return HasMany
387
     */
388
    public function useTable()
389
    {
390
        return $this->mode('table');
391
    }
392
393
    /**
394
     * Build Nested form for related data.
395
     *
396
     * @throws \Exception
397
     *
398
     * @return array
399
     */
400
    protected function buildRelatedForms()
401
    {
402
        if (is_null($this->form)) {
403
            return [];
404
        }
405
406
        $model = $this->form->model();
407
408
        $relation = call_user_func([$model, $this->relationName]);
409
410
        if (!$relation instanceof Relation && !$relation instanceof MorphMany) {
411
            throw new \Exception('hasMany field must be a HasMany or MorphMany relation.');
412
        }
413
414
        $forms = [];
415
416
        /*
417
         * If redirect from `exception` or `validation error` page.
418
         *
419
         * Then get form data from session flash.
420
         *
421
         * Else get data from database.
422
         */
423
        if ($values = old($this->column)) {
424
            foreach ($values as $key => $data) {
425
                if ($data[NestedForm::REMOVE_FLAG_NAME] == 1) {
426
                    continue;
427
                }
428
429
                $model = $relation->getRelated()->replicate()->forceFill($data);
430
431
                $forms[$key] = $this->buildNestedForm($this->column, $this->builder, $model)
432
                    ->fill($data);
433
            }
434
        } else {
435
            if (empty($this->value)) {
436
                return [];
437
            }
438
439
            foreach ($this->value as $data) {
440
                $key = Arr::get($data, $relation->getRelated()->getKeyName());
441
442
                $model = $relation->getRelated()->replicate()->forceFill($data);
443
444
                $forms[$key] = $this->buildNestedForm($this->column, $this->builder, $model)
445
                    ->fill($data);
446
            }
447
        }
448
449
        return $forms;
450
    }
451
452
    /**
453
     * Setup script for this field in different view mode.
454
     *
455
     * @param string $script
456
     *
457
     * @return void
458
     */
459
    protected function setupScript($script)
460
    {
461
        $method = 'setupScriptFor'.ucfirst($this->viewMode).'View';
462
463
        call_user_func([$this, $method], $script);
464
    }
465
466
    /**
467
     * Setup default template script.
468
     *
469
     * @param string $templateScript
470
     *
471
     * @return void
472
     */
473
    protected function setupScriptForDefaultView($templateScript)
474
    {
475
        $removeClass = NestedForm::REMOVE_FLAG_CLASS;
476
        $defaultKey = NestedForm::DEFAULT_KEY_NAME;
477
478
        /**
479
         * When add a new sub form, replace all element key in new sub form.
480
         *
481
         * @example comments[new___key__][title]  => comments[new_{index}][title]
482
         *
483
         * {count} is increment number of current sub form count.
484
         */
485
        $script = <<<EOT
486
var index = 0;
487
$('#has-many-{$this->column}').off('click', '.add').on('click', '.add', function () {
488
489
    var tpl = $('template.{$this->column}-tpl');
490
491
    index++;
492
493
    var template = tpl.html().replace(/{$defaultKey}/g, index);
494
    $('.has-many-{$this->column}-forms').append(template);
495
    {$templateScript}
496
    return false;
497
});
498
499
$('#has-many-{$this->column}').off('click', '.remove').on('click', '.remove', function () {
500
    $(this).closest('.has-many-{$this->column}-form').find('input').removeAttr('required');
501
    $(this).closest('.has-many-{$this->column}-form').hide();
502
    $(this).closest('.has-many-{$this->column}-form').find('.$removeClass').val(1);
503
    return false;
504
});
505
506
EOT;
507
508
        Admin::script($script);
509
    }
510
511
    /**
512
     * Setup tab template script.
513
     *
514
     * @param string $templateScript
515
     *
516
     * @return void
517
     */
518 View Code Duplication
    protected function setupScriptForTabView($templateScript)
519
    {
520
        $removeClass = NestedForm::REMOVE_FLAG_CLASS;
521
        $defaultKey = NestedForm::DEFAULT_KEY_NAME;
522
523
        $script = <<<EOT
524
525
$('#has-many-{$this->column} > .nav').off('click', 'i.close-tab').on('click', 'i.close-tab', function(){
526
    var \$navTab = $(this).siblings('a');
527
    var \$pane = $(\$navTab.attr('href'));
528
    if( \$pane.hasClass('new') ){
529
        \$pane.remove();
530
    }else{
531
        \$pane.removeClass('active').find('.$removeClass').val(1);
532
    }
533
    if(\$navTab.closest('li').hasClass('active')){
534
        \$navTab.closest('li').remove();
535
        $('#has-many-{$this->column} > .nav > li:nth-child(1) > a').tab('show');
536
    }else{
537
        \$navTab.closest('li').remove();
538
    }
539
});
540
541
var index = 0;
542
$('#has-many-{$this->column} > .header').off('click', '.add').on('click', '.add', function(){
543
    index++;
544
    var navTabHtml = $('#has-many-{$this->column} > template.nav-tab-tpl').html().replace(/{$defaultKey}/g, index);
545
    var paneHtml = $('#has-many-{$this->column} > template.pane-tpl').html().replace(/{$defaultKey}/g, index);
546
    $('#has-many-{$this->column} > .nav').append(navTabHtml);
547
    $('#has-many-{$this->column} > .tab-content').append(paneHtml);
548
    $('#has-many-{$this->column} > .nav > li:last-child a').tab('show');
549
    {$templateScript}
550
});
551
552
if ($('.has-error').length) {
553
    $('.has-error').parent('.tab-pane').each(function () {
554
        var tabId = '#'+$(this).attr('id');
555
        $('li a[href="'+tabId+'"] i').removeClass('hide');
556
    });
557
558
    var first = $('.has-error:first').parent().attr('id');
559
    $('li a[href="#'+first+'"]').tab('show');
560
}
561
EOT;
562
563
        Admin::script($script);
564
    }
565
566
    /**
567
     * Setup default template script.
568
     *
569
     * @param string $templateScript
570
     *
571
     * @return void
572
     */
573 View Code Duplication
    protected function setupScriptForTableView($templateScript)
574
    {
575
        $removeClass = NestedForm::REMOVE_FLAG_CLASS;
576
        $defaultKey = NestedForm::DEFAULT_KEY_NAME;
577
578
        /**
579
         * When add a new sub form, replace all element key in new sub form.
580
         *
581
         * @example comments[new___key__][title]  => comments[new_{index}][title]
582
         *
583
         * {count} is increment number of current sub form count.
584
         */
585
        $script = <<<EOT
586
var index = 0;
587
$('#has-many-{$this->column}').on('click', '.add', function () {
588
589
    var tpl = $('template.{$this->column}-tpl');
590
591
    index++;
592
593
    var template = tpl.html().replace(/{$defaultKey}/g, index);
594
    $('.has-many-{$this->column}-forms').append(template);
595
    {$templateScript}
596
    return false;
597
});
598
599
$('#has-many-{$this->column}').on('click', '.remove', function () {
600
    var first_input_name = $(this).closest('.has-many-{$this->column}-form').find('input[name]:first').attr('name');
601
    if (first_input_name.match('{$this->column}\\\[new_')) {
602
        $(this).closest('.has-many-{$this->column}-form').remove();
603
    } else {
604
        $(this).closest('.has-many-{$this->column}-form').hide();
605
        $(this).closest('.has-many-{$this->column}-form').find('.$removeClass').val(1);
606
        $(this).closest('.has-many-{$this->column}-form').find('input').removeAttr('required');
607
    }
608
    return false;
609
});
610
611
EOT;
612
613
        Admin::script($script);
614
    }
615
616
    /**
617
     * Disable create button.
618
     *
619
     * @return $this
620
     */
621
    public function disableCreate()
622
    {
623
        $this->options['allowCreate'] = false;
624
625
        return $this;
626
    }
627
628
    /**
629
     * Disable delete button.
630
     *
631
     * @return $this
632
     */
633
    public function disableDelete()
634
    {
635
        $this->options['allowDelete'] = false;
636
637
        return $this;
638
    }
639
640
    /**
641
     * Render the `HasMany` field.
642
     *
643
     * @throws \Exception
644
     *
645
     * @return \Illuminate\View\View
646
     */
647
    public function render()
648
    {
649
        if (!$this->shouldRender()) {
650
            return '';
0 ignored issues
show
Bug Best Practice introduced by
The return type of return ''; (string) is incompatible with the return type documented by Encore\Admin\Form\Field\HasMany::render of type Illuminate\View\View.

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...
651
        }
652
653
        if ($this->viewMode == 'table') {
654
            return $this->renderTable();
655
        }
656
657
        // specify a view to render.
658
        $this->view = $this->views[$this->viewMode];
659
660
        list($template, $script) = $this->buildNestedForm($this->column, $this->builder)
661
            ->getTemplateHtmlAndScript();
662
663
        $this->setupScript($script);
664
665
        return parent::render()->with([
0 ignored issues
show
The method with does only exist in Illuminate\View\View, but not in Illuminate\Contracts\View\Factory.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
666
            'forms'        => $this->buildRelatedForms(),
667
            'template'     => $template,
668
            'relationName' => $this->relationName,
669
            'options'      => $this->options,
670
        ]);
671
    }
672
673
    /**
674
     * Render the `HasMany` field for table style.
675
     *
676
     * @throws \Exception
677
     *
678
     * @return mixed
679
     */
680
    protected function renderTable()
681
    {
682
        $headers = [];
683
        $fields = [];
684
        $hidden = [];
685
        $scripts = [];
686
687
        /* @var Field $field */
688
        foreach ($this->buildNestedForm($this->column, $this->builder)->fields() as $field) {
689
            if (is_a($field, Hidden::class)) {
690
                $hidden[] = $field->render();
691
            } else {
692
                /* Hide label and set field width 100% */
693
                $field->setLabelClass(['hidden']);
694
                $field->setWidth(12, 0);
695
                $fields[] = $field->render();
696
                $headers[] = $field->label();
697
            }
698
699
            /*
700
             * Get and remove the last script of Admin::$script stack.
701
             */
702
            if ($field->getScript()) {
703
                $scripts[] = array_pop(Admin::$script);
704
            }
705
        }
706
707
        /* Build row elements */
708
        $template = array_reduce($fields, function ($all, $field) {
709
            $all .= "<td>{$field}</td>";
710
711
            return $all;
712
        }, '');
713
714
        /* Build cell with hidden elements */
715
        $template .= '<td class="hidden">'.implode('', $hidden).'</td>';
716
717
        $this->setupScript(implode("\r\n", $scripts));
718
719
        // specify a view to render.
720
        $this->view = $this->views[$this->viewMode];
721
722
        return parent::render()->with([
0 ignored issues
show
The method with does only exist in Illuminate\View\View, but not in Illuminate\Contracts\View\Factory.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
Comprehensibility Bug introduced by
It seems like you call parent on a different method (render() instead of renderTable()). Are you sure this is correct? If so, you might want to change this to $this->render().

This check looks for a call to a parent method whose name is different than the method from which it is called.

Consider the following code:

class Daddy
{
    protected function getFirstName()
    {
        return "Eidur";
    }

    protected function getSurName()
    {
        return "Gudjohnsen";
    }
}

class Son
{
    public function getFirstName()
    {
        return parent::getSurname();
    }
}

The getFirstName() method in the Son calls the wrong method in the parent class.

Loading history...
723
            'headers'      => $headers,
724
            'forms'        => $this->buildRelatedForms(),
725
            'template'     => $template,
726
            'relationName' => $this->relationName,
727
            'options'      => $this->options,
728
        ]);
729
    }
730
}
731