🎻

Symfony UX Autocompleteで、選択肢にない値を手入力もできる選択式のフォーム項目の作り方

2024/10/04に公開

やりたいこと

「選択肢から選ぶこともできるし、選択肢にない値を手入力もできる」ようなフォーム項目は、単一入力ならHTMLの datalist を使えばよいと思うのですが、datalistを使いつつ複数入力できるものを作るとなると、(Symfony Formなら CollectionType を使って)入力欄自体を増やせるようにする必要があり、あまりUXがよくありません。

そこで、Symfony UX Autocomplete を使って、下図のような「選択または入力」ができるフォーム項目を作ります🙆‍♂️

概要

Symfony UX Autocompleteは、とりあえずSymfonyアプリにインストールしておけば <select> タグを全部いい感じにしてくれる最高のやつ です。

フロントエンドの実装には Tom Select というライブラリが使われており、Tom Selectが備えている機能 を用いて、選択肢にない値を手入力もできる選択式のフォーム項目を作ることができます。

ただし、フロントエンド側のTom Selectの状態とバックエンド側のSymfony Formの状態を適切に連携させるには多少やることがあります。この記事ではその辺りも含めて順を追ってやり方を説明していきます。

Symfony UX Autocompleteについては過去に以下のような記事も書いているのでよかったら覗いてみてください🤲

Symfony UX Autocompleteとかいう顧客が本当に必要だったもの

1. FormTypeを作る

まずはFormTypeを作ります。

<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CreatableChoiceType extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'attr' => [
                'data-placeholder' => '選択または入力してください',
            ],
            'autocomplete' => true,
            'tom_select_options' => [
                'create' => true,
                'createOnBlur' => true,
                'persist' => false,
            ],
        ]);
    }

    public function getParent(): string
    {
        return ChoiceType::class;
    }
}

'autocomplete' => true でSymfony UX Autocompleteを有効にし、tom_select_optionsTom Selectの初期化オプション を任意に渡しています。

  • 'create' => true が、選択肢にない値を手入力できるようにする設定
  • 'createOnBlur' => true は、手入力中にフォーム項目からフォーカスを外したときに、入力をキャンセルするのではなく入力を完了させる設定
  • persist => false は、手入力した値を選択解除したときに選択肢に残さないための設定

です。

利用側では以下のような感じで使えます。

$builder
    ->add('foos', CreatableChoiceType::class, [
        'choices' => array_combine($values = ['選択肢1', '選択肢2', '選択肢3'], $values),
        'multiple' => true, // もちろん false でも OK
    ])
;

これで、「選択または入力」ができるフォーム項目を画面に出力することができます🙆‍♂️

2. 手入力された値がエラーにならずバックエンドに渡ってくるようにする

実は現状だと手入力した値(つまり選択肢にない値)が含まれる状態でフォームを送信すると、バックエンド側で「選択した値は無効です。」のエラーになってします。

これを解消するため、CreatableChoiceTypeに以下のコードを追記します。

  <?php
  
  declare(strict_types=1);
  
  namespace App\Form;
  
  use Symfony\Component\Form\AbstractType;
  use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+ use Symfony\Component\Form\FormBuilderInterface;
+ use Symfony\Component\Form\FormEvent;
+ use Symfony\Component\Form\FormEvents;
  use Symfony\Component\OptionsResolver\OptionsResolver;
  
  class CreatableChoiceType extends AbstractType
  {
+     public function buildForm(FormBuilderInterface $builder, array $options): void
+     {
+         $builder->addEventListener(FormEvents::PRE_SUBMIT, fn (FormEvent $event) => $event->stopPropagation(), 1); // ①
+         $builder->addEventListener(FormEvents::POST_SUBMIT, fn (FormEvent $event) => $event->stopPropagation(), 1); // ②
+         $builder->resetViewTransformers(); // ③
+     }
+ 
      public function configureOptions(OptionsResolver $resolver): void
      {
          $resolver->setDefaults([
              'attr' => [
                  'data-placeholder' => '選択または入力してください',
              ],
              'autocomplete' => true,
              'tom_select_options' => [
                  'create' => true,
                  'createOnBlur' => true,
                  'persist' => false,
              ],
          ]);
      }
  
      public function getParent(): string
      {
          return ChoiceType::class;
      }
  }

