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

2018-12-30 / PythonDjangoBulmaJavaScriptAjaxDjangoカスタムウィジェット

概要

DjangoとJavaScriptを使い、入力内容に応じてデータをサジェストするフォームを作成していきます。

具体的には、記事に関連記事のような欄があり、記事作成時に関連記事をサジェストしてくれるものを作成していきます。

次のような動作のするやつです。
1

管理画面でも同様に表示されます。 2

もちろん、更新時には既に指定している関連記事が表示されて、それを消したり、新しく足したりできます。
3

記事が100件とか200件とかになってくると、関連記事を探すのにも苦労します。記事に限らずカテゴリやタグも数が多ければそうです。セレクトボックスやチェックボックスでの全件表示だと幅を取りすぎたり、パフォーマンス的に厳しくなる場合もあります。

そういった場合にはこのウィジェットが役立ちます。覚えておくと、いつかきっと役に立つでしょう。

べた書きで実装しても良いのですが、こういったのはDjangoのカスタムウィジェットとして定義しておく便利です。

ソースコードをGithubに置いているので、試したい方はクローンしてください。

カスタムウィジェットの作成

まず、アプリケーション内にwidgets.pyを作ります。自作のウィジェットは、こういったファイルに入れておくのが良いでしょう。中身は次のようにしておきます。

from django import forms


class SuggestWidget(forms.SelectMultiple):
    template_name = 'app/widgets/suggest.html'

    class Media:
        js = ['app/js/suggest.js']
        css = {
            'all': ['app/css/suggest.css']
        }

    def __init__(self, attrs=None):
        super().__init__(attrs)
        if 'class' in self.attrs:
            self.attrs['class'] += ' suggest'
        else:
            self.attrs['class'] = 'suggest'

forms.SelectMultipleを基にしたウィジェットです。テンプレートファイルとしてapp/widgets/suggest.htmlを使い、このウィジェットを利用するのに必須なcss・jsファイルも指定しています。

__init__()では、suggestというCSSクラス名を必ず持つようにするための処理を書いています。

Django、テンプレートの探索順序で詳しく説明していますが、プロジェクト直下にtemplatesディレクトリを置くような作り(settings.pyDIRSに何か指定している)ならば、次のような設定をする必要があります。

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

次に、app/widgets/suggest.htmlを作ります。こういったテンプレートは、widgetsのような名前のディレクトリに入れておくとわかりやすいです。

<input type="text" id="{{ widget.name }}-input" data-target="{{ widget.name }}"
       autocomplete="off" {% include "django/forms/widgets/attrs.html" %}>

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

<div id="{{ widget.name }}-display">
    {% for group, options, index in widget.optgroups %}{% for option in options %}{% if option.selected %}
        <p class="suggest-item" data-target="{{ widget.name }}" data-pk="{{ option.value }}">{{ option.label }}</p>
    {% endif %}{% endfor %}{% endfor %}
</div>

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

大雑把に解説すると、次の4つの要素があります。

  1. 入力欄(input要素)

  2. サジェスト候補を表示する、サジェスト表示欄(ul)

  3. 実際に選択した関連記事の表示欄(<div id="{{ widget.name }}-display">)

  4. 実際に選択した関連記事の値を格納している非表示のエリア(<div id="{{ widget.name }}-values">)

suggest.cssは次のようになります。

ul.dropdown {
    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;
}

suggest.jsは、次のようになります。長いです。

