Chapter 17

一覧画面に検索と一括削除を実装

たつきち
たつきち
2022.07.29に更新

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

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

一覧画面に検索と一括削除を実装

いよいよこの章でラストです💪

多くのケースで一覧画面には検索機能と一括削除機能が求められるので、最後にこれを実装しておきましょう。

検索機能

検索ロジック

TtskchPaginatorBundle を導入しているので検索機能もとても簡単に導入できます👍

用意するのは

  • 検索条件を保持するモデルクラス
  • 上記モデルクラスに 対応するFormType
  • 検索条件をもとに実際にクエリを組み立てて、ページスライスと全体の件数を取得するクラス

の3つです。

僕はいつもこれらをそれぞれ

  • Criteria
  • SearchType
  • Paginator

と名付けて、今回であれば以下のような構成でファイルを作成しています。

$ tree src/Pagination
src/Pagination
├── Criteria
│   ├── CustomerCriteria.php
│   ├── ProjectCriteria.php
│   └── UserCriteria.php
├── Form
│   ├── CustomerSearchType.php
│   ├── ProjectSearchType.php
│   └── UserSearchType.php
└── Paginator
    ├── CustomerPaginator.php
    ├── ProjectPaginator.php
    └── UserPaginator.php

例えば CustomerCriteria CustomerSearchType CustomerPaginator のコードは以下のような内容になります。

// src/Pagination/Criteria/CustomerCriteria.php

namespace App\Pagination\Criteria;

use Ttskch\PaginatorBundle\Entity\Criteria;

class CustomerCriteria extends Criteria
{
    public ?string $query = null;
    public ?array $states = null;
}
// src/Pagination/Form/CustomerSearchType.php

namespace App\Pagination\Form;

use App\Form\Customer\StateChoiceType;
use App\Pagination\Criteria\CustomerCriteria;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
use Ttskch\PaginatorBundle\Form\CriteriaType;

class CustomerSearchType extends CriteriaType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        parent::buildForm($builder, $options);

        $builder
            ->add('query', SearchType::class, [
                'required' => false,
                'attr' => [
                    'placeholder' => '全文検索',
                    'class' => 'w-100',
                ],
            ])
            ->add('states', StateChoiceType::class, [
                'required' => false,
                'multiple' => true,
                'attr' => [
                    'data-placeholder' => '状態を選択',
                    'data-allow-clear' => true,
                    'class' => 'w-100',
                ],
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => CustomerCriteria::class,
            'csrf_protection' => false,
        ]);
    }
}
// src/Pagination/Paginator/CustomerPaginator.php

namespace App\Pagination\Paginator;

use App\Entity\Customer;
use App\Pagination\Criteria\CustomerCriteria;
use App\Repository\CustomerRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Ttskch\PaginatorBundle\Doctrine\Counter;
use Ttskch\PaginatorBundle\Doctrine\Slicer;

class CustomerPaginator
{
    private EntityManagerInterface $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    // ページスライスを取得するメソッド
    public function sliceByCriteria(CustomerCriteria $criteria): \ArrayIterator
    {
        $qb = $this->createQueryBuilderFromCriteria($criteria);
        $slicer = new Slicer($qb);

        return $slicer($criteria, true);
    }

    // 検索条件に一致したデータ全体の件数を取得するメソッド
    public function countByCriteria(CustomerCriteria $criteria): int
    {
        $qb = $this->createQueryBuilderFromCriteria($criteria);
        $counter = new Counter($qb);

        return $counter($criteria);
    }

    // 検索条件をもとにクエリを組み立てるメソッド
    public function createQueryBuilderFromCriteria(CustomerCriteria $criteria): QueryBuilder
    {
        $expr = $this->em->getExpressionBuilder();

        /** @var CustomerRepository $repository */
        $repository = $this->em->getRepository(Customer::class);

        $qb = $repository->createQueryBuilder('c')
            ->leftJoin('c.people', 'p')
        ;

        // queryが入力されていたら全文検索で絞り込み
        if ($criteria->query !== null) {
            $qb
                ->andWhere($expr->orX(
                    'c.name like :query',
                    'p.fullName like :query',
                    'p.email like :query',
                    'p.tel like :query',
                    'p.note like :query',
                ))
                ->setParameter('query', '%'.str_replace('%', '\%', $criteria->query).'%')
            ;
        }

        // statesが指定されていたら一致するもので絞り込み
        if ($criteria->states) {
            $qb->andWhere($expr->in('c.state', $criteria->states));
        }

        return $qb;
    }
}

