🎻

symfony/form + Select2で、別項目の選択状態に応じて選択肢が変化するフォームを実装する

2020/06/03に公開

やりたいこと

下図のフォームは、 カテゴリ で選択した値に応じて サブカテゴリ の選択肢が動的に変わる(選択されている カテゴリ 配下の サブカテゴリ しか表示されなくなる)ようになっています。

symfony/form + Select2 なフォームでこのような振る舞いを実装するにはどうすればいいかを説明します。

やり方

方針としては、ざっくり

  1. FormTypeの定義を工夫して、サブカテゴリの <select> タグと <option> タグに特定のclassや属性を振っておく
  2. フロントのJavaScriptでカテゴリの change イベントをハンドルして、対象でないサブカテゴリの <option>disabled にする
  3. Select2によってレンダリングされる disabled な選択肢を表示しないようにCSSを書く

という感じで実装できます。

以下に具体的なコードの例を書いていきます。

1. FormTypeの定義を工夫して、サブカテゴリの <select> タグと <option> タグに特定のclassや属性を振っておく

FormTypeは以下のようなコードになります。

class CategoryType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('category', ChoiceType::class, [
                'label' => 'カテゴリ',
                'choices' => [
                    'カテゴリ1' => 'カテゴリ1',
                    'カテゴリ2' => 'カテゴリ2',
                    'カテゴリ3' => 'カテゴリ3',
                ],
                'multiple' => false,
                'placeholder' => '',
                'attr' => [
                    'data-widget' => 'select2', // (EasyAdminBundleでSelect2を適用する場合はこれが必要)
                    'data-placeholder' => '選択してください',
                    'data-allow-clear' => true,
                    'class' => 'w-100 category-select',
                ],
            ])
            ->add('subCategory', ChoiceType::class, [
                'label' => 'サブカテゴリ',
                'choices' => [
                    'サブカテゴリ1-1' => 'サブカテゴリ1-1',
                    'サブカテゴリ1-2' => 'サブカテゴリ1-2',
                    'サブカテゴリ1-3' => 'サブカテゴリ1-3',
                    'サブカテゴリ2-1' => 'サブカテゴリ2-1',
                    'サブカテゴリ2-2' => 'サブカテゴリ2-2',
                    'サブカテゴリ2-3' => 'サブカテゴリ2-3',
                    'サブカテゴリ3-1' => 'サブカテゴリ3-1',
                    'サブカテゴリ3-2' => 'サブカテゴリ3-2',
                    'サブカテゴリ3-3' => 'サブカテゴリ3-3',
                ],
                'multiple' => false,
                'choice_attr' => function ($choice, $key, $value) {
                    switch (true) {
                        case preg_match('/^サブカテゴリ1-/', $value):
                            $category = 'カテゴリ1';
                            break;
                        case preg_match('/^サブカテゴリ2-/', $value):
                            $category = 'カテゴリ2';
                            break;
                        case preg_match('/^サブカテゴリ3-/', $value):
                        default:
                            $category = 'カテゴリ3';
                            break;
                    }
    
                    return [
                        'class' => 'subcategory-option',
                        'data-category' => $category,
                        'disabled' => true,
                    ];
                },
                'placeholder' => '',
                'attr' => [
                    'data-widget' => 'select2', // (EasyAdminBundleでSelect2を適用する場合はこれが必要)
                    'data-placeholder' => '選択してください',
                    'data-allow-clear' => true,
                    'class' => 'w-100 subcategory-select',
                ],
            ])
        ;
    }
}

ポイントは choice_attr を使って <option> タグに個別に

  • class="subcategory-option"
  • data-category="{親カテゴリ名}"
  • disabled="disabled"

を付与しているところです。(カテゴリが何も選択されていない初期状態ではサブカテゴリの選択肢はすべて非表示としたいので、すべて disabled でOK)

これにより、サブカテゴリの <select> タグは以下のような形でレンダリングされます。(説明に不要な属性など一部省略しています)

<select data-widget="select2" data-placeholder="選択してください" data-allow-clear="data-allow-clear" class="w-100 subcategory">
    <option value=""></option>
    <option value="サブカテゴリ1-1" class="subcategory-option" data-category="カテゴリ1" disabled="disabled">サブカテゴリ1-1</option>
    <option value="サブカテゴリ1-2" class="subcategory-option" data-category="カテゴリ1" disabled="disabled">サブカテゴリ1-2</option>
    <option value="サブカテゴリ1-3" class="subcategory-option" data-category="カテゴリ1" disabled="disabled">サブカテゴリ1-3</option>
    <option value="サブカテゴリ2-1" class="subcategory-option" data-category="カテゴリ2" disabled="disabled">サブカテゴリ2-1</option>
    <option value="サブカテゴリ2-2" class="subcategory-option" data-category="カテゴリ2" disabled="disabled">サブカテゴリ2-2</option>
    <option value="サブカテゴリ2-3" class="subcategory-option" data-category="カテゴリ2" disabled="disabled">サブカテゴリ2-3</option>
    <option value="サブカテゴリ3-1" class="subcategory-option" data-category="カテゴリ3" disabled="disabled">サブカテゴリ3-1</option>
    <option value="サブカテゴリ3-2" class="subcategory-option" data-category="カテゴリ3" disabled="disabled">サブカテゴリ3-2</option>
    <option value="サブカテゴリ3-3" class="subcategory-option" data-category="カテゴリ3" disabled="disabled">サブカテゴリ3-3</option>
</select>

もうこの時点で、あとはフロントのコードをちょっと書けば目的は果たせそうですね👍

