Narito Blog

Djangoで、サジェスト機能付きフォームを作る

- PythonDjangoBulmaJavaScriptAjax

概要

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

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

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

ソースコードのダウンロード

Githubに、ソースコードを置いています。

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 SuggestTagWidget(forms.SelectMultiple):
    template_name = 'app/widgets/suggest_tag.html'

    class Media:
        js = ['app/suggest_tag.js']


class TagSearchForm(forms.Form):
    tags = forms.ModelMultipleChoiceField(
        label='検索タグ',
        required=False,
        queryset=Tag.objects,
        widget=SuggestTagWidget,
    )


SuggestTagWidget(forms.SelectMultiple)という、独自のウィジェットクラスを定義しています。通常のforms.SelectMultipleウィジェットではシンプルな<select>要素ができますが、今回はもう少し凝ったHTMLを作る必要があります。具体的には、次のようなHTMLです。

<!-- サジェストキーワード入力欄 -->
<input type="text" id="tags-input" class="suggest input" data-url="/ajax/get/tags/" data-target="tags" autocomplete="off">

<!-- サジェスト候補の表示欄 -->
<div class="dropdown" id="tags-dropdown">
    <div class="dropdown-menu" id="dropdown-menu">
        <div class="dropdown-content" id="tags-list"><a data-pk="1" data-target="tags" class="dropdown-item">Python</a></div>
    </div>
</div>

<!-- 選択済みのタグを表示するエリア -->
<div id="tags-display">
    <span data-pk="1" data-target="tags" class="tag is-primary">Python<button type="button" class="delete is-small"></button></span>
</div>

<!-- 実際に送信するための、input type="hidden"なデータ置き場 -->
<div id="tags-values">    
    <input name="tags" type="hidden" value="1">
</div>

このようなHTMLを作る必要があるので、独自のウィジェットクラスを作成します。フォームのウィジェットというのは、それがどういうHTMLになるかをテンプレートで指定します。

class Media:部分ですが、これはフォームに紐づくcssやjsファイルを定義できる場所です。{{ form.media }}としただけで、それらのファイルが読み込まれるようになります。便利です。

widgets/suggest_tag.html

ウィジェットのテンプレートなので、widgetsのようなディレクトリに置いておくほうが捗ります。

中身は次のようにしておきましょう。

<input type="text" id="{{ widget.name }}-input" class="suggest input" data-url="{% url 'app:ajax_get_tags' %}" data-target="{{ widget.name }}" autocomplete="off">

<div class="dropdown" id="{{ widget.name }}-dropdown">
    <div class="dropdown-menu" id="dropdown-menu">
        <div class="dropdown-content" id="{{ widget.name }}-list"></div>
    </div>
</div>

<div id="{{ widget.name }}-display">
    {% for group, options, index in widget.optgroups %}
        {% for option in options %}
            {% if option.selected %}
                <span class="tag is-primary" data-target="{{ widget.name }}"
                      data-pk="{{ option.value }}">{{ option.label }}
                    <button type="button" class="delete is-small"></button></span>
            {% endif %}
        {% endfor %}
    {% endfor %}
</div>

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

それなりに汎用的に作っています。例えばタグでOR検索をしたいということで、TagSearchFormtags2とかtags3みたいな似たフィールドができたとしても、無事に動作するようになっています。また、フォームセットでこのウィジェットを利用する場合でも動作します。

templatesをアプリケーション内に置いていない場合

アプリケーションディレクトリ内にtemplatesを作り、そこにテンプレートを配置した場合はこのままで動作します。

しかし、アプリケーション内にtemplatesを作っていないケース...settings.pyにて'DIRS': [os.path.join(BASE_DIR, 'templates')]のようにして、そこで全てのテンプレートを管理している場合は、次のコードをsettings.pyに追記しておいてください。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.forms',  # これを足す
    'app.apps.AppConfig',  # マイアプリケーション
]

# これも足す
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'

なぜこれが必要かは、Django、テンプレートの探索順序で説明しています。

suggest_tag.js

肝心のJavaScriptは、次のようになっています。コメントを多くしておきました。

// バッジの×ボタンをクリックされたときの処理
const remove = e => {
    const badgeElement = e.target.parentNode;
    const targetName = badgeElement.dataset.target;
    const pk = badgeElement.dataset.pk;

    // <span...のバッジを削除する
    const displayElement = document.getElementById(`${targetName}-display`);
    displayElement.removeChild(badgeElement);

    // <input type="hidden"...を削除する
    const formValuesElement = document.getElementById(`${targetName}-values`);
    const tagValueElement = document.querySelector(`input[name="${targetName}"][value="${pk}"]`);
    formValuesElement.removeChild(tagValueElement);

};