①は、ChoiceTypeの このイベントリスナー を無効化するためのコードです。このイベントリスナーは、選択肢にない値をフォームの送信データから削除する処理を行っています。今回は選択肢にない値も正常な値として受け取りたいので、イベントリスナー自体を無効にしてしまって構いません。

より優先度の高い(第3引数の 1)イベントリスナーを登録し、その中で stopPropagation() することで次以降のイベントハンドラーの処理を無効化しています。CreatableChoiceTypeを継承したFormTypeで有効なイベントリスナーを登録したい場合には優先度を 2 以上にする必要があるので、その点は要注意です。

②は、ChoiceTypeの このイベントリスナー を無効化するためのコードです。このイベントリスナーは、選択肢にない値に対してフォームのエラーを出力する処理を行っています。これも今回はイベントリスナー自体を無効にしてしまって構いません。

③は、ChoiceTypeの この処理 を無効化するためのコードです。フォームの入力値をSymfony Formの内部表現に変換するためのViewTransformerを登録する処理ですが、このViewTransformerが実行されると選択肢にない値に対してエラーが発せられてしまうので、resetViewTransformers() でViewTransformerの登録を無かったことにしています。

これで、手入力した値もエラーになったり削除されたりせずバックエンドに渡ってくるようになりました🙆‍♂️

3. 手入力された値がセットされているフォームを描画したときにTom Select上でその値が選択状態になるようにする

これが最後のステップです。

現状だと、値がセットされているフォームを画面に描画した場合(例えば、検索フォームで検索条件が入力値に反映された状態で描画する場合や、フォームにエラーがあったために入力値を残したままエラーを伴って描画する場合など)に、実際にセットされている値のうち、選択肢にあるものだけが選択状態となり、手入力された値は選択されていないような表示になってしまいます。

これは、CreatableChoiceTypeを描画する際に、choices にあるものだけが <option> タグとして出力されるため、Tom Selectが <select> タグをもとに初期化処理を行う時点ですでに「選択肢以外にどんな値が手入力されていたのか」という情報が消失してしまっているためです。

なので、対処方法としては、PRE_SET_DATA などのタイミングで、実際にセットされているデータをもとに choices の内容を更新してあげればよいです。

実装例は以下のような感じです。

  <?php
  
  declare(strict_types=1);
  
  namespace App\Form;
  
  use Symfony\Component\Form\AbstractType;
  use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
  use Symfony\Component\Form\FormBuilderInterface;
  use Symfony\Component\Form\FormEvent;
  use Symfony\Component\Form\FormEvents;
  use Symfony\Component\OptionsResolver\OptionsResolver;
  
  class CreatableChoiceType extends AbstractType
  {
      public function buildForm(FormBuilderInterface $builder, array $options): void
      {
          $builder->addEventListener(FormEvents::PRE_SUBMIT, fn (FormEvent $event) => $event->stopPropagation(), 1);
          $builder->addEventListener(FormEvents::POST_SUBMIT, fn (FormEvent $event) => $event->stopPropagation(), 1);
          $builder->resetViewTransformers();
+ 
+         $builder->addEventListener(FormEvents::PRE_SET_DATA, $this->onPreSetData(...)); // ①
      }
+ 
+     public function onPreSetData(PreSetDataEvent $event): void
+     {
+         $this->updateChoices($event->getForm(), $event->getData()); // ②
+     }
  
      public function configureOptions(OptionsResolver $resolver): void
      {
          $resolver->setDefaults([
              'attr' => [
                  'data-placeholder' => '選択または入力してください',
              ],
              'autocomplete' => true,
              'tom_select_options' => [
                  'create' => true,
                  'createOnBlur' => true,
                  'persist' => false,
              ],
          ]);
      }
  
      public function getParent(): string
      {
          return ChoiceType::class;
      }
+ 
+     private function updateChoices(FormInterface $form, mixed $data): void
+     {
+         $fieldName = trim(strval($form->getPropertyPath()), '[]'); //
+         $fieldOptions = $form->getConfig()->getOptions();          // ③
+         $fieldOptions['choices'] ??= null;                         //
+ 
+         $parent = $form->getParent();
+ 
+         $originalChoices = $fieldOptions['choices'];
+ 
+         if ($fieldOptions['multiple']) {                                                                    //
+             $data ??= [];                                                                                   //
+             $fieldOptions['choices'] = array_unique([...$originalChoices, ...array_combine($data, $data)]); // ④
+         } else {                                                                                            //
+             $fieldOptions['choices'][strval($data)] = strval($data);                                        //
+         }
+ 
+         if ($originalChoices !== $fieldOptions['choices']) {       //
+             $type = $form->getConfig()->getType()->getInnerType(); // ⑤
+             $parent->add($fieldName, $type::class, $fieldOptions); //
+         }
+     }
  }

