Djangoのフォームにサジェスト機能を追加

Djangoのフォームにサジェスト機能を追加

DjangoではForeignKeyやManyToManyFieldなどを使うと簡単にモデル同士を紐づけられます。加えて、モデルを使って簡単にフォームを作成できるというのもDjangoならではの特徴です。例えばForeignKeyを使ったモデルからフォームを作成するとドロップダウンリストが自動で作成されます。しかし、ForeignKeyやManyToManyFieldで紐づけられているテーブルのレコード数が増えてくると、非常に長いドロップダウンリストとなり、目的の項目を探すことが困難になってしまします。今回はこの問題の対策としてサジェストやオートコンプリートなどと呼ばれる機能を持ったテキストボックスに置き換える方法を考えていきます。

大きなテーブルと紐づくフォームの問題

はじめに具体例を挙げながらDjangoデフォルトのフォームの挙動を確認します。

モデルケース

展示会の出展企業を管理するアプリケーションを作成する想定をします。テーブルは3つ用意し、それぞれ「イベント情報」「企業情報」「ブース情報」を持ちます。ブース情報を持つBoothテーブルは、イベント情報のテーブルおよび企業情報のテーブルとForeignKeyで1対多のリレーションを持っています。

models.py

from django.db import models
import datetime

# ユーザーを管理しているモデルをインポート。今回は説明しません。
from django.contrib.auth.models import User

class Event(models.Model):
name = models.CharField('イベント名称',max_length=255,unique=True)
place = models.CharField('開催場所',max_length=255)
date_start = models.DateField('開始日')
date_end = models.DateField('最終日')
url = models.CharField('webサイト',max_length=255,null = True,blank = True)
created_at = models.DateTimeField(default=datetime.datetime.now) #イベントを追加した日(自動入力)
updated_at = models.DateTimeField(auto_now=True) #イベントを更新した日(自動入力)
created_by = models.ForeignKey(User,on_delete=models.SET_NULL,null=True,blank=True) #イベントを追加したユーザー

def __str__(self):
return self.name

class Company(models.Model):
name = models.CharField('会社名・出展者名',max_length=255,unique = True)
name_kana = models.CharField('かな',max_length=255, null=True,blank =True)
created_at = models.DateTimeField(default=datetime.datetime.now) #企業を追加した日
updated_at = models.DateTimeField(auto_now=True) #企業を更新した日
created_by = models.ForeignKey(User,on_delete=models.SET_NULL,null=True,blank=True) #企業を追加したユーザー

def __str__(self):
return self.name

class Booth(models.Model):
company = models.ForeignKey(Company,on_delete=models.CASCADE,null=True)
event = models.ForeignKey(Event,on_delete=models.CASCADE)
place = models.CharField('場所',max_length=255,null=True,blank=True) #ブースの場所
created_at = models.DateTimeField(default=datetime.datetime.now)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(User,on_delete=models.SET_NULL,null=True,blank=True)

def __str__(self):
if self.company:
#companyはnull=Trueにしているため場合分けが必要
value = self.company.name
else:
value = self.event.name
return value

forms.py

class EventResisterForm(forms.ModelForm):
class Meta:
model = Event
fields = ('name', 'place', 'date_start', 'date_end', 'url')

class CompanyResisterForm(forms.ModelForm):
class Meta:
model = Company
fields = ('name','name_kana')

class BoothResisterForm(forms.ModelForm):
class Meta:
model = Booth
fields = ('company', 'event')

Djangoの解説でよく取り上げられるforms.ModelFormを継承してフォームを作るとこのようになります。views.pyやテンプレートは省略しますが、このフォームを表示させると次のようになります。

models.pyの情報をもとにして、Djangoがいい感じにHTMLを作成してくれています。

データが多いと見づらい!

さて、例に挙げた「ブースを追加」のフォームではCompanyおよびEventがドロップダウンリストで表示されていましたが、 Companyを選択しようとすると、次のようになります。

データ数が少ないうちはなんとか目的の項目を探すこともできるかもしれませんが、これが数百、数千となると探しだすのも一苦労になってしまいます。これこそが、今回解決したい問題です。

「ドロップダウンリストで表示する」というのはユーザーが設定したわけではなく、「ForeignKeyだからドロップダウンリストにする」ということをDjangoが自動で判断しています。このようにform.pyまたはmodels.pyの情報からHTMLに書き出す時の形式をコントロールする機能をDjangoは持っていて、この機能を「ウィジェット(widget)」と言います。ウィジェットは独自に作成することも可能です。今回は使いにくいところを改善するために独自にウィジェットを作成することにします。

