Djangoメモ(18) : django-widget-tweaksを使用してBootstrapのフォームを作成する
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.pyのINSTALLED_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>

ただし、上記のコードだとバリデーションメッセージが表示されないので、バリデーションのコードを追加する。
<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_boundがFalseになるので通常のフォームが作成される。
バリデーション失敗状態では.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>


再利用可能なテンプレート
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のデータがあるのが問題のようだ。

調べたところ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
まとめ
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' %}のようにしてテンプレートを再利用できる