①で PRE_SET_DATA にイベントリスナーを登録し、②で実処理をprivateメソッドに移譲、③〜⑤がその実処理です。

まず③でフォームフィールドの choices などの設定内容を取得しています。

④で、フォームフィールドが multiple かどうかに応じて、手入力されたデータを choices に追加しています。

最後に⑤で、choices のもともとの設定内容から変化があった(=手入力されたデータがあった)場合のみ、フォームフィールドをフォームに追加し直すことで変更後の choices を有効にしています。「変化があった場合のみ」というif文をつけないと無限ループしてしまうので注意してください。

これで(ほぼ)完成です!手入力された値がセットされているフォームであっても、描画時にすべての値が選択状態になってくれるようになりました🙆‍♂️

最初に貼った動画は Stimulusコントローラーを自作してTom Selectの色々なワーディングを日本語にしたりしています が、その辺りの細かいやり方はまた別の機会に記事にするかもしれません🙏

おまけ:handleRequest() によってデータがセットされるフォームへの対応

「(ほぼ)完成」と言ったのは、実はこれだけだと特定のユースケースにおいて期待どおりの挙動がなされないためです。

具体的には、コンストラクタ引数や setData() ではなく handleRequest() によってデータがセットされるようなフォームで、choices の内容が修正されないため、手入力されたデータがフロントエンドでの描画時に表示されない問題が発生します。

例えば検索フォームなどではクエリパラメータを handleRequest() することでデータをセットする実装が一般的でしょうから、このケースに当てはまります。

この問題に対処するため、PRE_SET_DATA だけでなく PRE_SUBMIT にもイベントリスナーを仕込みます。

