SymfonyのCollection Form Typeをいい感じにするライブラリの紹介

# はじめに
SymfonyでOneToManyのリレーションがあるとき、CollectionFormTypeは頻繁に出てきます。しかし、サーバーサイドはSymfonyがよしなにやってくれるものの、実際にフォームを表示するフロント側の、アイテムを増やしたり減らしたりする実装は意外とめんどくさかったりします。

今回は、そんなときに役に立つライブラリの紹介です。

# 準備
まず、事前準備として、OneToManyのリレーションを持つEntityとそのFormTypeを作成します。今回は、`Process`と`Step`というエンティティを作り、`Proccess`が複数の`Step`を持つことにします。

それぞれのEntityとFormTypeの定義は以下になります。

```php:src/Entity/Step.php

<?php

namespace App\Entity;

use App\Repository\StepRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=StepRepository::class)
 */
class Step
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity=Process::class, inversedBy="steps")
     */
    private $process;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * @ORM\Column(type="text")
     */
    private $detail;

...
// setter / getter

```

```php:src/Entity/Process.php

<?php

namespace App\Entity;

use App\Repository\ProcessRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=ProcessRepository::class)
 */
class Process
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\OneToMany(targetEntity=Step::class, mappedBy="process", cascade={"persist"})
     */
    private $steps;

    public function __construct()
    {
        $this->steps = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return Collection|Step[]
     */
    public function getSteps(): Collection
    {
        return $this->steps;
    }

    public function addStep(Step $step): self
    {
        if (!$this->steps->contains($step)) {
            $this->steps[] = $step;
            $step->setProcess($this);
        }

        return $this;
    }

    public function removeStep(Step $step): self
    {
        if ($this->steps->contains($step)) {
            $this->steps->removeElement($step);
            // set the owning side to null (unless already changed)
            if ($step->getProcess() === $this) {
                $step->setProcess(null);
            }
        }

        return $this;
    }
}


```

```php:src/Form/StepType.php

<?php

namespace App\Form;

use App\Entity\Step;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class StepType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('detail')
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Step::class,
        ]);
    }
}

```


```php:src/Form/ProcessType.php

<?php

namespace App\Form;

use App\Entity\Process;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ProcessType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('steps', CollectionType::class, [
            'entry_type' => StepType::class,
            'entry_options' => [
                'label' => false,
            ],
            'allow_add' => true,
            'allow_delete' => true,
        ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Process::class,
        ]);
    }
}


```

適当なControllerを作成し、`ProcessType`を表示するとこんな感じになります。`Step`が一つも登録されていないので、ラベル以外はまだなにも表示されていません。

![スクリーンショット 2020-09-20 19.38.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/b6a97a27-6f06-567e-97e9-7048d57dce63.png)



# 表示の改善

今回使用するライブラリはこちら( https://github.com/ninsuo/symfony-collection )になります。このライブラリを使って、CollectionTypeをいい感じにしていきます。

インストール方法はnpm、composer、Bowerが用意されています。webpackなどを用いている場合はnpmでインストールするのが良いと思いますが、今回はcomposerを用いてインストールします。

composerでインストールした後、`vendor/ninsuo/symfony-collection/jquery.collection.js`と`vendor/ninsuo/symfony-collection/jquery.collection.html.twig`をそれぞれ`public`ディレクトリと`templates`ディレクトリに移動させます。

```
$ composer req ninsuo/symfony-collection
$ cp vendor/ninsuo/symfony-collection/jquery.collection.js public 
$ cp vendor/ninsuo/symfony-collection/jquery.collection.html.twig templates 
```

テンプレートファイルを以下のように書き換え、bootstrapのCSSとJQueryをCDNから読み込みます。そして、先ほどインストールしたライブラリのjavascriptと、フォームテーマを使用し、Collectionになっているフォームに対して`collection()`メソッドを叩くと、以下のような表示になります。

```twig:templates/default/index.twig

{% extends 'base.html.twig' %}

{% block title %}Hello DefaultController!{% endblock %}

{% block body %}
    {% form_theme form
        'bootstrap_4_layout.html.twig'
        'jquery.collection.html.twig'
    %}

    <div class="container">
        <div class="row">
            <div class="col-12 mt-5">
                {{ form_start(form) }}
                {{ form_row(form.steps, {attr: {class: 'collection-form'}}) }}
                {{ form_rest(form) }}
                {{ form_end(form) }}
            </div>
        </div>
    </div>
{% endblock %}

{% block stylesheets %}
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
{% endblock %}

{% block javascripts %}
    <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
    <script src="jquery.collection.js"></script>
    <script>
        $('.collection-form').collection()
    </script>
{% endblock %}
```


![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/3f334f76-f067-3e68-3dc6-230cf255cf26.gif)

# もう少しカスタマイズ

今回使用したライブラリは、`collection()`メソッドを呼び出すときにオプションを渡すことで表示をカスタマイズできます。
また、FormTypeをオーバーライドすることでボタンの位置などを変えることもできます。

例えば、fontawsomeのフォントを用いて、「追加ボタン」はコレクションの一番最後にのみ表示するオプションを有効にし、FormTypeを次のようにオーバーライドすると、こんな表示になります。


```twig:templates/jquery.collection.html.twig

...
{% block step_row %}
    <div class="card mb-3">
        <div class="card-header text-right">
            <a href="#" class="collection-up btn btn-secondary mr-1"><i class="fas fa-angle-up"></i></a>
            <a href="#" class="collection-down btn btn-secondary mr-1"><i class="fas fa-angle-down"></i></a>
            <a href="#" class="collection-remove btn btn-danger mr-1"><i class="fas fa-trash"></i></a>
        </div>
        <div class="card-body">
            {{ form_row(form) }}
        </div>
    </div>
{% endblock %}
```


```twig:templates/default/index.twig

...
{% block stylesheets %}
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    <link rel="stylesheet" href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css" integrity="sha384-AYmEC3Yw5cVb3ZcuHtOA93w35dYTsvhLPVnYs9eStHfGJvOvKxVfELGroGkvsg+p" crossorigin="anonymous"/>
{% endblock %}

{% block javascripts %}
    <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
    <script src="jquery.collection.js"></script>
    <script>
        $('.collection-form').collection({
            add_at_the_end: true,
            add: '<a href="#" class="btn btn-primary btn-block"><i class="fas fa-plus"></i></a>',
        })
    </script>
{% endblock %}
```

![demo2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/e6abfe88-c319-793e-39d1-d95ba93b82fe.gif)


また、表示だけでなく、アイテムが追加される前後のイベントをフックしたりもできるので、とても柔軟に使用することができます。
SymfonyのCollectionTypeのフロント実装で疲弊している方は是非採用を考えてみてください！

# 今回のソース

https://github.com/ggg-mzkr/collection-form-example