Chapter 14

CRUDを実装

たつきち
たつきち
2022.07.30に更新

この章に対応するコミット

デモアプリは日本語と英語に対応するためすべての文字列リテラルを翻訳しているので、コミットの内容は本文の解説と若干異なります。

CRUDを実装

Customer エンティティと、その子エンティティである Person エンティティを作ったので、次は Customer のCRUDを実装していきます。

make:crud を使うか、既存のCRUDをコピペする

make:crud を使えばCRUDのためのコントローラ・フォーム・テンプレートを一気に自動生成してくれますが、ユーザーのCRUDを作る章でも述べたとおり、僕の場合は自動生成したあとに手で修正する部分が多いので基本的には別のエンティティのCRUDのコードをコピペしてから文字列の一括置換などで内容を変更してベースを作ることが多いです。

この辺は好みというか自分が楽な方法でやればいいかなと思います。

FormType

まずは Person Customer それぞれのFormTypeを作成します。

// src/Form/Customer/PersonType.php

class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('fullName', TextType::class, [
                'label' => '氏名',
                'attr' => [
                    'required' => true,
                ],
                'label_attr' => [
                    'class' => 'required',
                ],
                'constraints' => [
                    new Assert\NotBlank(),
                ],
            ])
            ->add('email', EmailType::class, [
                'required' => false,
                'label' => 'メールアドレス',
            ])
            ->add('tel', TelType::class, [
                'required' => false,
                'label' => '電話番号',
            ])
            ->add('address', TextareaType::class, [
                'required' => false,
                'label' => '住所',
                'attr' => [
                    'rows' => 3,
                ],
            ])
        ;
    }

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

fullName フィールドに対して何やらごちゃごちゃやっていますが、これについては以下の過去記事で解説しているのでご参照ください🙏

[Symfony] コレクションプロパティのバリデーションにおいて子オブジェクト側のNotBlank制約が無視されるケースについて

// src/Form/CustomerType.php

class CustomerType extends AbstractType
{
    private TranslatorInterface $translator;

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

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('state', ChoiceType::class, [
                'label' => '状態',
                'choices' => array_combine(CustomerConstant::getValidStates(), CustomerConstant::getValidStates()),
                'multiple' => false,
                'placeholder' => '',
                'attr' => [
                    'data-placeholder' => '選択してください',
                    'data-allow-clear' => true,
                    'class' => 'w-100',
                ],
            ])
            ->add('name', TextType::class, [
                'label' => '顧客名',
            ])
            ->add('people', CollectionType::class, [
                'required' => false,
                'entry_type' => PersonType::class,
                'label' => '先方担当者',
                'prototype' => true,
                'allow_add' => true,
                'allow_delete' => true,
            ])
        ;
    }

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

CollectionTypeのオプションがやや複雑ですが、これについても以下の過去記事をご参照ください🙏

[Symfony/Form] CollectionTypeの基本的な使い方

CustomerTypestate フィールドの choicesCustomerConstant::getValidStates() を引いているので、選択肢が増減しても CustomerConstant クラスを修正するだけでバリデーションもFormTypeも対応完了できて嬉しいですね👌

このままでも特に問題はないのですが、僕はいつもこういう定数選択系のフォームフィールドはそれぞれ個別のFormTypeとして定義して、別のフォームでいつでも使い回せるようにしています。

// src/Form/Customer/StateChoiceType.php

class StateChoiceType extends ChoiceType
{
    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        $resolver->setDefaults([
            'choices' => array_combine(CustomerConstant::getValidStates(), CustomerConstant::getValidStates()),
            'multiple' => false,
            'placeholder' => '',
            'attr' => [
                'data-placeholder' => '選択してください',
                'data-allow-clear' => true,
                'class' => 'w-100',
            ],
        ]);
    }
}
  // src/Form/CustomerType.php
  
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
      $builder
