Djangoで、タグのサジェスト機能つきフォーム

Python Django Bulma JavaScript Ajax


概要

DjangoとBulma,そしてJavaScriptを使い、タグのサジェスト機能のついたフォームを実装していきます。

どういうことがというと、次のような動作のするやつです。 1

Django、選択した親カテゴリに合わせて子カテゴリを表示と同様に、こちらも便利な機能です。

models.py

記事と、ManyToManyで紐づくタグがあります。

from django.db import models


class Tag(models.Model):
    name = models.CharField('カテゴリ名', max_length=255)

    def __str__(self):
        return self.name


class Post(models.Model):
    title = models.CharField('タイトル', max_length=255)
    tags = models.ManyToManyField(Tag, verbose_name='タグ', blank=True)

    def __str__(self):
        return self.title

forms.py

次のようなフォームを用意しておきます。

from django import forms
from .models import Tag


class TagSearchForm(forms.Form):
    tags = forms.ModelMultipleChoiceField(
        required=False,
        queryset=Tag.objects,
    )

フォームの役割は大雑把にわけると、input等のHTML生成と、送信されたデータをPythonの適切な型に変換したり、入力データのチェック等の扱いやすくするためにあります。

今回はHTMLの生成自体は殆ど手作業で行い、送信されたデータをモデルインスタンスに自動変換してもらうためにフォームを利用します。

forms.ModelMultipleChoiceFieldはデフォルトでselect要素を作成しますが、別にselect要素じゃなきゃ値を受け取れないってことはありません。正しく書けばinput type="hidden" な要素からでも値を受け取ることができます。その正しく手作業で書くのが面倒なので普段はHTML生成もフォームにやってもらうのですが、今回はそれだと不都合があるので、HTML生成部分は手作業でやります。

カスタムウィジェットを頑張って作ることも考えましたが、今回は見送りました。

views.py

記事の一覧表示のビューと、そのキーワードから始まるタグをJsonResponseで返すビューの2つを用意しておきます。

from django.http import JsonResponse
from django.views import generic
from .forms import TagSearchForm
from .models import Tag, Post


class PosList(generic.ListView):
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        if hasattr(self, 'form'):
            context['form'] = self.form
        else:
            context['form'] = TagSearchForm()
        return context

    def get_queryset(self):
        queryset = super().get_queryset()
        form = self.form = TagSearchForm(self.request.GET)
        form.is_valid()
        tags = form.cleaned_data.get('tags')
        if tags:
            for tag in tags:
                queryset = queryset.filter(tags=tag)
        return queryset.distinct()  # ManyToManyのfilterではデータが重複することがあり、それを削除


def ajax_get_tags(request):
    keyword = request.GET.get('keyword')
    if keyword:
        tag_list = [{'pk': tag.pk, 'name': tag.name} for tag in Tag.objects.filter(name__istartswith=keyword)]
    else:
        tag_list = []
    return JsonResponse({'tag_list': tag_list})

ListViewをベースにしているので、検索フォームをテンプレートへ渡すためにget_context_data()を上書きし、送信されたタグの値で絞り込み検索をするためにget_queryset()を上書きしています。

get_queryset()で既にフォームを作成しているので、get_context_data()ではそのフォームを再利用しています。

base.html

Bulmaを使っています。勉強もかねて、JavaScript部分は手作りしていきます。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>タグのサジェスト機能つきフォーム</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
</head>
<body>
{% block content %}{% endblock %}
{% block extrajs %}{% endblock %}
</body>
</html>

{% block extrajs %}{% endblock %}部分は、次のテンプレートで上書きしていきます。

post_list.html

今回はJavaScriptのコードが多いので、長くなっています。

{% extends 'app/base.html' %}