サジェスト機能を持つウィジェットにしよう

それでは、ウィジェットをカスタマイズしていきます。

完成イメージ

ウィジェットのポイントを簡単にまとめると次の通りです。

  • inputのtypeをtextに変更
  • javascriptを使って、文字が入力されたときに候補を検索
  • 候補をクリックしたらデータをフォームに入力

ManyToMany(多対多)の場合

ManyToManyの実装例はこちらのサイトが非常に参考になります。

特に補足などもないので、まずはこちらのページを読んでウィジェットの実装について理解を深めてください。

 ForeignKey(1対多)の場合

前述のサイトのコードをベースにForeignKey用のコードを作っていきます。変更のポイントは次の通りです。

  • 入力される文字が「正式名称(name)」と「かな(name_kana)」の両方で検索可能にする
  • ブースを登録すると同時にCompanyも登録できるようにする
  • サジェストの候補を選ぶと、Companyのデータがフォームに入力される

これらの仕様を実装するためにはforms.ModelFormを継承してつくるフォームでは若干力不足なので、面倒ですがforms.Formを継承してフォームを作り込んでいきます。記述がないファイルはベースにしているコードと同じだと考えてください。

suggest.html

<!-- ポイント1:name属性を追加する -->
<input type="text" id="id_{{ widget.name }}-input" name="{{ widget.name }}" data-target="{{ widget.name }}" autocomplete="off" {% include "django/forms/widgets/attrs.html" %}>


<div class="suggest-scroll">
    
<ul id="{{ widget.name }}-list" class="dropdown"></ul>

</div>



<div id="{{ widget.name }}-display">
    {% for group, options, index in widget.optgroups %}{% for option in options %}{% if option.selected %}
        {{ option.label }}
    {% endif %}{% endfor %}{% endfor %}
</div>


<!-- ポイント2:以下は使わないので削除orコメントアウト -->
<!--

<div id="{{ widget.name }}-values">
    {% for value in widget.value %}
        &lt;input type="hidden" name="{{ widget.name }}" value="{{ value }}"&gt;
    {% endfor %}
</div>

--->

HTMLのフォームでは、name属性を指定したデータが送信されます。ベースとしているコードではポイント2でコメントアウトしているhiddenタイプのinputにname=”{{widget.name}}”を記載してフォーム処理時にデータを送信しています。今回はユーザーが入力しているテキストボックスのデータを送信したいので、ポイント1の通りtextタイプのinputにname属性を設定します。

suggest.css

ul.dropdown {
    /* ポイント1 */
    overflow:auto;
    height:150px;

    display: none;
    list-style-type: none;
    padding: 5px;
    position: absolute;
    z-index: 1;
    background-color: #ddd;
}

ul.dropdown li {
    margin: 3px;
    cursor: pointer;
}

ul.dropdown li:hover {
    opacity: 0.5;
}

.suggest-item {
    cursor: pointer;
}

.suggest-item:hover {
    opacity: 0.5;
}

ポイント1のところに二行追加しています。これはサジェストの候補が多かった時にスクロール可能にするための設定です。

suggest.js

// const remove = e =&gt; {
//     // 選択済みのアイテムをクリックした際、つまり削除処理。
//     const suggestItem = e.target;
//     const targetName = suggestItem.dataset.target;
//     const pk = suggestItem.dataset.pk;
//     const displayElement = document.getElementById(`${targetName}-display`);
//     displayElement.removeChild(suggestItem);
//     const formValuesElement = document.getElementById(`${targetName}-values`);
//     const inputValueElement = document.querySelector(`input[name="${targetName}"][value="${pk}"]`);
//     formValuesElement.removeChild(inputValueElement);

// };

const createSuggestItem = element =&gt; {
    // サジェスト表示欄内で選択したアイテムの表示用データを作成する。
    const displayElement = document.getElementById(`${element.dataset.target}-display`);
    const suggestItem = document.createElement('p');
    suggestItem.dataset.pk = element.dataset.pk;
    suggestItem.dataset.target = element.dataset.target;
    suggestItem.textContent = element.textContent;
    suggestItem.classList.add('suggest-item');
    suggestItem.addEventListener('click', remove);
    displayElement.appendChild(suggestItem);
};