const remove = e => {
    // 選択済みのアイテムをクリックした際、つまり削除処理。
    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 => {
    // サジェスト表示欄内で選択したアイテムの表示用データを作成する。
    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 => {
    // サジェスト表示欄内で選択したアイテムの送信用データを作成する。
    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;

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


document.addEventListener('DOMContentLoaded', e => {
    for (const element of document.getElementsByClassName('suggest')) {
        const targetName = element.dataset.target;
        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 => {
                        const frag = document.createDocumentFragment();
                        suggestListElement.innerHTML = '';

                        // サジェスト候補を一つずつ取り出し、それを<li>要素として作成
                        // <li>要素をクリックした際のイベントも設定
                        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;
                            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 => {
                        console.log(error);
                    });
            }
        });


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

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

使い方

今回は記事の関連記事ということで、次のようなモデルがある例です。

from django.db import models


class Post(models.Model):
    """記事"""
    title = models.CharField('タイトル', max_length=32)
    text = models.TextField('本文')
    relation_posts = models.ManyToManyField('self', verbose_name='関連記事', blank=True)

    def __str__(self):
        return self.title

そして、関連記事のサジェスト候補を作るためのビューも作成しておきます。

from django.http import JsonResponse
from .models import Post
# 略

def api_posts_get(request):
    """サジェスト候補の記事をJSONで返す。"""
    keyword = request.GET.get('keyword')
    if keyword:
        post_list = [{'pk': post.pk, 'name': str(post)} for post in Post.objects.filter(title__icontains=keyword)]
    else:
        post_list = []
    return JsonResponse({'object_list': post_list})

やっていることは単純で、入力されたキーワードを受け取り、そのキーワードから始まる記事の一覧を返すだけです。こんな感じで、関連記事に限らず様々なデータをサジェスト候補として返却できます。

urls.pyで、上のビューを追加するのも忘れないように。

path('api/posts/get/', views.api_posts_get, name='api_posts_get')

フォームを定義して、カスタムウィジェットを利用するように変更しましょう。

from django import forms
from django.urls import reverse_lazy
from .models import Post
from .widgets import SuggestWidget


class PostCreateForm(forms.ModelForm):

    class Meta:
        model = Post
        fields = '__all__'
        widgets = {
            'relation_posts': SuggestWidget(attrs={'data-url': reverse_lazy('app:api_posts_get')}),
        }

今回、カスタムウィジェット自体は、関連記事だけでなく他のケースにも対応できるように作っています。例えばタグのサジェストとかですね。

サジェスト候補はモデルや処理の内容によって代わりますので、カスタムウィジェットのattrs引数のdata-urlキーに、サジェスト候補を作成するためのビューURLを渡すようにしています。

後は、このフォームをテンプレートに表示するだけです。{{ form.media }}が必要なので、そ忘れないようにしましょう。

    <form action="" method="POST">
        {{ form.as_p }}
        {% csrf_token %}
        <button type="submit">送信</button>
        {{ form.media }}
    </form>

(正確には、form.as_pだとちょっと問題になることがあります。コンテンツモデル等の話になるのですが、興味があればDjango、フォームの表示方法まとめもご覧ください。もしくはGithubのソースコードのように、div要素を使ってフォームフィールドを囲むようにしてください。)

admin管理サイトでも利用する場合は、admin.pyを次のようにしましょう。

from django.contrib import admin
from .forms import PostCreateForm
from .models import Post


class PostAdmin(admin.ModelAdmin):
    form = PostCreateForm


admin.site.register(Post, PostAdmin)

この記事の関連記事

Django、選択した親カテゴリに合わせて子カテゴリを表示

2018-10-31 / PythonDjangoJavaScriptAjax

- Djangoで、選択した大カテゴリによって、小カテゴリの内容を絞りこむような例を解説していきます。JavaScriptで単純に解決する方法と、Ajaxの方法の2つを解説します。

Djangoで、Ajax

2018-11-23 / PythonDjangoJavaScriptAjax

- Ajaxを使うことで、ページ遷移することなくページ内容を更新することができます。今回はDjangoフレームワークでAjaxを使う例を紹介していきます。

Django、フォームの表示方法まとめ

2018-12-06 / PythonDjangoBulmaBootstrap4

- Djangoで、フォームオブジェクトをテンプレートファイルに渡した際の、様々な表示方法についてです。{{ form.as_p }}や{% for field in form %} 、手作業での取り出し方法を説明していきます。

Bulmaでよく使うJavaScriptコード

2019-02-26 / BulmaJavaScript

- CSSフレームワークBulmaにはjsファイルは付属しません。なので、幾つかの処理は自分でJavaScriptを書く必要があります。良く使うものを、今回紹介していきます。

Djangoで、ブログ等によくある関連記事欄を作る

2019-02-26 / PythonDjango

- ブログではよく、「関連記事」のようなリンクがあります。今回はDjangoでそれを実装する2つの方法を紹介します。

Django、テンプレートファイルの探索順序について

2019-02-26 / PythonDjango

- Djangoのテンプレートが探される順序を説明していきます。通常のテンプレートのほか、フォームウィジェットのテンプレートにも触れていきます。

Djangoで、プレビュー付き画像アップロード欄を作る

2019-05-17 / PythonDjangoDjangoカスタムウィジェット

- DjangoでImageFieldを使うと画像のアップロード欄が作られますが、プレビューは表示されません。ウィジェットを自作し、プレビュー付きの画像アップロード欄を作ります。

Djangoで、表示・非表示切り替え可能なチェックボックスを作る

2019-07-02 / PythonDjangoDjangoカスタムウィジェット

- Djangoで、表示・非表示切り替え可能なチェックボックスとなるカスタムウィジェットを作成していきます。

Django、ファイルアップロード付きテキストエリアを作る

2019-07-12 / PythonDjangoDjangoカスタムウィジェット

- Djangoで、テキストウィジェットにファイルをドラッグアンドドロップすると、本文にファイルURLが挿入される処理を実装していきます。ブログ等のアプリケーションでは便利な機能です。

コメント欄

記事にコメントする

まだコメントはありません。