コントローラ

検索ロジックができたら、実際にコントローラに適用します。

- public function index(Context $context): Response
+ public function index(Context $context, CustomerPaginator $paginator): Response
  {
-     $qb = $this->repository->createQueryBuilder('c');
-     $context->initialize('id', new Slicer($qb), new Counter($qb));
+     $context->initialize(
+         'id',
+         [$paginator, 'sliceByCriteria'],
+         [$paginator, 'countByCriteria'],
+         CustomerCriteria::class,
+         CustomerSearchType::class,
+     );
  
      return $this->render('customer/index.html.twig', [
          'slice' => $context->slice,
+         'form' => $context->form->createView(),
      ]);
  }

$context->initialize() の引数を以下のように変更しただけです。

変更前 変更後 備考
デフォルトのソート対象 id c.id Paginatorのクエリで指定しているエンティティエイリアスを付加
スライサー new Slicer($pb) [$paginator, 'sliceByCriteria'] Paginatorのスライサーを使用
カウンター new Counter($pb) [$paginator, 'countByCriteria'] Paginatorのカウンターを使用
Criteria 省略 CustomerCriteria::class Criteriaを指定
FormType 省略 CustomerSearchType::class FormTypeを指定

また、handleRequest() 済みのFormインスタンスは $context->form で取得できるので、それの createView() の戻り値をビューに渡しています。

ビュー

あとは画面に検索フォームをレンダリングすれば完了です。

検索フォームは基本的にすべての一覧画面に設置する上に、画面によって検索項目のバリエーションが異なるので、ある程度どんなケースでもきれいにレンダリングできるよう僕なりのセンスで部品化してあります。

{% set row1 %}{% block row1 %}{% endblock %}{% endset %}
{% set row1_col1 %}{% block row1_col1 %}{% endblock %}{% endset %}
{% set row1_col2 %}{% block row1_col2 %}{% endblock %}{% endset %}

{% set row2 %}{% block row2 %}{% endblock %}{% endset %}
{% set row2_col1 %}{% block row2_col1 %}{% endblock %}{% endset %}
{% set row2_col2 %}{% block row2_col2 %}{% endblock %}{% endset %}

{% set row3 %}{% block row3 %}{% endblock %}{% endset %}
{% set row3_col1 %}{% block row3_col1 %}{% endblock %}{% endset %}
{% set row3_col2 %}{% block row3_col2 %}{% endblock %}{% endset %}