// <span class="tag is-primary" data-target="tags" data-pk="1">Python <button type="button" class="delete is-small"></button></span>
// のような要素を作成する。選択したタグをカッコよく表示するバッジ。
const createBadge = element => {
    const displayElement = document.getElementById(`${element.dataset.target}-display`);

    // <span...部分の作成
    const badge = document.createElement('span');
    badge.dataset.pk = element.dataset.pk;
    badge.dataset.target = element.dataset.target;
    badge.textContent = element.textContent;
    badge.classList.add('tag', 'is-primary');

    // <button...部分の作成
    const closeButton = document.createElement('button');
    closeButton.type = 'button';
    closeButton.classList.add('delete', 'is-small');
    closeButton.addEventListener('click', remove);

    // buttonをspanに追加し、それをバッジ表示エリアに追加
    badge.appendChild(closeButton);
    displayElement.appendChild(badge);
};


// 送信するための<input type="hidden" name="tags" value="1"> のような要素を作成
const createFormValue = element => {
    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 => {
    const element = e.target;
    const targetName = element.dataset.target;
    const pk = element.dataset.pk;

    // 既に追加したタグじゃなければ、追加する。
    // <input type="hidden" name="tags" value="1">のような要素がなければ、それはまだ追加していないと判断
    if (!document.querySelector(`input[name="${targetName}"][value="${pk}"]`)) {
        document.getElementById(`${element.dataset.target}-input`).value = '';  // キーワード入力欄をクリア
        createBadge(element);  // バッジの作成
        createFormValue(element);  // 送信するためのinput type=hidden な要素の作成
    }
};


// タグのキーワード入力欄に何か入力されたら、そのキーワードから始まるタグの一覧をAjaxで取得し
// それをドロップダウン内に表示する。
for (const element of document.getElementsByClassName('suggest')) {
    const targetName = element.dataset.target;
    const dropdown = document.getElementById(`${targetName}-dropdown`);
    const suggestListElement = document.getElementById(`${targetName}-list`);

    element.addEventListener('keyup', () => {
        const keyword = element.value;
        const url = `${element.dataset.url}?keyword=${keyword}`;
        if (keyword) {
            fetch(url)
                .then(response => {
                    return response.json();
                })
                .then(response => {
                    let isFound = false;
                    const frag = document.createDocumentFragment();
                    suggestListElement.innerHTML = '';  // ドロップダウン内の候補リストを空にする(前回の候補が残っているかもなので)
                    for (const tag of response.tag_list) {
                        // <a class="dropdown-item" data-pk="1" data-target="tags">Python</a>のような要素を作る
                        const item = document.createElement('a');
                        item.textContent = tag.name;
                        item.dataset.pk = tag.pk;
                        item.dataset.target = targetName;
                        item.classList.add('dropdown-item');

                        item.addEventListener('mousedown', clickSuggest);
                        frag.appendChild(item);
                        isFound = true;
                    }

                    // 1つでも候補があれば、ドロップダウンを表示し、今まで作ったa要素を候補リストに追加する。
                    if (isFound) {
                        dropdown.classList.add('is-active');
                        suggestListElement.appendChild(frag);

                    // 候補がなければ、ドロップダウンを非表示にする。
                    } else {
                        dropdown.classList.remove('is-active');
                    }

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

    // キーワード入力欄からフォーカスが外れたら、ドロップダウンを非表示にする
    element.addEventListener('blur', () => {
        dropdown.classList.remove('is-active');
    });
}


// バッジの×ボタンを押すと、remove関数が呼ばれるように設定。
// 最初から表示されている、選択済みタグを削除するための処理です。
for (const element of document.getElementsByClassName('delete')) {
    element.addEventListener('click', remove);
}

views.py

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

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


class PostList(generic.ListView):
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['form'] = self.form
        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__icontains=keyword)]
    else:
        tag_list = []
    return JsonResponse({'tag_list': tag_list})

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

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

base.html

Bulmaを使っています。

<!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 %}
</body>
</html>

post_list.html

ウィジェットクラスを自作したおかげで、テンプレートの検索欄部分はシンプルになっています。

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

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

            <!-- 右側、検索フォーム -->
            <aside class="column is-3">
                <h2 class="title">記事の検索</h2>
                <form action="" method="GET" id="form1">
                    <div class="field">
                        <label class="label" for="{{ form.tags.id_for_label }}">{{ form.tags.label }}</label>
                        <div class="control">
                            {{ form.tags }}
                        </div>
                    </div>
                    {{ form.media }}<!-- フォームに紐づくcss・jsファイルの読み込み -->
                    <button type="submit" class="button is-primary">検索</button>
                </form>
            </aside>
        </div>
    </div>

{% endblock %}