-         ->add('state', ChoiceType::class, [
+         ->add('state', StateChoiceType::class, [
              'label' => '状態',
-             'choices' => array_combine(CustomerConstant::getValidStates(), CustomerConstant::getValidStates()),
-             'multiple' => false,
-             'placeholder' => '',
-             'attr' => [
-                 'data-placeholder' => '選択してください',
-                 'data-allow-clear' => true,
-                 'class' => 'w-100',
-             ],
          ])
          ->add('name', TextType::class, [
              'label' => '顧客名',
          ])
          ->add('people', CollectionType::class, [
              'required' => false,
              'entry_type' => PersonType::class,
              'label' => '先方担当者',
              'prototype' => true,
              'allow_add' => true,
              'allow_delete' => true,
          ])
      ;
  }

コントローラ

次にコントローラを書きます。

内容的には make:crud コマンドで自動生成したコードを多少整形して、自作の ReturnToAwareControllerTrait を適用したりページネーションを導入したりするぐらいで、ユーザーのCRUDを作ったときとほぼ同じなので、詳細な説明は割愛します🙏

最終的なコードは こんな感じ になります。

ビュー

最後はビューの実装です。

これもほとんどユーザーのCRUDを作ったときと同じですが、一点、今回はフォームに allow_add allow_delete なCollectionTypeを使ったので、フロント側で多少のDOM操作が必要になります。

この辺りの処理は毎回書くのは面倒くさすぎるので、いつでも使い回せるように部品化しておきます。

{% macro template(form) %}
  {% set wrap = form.children|length > 1 %}
  <div class="{% if wrap %}card mb-3{% endif %} collection-item">
    <div class="{% if wrap %}card-body mb-n3{% endif%} d-flex">
      <div class="flex-grow-1">
        {{ form_widget(form, {attr: {class: 'mb-2'}}) }}
      </div>
      <div class="ml-2">
        <button type="button" class="btn btn-sm btn-outline-secondary collection-item-remover">
          <i class="far fa-trash-alt"></i>
        </button>
      </div>
    </div>
  </div>
{% endmacro %}

<div class="form-collection">
  {% for childForm in form.children %}
    {{ _self.template(childForm) }}
  {% endfor %}

  <div class="placeholder"></div>
  <button type="button" class="btn btn-block btn-outline-secondary m-0 collection-item-adder"><i class="fa fa-plus"></i> 追加</button>
  <div class="prototype" data-prototype="{{ _self.template(form.vars.prototype)|e }}" data-last-index="{{ form|length - 1 }}" data-required-num="{{ requiredNum ?? 0 }}"></div>
</div>

こんな感じのTwig部品と、

import applySelect2 from '../lib/applySelect2';

ensureRemoverDisabled();

$(document).on('click', '.collection-item-adder', function () {
  const $prototype = $(this).closest('.form-collection').find('.prototype');
  const $placeholder = $(this).closest('.form-collection').find('.placeholder');

  let lastIndex = parseInt($prototype.data('last-index'));
  const html = $prototype.data('prototype').replace(/__name__/g, ++lastIndex);
  const $element = $(html);
  $placeholder.append($element);
  $prototype.data('lastIndex', lastIndex);

  applySelect2($element.find('select'));
  ensureRemoverDisabled();
});

$(document).on('click', '.collection-item-remover', function () {
  $(this).closest('.collection-item').remove();

  ensureRemoverDisabled();
});

function ensureRemoverDisabled() {
  $('.prototype').each(function () {
    const requiredNum = $(this).data('required-num');
    const $removers = $(this).closest('.form-collection').find('.collection-item-remover');

    if ($removers.length <= requiredNum) {
      $removers.prop('disabled', true);
    } else {
      $removers.prop('disabled', false);
    }
  });
}

Twigのマークアップに対応するこんな感じのJavaScriptの処理を用意しておきます。

マークアップとJavaScriptの処理の説明はここでは割愛しますが、やっていることは古き良きjQueryを使ったゴリゴリのDOM操作だけなのでそんなに難しくはありません👌

JavaScriptファイルは忘れずにWebpack Encoreエントリーとして追加しておきましょう。

  Encore
+   .addEntry('widgets_form-collection', [
+     './assets/js/widgets/form-collection.js',
+   ])

この上で、 new.html.twigedit.html.twig でのみ

{% block javascripts %}
  {{ parent() }}
  {{ encore_entry_script_tags('widgets_form-collection') }}
{% endblock %}

としてアセットの読み込みを行うようにします。

フォーム部分のテンプレートの記述はこんな感じになります。

{{ form_start(form) }}
{{ form_errors(form) }}
{{ form_row(form.state) }}
{{ form_row(form.name) }}
<div class="form-group row">
  {{ form_label(form.people) }}
  <div class="col-sm-9">
    {% include 'widgets/form-collection.html.twig' with {
      form: form.people,
    } %}
  </div>
</div>
<div class="float-right">
  <button type="submit" class="btn btn-primary float-right ml-2">保存</button>
  <a href="{{ cancelPath }}" class="btn btn-outline-secondary">キャンセル</a>
</div>
{{ form_widget(form._token) }}
{{ form_end(form, {render_rest: false}) }}

動作確認

これで、以下のようにいい感じな顧客のCRUDが完成しました🙌