{{ form_start(form, {method: 'get', attr: {class: 'form-inline align-items-start'}}) }}
<div class="container ml-0 px-0 mb-3">
  <div class="d-flex flex-column flex-sm-row">

    {# 入力欄エリア #}
    <div class="flex-grow-1 d-flex flex-column">
      {% for i in 1..3 %}
        {% set row = attribute(_context, 'row'~i) %}
        {% set col1 = attribute(_context, 'row'~i~'_col1') %}
        {% set col2 = attribute(_context, 'row'~i~'_col2') %}

        {% if row or col1 or col2 %}
          <div class="{{ i > 1 ? 'mt-2' : '' }}">
            {% if row %}
              {{ row }}
            {% else %}
              <div class="d-flex flex-column flex-sm-row">
                {% if col1 %}
                  <div class="w-100 mr-sm-2">
                    {{ col1 }}
                  </div>
                {% endif %}

                {% if col1 or col2 %}
                  <div class="w-100 mt-2 mt-sm-0" style="width:1px">
                    {{ col2 }}
                  </div>
                {% endif %}
              </div>
            {% endif %}
          </div>
        {% endif %}
      {% endfor %}
    </div>

    {# 検索ボタンエリア #}
    <div class="mt-2 mt-sm-0 ml-sm-2">
      <button type="submit" class="btn btn-block btn-outline-secondary"><i class="fa fa-search"></i></button>
    </div>
  </div>
</div>
{{ form_end(form) }}

これで、 row1 row1_col1 row1_col2 等のブロックに {{ form_widget(form.query) }} などを差し込めば、とてもいい感じの見た目で表示させてくれます。

ちなみに、{{ form_widget() }} によるフォームのレンダリングは 1回しか行われない仕様 なので、

{% if block('row1') is not empty %}
  {% block row1 %}
{% endif %}

みたいな書き方をしてしまうと、if 文の中で一度レンダリングが実行されてしまって、肝心の {% block row1 %} の部分には何も出力されない という現象になるので要注意です。

これを避けるために、上記では一度 {% set %}{% block row1 %}{% endblock %}{% endset %} というようにレンダリング結果を変数に入れて使い回すようにしています。

顧客一覧画面のテンプレートには、これを使って以下のようなコードを追記します。

{% embed 'widgets/search-form.html.twig' %}
  {% block row1_col1 %}
    {{ form_widget(form.query) }}
  {% endblock %}
  {% block row1_col2 %}
    {{ form_widget(form.states) }}
  {% endblock %}
{% endembed %}

スッキリしていてよいですね!

動作確認

これで、こんな感じの検索フォームが設置できました!🙌

一括削除機能

続いて一括削除機能です。

コントローラ

まずは一括削除のためのコントローラアクションを追加しましょう。(例によって、ファイル内でのメソッドの定義位置に気をつけてください👌)

// src/Controller/CustomerController.php

/**
 * @Route("/multiple", name="delete_multiple", methods={"DELETE"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT")
 */
public function deleteMultiple(Request $request)
{
    $ids = explode(',', $request->request->get('ids'));

    if ($this->isCsrfTokenValid('delete_multiple', $request->request->get('_token'))) {
        foreach ($ids as $id) {
            $this->em->remove($this->repository->find($id));
        }

        try {
            $this->em->flush();
            $this->addFlash('success', '顧客の一括削除が完了しました。');
        } catch (ForeignKeyConstraintViolationException $e) {
            $this->addFlash('danger', 'その顧客に紐づいているデータがあるため削除できません。');
        }
    }

    return $this->redirectToRouteOrReturn('customer_index');
}

こんな感じで、クエリパラメータ ids でカンマ区切りのID列を受け取って、指定されたIDのエンティティを一括で削除します。

ProjectController にもまったく同じアクションを実装します。

ユーザーは気軽に一括削除などできないほうがいいので、あえて UserController には実装しません。

ビュー

次に、一覧画面のビューに以下のような一括削除のためのフォームを設置し、

<form action="{{ pathWithReturnTo('customer_delete_multiple') }}" method="post" onsubmit="return confirm('本当に削除してよいですか?')">
  <input type="hidden" name="_method" value="DELETE">
  <input type="hidden" name="_token" value="{{ csrf_token('delete_multiple') }}">
  <input type="hidden" name="ids">
  <a class="multiple-checker-action" onclick="$(this).closest('form').submit();">一括削除...</a>
</form>

さらに行選択のためのチェックボックスを設置します。

<table>
  <thead>
  <tr>
    <th><input type="checkbox" class="multiple-checker-all"></th> {# すべて選択 #}
    {# ... 略 #}
  </tr>
  </thead>
  <tbody>
  {% for customer in slice %}
    <tr>
      <td><input type="checkbox" class="multiple-checker" value="{{ customer.id }}"></td> {# 個別に選択 #}
      {# ... 略 #}
    </tr>
  {% endfor %}
  </tbody>
</table>

JavaScript

最後はチェックボックスの操作に応じてフォームの ids に値をセットする処理を素朴にjQueryで実装します。

ちょっと長いので 実際のコード を見てみてください🙏

複雑ではありますが、特別に難しいことはしていないので、読んでいただければ何をしているかは理解できると思います👍

JavaScriptを書いたら忘れずにWebpackEncoreに登録して

  Encore
+   .addEntry('widgets_multiple-checker', [
+     './assets/js/widgets/multiple-checker.js',
+   ])

テンプレートで読み込んでおきましょう。

{% block javascripts %}
  {{ parent() }}
  {{ encore_entry_script_tags('widgets_multiple-checker') }}
{% endblock %}

動作確認

これで、以下のように複数の行を選択して一括削除ができるようになりました🙌

コントローラアクションを増やせば一括編集など別の一括処理にも簡単に対応できますね👍