{% block content %}
    <div class="container section">
        <div class="columns">
            <!-- 左側、記事の一覧 -->
            <div class="column is-9">
                {% for post in post_list %}
                    <div class="box">
                        <p>{{ post.title }}</p>
                        <div class="tags">
                            {% for tag in post.tags.all %}<span class="tag is-primary">{{ tag }}</span>{% endfor %}
                        </div>
                    </div>
                {% endfor %}
            </div>

            <!-- 右側、検索フォーム -->
            <div class="column is-3">
                <form action="" method="GET" id="form1">
                    <div class="field">
                        <label class="label">検索タグ(AND)</label>
                        <!-- 選択したタグがカッコよく表示されるエリア -->
                        <div class="tags" id="tag-display-area"></div>
                    </div>

                    <div class="field">
                        <label class="label" for="tag-name">検索するタグの追加</label>
                        <input type="text" class="input" autocomplete="off" id="tag-name">
                        <!-- タグがサジェストされるエリア -->
                        <div class="dropdown" id="dropdown">
                            <div class="dropdown-menu" id="dropdown-menu">
                                <div class="dropdown-content" id="suggest-tag-list"></div>
                            </div>
                        </div>
                    </div>

                    <button type="submit" class="button is-primary">検索</button>
                </form>
            </div>
        </div>
    </div>


{% endblock %}

{% block extrajs %}
    <script>
        const formElement = document.getElementById('form1');
        const tagNameInputElement = document.getElementById('tag-name');
        const dropdown = document.getElementById('dropdown');
        const tagDisplayArea = document.getElementById('tag-display-area');
        const suggestTagList = document.getElementById('suggest-tag-list');

        const removeTag = e => {
            const badgeElement = e.target.parentElement;
            const tagValueElement = document.getElementById(badgeElement.dataset.pk);
            formElement.removeChild(tagValueElement);
            tagDisplayArea.removeChild(badgeElement);
        };

        const createBadge = (text, pk) => {
            const badge = document.createElement('span');
            const deleteButton = document.createElement('button');
            deleteButton.classList.add('delete', 'is-small');
            deleteButton.type = 'button';
            deleteButton.addEventListener('click', removeTag);
            badge.dataset.pk = pk;
            badge.textContent = text;
            badge.classList.add('tag', 'is-primary');
            badge.appendChild(deleteButton);
            tagDisplayArea.appendChild(badge);
        };

        const createItem = (text, pk) => {
            const tagValueElement = document.createElement('input');
            createBadge(text, pk);
            tagValueElement.name = 'tags';
            tagValueElement.type = 'hidden';
            tagValueElement.value = pk;
            tagValueElement.id = pk;
            formElement.appendChild(tagValueElement);
        };

        const clickSuggestTag = e => {
            tagNameInputElement.value = '';
            const item = e.target;
            if (!document.getElementById(item.dataset.pk)) {
                createItem(item.textContent, item.dataset.pk);
            }
        };

        tagNameInputElement.addEventListener('keyup', () => {
            const keyword = tagNameInputElement.value;
            if (keyword) {
                const url = `{% url 'app:ajax_get_tags' %}?keyword=${keyword}`;
                fetch(url)
                    .then(response => {
                        return response.json();
                    })
                    .then(response => {
                        let isFound = false;
                        const frag = document.createDocumentFragment();
                        suggestTagList.innerHTML = '';
                        for (const tag of response.tag_list) {
                            const item = document.createElement('a');
                            item.textContent = tag.name;
                            item.classList.add('dropdown-item');
                            item.dataset.pk = tag.pk;
                            item.addEventListener('mousedown', clickSuggestTag);
                            frag.appendChild(item);
                            isFound = true;
                        }

                        if (isFound) {
                            suggestTagList.appendChild(frag);
                            dropdown.classList.add('is-active');

                        } else {
                            dropdown.classList.remove('is-active');
                        }

                    })
                    .catch(error => {
                        console.log(error);
                    });
            }
        });

        tagNameInputElement.addEventListener('blur', () => {
            dropdown.classList.remove('is-active');
        });

        {% for tag in form.cleaned_data.tags %}
            createItem('{{ tag.name }}', '{{ tag.pk }}');
        {% endfor %}

    </script>
{% endblock %}

タグのサジェスト表示

pと押すと、Pythonのようなpから始まるタグをサジェストする部分です。画面でいうと、次の部分。