// const createFormValue = element =&gt; {
//     // サジェスト表示欄内で選択したアイテムの送信用データを作成する。
//     const targetName = element.dataset.target;
//     const formValuesElement = document.getElementById(`${targetName}-values`);
//     const inputHiddenElement = document.createElement('input');
//     inputHiddenElement.name = targetName;
//     inputHiddenElement.type = 'hidden';
//     inputHiddenElement.value = element.dataset.pk;
//     formValuesElement.appendChild(inputHiddenElement);
// };

const clickSuggest = e =&gt; {
    // サジェスト表示欄内のアイテムをクリックした際の処理
    const element = e.target;
    // const targetName = element.dataset.target;
    // const pk = element.dataset.pk;

    // // そのアイテムが選択済みじゃないかを確認する
    // if (!document.querySelector(`input[name="${targetName}"][value="${pk}"]`)) {
    //     //document.getElementById(`${element.dataset.target}-input`).value = '';
    //     createSuggestItem(element);
    //     createFormValue(element);
    // }

    // ポイント2:elementの中に86~90行目で設定したデータが入っている。
    // この中からデータを取りだす。
    document.getElementById(`id_${element.dataset.target}-input`).value = element.textContent;
    
    // ポイント3:カナを入力するところがある場合(主に企業名)はカナも入力する
    if (document.getElementById(`id_${element.dataset.target}_kana`) !== null) {
        if (typeof element.dataset.kana !== "undefined"){
            document.getElementById(`id_${element.dataset.target}_kana`).value = element.dataset.kana;
        }
    }
};


document.addEventListener('DOMContentLoaded', e =&gt; {
    for (const element of document.getElementsByClassName('suggest')) {
        const targetName = element.dataset.target;
        const suggestListElement = document.getElementById(`${targetName}-list`);

        // 全てのサジェスト入力欄に対しイベントを設定
        element.addEventListener('keyup', () =&gt; {
            const keyword = element.value;
            const url = `${element.dataset.url}?keyword=${keyword}`;
            if (keyword) {
                // 入力があるたびに、サーバーにそれを送信し、サジェスト候補を受け取る
                fetch(url)
                    .then(response =&gt; {
                        return response.json();
                    })
                    .then(response =&gt; {
                        const frag = document.createDocumentFragment();
                        suggestListElement.innerHTML = '';

                        // サジェスト候補を一つずつ取り出し、それを
&lt;li&gt;要素として作成
                        //
&lt;li&gt;要素をクリックした際のイベントも設定
                        for (const obj of response.object_list) {
                            const li = document.createElement('li');
                            li.textContent = obj.name;
                            li.dataset.pk = obj.pk;
                            li.dataset.target = targetName;
                            // ポイント1:detasetに「かな」を追加。views.pyのAPIも要変更
                            li.dataset.kana = obj.name_kana;
                            li.addEventListener('mousedown', clickSuggest);
                            frag.appendChild(li);
                        }

                        // サジェスト候補があればサジェスト表示欄に候補を追加し、display:block でサジェスト表示欄を見せる
                        if (frag.children.length !== 0) {
                            suggestListElement.appendChild(frag);
                            suggestListElement.style.display = 'block';

                        } else {
                            suggestListElement.style.display = 'none';
                        }

                    })
                    .catch(error =&gt; {
                        console.log(error);
                    });
            }
        });


        // 入力欄に対して、フォーカスが外れたらサジェスト表示欄を非表示にするよう設定
        element.addEventListener('blur', () =&gt; {
            suggestListElement.style.display = 'none';
        });
    }

    // 更新ページ等のように、ページ表示時に選択済みのデータがある場合
    // それをクリックすると消せるようにイベントを設定
    // for (const element of document.getElementsByClassName('suggest-item')) {
    //     element.addEventListener('click', remove);
    // }
});

コメントアウトしているところはForeignKeyでは使わないところです。

コードが長くデータの流れが分かりづらいので簡単に説明します。”suggest”クラスを持つ入力欄イベントリスナーを設定し、文字が入力されるたびに文字列をviews.pyに送信します(68~78行)。views.pyでは受け取ったデータから予測される候補を抽出し、JSON形式でjavascriptに返します(詳細後述)。javascriptではJSONデータを読み解きながらサジェストとして表示するリストを作成すると同時にdatasetとして裏でデータを格納しておきます(82~93行)。表示されたサジェストリストから項目を選択すると先ほど格納したデータを引き出してフォームの適切なところに出力します(50~59行)。

ポイント3の箇所は汎用性を持たせようとしてif文を入れ子にしていますが、確実にdatasetにデータが入っていること、フォームに入力欄があることが担保されていれば中の


document.getElementById(`id_${element.dataset.target}_kana`).value = element.dataset.kana;

だけ記載されていれば問題ありません。

