Completed
Push — master ( a13290...ae4efa )
by Song
30s
created

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

Labels
Severity

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 Encore\Admin\Widgets\Form as WidgetForm;
10
use Illuminate\Database\Eloquent\Relations\HasMany as Relation;
11
use Illuminate\Database\Eloquent\Relations\MorphMany;
12
use Illuminate\Support\Arr;
13
use Illuminate\Support\Str;
14
15
/**
16
 * Class HasMany.
17
 */
18
class HasMany extends Field
19
{
20
    /**
21
     * Relation name.
22
     *
23
     * @var string
24
     */
25
    protected $relationName = '';
26
27
    /**
28
     * Form builder.
29
     *
30
     * @var \Closure
31
     */
32
    protected $builder = null;
33
34
    /**
35
     * Form data.
36
     *
37
     * @var array
38
     */
39
    protected $value = [];
40
41
    /**
42
     * View Mode.
43
     *
44
     * Supports `default` and `tab` currently.
45
     *
46
     * @var string
47
     */
48
    protected $viewMode = 'default';
49
50
    /**
51
     * Available views for HasMany field.
52
     *
53
     * @var array
54
     */
55
    protected $views = [
56
        'default' => 'admin::form.hasmany',
57
        'tab'     => 'admin::form.hasmanytab',
58
        'table'   => 'admin::form.hasmanytable',
59
    ];
60
61
    /**
62
     * Options for template.
63
     *
64
     * @var array
65
     */
66
    protected $options = [
67
        'allowCreate' => true,
68
        'allowDelete' => true,
69
    ];
70
71
    /**
72
     * Distinct fields.
73
     *
74
     * @var array
75
     */
76
    protected $distinctFields = [];
77
78
    /**
79
     * Create a new HasMany field instance.
80
     *
81
     * @param $relationName
82
     * @param array $arguments
83
     */
84 View Code Duplication
    public function __construct($relationName, $arguments = [])
85
    {
86
        $this->relationName = $relationName;
87
88
        $this->column = $relationName;
89
90
        if (count($arguments) == 1) {
91
            $this->label = $this->formatLabel();
92
            $this->builder = $arguments[0];
93
        }
94
95
        if (count($arguments) == 2) {
96
            list($this->label, $this->builder) = $arguments;
97
        }
98
    }
99
100
    /**
101
     * Get validator for this field.
102
     *
103
     * @param array $input
104
     *
105
     * @return bool|\Illuminate\Contracts\Validation\Validator
106
     */
107
    public function getValidator(array $input)
108
    {
109
        if (!array_key_exists($this->column, $input)) {
110
            return false;
111
        }
112
113
        $input = Arr::only($input, $this->column);
114
115
        /** unset item that contains remove flag */
116
        foreach ($input[$this->column] as $key => $value) {
117
            if ($value[NestedForm::REMOVE_FLAG_NAME]) {
118
                unset($input[$this->column][$key]);
119
            }
120
        }
121
122
        $form = $this->buildNestedForm($this->column, $this->builder);
123
124
        $rules = $attributes = [];
125
126
        /* @var Field $field */
127
        foreach ($form->fields() as $field) {
128
            if (!$fieldRules = $field->getRules()) {
129
                continue;
130
            }
131
132
            $column = $field->column();
133
134
            if (is_array($column)) {
135
                foreach ($column as $key => $name) {
136
                    $rules[$name.$key] = $fieldRules;
137
                }
138
139
                $this->resetInputKey($input, $column);
140
            } else {
141
                $rules[$column] = $fieldRules;
142
            }
143
144
            $attributes = array_merge(
145
                $attributes,
146
                $this->formatValidationAttribute($input, $field->label(), $column)
147
            );
148
        }
149
150
        Arr::forget($rules, NestedForm::REMOVE_FLAG_NAME);
151
152
        if (empty($rules)) {
153
            return false;
154
        }
155
156
        $newRules = [];
157
        $newInput = [];
158
159
        foreach ($rules as $column => $rule) {
160
            foreach (array_keys($input[$this->column]) as $key) {
161
                $newRules["{$this->column}.$key.$column"] = $rule;
162
                if (isset($input[$this->column][$key][$column]) &&
163
                    is_array($input[$this->column][$key][$column])) {
164
                    foreach ($input[$this->column][$key][$column] as $vkey => $value) {
165
                        $newInput["{$this->column}.$key.{$column}$vkey"] = $value;
166
                    }
167
                }
168
            }
169
        }
170
171
        if (empty($newInput)) {
172
            $newInput = $input;
173
        }
174
175
        $this->appendDistinctRules($newRules);
176
177
        return \validator($newInput, $newRules, $this->getValidationMessages(), $attributes);
178
    }
179
180
    /**
181
     * Set distinct fields.
182
     *
183
     * @param array $fields
184
     *
185
     * @return $this
186
     */
187
    public function distinctFields(array $fields)
188
    {
189
        $this->distinctFields = $fields;
190
191
        return $this;
192
    }
193
194
    /**
195
     * Append distinct rules.
196
     *
197
     * @param array $rules
198
     */
199
    protected function appendDistinctRules(array &$rules)
200
    {
201
        foreach ($this->distinctFields as $field) {
202
            $rules["{$this->column}.*.$field"] = 'distinct';
203
        }
204
    }
205
206
    /**
207
     * Format validation attributes.
208
     *
209
     * @param array  $input
210
     * @param string $label
211
     * @param string $column
212
     *
213
     * @return array
214
     */
215 View Code Duplication
    protected function formatValidationAttribute($input, $label, $column)
216
    {
217
        $new = $attributes = [];
218
219
        if (is_array($column)) {
220
            foreach ($column as $index => $col) {
221
                $new[$col.$index] = $col;
222
            }
223
        }
224
225
        foreach (array_keys(Arr::dot($input)) as $key) {
226
            if (is_string($column)) {
227
                if (Str::endsWith($key, ".$column")) {
228
                    $attributes[$key] = $label;
229
                }
230
            } else {
231
                foreach ($new as $k => $val) {
232
                    if (Str::endsWith($key, ".$k")) {
233
                        $attributes[$key] = $label."[$val]";
234
                    }
235
                }
236
            }
237
        }
238
239
        return $attributes;
240
    }
241
242
    /**
243
     * Reset input key for validation.
244
     *
245
     * @param array $input
246
     * @param array $column $column is the column name array set
247
     *
248
     * @return void.
249
     */
250
    protected function resetInputKey(array &$input, array $column)
251
    {
252
        /**
253
         * flip the column name array set.
254
         *
255
         * for example, for the DateRange, the column like as below
256
         *
257
         * ["start" => "created_at", "end" => "updated_at"]
258
         *
259
         * to:
260
         *
261
         * [ "created_at" => "start", "updated_at" => "end" ]
262
         */
263
        $column = array_flip($column);
264
265
        /**
266
         * $this->column is the inputs array's node name, default is the relation name.
267
         *
268
         * So... $input[$this->column] is the data of this column's inputs data
269
         *
270
         * in the HasMany relation, has many data/field set, $set is field set in the below
271
         */
272
        foreach ($input[$this->column] as $index => $set) {
273
274
            /*
275
             * foreach the field set to find the corresponding $column
276
             */
277 View Code Duplication
            foreach ($set as $name => $value) {
278
                /*
279
                 * if doesn't have column name, continue to the next loop
280
                 */
281
                if (!array_key_exists($name, $column)) {
282
                    continue;
283
                }
284
285
                /**
286
                 * example:  $newKey = created_atstart.
287
                 *
288
                 * Σ( ° △ °|||)︴
289
                 *
290
                 * I don't know why a form need range input? Only can imagine is for range search....
291
                 */
292
                $newKey = $name.$column[$name];
293
294
                /*
295
                 * set new key
296
                 */
297
                Arr::set($input, "{$this->column}.$index.$newKey", $value);
298
                /*
299
                 * forget the old key and value
300
                 */
301
                Arr::forget($input, "{$this->column}.$index.$name");
302
            }
303
        }
304
    }
305
306
    /**
307
     * Prepare input data for insert or update.
308
     *
309
     * @param array $input
310
     *
311
     * @return array
312
     */
313
    public function prepare($input)
314
    {
315
        $form = $this->buildNestedForm($this->column, $this->builder);
316
317
        return $form->setOriginal($this->original, $this->getKeyName())->prepare($input);
318
    }
319
320
    /**
321
     * Build a Nested form.
322
     *
323
     * @param string   $column
324
     * @param \Closure $builder
325
     * @param null     $model
326
     *
327
     * @return NestedForm
328
     */
329 View Code Duplication
    protected function buildNestedForm($column, \Closure $builder, $model = null)
330
    {
331
        $form = new Form\NestedForm($column, $model);
332
333
        if ($this->form instanceof WidgetForm) {
334
            $form->setWidgetForm($this->form);
335
        } else {
336
            $form->setForm($this->form);
337
        }
338
339
        call_user_func($builder, $form);
340
341
        $form->hidden($this->getKeyName());
342
343
        $form->hidden(NestedForm::REMOVE_FLAG_NAME)->default(0)->addElementClass(NestedForm::REMOVE_FLAG_CLASS);
344
345
        return $form;
346
    }
347
348
    /**
349
     * Get the HasMany relation key name.
350
     *
351
     * @return string
352
     */
353
    protected function getKeyName()
354
    {
355
        if (is_null($this->form)) {
356
            return;
357
        }
358
359
        return $this->form->model()->{$this->relationName}()->getRelated()->getKeyName();
0 ignored issues
show
The method model does only exist in Encore\Admin\Form, but not in Encore\Admin\Widgets\Form.

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...
360
    }
361
362
    /**
363
     * Set view mode.
364
     *
365
     * @param string $mode currently support `tab` mode.
366
     *
367
     * @return $this
368
     *
369
     * @author Edwin Hui
370
     */
371
    public function mode($mode)
372
    {
373
        $this->viewMode = $mode;
374
375
        return $this;
376
    }
377
378
    /**
379
     * Use tab mode to showing hasmany field.
380
     *
381
     * @return HasMany
382
     */
383
    public function useTab()
384
    {
385
        return $this->mode('tab');
386
    }
387
388
    /**
389
     * Use table mode to showing hasmany field.
390
     *
391
     * @return HasMany
392
     */
393
    public function useTable()
394
    {
395
        return $this->mode('table');
396
    }
397
398
    /**
399
     * Build Nested form for related data.
400
     *
401
     * @throws \Exception
402
     *
403
     * @return array
404
     */
405
    protected function buildRelatedForms()
406
    {
407
        if (is_null($this->form)) {
408
            return [];
409
        }
410
411
        $model = $this->form->model();
0 ignored issues
show
The method model does only exist in Encore\Admin\Form, but not in Encore\Admin\Widgets\Form.

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...
412
413
        $relation = call_user_func([$model, $this->relationName]);
414
415
        if (!$relation instanceof Relation && !$relation instanceof MorphMany) {
416
            throw new \Exception('hasMany field must be a HasMany or MorphMany relation.');
417
        }
418
419
        $forms = [];
420
421
        /*
422
         * If redirect from `exception` or `validation error` page.
423
         *
424
         * Then get form data from session flash.
425
         *
426
         * Else get data from database.
427
         */
428
        if ($values = old($this->column)) {
429
            foreach ($values as $key => $data) {
430
                if ($data[NestedForm::REMOVE_FLAG_NAME] == 1) {
431
                    continue;
432
                }
433
434
                $model = $relation->getRelated()->replicate()->forceFill($data);
435
436
                $forms[$key] = $this->buildNestedForm($this->column, $this->builder, $model)
437
                    ->fill($data);
438
            }
439
        } else {
440
            if (empty($this->value)) {
441
                return [];
442
            }
443
444
            foreach ($this->value as $data) {
445
                $key = Arr::get($data, $relation->getRelated()->getKeyName());
446
447
                $model = $relation->getRelated()->replicate()->forceFill($data);
448
449
                $forms[$key] = $this->buildNestedForm($this->column, $this->builder, $model)
450
                    ->fill($data);
451
            }
452
        }
453
454
        return $forms;
455
    }
456
457
    /**
458
     * Setup script for this field in different view mode.
459
     *
460
     * @param string $script
461
     *
462
     * @return void
463
     */
464
    protected function setupScript($script)
465
    {
466
        $method = 'setupScriptFor'.ucfirst($this->viewMode).'View';
467
468
        call_user_func([$this, $method], $script);
469
    }
470
471
    /**
472
     * Setup default template script.
473
     *
474
     * @param string $templateScript
475
     *
476
     * @return void
477
     */
478
    protected function setupScriptForDefaultView($templateScript)
479
    {
480
        $removeClass = NestedForm::REMOVE_FLAG_CLASS;
481
        $defaultKey = NestedForm::DEFAULT_KEY_NAME;
482
483
        /**
484
         * When add a new sub form, replace all element key in new sub form.
485
         *
486
         * @example comments[new___key__][title]  => comments[new_{index}][title]
487
         *
488
         * {count} is increment number of current sub form count.
489
         */
490
        $script = <<<EOT
491
var index = 0;
492
$('#has-many-{$this->column}').off('click', '.add').on('click', '.add', function () {
493
494
    var tpl = $('template.{$this->column}-tpl');
495
496
    index++;
497
498
    var template = tpl.html().replace(/{$defaultKey}/g, index);
499
    $('.has-many-{$this->column}-forms').append(template);
500
    {$templateScript}
501
    return false;
502
});
503
504
$('#has-many-{$this->column}').off('click', '.remove').on('click', '.remove', function () {
505
    $(this).closest('.has-many-{$this->column}-form').find('input').removeAttr('required');
506
    $(this).closest('.has-many-{$this->column}-form').hide();
507
    $(this).closest('.has-many-{$this->column}-form').find('.$removeClass').val(1);
508
    return false;
509
});
510
511
EOT;
512
513
        Admin::script($script);
514
    }
515
516
    /**
517
     * Setup tab template script.
518
     *
519
     * @param string $templateScript
520
     *
521
     * @return void
522
     */
523 View Code Duplication
    protected function setupScriptForTabView($templateScript)
524
    {
525
        $removeClass = NestedForm::REMOVE_FLAG_CLASS;
526
        $defaultKey = NestedForm::DEFAULT_KEY_NAME;
527
528
        $script = <<<EOT
529
530
$('#has-many-{$this->column} > .nav').off('click', 'i.close-tab').on('click', 'i.close-tab', function(){
531
    var \$navTab = $(this).siblings('a');
532
    var \$pane = $(\$navTab.attr('href'));
533
    if( \$pane.hasClass('new') ){
534
        \$pane.remove();
535
    }else{
536
        \$pane.removeClass('active').find('.$removeClass').val(1);
537
    }
538
    if(\$navTab.closest('li').hasClass('active')){
539
        \$navTab.closest('li').remove();
540
        $('#has-many-{$this->column} > .nav > li:nth-child(1) > a').tab('show');
541
    }else{
542
        \$navTab.closest('li').remove();
543
    }
544
});
545
546
var index = 0;
547
$('#has-many-{$this->column} > .header').off('click', '.add').on('click', '.add', function(){
548
    index++;
549
    var navTabHtml = $('#has-many-{$this->column} > template.nav-tab-tpl').html().replace(/{$defaultKey}/g, index);
550
    var paneHtml = $('#has-many-{$this->column} > template.pane-tpl').html().replace(/{$defaultKey}/g, index);
551
    $('#has-many-{$this->column} > .nav').append(navTabHtml);
552
    $('#has-many-{$this->column} > .tab-content').append(paneHtml);
553
    $('#has-many-{$this->column} > .nav > li:last-child a').tab('show');
554
    {$templateScript}
555
});
556
557
if ($('.has-error').length) {
558
    $('.has-error').parent('.tab-pane').each(function () {
559
        var tabId = '#'+$(this).attr('id');
560
        $('li a[href="'+tabId+'"] i').removeClass('hide');
561
    });
562
563
    var first = $('.has-error:first').parent().attr('id');
564
    $('li a[href="#'+first+'"]').tab('show');
565
}
566
EOT;
567
568
        Admin::script($script);
569
    }
570
571
    /**
572
     * Setup default template script.
573
     *
574
     * @param string $templateScript
575
     *
576
     * @return void
577
     */
578 View Code Duplication
    protected function setupScriptForTableView($templateScript)
579
    {
580
        $removeClass = NestedForm::REMOVE_FLAG_CLASS;
581
        $defaultKey = NestedForm::DEFAULT_KEY_NAME;
582
583
        /**
584
         * When add a new sub form, replace all element key in new sub form.
585
         *
586
         * @example comments[new___key__][title]  => comments[new_{index}][title]
587
         *
588
         * {count} is increment number of current sub form count.
589
         */
590
        $script = <<<EOT
591
var index = 0;
592
$('#has-many-{$this->column}').on('click', '.add', function () {
593
594
    var tpl = $('template.{$this->column}-tpl');
595
596
    index++;
597
598
    var template = tpl.html().replace(/{$defaultKey}/g, index);
599
    $('.has-many-{$this->column}-forms').append(template);
600
    {$templateScript}
601
    return false;
602
});
603
604
$('#has-many-{$this->column}').on('click', '.remove', function () {
605
    var first_input_name = $(this).closest('.has-many-{$this->column}-form').find('input[name]:first').attr('name');
606
    if (first_input_name.match('{$this->column}\\\[new_')) {
607
        $(this).closest('.has-many-{$this->column}-form').remove();
608
    } else {
609
        $(this).closest('.has-many-{$this->column}-form').hide();
610
        $(this).closest('.has-many-{$this->column}-form').find('.$removeClass').val(1);
611
        $(this).closest('.has-many-{$this->column}-form').find('input').removeAttr('required');
612
    }
613
    return false;
614
});
615
616
EOT;
617
618
        Admin::script($script);
619
    }
620
621
    /**
622
     * Disable create button.
623
     *
624
     * @return $this
625
     */
626
    public function disableCreate()
627
    {
628
        $this->options['allowCreate'] = false;
629
630
        return $this;
631
    }
632
633
    /**
634
     * Disable delete button.
635
     *
636
     * @return $this
637
     */
638
    public function disableDelete()
639
    {
640
        $this->options['allowDelete'] = false;
641
642
        return $this;
643
    }
644
645
    /**
646
     * Render the `HasMany` field.
647
     *
648
     * @throws \Exception
649
     *
650
     * @return \Illuminate\View\View
651
     */
652
    public function render()
653
    {
654
        if (!$this->shouldRender()) {
655
            return '';
656
        }
657
658
        if ($this->viewMode == 'table') {
659
            return $this->renderTable();
660
        }
661
662
        // specify a view to render.
663
        $this->view = $this->views[$this->viewMode];
664
665
        list($template, $script) = $this->buildNestedForm($this->column, $this->builder)
666
            ->getTemplateHtmlAndScript();
667
668
        $this->setupScript($script);
669
670
        return parent::fieldRender([
671
            'forms'        => $this->buildRelatedForms(),
672
            'template'     => $template,
673
            'relationName' => $this->relationName,
674
            'options'      => $this->options,
675
        ]);
676
    }
677
678
    /**
679
     * Render the `HasMany` field for table style.
680
     *
681
     * @throws \Exception
682
     *
683
     * @return mixed
684
     */
685
    protected function renderTable()
686
    {
687
        $headers = [];
688
        $fields = [];
689
        $hidden = [];
690
        $scripts = [];
691
692
        /* @var Field $field */
693
        foreach ($this->buildNestedForm($this->column, $this->builder)->fields() as $field) {
694
            if (is_a($field, Hidden::class)) {
695
                $hidden[] = $field->render();
696
            } else {
697
                /* Hide label and set field width 100% */
698
                $field->setLabelClass(['hidden']);
699
                $field->setWidth(12, 0);
700
                $fields[] = $field->render();
701
                $headers[] = $field->label();
702
            }
703
704
            /*
705
             * Get and remove the last script of Admin::$script stack.
706
             */
707
            if ($field->getScript()) {
708
                $scripts[] = array_pop(Admin::$script);
709
            }
710
        }
711
712
        /* Build row elements */
713
        $template = array_reduce($fields, function ($all, $field) {
714
            $all .= "<td>{$field}</td>";
715
716
            return $all;
717
        }, '');
718
719
        /* Build cell with hidden elements */
720
        $template .= '<td class="hidden">'.implode('', $hidden).'</td>';
721
722
        $this->setupScript(implode("\r\n", $scripts));
723
724
        // specify a view to render.
725
        $this->view = $this->views[$this->viewMode];
726
727
        return parent::fieldRender([
728
            'headers'      => $headers,
729
            'forms'        => $this->buildRelatedForms(),
730
            'template'     => $template,
731
            'relationName' => $this->relationName,
732
            'options'      => $this->options,
733
        ]);
734
    }
735
}
736