サジェスト部分

        tagNameInputElement.addEventListener('keyup', () => {
            const keyword = tagNameInputElement.value;
            if (keyword) {
                const url = `{% url 'app:ajax_get_tags' %}?keyword=${keyword}`;
                fetch(url)
                    .then(response => {
                        return response.json();
                    })
                    .then(response => {
                        let isFound = false;
                        const frag = document.createDocumentFragment();
                        suggestTagList.innerHTML = '';  // 一旦サジェスト部分を空にする。
                        for (const tag of response.tag_list) {
                            const item = document.createElement('a');
                            item.textContent = tag.name;
                            item.classList.add('dropdown-item');
                            item.dataset.pk = tag.pk;
                            item.addEventListener('mousedown', clickSuggestTag);
                            frag.appendChild(item);
                            isFound = true;
                        }

                        if (isFound) {
                            // 候補が一つでもあれば、候補をサジェスト部分に追加し、表示
                            suggestTagList.appendChild(frag);
                            dropdown.classList.add('is-active');

                        } else {
                            // 候補が一つもなければ、非表示
                            dropdown.classList.remove('is-active');
                        }

                    })
                    .catch(error => {
                        console.log(error);
                    });
            }
        });

keyupイベントは、キーを押して、そのキーが上がった瞬間に動作するイベントです。なので、入力するごとにこのイベントが発生します。

const keyword = tagNameInputElement.value;として入力されている内容を取得し、/ajax/get/tags/?keyword=pといったURLを作成し、AjaxでGETリクエストを送ります。 URLはビューのajax_get_tagsと紐づけています。

Ajaxでのデータ送信については、Django、選択した親カテゴリに合わせて子カテゴリを表示Djangoで、Ajaxでも紹介しています。

ビューからは

{
    "tag_list": [
        {
            "pk": "1",
            "name": "Python"
        }
    ]
}

