もた日記

くだらないことを真面目にやる

Djangoメモ(18) : django-widget-tweaksを使用してBootstrapのフォームを作成する

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にdjango-widget-tweaksを使用してBootstrapのフォームを作成してみる。


django-widget-tweaksのインストール

チュートリアルではdjango-widget-tweaksを使用しているが、Django 2.0ではエラーが発生(後述)したのでdjango-widget-tweaksをフォークしたdjango-widgets-improvedをインストールする(ただしこの記事ではdjango-widget-tweaksとして説明する)。
django-widget-tweaksはテンプレートでフォームをレンダリングするときにCSSクラスやHTML属性を変更できるモジュール。

$ pip install django-widgets-improved

今回はPipenvで環境を構築しているので下記コマンドでインストール。

$ pipenv install django-widgets-improved

settings.pyINSTALLED_APPS'widget_tweaks'を追加する。

INSTALLED_APPS = [
    ...
    'widget_tweaks',
    ...
]


テンプレートの編集

インストールが完了したらtemplates/new_topic.htmlを編集する。
Bootstrapのフォームの使い方はドキュメントに書いてあるが、簡単にまとめると各フィールドを<div class="form-group">で囲み、入力タグにclass="form-control"を追加すればよい。

{% extends 'base.html' %}

{% load widget_tweaks %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post" novalidate>
    {% csrf_token %}

    {% for field in form %}
      <div class="form-group">
        {{ field.label_tag }}

        {% render_field field class="form-control" %}

        {% if field.help_text %}
          <small class="form-text text-muted">
            {{ field.help_text }}
          </small>
        {% endif %}
      </div>
    {% endfor %}

    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

最初に{% load widget_tweaks %}タグによりdjango-widget-tweaksをロードする。
{% render_field field class="form-control" %}django-widget-tweaksのタグで最初の引数でフォームフィールドのインスタンスを指定し、次の引数で追加したい属性を指定する。
render_fieldタグの使い方の例は以下の通り。

{% render_field form.subject class="form-control" %}
{% render_field form.message class="form-control" placeholder=form.message.label %}
{% render_field field class="form-control" placeholder="Write a message!" %}
{% render_field field style="font-size: 20px" %}

これでBootstrapのCSSクラスが適用されたフォームが作成される。

HTML出力(クリックで展開)

<form method="post" novalidate>
    <input type='hidden' name='csrfmiddlewaretoken' value='elpoqJPS00rjFpbgNQV3441yfVFd82aX6L81VTfaS76DduY7Ux9cZsS6gz9211D8' />
    <div class="form-group">
        <label for="id_subject">Subject:</label>
        <input type="text" name="subject" maxlength="255" class="form-control" required id="id_subject" />
    </div>
    <div class="form-group">
        <label for="id_message">Message:</label>
        <textarea name="message" cols="40" rows="5" placeholder="What is on your mind?" maxlength="4000" class="form-control" required id="id_message">
        </textarea>
        <small class="form-text text-muted">
            The max length of the text is 4000.
          </small>
    </div>
    <button type="submit" class="btn btn-success">Post</button>
</form>

f:id:wonder-wall:20180318175701p:plain


ただし、上記のコードだとバリデーションメッセージが表示されないので、バリデーションのコードを追加する。

<form method="post" novalidate>
  {% csrf_token %}

  {% for field in form %}
    <div class="form-group">
      {{ field.label_tag }}

      {% if form.is_bound %}
        {% if field.errors %}

          {% render_field field class="form-control is-invalid" %}
          {% for error in field.errors %}
            <div class="invalid-feedback">
              {{ error }}
            </div>
          {% endfor %}

        {% else %}
          {% render_field field class="form-control is-valid" %}
        {% endif %}
      {% else %}
        {% render_field field class="form-control" %}
      {% endif %}

      {% if field.help_text %}
        <small class="form-text text-muted">
          {{ field.help_text }}
        </small>
      {% endif %}
    </div>
  {% endfor %}

  <button type="submit" class="btn btn-success">Post</button>
</form>

レンダリング時には3つの状態があり、初期状態ではform.is_boundFalseになるので通常のフォームが作成される。
バリデーション失敗状態では.is-invalidクラスを追加し、.invalid-feedbackクラスでエラーメッセージを赤色で表示する。
バリデーション成功状態では.is-validクラスを追加し、入力データが適切だった場合にフィールドを緑色で表示する。

バリデーションメッセージ表示時のHTML出力(クリックで展開)

<form method="post" novalidate>
    <input type='hidden' name='csrfmiddlewaretoken' value='NaS7H4NUyekGiA0zOghls6gq22ZWBq8FFABKcedcqlZ0QFNqVXvunu7Y3GtLupBQ' />
    <div class="form-group">
        <label for="id_subject">Subject:</label>
        <input type="text" name="subject" maxlength="255" class="form-control is-invalid" required id="id_subject" />
        <div class="invalid-feedback">
            このフィールドは必須です。
        </div>
    </div>
    <div class="form-group">
        <label for="id_message">Message:</label>
        <textarea name="message" cols="40" rows="5" placeholder="What is on your mind?" maxlength="4000" class="form-control is-invalid" required id="id_message">
        </textarea>
        <div class="invalid-feedback">
            このフィールドは必須です。
        </div>
        <small class="form-text text-muted">
          The max length of the text is 4000.
        </small>
    </div>
    <button type="submit" class="btn btn-success">Post</button>
</form>

f:id:wonder-wall:20180318175921p:plain

f:id:wonder-wall:20180318202914p:plain


再利用可能なテンプレート

Bootstrapのフォームを作成することはできたが、毎回このコードを書くのは大変なので再利用可能にする。
まずtemplatesディレクトリの中にincludesディレクトリを作成。

├── myproject/
│  ├── boards/
│  ├── db.sqlite3
│  ├── manage.py
│  ├── myproject/
│  ├── static/
│  └── templates/
│     ├── base.html
│     ├── home.html
│     ├── includes/       # mkdirで作成
│     ├── new_topic.html
│     └── topics.html
├── Pipfile
└── Pipfile.lock

次にtemplates/includes/form.htmlファイルを作成し下記コードを記述する。

{% load widget_tweaks %}

{% for field in form %}
  <div class="form-group">
    {{ field.label_tag }}

    {% if form.is_bound %}
      {% if field.errors %}
        {% render_field field class="form-control is-invalid" %}
        {% for error in field.errors %}
          <div class="invalid-feedback">
            {{ error }}
          </div>
        {% endfor %}
      {% else %}
        {% render_field field class="form-control is-valid" %}
      {% endif %}
    {% else %}
      {% render_field field class="form-control" %}
    {% endif %}

    {% if field.help_text %}
      <small class="form-text text-muted">
        {{ field.help_text }}
      </small>
    {% endif %}
  </div>
{% endfor %}

templates/new_topic.htmlでは別テンプレートを読み込む{% include %}タグでform.htmlを読み込むようにする。

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post" novalidate>
    {% csrf_token %}
    {% include 'includes/form.html' %}
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

これで今後は{% include 'includes/form.html' %}と書くだけでテンプレートの再利用が可能になる。


フォームのテストを追加

boards/tests.pyにフォームのテストを追加する。

# ... other imports
from .forms import NewTopicForm

class NewTopicTests(TestCase):
    # ... other tests

    def test_contains_form(self):  # <- new test
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        form = response.context.get('form')
        self.assertIsInstance(form, NewTopicForm)

    def test_new_topic_invalid_post_data(self):  # <- updated this one
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.post(url, {})
        form = response.context.get('form')
        self.assertEquals(response.status_code, 200)
        self.assertTrue(form.errors)

test_contains_form(self)は新しいテストでresponse.context.get('form')によりインスタンスを取得し、assertIsInstanceメソッドでNewTopicFormのインスタンスか確認している。
test_new_topic_invalid_post_data(self)は既存テストのアップデートで、データがない場合にバリデーションエラーが表示されるかself.assertTrue(form.errors)により確認している。

テストコードを書いたらテストが通ることを確認しておく。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
................
----------------------------------------------------------------------
Ran 16 tests in 1.354s

OK
Destroying test database for alias 'default'...


(参考)django-widget-tweaksで発生したエラー

最初にdjango-widget-tweaksでやったところTypeErrorが発生した。バージョンは1.4.1。
strを期待しているところでBoundFieldのデータがあるのが問題のようだ。

f:id:wonder-wall:20180318173827p:plain

調べたところDjango 2.0では下記リンクのコミットを適用する必要があるとのことで、django-widget-tweaksをフォークしたdjango-widgets-improvedが紹介されている(django-widget-tweaksをインストールしている場合はアンインストールすること)。

Always cast the result of render to a string · simhnna/django-widget-tweaks@a327bbe · GitHub

github.com


まとめ

  • django-widget-tweaksはフォームをレンダリング時にCSSクラスなどを追加できる
  • Django 2.0ではdjango-widget-tweaksをフォークしたdjango-widgets-improvedを使う
  • {% load widget_tweaks %}タグによりdjango-widget-tweaksをロードする
  • {% render_field field class="form-control" %}のようにしてクラスを追加する
  • {% include 'includes/form.html' %}のようにしてテンプレートを再利用できる