実装例は以下のようになります。

  <?php
  
  declare(strict_types=1);
  
  namespace App\Form;
  
  use Symfony\Component\Form\AbstractType;
  use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
  use Symfony\Component\Form\FormBuilderInterface;
  use Symfony\Component\Form\FormEvent;
  use Symfony\Component\Form\FormEvents;
  use Symfony\Component\OptionsResolver\OptionsResolver;
  
  class CreatableChoiceType extends AbstractType
  {
+     public function __construct(private PropertyAccessorInterface $propertyAccessor) // ③
+     {
+     }
+ 
      public function buildForm(FormBuilderInterface $builder, array $options): void
      {
          $builder->addEventListener(FormEvents::PRE_SUBMIT, fn (FormEvent $event) => $event->stopPropagation(), 1);
          $builder->addEventListener(FormEvents::POST_SUBMIT, fn (FormEvent $event) => $event->stopPropagation(), 1);
          $builder->resetViewTransformers();
  
          $builder->addEventListener(FormEvents::PRE_SET_DATA, $this->onPreSetData(...));
+         $builder->addEventListener(FormEvents::PRE_SUBMIT, $this->onPreSubmit(...), 2); // ①
      }
  
      public function onPreSetData(PreSetDataEvent $event): void
      {
          $this->updateChoices($event->getForm(), $event->getData());
      }
+ 
+     public function onPreSubmit(PreSubmitEvent $event): void
+     {
+         if ($event->getForm()->getData() === null || $event->getForm()->getData() === []) { //
+             $this->setDataAndUpdateChoices($event->getForm(), $event->getData());           // ②
+         }                                                                                   //
+     }
  
      public function configureOptions(OptionsResolver $resolver): void
      {
          $resolver->setDefaults([
              'attr' => [
                  'data-placeholder' => '選択または入力してください',
              ],
              'autocomplete' => true,
              'tom_select_options' => [
                  'create' => true,
                  'createOnBlur' => true,
                  'persist' => false,
              ],
          ]);
      }
  
      public function getParent(): string
      {
          return ChoiceType::class;
      }
  
      private function updateChoices(FormInterface $form, mixed $data): void
      {
          $fieldName = trim(strval($form->getPropertyPath()), '[]');
          $fieldOptions = $form->getConfig()->getOptions();
          $fieldOptions['choices'] ??= null;
  
          $parent = $form->getParent();
  
          $originalChoices = $fieldOptions['choices'];
  
          if ($fieldOptions['multiple']) {
              $data ??= [];
              $fieldOptions['choices'] = array_unique([...$originalChoices, ...array_combine($data, $data)]);
          } else {
              $fieldOptions['choices'][strval($data)] = strval($data);
          }
  
          if ($originalChoices !== $fieldOptions['choices']) {
              $type = $form->getConfig()->getType()->getInnerType();
              $parent->add($fieldName, $type::class, $fieldOptions);
          }
      }
+ 
+     private function setDataAndUpdateChoices(FormInterface $form, mixed $data): void // ④
+     {
+         $parentData = $form->getParent()?->getData();
+         $propertyPath = $form->getPropertyPath();
+ 
+         if ($parentData === null || $propertyPath === null || $data === null) {
+             return;
+         }
+ 
+         $this->propertyAccessor->setValue($parentData, $propertyPath, $data);
+ 
+         $this->updateChoices($form, $data);
+     }
  }

番号振りが上から順ではないので要注意ですが、まず①で PRE_SUBMIT にイベントリスナーを登録します。ここで、すでに登録済みの、「以降のイベントリスナーへのイベントの伝播を止めるイベントリスナー」よりも高い優先度である 2 を明示的に指定している点に注意してください。

②では、フォームにセットされているデータが空の場合のみ、「データをセットした上で choices を更新する処理」を実行する、ということをしています。これは、PRE_SET_DATA 時点で choices が更新されていないフォームがsubmitされると、PRE_SUBMIT 時点ではデータは空になっているというのがSymfony Formの仕様[1]であるため、その場合にのみ、改めてデータをセットした上で choices を更新する、ということをする必要があるためです。

最後に③と④です。この時点でフォームフィールドに setData() をしても、親フォームのデータの対応するフィールドのデータは上書きされないので、親フォームのデータ自体に対してフィールド名を指定してデータをセットする必要があり、そのために Property Accessor を使用しています。③でProperty AccessorをDIして、④のメソッドでそれを使って親フォームにデータをセットし、その上で先ほど作成した updateChocies() メソッドを呼んで choices を更新しているというわけです。

これで、今度こそ完璧に動作するものができたはずです🎉

ちなみに

https://tom-select.js.org/examples/ の1つ目の例のように、

<input value="選択肢1,選択肢2,選択肢3">

のような value に値をカンマ区切りで繋いだ文字列が入っている <input> タグもTom Select化することができますが、Symfony FormでこれをやるとTom Select上の選択状態にかかわらず、フォーム送信時には結局すべての値がバックエンドに送信されてしまうため、そのままでは今回の用途には使えません。

脚注
  1. ドキュメントやコードの該当箇所を調べたわけではなく実験的に知ったことなので詳細はよく分かっていません🙏 詳しい方いたらぜひコメントください🙏 ↩︎

GitHubで編集を提案

Discussion