views.py(1部抜粋)

# ポイント1:Qを使ってor検索を行う
from django.db.models import Q

def api_companies_get(request):
    keyword = request.GET.get('keyword')
    if keyword:
        company_list = [{'pk': company.pk, 
                        'name': str(company), 
                        'name_kana':str(company.name_kana)} 
                        for company in Company.objects.filter(Q(name__icontains=keyword)|Q(name_kana__icontains=keyword))]
    else:
        company_list = []
    return JsonResponse({'object_list': company_list})

javascriptから呼ばれる関数はこのようになっています。DjangoのfilterではQ()|Q()の形にすることでor検索ができることと、__icontains=’xx’の形にすることでxxを含むものを抽出できることがポイントです。
forms.py

class BoothResisterForm(forms.Form):
    event = forms.CharField(
        label='イベント',
        max_length = 255,
        required = True,
        widget = forms.HiddenInput
    )

    company = forms.CharField(
        label='企業・団体名',
        max_length = 255,
        required = True,
        widget = SuggestWidget(attrs={'data-url': reverse_lazy('schedule:api_companies_get')})
    )

    company_kana = forms.CharField(
        label='企業・団体名(かな)',
        max_length = 255,
        required = True,
    )

    place = forms.CharField(
        label='場所',
        max_length = 255,
        required = False,
    )

宣言通りforms.Formを継承するタイプのフォームで書き直しています。最初のモデルケースでは
EventResisterForm、CompanyResisterForm、BoothResisterFormの3つのフォームを用意していました。しかし、イベント・企業・ブースをそれぞれ登録しなければならないのはユーザービリティが悪いため、CompanyResisterFormはBoothResisterFormに統合してしまいます。つまり、フォームには企業・ブースの両方の登録に必要な項目を表示し、フォームが送信されてから内部でデータを分けて処理します。このような複雑な処理を行う場合はfoms.ModelFormを継承するフォームでは対応しきれません。

eventフィールドは説明は省きますが自動的に取得できるものとし、HiddenInputというウィジェットを設定しています。名前の通り、全く表示されない要素となります。すでに登録されている企業とブースを紐づけるだけならば「かな」のフィールドは必要ありませんが、未登録の企業が入力された場合にはブースと併せて企業も登録しするためcompany_kanaフィールドも用意しておきます。登録されている企業を入力する場合はこれまで説明してきたサジェスト機能によりcompany_kanaの入力欄は自動で埋まるため、ユーザーの手間にはなりません。

views.py(フォーム処理部分抜粋)

from .form import BoothResisterForm

def addBooth(request):
    if request.method == 'POST':
        form = BoothResisterForm(request.POST or None)

        # バリデーションはforms.Formでも使用可能
        if form.is_valid():

            #新規でブースをインスタンス化
            newBooth = Booth()

            # Companyが未登録の場合、先にセーブしておく
            if not Company.objects.filter(name=form.cleaned_data['company']).exists():
                newCompany = Company()
                newCompany.name = form.cleaned_data['company']
                newCompany.name_kana = form.cleaned_data['company_kana']
                if request.user.is_authenticated:
                    newCompany.created_by = request.user
                newCompany.save()

            # イベントと企業の組み合わせが同じブースがすでに存在する場合はエラー書き出して終了
            if Booth.objects.filter(event__name=form.cleaned_data['event'],company__name=form.cleaned_data['company']).exists():
                messages.info(request,'登録済みのブースです。')

            # 正常に登録される場合
            else:
                #必要なデータ入れていく
                newBooth.company = Company.objects.get(name=form.cleaned_data['company'])
                newBooth.event = Event.objects.get(name=form.cleaned_data['event'])
                newBooth.place = form.cleaned_data['place']
                if request.user.is_authenticated:
                    newBooth.created_by = request.user
                newBooth.save()
                messages.info(request,'ブースを追加しました。')
        else:
            messages.info(request,'ブースの追加に失敗しました。入力に誤りがあります。')
            
        return redirect('schedule:event',event_name=form.cleaned_data['event'])
    else:
        return redirect('schedule:index')

views.pyもforms.ModelFormを継承したときよりも長くなってしまいますが、例に挙げたような1つのフォームから2つのテーブルにデータを保存する場合などはforms.Formを継承する方が融通が利きます。

まとめ

情報が少ないDjangoのウィジェット機能のカスタマイズ方法について、ForeignKeyの場合を中心にまとめました。javascriptも登場してなかなか難しいですが、フォーム設計が行き詰まったときのヒントになるかと思います。