EC-CUBEで学ぶSymfony CollectionTypeの使い方

SymfonyのCollectionTypeの日本語記事がなかったので書いてみます。
バージョンはEC-CUBE3.nで利用されているはSymfony3.4です。

---

# 参考

[Symfony公式ドキュメント](http://symfony.com/doc/3.4/reference/forms/types/collection.html)
[EC-CUBE](https://github.com/EC-CUBE/ec-cube/tree/4.0)

---

# SymfonyのCollectionTypeとは

1対多の関係にあるフォームを動的に作成するときに使うFormType。

---

# CollectionTypeの基本的な使い方

この章は[こちら](http://symfony.com/doc/3.4/reference/forms/types/collection.html)の公式ドキュメントの例を私の言葉で解説したものです。
正確な情報は公式ドキュメントを参照してください。

---

## CollectionTypeの表示

例えばE-mailの配列の入力フォームを表示させるには以下のようなFormTypeを作成します。

```php
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
// ...

$builder->add('emails', CollectionType::class, array(
    // それぞれのフィールドはEmailTypeで作成する
    'entry_type' => EmailType::class,
    // entry_typeで指定したFormTypeにわたすオプションを指定する
    'entry_options' => array(
        'attr' => array('class' => 'email-box'),
    ),
));
```

---

twig側で `form_row` を記述すると配列のすべてのフォームを表示可能です。

```twig
{{ form_row(form.emails) }}
```

---

フォーマットを指定して出力させたい場合はループと `form_widget` を使って以下のように書けます。

```twig
{{ form_label(form.emails) }}
{{ form_errors(form.emails) }}

<ul>
{% for emailField in form.emails %}
    <li>
        {{ form_errors(emailField) }}
        {{ form_widget(emailField) }}
    </li>
{% endfor %}
</ul>
```

`form_errors(form.emails)` ではCollectionTypeのエラーが出力され、 `form_widget(emailField)` では各フォームごとのエラーが出力されます。

---

## 項目の追加

CollectionTypeの `allow_add` オプションを有効にしておくと項目の追加が可能になります。

さて、ややこしいのはここからです。
項目を動的に追加するには少し工夫が必要です。

具体的には、
1. 項目を追加するためのテンプレート(以下、プロトタイプと呼びます)を準備しておき
2. JavaScriptで採番処理をしてから追加してやる
必要があります。

---

例えば配列に2つのEmailアドレスがある場合には以下のように表示されるとします。

```html
<input type="email" id="form_emails_0" name="form[emails][0]" value="foo@foo.com" />
<input type="email" id="form_emails_1" name="form[emails][1]" value="bar@bar.com" />
```

ここに新しくフォームを追加するために、まずは以下のようなプロトタイプを準備します。

```html
<input type="email"
    id="form_emails___name__"
    name="form[emails][__name__]"
    value=""
/>
```

上記の例のフォームの連番の部分が `__name__` に置き換わっている事がわかります。
この `__name__` をJavaScriptで数字に置き換えて項目を追加してやることになります。
上記のプロトタイプは `prototype` オプションを有効にしておき `{{ form_widget(form.emails.vars.prototype)|e }}` で出力が可能です。
（ただし `|e` はエスケープ処理をするためのフィルタです。）

---

twigを以下のように拡張します。 `data-prototype` にプロトタイプを持たせておきます。

```twig
{{ form_start(form) }}
    {# ... #}

    {# data-prototypeにプロトタイプをセットしておく #}
    <ul id="email-fields-list"
        data-prototype="{{ form_widget(form.emails.vars.prototype)|e }}"
        data-widget-tags="{{ '<li></li>'|e }}">
    {% for emailField in form.emails %}
        <li>
            {{ form_errors(emailField) }}
            {{ form_widget(emailField) }}
        </li>
    {% endfor %}
    </ul>

    <button type="button"
        class="add-another-collection-widget"
        data-list="#email-fields-list">Add another email</button>

    {# ... #}
{{ form_end(form) }}
```

---

以下はJavaScriptで項目を追加する実装例です。

```javascript
jQuery(document).ready(function () {
    // 「Add another email」ボタンがクリックされた場合に実行
    jQuery('.add-another-collection-widget').click(function (e) {
        // 項目を追加するリスト
        var list = jQuery(jQuery(this).attr('data-list'));
        // 'widget-counter' から項目番号を取得
        var counter = list.data('widget-counter') | list.children().length;
        // できない場合はリストの個数を項目番号として使用
        if (!counter) { counter = list.children().length; }

        // 'data-prototype' からプロトタイプを取得
        var newWidget = list.attr('data-prototype');
        // "__name__" を項目番号で置換
        newWidget = newWidget.replace(/__name__/g, counter);
        // 項目番号をインクリメント
        counter++;
        // 項目が削除されたときのために項目番号を保存しておく
        list.data(' widget-counter', counter);

        // テンプレートを作成してリストに追加
        var newElem = jQuery(list.attr('data-widget-tags')).html(newWidget);
        newElem.appendTo(list);
    });
});
```

以上がCollectionTypeの基本的な使い方となります。

---

## よく使うオプション

- `entry_type` : 各項目のFormTypeを指定
- `prototype` : プロトタイプの出力できるようにする
- `allow_add` : 項目の追加を許可する（登録されていない項目が送信された場合に新規追加する）
- `allow_delete` : 項目の削除を許可する（送信されなかった項目は削除する）

---

## 何か処理をしてから項目の削除をしたい時

`allow_delete` を指定すると送信されなかった項目は削除されるのですが、削除される前に何か処理をしたい場合があります。
その場合はあらかじめ削除される項目のクローンを作成しておき、処理をしてから消すという手順を踏む必要があります。
詳しくはまた追記します。

---

# EC-CUBEでの応用

EC-CUBEでは以下のようなところで利用されています。

- 商品に対する商品画像やタグ、商品規格
- 受注に対する商品や配送先
- 配送方法に対する配送時間や配送料金
- マスタデータ

---

## 配送方法と配送時間の実装例

EC-CUBEの最新ブランチからソースコードから一部抜粋

DeliveryTypeでCollectionTypeを利用しています。
entry_typeオプションでDeliveryTimeTypeを指定しています。
allow_add、allow_delete、prototypeのオプションをそれぞれtrueに設定しています。

[/src/Eccube/Form/Type/Admin/DeliveryType.php](https://github.com/EC-CUBE/ec-cube/blob/4.0/src/Eccube/Form/Type/Admin/DeliveryType.php#L88)

```php:/src/Eccube/Form/Type/Admin/DeliveryType.php
$builder
    ->add('delivery_times', CollectionType::class, [
        'label' => 'delivery.label.delivery_time',
        'required' => false,
        'entry_type' => DeliveryTimeType::class,
        'allow_add' => true,
        'allow_delete' => true,
        'prototype' => true,
;
```

---

DeliveryTimeTypeではTextTypeのdelivery_timeとHiddenTypeのsort_noを追加しています。

[/src/Eccube/Form/Type/Admin/DeliveryTimeType.php](https://github.com/EC-CUBE/ec-cube/blob/4.0/src/Eccube/Form/Type/Admin/DeliveryTimeType.php)

```php:/src/Eccube/Form/Type/Admin/DeliveryTimeType.php
class DeliveryTimeType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('delivery_time', TextType::class, [
                'label' => false,
                'attr' => [
                    'placeholder' => 'admin.setting.shop.delivery_edit.delivery_times_placeholder',
                ],
                'constraints' => [
                    new Assert\NotBlank(),
                ],
            ])
            ->add('sort_no', HiddenType::class, [
                'label' => false,
                'constraints' => [
                    new Assert\NotBlank(),
                ],
            ])
        ;
    }
}
```

---

フォームを動的に追加するJavaScriptは以下のようになっています。

[/src/Eccube/Resource/template/admin/Setting/Shop/delivery_edit.twig](https://github.com/EC-CUBE/ec-cube/blob/4.0/src/Eccube/Resource/template/admin/Setting/Shop/delivery_edit.twig#L78)

```javascript:/src/Eccube/Resource/template/admin/Setting/Shop/delivery_edit.twig
var $collectionHolder = $('#delivery-time-group');
var index = $collectionHolder.find('.delivery-time-item').length;

// お届け時間設定の新規作成ボタンでお届け時間項目を追加する
$('#add-delivery-time-button').on('click', function (event) {

    // 追加するお届け時間名を取得
    var deliveryTimeName = $('#add-delivery-time-value').val();

    if (deliveryTimeName == '') {
        return;
    }

    var prototype = $collectionHolder.data('prototype');

    var newForm = prototype.replace(/__name__/g, index);
    var newForm = newForm.replace(/__value__/g, deliveryTimeName);

    // 要素を追加
    var $lastRow = $('#delivery-time-group > li:last');
    $lastRow.after(newForm);

    // お届け時間名を入れる
    var inputId = '#delivery_delivery_times_' + index + '_delivery_time';
    $(inputId).val(deliveryTimeName);

    // 入力欄を初期化
    $('#add-delivery-time-value').val('');

    // 要素数をインクリメント
    index++;
});
```

---

新規追加ボタン押下で新規項目を追加します。
項目のテンプレートは `delivery_time_prototype.twig` ファイルに切り出しています。
プロトタイプの場合は `form.delivery_times.vars.prototype` を引数として渡して `delivery_time_prototype.twig` をincludeするようにしています。

[/src/Eccube/Resource/template/admin/Setting/Shop/delivery_edit.twig](https://github.com/EC-CUBE/ec-cube/blob/4.0/src/Eccube/Resource/template/admin/Setting/Shop/delivery_edit.twig#L265)

```twig:/src/Eccube/Resource/template/admin/Setting/Shop/delivery_edit.twig
{# ... #}

<ul id="delivery-time-group" class="list-group list-group-flush sortable-container"
    data-prototype="{% filter escape %}{{ include('@admin/Setting/Shop/delivery_time_prototype.twig', {'form': form.delivery_times.vars.prototype}) }}{% endfilter %}">
    <li class="list-group-item">
        <div class="form-row">
            <div class="col-auto d-flex align-items-center">
                <input id="add-delivery-time-value" class="form-control" type="text">
            </div>
            <div class="col-auto d-flex align-items-center">
                <button id="add-delivery-time-button" class="btn btn-ec-regular" type="button">新規追加</button>
            </div>
        </div>
    </li>
    {% for child in form.delivery_times %}
        {{ include('@admin/Setting/Shop/delivery_time_prototype.twig', {'form': child}) }}
    {% endfor %}
</ul>

{# ... #}
```

---

prototypeのtwigファイルは以下です、

[/src/Eccube/Resource/template/admin/Setting/Shop/delivery_time_prototype.twig](https://github.com/EC-CUBE/ec-cube/blob/4.0/src/Eccube/Resource/template/admin/Setting/Shop/delivery_time_prototype.twig)

```twig:/src/Eccube/Resource/template/admin/Setting/Shop/delivery_time_prototype.twig
<li class="list-group-item delivery-time-item sortable-item">
    <div class="row justify-content-around mode-view">
        <div class="col-auto d-flex align-items-center">
            <i class="fa fa-bars text-ec-gray"></i>
        </div>
        <div class="col d-flex align-items-center">
            <a class="display-label">{% if form.vars.value == '' %}__value__{% else %}{{ form.vars.value }}{% endif %}</a>
        </div>

{# ... #}

    </div>
    <div class="row justify-content-around mode-edit d-none">
        <div class="col d-flex align-items-center">
            <div class="form-row">
                <div class="col-auto d-flex align-items-center">
                    {{ form_widget(form.delivery_time, {'attr': {'data-origin-value': form.vars.value }}) }}
                </div>

{# ... #}

                {{ form_errors(form.delivery_time) }}
                {{ form_widget(form.sort_no, {'attr': {'class': "sort-no" }}) }}
                {{ form_errors(form.sort_no) }}
            </div>
        </div>
    </div>
</li>
```

少し複雑になっていますがやっていることは前半で説明したことと同じです。

---

この記事およびコードは[Creative Commons BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/)ライセンスに従います。