NARITO BLOG

Djangoで、タグのサジェスト機能つきフォーム(ウィジェットクラスの自作)

Python, Django, Bulma, HTML・CSS・JavaScript, Ajax,

概要

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

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

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

ウィジェットクラスを自作し、少し複雑なHTMLを出力するように実装するので、カスタムウィジェットを作りたい場合にも参考になるかもしれません。

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

中々に複雑なので、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,
    )


ウィジェットクラスを自作しています。

なぜ自作したかといいますと、forms.ModelMultipleChoiceFieldはデフォルトでforms.SelectMultipleを使い、HTMLではselect要素として出力されますが、今回はselectではなくもう少し複雑なHTMLを出力する必要があります。

具体的には、次のようなHTMLが必要です。

<!-- サジェストのための入力欄 -->
<input type="text" class="input" autocomplete="off">

<!-- 入力欄にくっついている、候補を表示するためのドロップダウン -->
<div class="dropdown" id="tags-dropdown">
    <div class="dropdown-menu">
        <div class="dropdown-content"></div>
    </div>
</div>

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

    <span class="tag is-primary" data-target="tags" data-pk="2">
        Django
        <button type="button" class="delete is-small"></button>
    </span>
</div>

<!-- 選択したタグをDjangoに送信するためのinput hidden の集まり -->
<div id="tags-values">
    <input type="hidden" name="tags" value="2">
    <input type="hidden" name="tags" value="1">
</div>

中々に複雑です。テンプレートに直接上のようなHTMLを書いてもいいのですが、こういったHTMLを出力する独自のウィジェットクラス...今回でいうSuggestTagWidgetを定義したほうが良いです。こうしておくと、テンプレートにて{{ form.tags }}と書いただけで上のようなHTMLが自動的に出力されるようになり、使いやすくなります。また、フォームセット等でもそのまま使えるようになります。

template_name = 'app/widgets/suggest_tag.html'とあるように、ウィジェットはテンプレートを使って実際のHTMLを出力します。これは後程作成していきます。

今回はforms.pyにウィジェットクラスを定義していますが、widgets.pyのようなファイルに定義するのが一般的です。

また、class Media:に、そのウィジェットで使いたいjs・cssファイルを指定することもできます。これをしておくと、{{ form.media }}としただけで必要なjs・cssファイルを自動的に読み込んでくれます。

suggest_tag.html

次のようにしておきます。

<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>

それなりに汎用的に作っています。今回はtagsというフィールドでのみサジェストのウィジェットが使われていますが、もしかしたらORでの検索のためにtag2のような同様にタグのサジェストを行うフィールドも増えるかもしれません。そういった場合でも干渉しないように作っています。

{{ widget.name }}は、フィールド名です。今回ならばtagsですね。キーワードの入力欄のidはtags-input、ドロップダウンはtags-dropdown、ドロップダウン内のサジェストリスト部分はtags-list、選択したタグを表示するエリアにはtags-display、フォームに送信するためのinput hiddenな要素を格納するtags-valuesというようなルールで、それぞれidをつけておきます。もちろん、フォームのフィールド名が違えばこれらは別のidになりますので、tags2のような似たようなフィールドができても干渉せずに操作ができるようになります。

data-targetはフィールド名...tagsのような文字列が入ります。クリック等のイベント時にこの値を読み、これがtagsフィールドの部品なのかtags2フィールドの部品なのかを識別します。

data-urlは、Ajaxを使ってこのURLに問い合わせ、候補となるタグの一覧を取得します。

{% for group, options, index in widget.optgroups %}{% for option in options %}の部分ですが、ちょっと難しいです。イメージしやすくするために通常のforms.SelectMultiple(select要素のやつ)で説明すると、次のようにしてoption...選択肢部分を作成しています。

{% for group, options, index in widget.optgroups %}
    {% for option in options %}
        <option value="{{ option.value }}" {% if option.selected %}selected{% endif %}>{{ option.label }}</option>
    {% endfor %}
{% endfor %}

{% if option.selected %}のようにして、そのタグが選択済みかを判断できます。バリデーションに失敗した後の再表示や、フォームの入力内容を次ページにも引き継ぐ場合は選択済みにしておく必要がありますので、そういった際に使います。ただ今回はselect要素の場合と違い、全ての選択肢を作る必要はありません。選択肢はAjaxで問い合わせて、ドロップダウン内に表示するからです。なので、選択済みじゃないタグについては何もしません。

{{ option.value }}でタグのpkが、{{ option.label }}でタグの名前...上の画像の例でいうとPythonとかDjangoです。

{% for value in widget.value %}は、選択済みタグのpkのリストだけを取り出せます。上の{% for option in options %}と似ていますが、pkだけ欲しい場合はこちらのほうが楽です。

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

ちょっと長いです。

// バッジの×ボタンをクリックされたときの処理
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 PosList(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()ではそのフォームを再利用しています。self.formの部分ですね。

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

タグのサジェスト部分も、{{ form.tags }}だけでOKです。

{% 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 }}
                    <button type="submit" class="button is-primary">検索</button>
                </form>
            </aside>
        </div>
    </div>

{% endblock %}