のようなJSONが帰り、.then(response => {以降が呼ばれます。

for (const tag of response.tag_list) {で候補タグを一つ一つ取得し、<a class="dropdown-item" data-pk="1">Python</a>という要素を作成し、それが押された際にJavaScriptのclickSuggestTag関数が呼ばれるようにしておきます。dropdown-itemというのは、Bulmaのドロップダウンに指定するcssです。

最終的にはpkの値を送信する必要があるので、候補をクリックしたときにそれを<input type="hidden" value="pkの値" id="pkの値"として作るのですが、そのpkの値を取得するためにdata-pkという属性として指定しています。それがitem.dataset.pk = tag.pk;です。

const frag = document.createDocumentFragment();ですが、for文内で実際の要素にappendChild()すると毎回描画更新され、パフォーマンス的にがよろしくないです。こういったときのために、document.createDocumentFragment()で作成したものにappendChild()しておき、全て済んだらそれを実際の要素に追加することで、描画の更新が1度だけになります。

サジェスト部分の表示・非表示はis-activeをつけたりけしたりするだけで良いのですが、タグ名の入力欄からフォーカスが外れたら非表示にするべきですね。それをしているのが、次の部分です。blurイベントですね。

        tagNameInputElement.addEventListener('blur', () => {
            dropdown.classList.remove('is-active');
        });

サジェストクリック時

次はサジェストをクリックされたときの処理です。次の画像のように、選択されたタグがカッコよく表示される部分ですね。

サジェストクリック時

サジェストを作成した際、それぞれの要素がクリックされるとclickSuggestTagという関数が呼ばれるようにしました。

        const clickSuggestTag = e => {
            tagNameInputElement.value = '';  // 候補タグ名の入力欄を消去する
            const item = e.target;
            if (!document.getElementById(item.dataset.pk)) {
                createItem(item.textContent, item.dataset.pk);
            }
        };

const item = e.target;として、押された要素...つまり<a class="dropdown-item" data-pk="1">Python</a>を取得します。if (!document.getElementById(item.dataset.pk)) {の部分は後でわかるのですが、既に検索タグとして追加されてなければ、という指定です。検索タグに同じものが何個もあるのは変ですからね。

そして、createItem("Python", "1")のようにして呼び出します。createItem()は、タグの表示テキストとタグのpkを受け取り、それを検索タグとしてカッコよく表示するのと、Djangoに送信するために必要な<input type="hidden" value="pkの値" id="pkの値"要素を作成する役割です。

        const createItem = (text, pk) => {
            const tagValueElement = document.createElement('input');
            createBadge(text, pk);
            tagValueElement.name = 'tags';
            tagValueElement.type = 'hidden';
            tagValueElement.value = pk;
            tagValueElement.id = pk;
            formElement.appendChild(tagValueElement);
        };

createBadge(text, pk);は、検索タグの部分にカッコよくタグを表示する部分です。それ以降は、<input type="hidden" value="pkの値" id="pkの値"といった要素を生成するための記述です。これはDjangoにデータを送信するために必要な要素です。

        const createBadge = (text, pk) => {
            const badge = document.createElement('span');
            const deleteButton = document.createElement('button');
            deleteButton.classList.add('delete', 'is-small');
            deleteButton.type = 'button';
            deleteButton.addEventListener('click', removeTag);
            badge.dataset.pk = pk;
            badge.textContent = text;
            badge.classList.add('tag', 'is-primary');
            badge.appendChild(deleteButton);
            tagDisplayArea.appendChild(badge);
        };

createBadge()では、次のような要素を作成します。

`<span class="tag is-primary" data-pk="1">
    Python
    <button type="button" class="delete is-small"></button>
</span>`

次の部分ですね。BulmaのTagに、デリートボタンを組み合わせたものです。

タグそのもの

そして、×部分を押すとremoveTag()を呼び出すようにしています。data-pk=1のようにここでも定義していますが、これは×を押して検索タグを削除する際に<input type="hidden" value="pkの値" id="pkの値"も一緒に削除したいので、ここにも定義しています。

検索タグの削除時

×部分を押した際の処理です。

        const removeTag = e => {
            const badgeElement = e.target.parentElement;
            const tagValueElement = document.getElementById(badgeElement.dataset.pk);
            formElement.removeChild(tagValueElement);  // input type=hidden value=pk id=pk を削除する
            tagDisplayArea.removeChild(badgeElement);  // 検索タグ内のspan を削除する
        };

const badgeElement = e.target.parentElement;で、先ほど作成したspan要素が取得できます。span要素にはdata-pkという属性を作っておきましたね。それを使ってconst tagValueElement = document.getElementById(badgeElement.dataset.pk);とし、input type="hidden"な要素を取得できます。後はそれを削除して、カッコいいタグ部分も削除するだけです。

フォームを引き継いだ際の初期値設定

検索フォームでは、検索後のページに、前ページで入力したデータが残っているのが一般的です。PythonとDjangoで検索したら、次ページにもPythonとDjangoが検索タグとして表示済みにしたいことでしょう。

それをしているのが次の部分です。

        {% for tag in form.cleaned_data.tags %}
            createItem('{{ tag.name }}', '{{ tag.pk }}');
        {% endfor %}

form.is_valid()の段階で、form.cleaned_dataには送信されたデータが入っています。tags = forms.ModelMultipleChoiceFieldならば、cleaned_data['tags']にはTagモデルインスタンスが詰まっていますので、上のようにforで取り出せます。

<input type="hidden"...<span class="tags is-primary"...の2つを作る必要があるのですが、それらを作るJavaScriptのcreateItem()関数があるのでそれを呼び出します。

複数フィールド、フォームに対応する

例えばですが、次のようなフォームならばどうでしょうか。

class TagSearchForm(forms.Form):
    tags = forms.ModelMultipleChoiceField(
        required=False,
        queryset=Tag.objects,
    )

    tags2 = forms.ModelMultipleChoiceField(
        required=False,
        queryset=Tag.objects,
    )

両方ともタグのサジェストを行いたいかもしれません。もしかしたら、フォームセットなどでの利用もあり得るでしょう。

そのような場合に備えた、少し汎用的なものも作りました。JavaScriptのクラス構文で作っています。

        class SuggestManager {
            constructor(formId, tagDisplayAreaId, tagNameInputId, dropdownId, suggestTagListId, fieldName) {
                this.formElement = document.getElementById(formId);
                this.tagDisplayArea = document.getElementById(tagDisplayAreaId);
                this.tagNameInputElement = document.getElementById(tagNameInputId);
                this.dropdownElement = document.getElementById(dropdownId);
                this.suggestTagList = document.getElementById(suggestTagListId);
                this.formId = formId;
                this.fieldName = fieldName;
                this.createEvent();
            }

            getInputId(pk) {
                return `${this.formId}-${this.fieldName}-${pk}`;
            }

            removeTag(e) {
                const badgeElement = e.target.parentElement;
                const tagValueElement = document.getElementById(this.getInputId(badgeElement.dataset.pk));
                this.formElement.removeChild(tagValueElement);
                this.tagDisplayArea.removeChild(badgeElement);
            }

            createBadge(text, pk) {
                const badge = document.createElement('span');
                const deleteButton = document.createElement('button');
                deleteButton.classList.add('delete', 'is-small');
                deleteButton.type = 'button';
                deleteButton.addEventListener('click', this.removeTag.bind(this));
                badge.dataset.pk = pk;
                badge.textContent = text;
                badge.classList.add('tag', 'is-primary');
                badge.appendChild(deleteButton);
                this.tagDisplayArea.appendChild(badge);
            }

            createItem(text, pk) {
                const tagValueElement = document.createElement('input');
                this.createBadge(text, pk);
                tagValueElement.name = this.fieldName;
                tagValueElement.type = 'hidden';
                tagValueElement.value = pk;
                tagValueElement.id = this.getInputId(pk);
                this.formElement.appendChild(tagValueElement);
            };

            clickSuggestTag(e) {
                this.tagNameInputElement.value = '';
                const item = e.target;
                if (!document.getElementById(this.getInputId(item.dataset.pk))) {
                    this.createItem(item.textContent, item.dataset.pk);
                }
            }

            createEvent() {
                this.tagNameInputElement.addEventListener('keyup', this.getSuggest.bind(this));
                this.tagNameInputElement.addEventListener('blur', () => {
                    this.dropdownElement.classList.remove('is-active');
                });
            }

            getSuggest() {
                const keyword = this.tagNameInputElement.value;
                if (keyword) {
                    const url = `{% url 'app:ajax_get_tags' %}?keyword=${keyword}`;
                    fetch(url)
                        .then(response => {
                            return response.json();
                        })
                        .then(response => {
                            let isFound = false;
                            const frag = document.createDocumentFragment();
                            this.suggestTagList.innerHTML = '';
                            for (const tag of response.tag_list) {
                                const item = document.createElement('a');
                                item.textContent = tag.name;
                                item.classList.add('dropdown-item');
                                item.dataset.pk = tag.pk;
                                item.addEventListener('mousedown', this.clickSuggestTag.bind(this));
                                frag.appendChild(item);
                                isFound = true;
                            }

                            if (isFound) {
                                this.suggestTagList.appendChild(frag);
                                this.dropdownElement.classList.add('is-active');

                            } else {
                                this.dropdownElement.classList.remove('is-active');
                            }

                        })
                        .catch(error => {
                            console.log(error);
                        });
                }
            }
        }

        // 1つめのサジェスト部分
        const manager1 = new SuggestManager(
            // 各欄のidを渡す
            'form1',
            'tag-display-area',
            'tag-name',
            'dropdown',
            'suggest-tag-list',
            '{{ form.tags.name }}'  // これはフォームのフィールド名で、送信するinput nameの値。被ったら面倒なため。
        );
        // 1つめのサジェスト部分に初期値
        {% for tag in form.cleaned_data.tags %}
            manager1.createItem('{{ tag.name }}', '{{ tag.pk }}');
        {% endfor %}

        // 2つめのサジェスト部分
        const manager2 = new SuggestManager(
            'form1',
            'tag-display-area2',
            'tag-name2',
            'dropdown2',
            'suggest-tag-list2',
            '{{ form.tags2.name }}'
        );
        // 2つめのサジェスト部分に初期値
        {% for tag in form.cleaned_data.tags2 %}
            manager2.createItem('{{ tag.name }}', '{{ tag.pk }}');
        {% endfor %}

記事にコメントする