2. フロントのJavaScriptでカテゴリの change イベントをハンドルして、対象でないサブカテゴリの <option>disabled にする

jQueryを使う例だと以下のようになります。

$('.category-select').on('change', function () {
    const category = $(this).val();
    const selectorForSubcategorySelect = '.subcategory-select';
    const selectorForSubcategoryOptionsToHide = '.subcategory-option:not([data-category="' + category + '"])';
    const selectorForSubcategoryOptionsToShow = '.subcategory-option[data-category="' + category + '"]';
    $(selectorForSubcategoryOptionsToShow).attr('disabled', false);
    $(selectorForSubcategoryOptionsToHide).attr('disabled', true);
    $(selectorForSubcategorySelect).val('').change(); // カテゴリが変わったらサブカテゴリは一度空欄に
}).change(); // 編集画面で初期値が入っているときにも適切な選択肢だけが表示されるように、一度changeイベントを発火させる

選択されたカテゴリの値に応じて、サブカテゴリの <option> タグそれぞれについて disabledtrue false を適切にセットして、対象のサブカテゴリ以外がすべて disabled になるようにしています。

また、 $(selectorForSelect).val('').change(); によって、カテゴリが変わる度にサブカテゴリの選択を毎回リセットするようにしています。

3. Select2によってレンダリングされる disabled な選択肢を表示しないようにCSSを書く

ここまでで、選択されているカテゴリ配下でないサブカテゴリの <option> タグに disabled がセットされるようになりました。

あとは、 disabled<option> タグに対してSelect2がレンダリングする選択肢のブロックをCSSで非表示にしてあげれば完成です。

.select2-container .select2-results__option[aria-disabled=true] {
  display: none;
}

「いちいち disabled 属性を使わなくても、 <option> タグを直接JavaScriptで非表示にしちゃえばよかったんじゃないの?」

と思った方もいるかもしれませんが、<option> タグそのものが display: none とかになっていても、Select2は特に気にせず選択肢をレンダリングしてしまいます。 <option> タグの状態をSelect2がレンダリングする選択肢と関連づける方法は、(多分) disabled 属性を使うしかありません。

参考

おまけ:FormTypeの定義の中にフロントのJavaScriptのコードも持たせたい場合

この振る舞い自体がフォーム定義の一部だと考えると、フロントのJavaScriptのコードもFormTypeに持たせたくなるかもしれません。

一応、

->add('category', ChoiceType::class, [
    'label' => 'カテゴリ',
    'choices' => [
        'カテゴリ1' => 'カテゴリ1',
        'カテゴリ2' => 'カテゴリ2',
        'カテゴリ3' => 'カテゴリ3',
    ],
    'multiple' => false,
    'placeholder' => '',
    'attr' => [
        'data-widget' => 'select2', // (EasyAdminBundleでSelect2を適用する場合はこれが必要)
        'data-placeholder' => '選択してください',
        'data-allow-clear' => true,
        'class' => 'w-100',
        'onchange' => <<<EOT
const category = $(this).val();
const subcategorySelectElement = $('#' + $(this).attr('id') + 'Detail');
const subcategoryOptionElementsToShow = subcategorySelectElement.find('.subcategory-option[data-category="' + category + '"]');
const subcategoryOptionElementsToHide = subcategorySelectElement.find('.subcategory-option:not([data-category="' + category + '"])');
subcategoryOptionElementsToShow.attr('disabled', false);
subcategoryOptionElementsToHide.attr('disabled', true);
subcategorySelectElement.val('').change(); // カテゴリが変わったらサブカテゴリは一度空欄に
EOT,
    ],
])

こんな感じで onchange 属性にまるっとJSのコードを書いてしまえ実現はできます。

が、コードの見通しの良さや全体としての可読性という意味でも、やはりフロントのコードはフロントのアセットとして分割したほうがいいと思います😅

ちなみに↑この方法だと「読み込み直後に1回だけ change イベントを発火する」ということができないので、あくまでFormTypeの中にコードを閉じ込める前提で強引にでもこれを解決するとしたら、

 ->add('category', ChoiceType::class, [
    'label' => 'カテゴリ',
    'choices' => [
        'カテゴリ1' => 'カテゴリ1',
        'カテゴリ2' => 'カテゴリ2',
        'カテゴリ3' => 'カテゴリ3',
    ],
    'multiple' => false,
    'placeholder' => '',
    'attr' => [
        'data-widget' => 'select2', // (EasyAdminBundleでSelect2を適用する場合はこれが必要)
        'data-placeholder' => '選択してください',
        'data-allow-clear' => true,
        'class' => 'w-100 category-select',
        'onchange' => <<<EOT1
const category = $(this).val();
const subcategorySelectElement = $('#' + $(this).attr('id') + 'Detail');
const subcategoryOptionElementsToShow = subcategorySelectElement.find('.subcategory-option[data-category="' + category + '"]');
const subcategoryOptionElementsToHide = subcategorySelectElement.find('.subcategory-option:not([data-category="' + category + '"])');
subcategoryOptionElementsToShow.attr('disabled', false);
subcategoryOptionElementsToHide.attr('disabled', true);
subcategorySelectElement.val('').change(); // カテゴリが変わったらサブカテゴリは一度空欄に
EOT1,
+       'help' => <<<EOT2
+ <script>$(function () { $('.category-select').change(); })</script>
+ EOT2,
+       'help_html' => true,
    ],
])

こんな感じで、唯一HTMLを渡せる help オプションを使って <script> タグをレンダリングしちゃうという荒技が考えられます。

が、もちろんこれもまったくおすすめしません笑

GitHubで編集を提案

Discussion