この記事で解説すること3つ
今回は、Djangoを用いてHTML5の機能の一つであるDatalistを実装する方法について解説します。
入力候補を表示させたいときに便利です。

今回は、WidgetやFormを自作して、汎用的な形式で提供するので、他の事例でも活用できると思います。
実際はTomSelectなどSelectに検索機能を搭載した方が使いやすいですが、実装時の備忘録として残しておくことにしました。
DataListとは?Selectとの違い
Datalistは、HTML5から新たに追加されたhtml要素で、入力時の候補を表示できる機能を持っています。
<datalist>は HTML の要素で、この要素には <option>要素の集合が含まれ、他のコントロール内で選択できる許容または推奨オプションを表します。
<datalist>: HTML データリスト要素
実際の動きとしては、以下に示すような感じで、input要素にカーソルを当てると入力時の候補が表示されます。

また、DatalistとSelectの違いとしては、以下の2点が挙げられます。
観点 | Datalist | Select |
---|---|---|
選択肢の内容可否 | 変更できる(※ベースはinput要素になる) | 変更できない |
検索機能 | 検索可能 | (デフォルトでは)検索不可能 ※Javascriptライブラリを使えば実現可能 |
上記にも記載した通り、Datalistのベースはinput要素になるため、フォーム送信時に決まった形式でデータを送信する場合は、一工夫必要です。
工夫点は、実装の中で解説します。

実現したいことと事例2つ
中身の解説をする前に、実現したいことと事例2つを紹介します。
【実現したいこと】外部キー(Foreign Key)の設定にDatalistを使う
Djangoで開発を行っている場合、他のモデルで定義した情報を外部キーとして紐づけたいときがあります。
デフォルトの設定では、Select要素が用いられますが、紐づけたモデルのデータが増えてくると検索が大変になります。

この課題を解決する方法の一例として、今回は、Datalistを使うことにしました。
TomSelectなどのJavascriptライブラリを活用する方法もありますよ。

【参考事例①】購入した書籍と著者を紐づける
1つ目のDatalistの活用事例として、書籍と著者を紐づけることが挙げられます。

この場合、著者が外部キー(Foreign key)となります。
「著者欄に候補が表示される」という構成にするのが目標となります。

【参考事例②】購入した株式と企業を紐づける
2つ目のDatalistの活用事例として、株式と企業を紐づけることが挙げられます。

この場合、企業が外部キー(Foreign key)となります。
先ほどと同様に「企業欄に候補が表示される」という構成にするのが目標となります。

全体構成
前置きはこの位にしておき、実際に作り込んでいきましょう。
今回は、書籍と著者を紐づける例を題材に、解説していきたいと思います。
また、完成版のGitHubのパスを以下に示しておきます。
https://github.com/yuruto-free/implement-custom-form-using-django/tree/master
ディレクトリツリー
全体構成を伝えるために、ディレクトリツリーを紹介します。
./
|-- docker-compose.yml
|-- LICENSE
|-- README.md
|-- env.sample
|-- .env
|-- env_files/
| |-- django/
| | |-- README.md
| | |-- env.sample
| | `-- .env
| `-- postgres/
| |-- README.md
| |-- env.sample
| `-- .env
|-- postgres/
| `-- Dockerfile
`-- django/
|-- Dockerfile
|-- pyproject.toml
|-- README.md
`-- app/
|-- manage.py
|-- config/
| |-- asgi.py
| |-- urls.py
| |-- wsgi.py
| |-- __init__.py
| `-- settings.py
|-- book/
| |-- __init__.py
| |-- admin.py
| |-- apps.py
| |-- forms.py
| |-- models.py
| |-- tests.py
| |-- urls.py
| `-- views.py
|-- utils/
| |-- __init__.py
| |-- templates/
| | |-- renderer/
| | | |-- utils_form.html
| | | `-- utils_datalist_javascript.html
| | `-- widgets/
| | |-- utils_datalist.html
| | `-- utils_datalist_option.html
| |-- admin.py
| |-- apps.py
| |-- forms.py
| |-- models.py
| |-- tests.py
| |-- views.py
| `-- widgets.py
`-- templates/
|-- base.html
|-- list.html
`-- book/
|-- index.html
|-- author_list.html
|-- author_form.html
|-- book_list.html
`-- book_form.html
では、実装内容とあわせて解説していきます。
読みながら作れるように、コードも一緒に掲載しますね。

開発に向けた準備3ステップ
まずは、開発環境を整えていきましょう。
開発に向けた準備3ステップ
また、環境構築した後の実装結果は、GitHubの下記から取得できます。
https://github.com/yuruto-free/implement-custom-form-using-django/tree/v0.1.0
サービス一覧と環境変数の設定
今回構築する環境は、以下のような階層構造になります。

上記の図は、あくまでイメージ図となります。

docker-compose.yml
上記の構造を実現するために、以下のようなdocker-compose.yml
を作成しました。
services:
django:
build:
context: ./django
args:
- UID
- GID
- ARCHITECTURE=${HOST_ARCHITECTURE:-arm64v8}
- TZ=${HOST_TIMEZONE:-Asia/Tokyo}
image: django.custom-form
container_name: django.custom-form
env_file:
- ./env_files/django/.env
- ./env_files/postgres/.env
environment:
- DJANGO_DB_HOST=postgres
- DJANGO_DB_PORT=5432
- DJANGO_LANGUAGE_CODE=${HOST_LANGCODE:-ja}
- DJANGO_TIME_ZONE=${HOST_TIMEZONE:-Asia/Tokyo}
volumes:
- static.custom-form:/var/www/static
- ./django/bashrc:/opt/home/.bashrc:ro
- ./django/app:/opt/app
- ./django/pyproject.toml:/opt/pyproject.toml
ports:
- ${HOST_ACCESS_PORT:-3001}:8000
depends_on:
- postgres
networks:
- custom-form-dev-net
restart: always
postgres:
build:
context: ./postgres
args:
- ARCHITECTURE=${HOST_ARCHITECTURE:-arm64v8}
- TZ=${HOST_TIMEZONE:-Asia/Tokyo}
image: postgres.custom-form
container_name: postgres.custom-form
env_file:
- ./env_files/postgres/.env
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
- LANG=C
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --locale=C
volumes:
- db.custom-form:/var/lib/postgresql/data
expose:
- 5432
networks:
- custom-form-dev-net
restart: always
networks:
custom-form-dev-net:
name: custom-form-dev-net
volumes:
static.custom-form:
name: static-custom-form
driver: local
db.custom-form:
name: postgres-custom-form
driver: local
上記のdocker-compose.yml
ファイルを使う上で、環境変数の設定が必要になります。
このため、docker-compose.yml
と同じ階層に下記の環境変数を記載した.env
ファイルを作成してください。
環境変数名 | 詳細 | 設定例 |
---|---|---|
HOST_ACCESS_PORT | ホスト環境からコンテナ内にアクセスする際のポート番号 | 3001 |
HOST_ARCHITECTURE | ホスト環境のアーキテクチャ | arm64v8 |
HOST_TIMEZONE | ホスト環境のタイムゾーン | Asia/Tokyo |
HOST_LANGCODE | ホスト環境の言語設定 | ja |
.env
ファイルに記載する環境変数一覧コピペ用にサンプルを掲載しておきますね。
HOST_ACCESS_PORT=3001
HOST_ARCHITECTURE=arm64v8
HOST_TIMEZONE=Asia/Tokyo
HOST_LANGCODE=ja
今回は、Raspberry Pi 4 Model B上で開発を行っているため、arm64v8になっています。

Django向けの.envファイル
同様の方針で、Djangoコンテナで用いる環境変数も設定しておきましょう。
下記の環境変数を記載した.env
ファイルを./env_files/django/.env
に保存してください。
環境変数名 | 詳細 | 設定例 |
---|---|---|
DJANGO_SECRET_KEY | Djangoで使うシークレットキー | django-insecure-key |
DJANGO_SUPERUSER_NAME | Djangoのスーパーユーザ名 | superuser |
DJANGO_SUPERUSER_EMAIL | DjangoのスーパーユーザのE-mailアドレス | superuser@example.com |
DJANGO_SUPERUSER_PASSWORD | Djangoのスーパユーザのパスワード | superuser-password |
.env
ファイルに記載する環境変数一覧コピペ用にサンプルを掲載しておきますね。
DJANGO_SECRET_KEY=django-insecure-key
DJANGO_SUPERUSER_NAME=superuser
DJANGO_SUPERUSER_EMAIL=superuser@local.access
DJANGO_SUPERUSER_PASSWORD=superuser-password
PostgreSQL向けの.envファイル
PostgreSQL(データベース)にも.envファイルを用意したので、同様の手順で設定します。
下記の環境変数を記載した.env
ファイルを./env_files/postgres/.env
に保存してください。
環境変数名 | 詳細 | 設定例 |
---|---|---|
POSTGRES_USER | データベース作成時に新規作成するPostgreSQLのユーザ名 | admin |
POSTGRES_PASSWORD | PostgreSQLのユーザのパスワード | adminpassword |
POSTGRES_DB | 新規作成するデータベース名 | postgres |
.env
ファイルに記載する環境変数一覧先ほどと同様に、コピペ用のサンプルを掲載しておきますね。
POSTGRES_USER=admin
POSTGRES_PASSWORD=adminpassword
POSTGRES_DB=postgres
環境構築
続いて、Dockerfileやpoetryの設定を行います。
DjangoのDockerfile
下記に記載した内容を./django/Dockerfile
に書き込んでください。
ARG ARCHITECTURE=arm64v8
From ${ARCHITECTURE}/python:3.12.10-alpine3.21
ARG UID
ARG GID
ARG USERNAME=user
ARG GROUPNAME=user
ARG TZ=Asia/Tokyo
ENV APP_ROOT_PATH=/opt/app
LABEL maintainer="yuruto-free"
LABEL description="Build the environment of Django application"
COPY ./pyproject.toml /opt/pyproject.toml
RUN apk update \
&& apk upgrade \
\
# Install mandatory libraries
\
&& apk add --no-cache bash tzdata libpq-dev pcre-dev libxml2-dev gettext \
\
# Install relevant libraries for development
\
&& apk add --no-cache gcc musl-dev libffi-dev g++ libgcc libstdc++ libxslt-dev \
python3-dev libc-dev linux-headers curl shadow \
&& ln -s /usr/share/zoneinfo/${TZ} /etc/localtime \
&& pip install --upgrade setuptools \
&& pip install poetry \
&& groupadd -g ${GID} ${GROUPNAME} \
&& useradd --shell /bin/bash --no-log-init --create-home --home-dir /opt/home --gid ${GID} --uid ${UID} ${USERNAME} \
&& mkdir -p ${APP_ROOT_PATH} \
&& chown -R ${USERNAME}:${GROUPNAME} /opt/home \
&& cd /opt \
&& poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi \
&& chown -R ${USERNAME}:${GROUPNAME} /opt/poetry.lock \
&& cd / \
&& rm -rf /root/.cache /var/cache/apk/* /tmp/*
WORKDIR ${APP_ROOT_PATH}
USER ${USERNAME}
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
コンテナ内でライブラリ等を追加しやすいように、GCCなどコンパイル時に必要なツールは残しています。

poetry向けのtomlファイル
Djangoで環境構築を行う上で、仮想環境があると便利です。
今回は、poetryを使うことにしたので、下記の内容を./django/pyproject.toml
に反映してください。
[project]
name = "app"
version = "0.0.1"
authors = [{ name="yuruto-free", email="103588113+yuruto-free@users.noreply.github.com" },]
maintainers = [{ name="yuruto-free", email="103588113+yuruto-free@users.noreply.github.com" },]
description = "Backend application using Django"
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.10"
[tool.poetry]
package-mode = false
[tool.poetry.dependencies]
python = "^3.12"
django = "^5.2"
psycopg = { version="^3.2.6", extras=["c", "pool"] }
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
PostgreSQL向けのDockerfile
下記に記載した内容を./postgres/Dockerfile
に書き込んでください。
ARG ARCHITECTURE=arm64v8
From ${ARCHITECTURE}/postgres:17-alpine3.21
ARG TZ=Asia/Tokyo
LABEL maintainer="yuruto-free"
LABEL description="Build the environment of PostgreSQL"
RUN apk update \
&& apk upgrade \
&& apk add --no-cache bash tzdata \
&& ln -s /usr/share/zoneinfo/${TZ} /etc/localtime
以上で、Docker imageを作成するための準備が整いました。
Docker imageの作成
では、下記のコマンドを用いて、Docker imageを作成していきます。
docker-compose build --no-cache --build-arg UID="$(id -u)" --build-arg GID="$(id -g)"
しばらくすると、下記のようなイメージが作成されます。
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
django.custom-form latest 9a8120bdc502 XX hours ago 582MB # <--- 作成されたDocker image
postgres.custom-form latest 1edc42a7a9e2 XX hours ago 419MB # <--- 作成されたDocker image
arm64v8/python 3.12.10-alpine3.21 cce969eea970 XX weeks ago 52.4MB
arm64v8/postgres 17-alpine3.21 6b8369bf21ff XX months ago 270MB
# 以下、省略
環境によっては意図通り動作しない可能性もあります。エラーメッセージに応じて、修正を行ってください。

初期設定
データベースの初期設定とスーパーユーザの作成を行っておきます。
# =====================
# データベースの初期設定
# =====================
# ホスト環境での操作
docker-compose run --rm django bash
# コンテナ内での操作
python manage.py makemigrations
python manage.py migrate
# =====================
# スーパユーザの作成
# =====================
echo 'from django.contrib.auth.models import User; User.objects.create_superuser("'${DJANGO_SUPERUSER_NAME}'", "'${DJANGO_SUPERUSER_EMAIL}'", "'${DJANGO_SUPERUSER_PASSWORD}'")' | python manage.py shell
# ホスト環境に戻る
exit # or press Ctrl + D
以上で、今回の開発に向けた準備は終了です。
試しに、以下のコマンドを実行し、ここまでの実装内容を確認しておきましょう。
docker-compose up -d
# その後、http://your-server-ip-address:your-port-number にアクセス
# 例)http://192.168.11.30:3001
Webブラウザで該当ページにアクセスし、下記のようなスタートページが表示されれば成功です。

続いて、本題のDatalistに関する実装について、解説します。
Widget / Field / Form / Templateの作成
早速、対象のWidget/Field/Form/Templateを作成していきます。
今回の機能は、メイン機能と分けた構成としたいため、utils
というアプリ内にこれらの機能を実装していきます。
具体的な実装例は、GitHubの以下のパスに格納しているので、あわせてご覧ください。
https://github.com/yuruto-free/implement-custom-form-using-django/tree/add-utils-app(v0.1.1)
WidgetとFormの作成4ステップ
utilsアプリの作成
djangoアプリのコンテナ内に入り、以下のコマンドを実行します。
# ホスト環境での操作
docker exec -it django.custom-form bash
# コンテナ内での操作
python manage.py startapp utils
cd utils
touch forms.py widgets.py
mkdir -p templates/renderer templates/widgets
exit # or press Ctrl + D
また、config/settings.py
の下記も更新しておきます。
# 省略
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
os.path.join(BASE_DIR, 'utils', 'templates'), # <--- 追加
],
'APP_DIRS': True,
# 以下、省略
}
]
GitHubからcloneした人は、この処理は実施済みのため、実施不要です。

Formの内部構造
中身の解説に入る前に、Formの内部構造について説明しておきます。
私の理解では、DjangoのFormは以下のような構造になっています。

また、HTMLのデータ構造を対応付けると、以下のようになります。
<form action="/login/" method="POST">
<!-- Form -->
<div class="row row-cols-1 g-4">
<!-- Field (ユーザ名) -->
<div class="col">
<label class="form-label">ユーザー名</label>
<!-- Widget (ユーザ名) -->
<input
type="text"
name="username"
autofocus=""
autocapitalize="none"
autocomplete="username"
maxlength="128"
class="form-control"
required=""
id="id_username"
/>
<!-- end Widget (ユーザ名) -->
</div>
<!-- end Field (ユーザ名) -->
<!-- Field (パスワード) -->
<div class="col">
<label class="form-label">パスワード</label>
<!-- Widget (パスワード) -->
<input
type="password"
name="password"
autocomplete="current-password"
class="form-control"
required=""
id="id_password"
/>
<!-- end Widget (パスワード) -->
</div>
<!-- end Field (パスワード) -->
</div>
<!-- end Form -->
<!-- 以下、省略 -->
</form>
このため、下記のような構造になっていると整理できますね。
項目 | 構成要素 | 例 |
---|---|---|
Form | 複数のField | ユーザ名(label, input)、パスワード(label, input) |
Field | label要素とwidgetのペア | ユーザ名の「label要素」と「input要素」 |
Widget | フィールド要素そのもの | input要素、select要素、チェックボックスなど |
上記を踏まえてWidgetとFormを作っていきます。
WidgetとFieldの作成
MDN Web Docsの解説から、Datalist要素は、以下のような構成を取ることが分かります。
<!-- **valid-pattern-name**: text, time, number などを取る -->
<input
type="**valid-pattern-name**"
list="datalist-id"
id="input-id"
name="input-name"
/>
<datalist id="datalist-id">
<option value="value1">value1</option>
<option value="value2">value2</option>
<option value="value3">value3</option>
<!-- 以下省略 -->
</datalist>
上記を踏まえると、Widgetには、以下の情報が保持できていれば良いことになります。
項目名 | 理由 | 上記の該当箇所 | 設定例 |
---|---|---|---|
type | 入力形式を指定するため。 | valid-pattern-name | text |
list | input要素とDatalist要素を紐づけるため。 | datalist-id | datalist_xxx |
dataset.value | Djangoの外部キー(Foreign key)の情報を保持するため。 | -(新規追加) | data-value="fk-1" |
唐突にdatasetが登場していますが、これは、input要素に外部キー(Foreign key)の値が表示されてしまうのを回避するための処置です。
ベースのWidgetがSelectであることを考慮し、Djangoのオリジナルの実装をもとにDatalist要素のWidgetを実装すると以下のようになります。
# utils/widgets.py
from django import forms
class Datalist(forms.Select):
# input要素のtypeに指定する内容(text, numberなどが指定可能)
input_type = 'text'
# Datalist要素のlistに指定する内容(未指定の場合は、「field名_datalist」とする)
input_list = ''
# optionのvalueに該当するデータの設定方法
# True: dataset.valueの形式で設定
# False: <option value="***">の形式で設定
use_dataset_attr = False
# Datalist要素をレンダリングする際のテンプレートファイル名
template_name = 'widgets/utils_datalist.html'
# Datalist要素のoptionをレンダリングする際のテンプレートファイル名
option_template_name = 'widgets/utils_datalist_option.html'
def __init__(self, attrs=None):
# Widgetのインスタンス生成時にattrsの指定があれば、その内容に基づいて処理
if attrs is not None:
self.input_type = attrs.get('type', self.input_type)
self.input_list = attrs.pop('list', self.input_list)
self.use_dataset_attr = attrs.pop('use-dataset', self.use_dataset_attr)
super().__init__(attrs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
# templateファイル内で使用する変数の定義
# 親クラスで設定される値は、GitHubの下記のリンク参照(django/forms/widgets.py::Widgetsクラス::get_contextメソッド)
# https://github.com/django/django/blob/main/django/forms/widgets.py#L315
context['widget']['type'] = self.input_type
context['widget']['id'] = self.input_list if self.input_list else f'{name}_datalist'
context['widget']['use_dataset'] = self.use_dataset()
return context
def use_dataset(self):
return bool(self.use_dataset_attr)
プログラムとDatalist要素の対応関係もあわせてまとめておきます。
変数名 | インスタンス生成時に渡すキー名 | 項目名 | Datalist要素の例の該当箇所 |
---|---|---|---|
input_type | 'type' | type | valid-pattern-name |
input_list | 'list' | list | datalist-id |
use_dataset_attr | 'use-dataset' | dataset.value | -(新規追加) |
また、templateファイルもあわせて記載しておきます。
{# utils/templates/widgets/utils_datalist.html #}
<input type="{{ widget.type }}" list="{{ widget.id }}" name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %} />
<datalist id="{{ widget.id }}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
{% include option.template_name with widget=option use_dataset=widget.use_dataset %}
{% endfor %}{% if group_name %}
</optgroup>{% endif %}{% endfor %}
</datalist>
{# utils/templates/widgets/utils_datalist_option.html #}
<option {% if use_dataset %}data-{% endif %}value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}</option>
連動する要素として、Fieldも作成しておきましょう。
Datalist要素はSelect
要素をベースにしたので、FieldもChoiceField
とModelChoiceField
を参考に作成します。
Djangoは拡張しやすいように作られているため、元のコードがほぼ流用できます。

Datalist要素に対するFieldの定義は、以下に示す通りです。
# utils/widgets.py
class DatalistField(forms.ChoiceField):
widget = Datalist
class ModelDatalistField(forms.ModelChoiceField):
widget = Datalist
def __init__(self, queryset, *, widget=None, **kwargs):
widget = widget or self.widget
super().__init__(queryset, widget=widget, **kwargs)
非常にシンプルな実装で済みましたね。
FormとTemplateの作成
先ほど作成したDatalist要素を利用するためのFormとTemplateを作成しますが、実装時の注意点が1つ存在します。
実装時の注意点1つ
外部キー(Foreign key)の設定にDatalist要素を用いる場合、formのsubmit時にinput要素のデータをdataset.valueに指定した値に置き換える必要がある。
図解すると、以下に様になります。

このような対応が必要になる理由として、モデルで外部キーを指定している場合、form送信時も外部キーを送信する必要があるためです。
上記の副作用を解決するために、今回は以下のような対応を取ることにしました。
副作用解消のための対応2つ
- Datalist要素を動的に作成する。
Datalist要素に置き換えるfield名とFieldの引数をMetaクラスで定義する。 - submit時にJavascriptで該当要素の値を書き換える。
事前にJavascriptを実装したテンプレートを用意し、呼び出し元でincludeする。
上記をプログラムで実装した結果は、以下のようになります。
# utils/forms.py
from django import forms
from .widgets import ModelDatalistField
class BaseDatalistModelForm(forms.ModelForm):
template_name = 'renderer/utils_form.html'
class Meta:
#
# Datalist要素を動的に追加するための設定
#
datalist_fields = [] # Datalist要素に置き換えるfield名一覧
datalist_kwargs = {} # 上記のfields名ごとの引数情報
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
#
# Datalist要素の設定
#
_meta = getattr(self, 'Meta', None)
# Metaクラスから必要な情報を取得
datalist_fields = getattr(_meta, 'datalist_fields', [])
datalist_kwargs = getattr(_meta, 'datalist_kwargs', {})
widgets = getattr(_meta, 'widgets', {})
dynamic_fields = {}
# 動的にフィールドを作成
for field_name in datalist_fields:
widget = widgets[field_name]
options = datalist_kwargs[field_name]
dynamic_fields[field_name] = ModelDatalistField(widget=widget, **options)
# property作成時に必要になるため、明示的に保持
self._extra_datalist_fields = [field_name for field_name in datalist_fields]
# 関連する変数を更新
self.declared_fields.update(dynamic_fields)
self.fields.update(self.declared_fields)
@property
def datalist_js_template_name(self):
return 'renderer/utils_datalist_javascript.html'
@property
def datalist_ids(self):
# dataset.valueとして参照することにしたfieldのみを抽出
related_fields = [
self.fields[field_name]
for field_name in self._extra_datalist_fields if self.fields[field_name].widget.use_dataset()
]
# 該当するHTML要素のidを取得
datalist_ids = [field.widget.attrs['id'] for field in related_fields]
return datalist_ids
また、関連するtemplateファイルは、以下のようになっています。
{# utils/templates/renderer/utils_form.html #}
{% load i18n %}
<div class="row row-cols-1 g-2">
{% if form.non_field_errors %}
<div class="col text-danger">
<p class="fs-5">{% trans "Errors" %}</p>
{{ form.non_field_errors }}
</div>
{% endif %}
{% for field in form %}
<div class="col">
<div class="row">
<div class="col-auto">
<label for="{{ field.id_for_label }}" class="col-form-label">{{ field.label }}</label>
</div>
<div class="col-auto">
{{ field }}
</div>
{% if field.help_text %}
<div class="col-auto align-self-center helptext" {% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>
{{ field.help_text|safe }}
</div>
{% endif %}
</div>
{% if field.errors %}
<div class="row">
<div class="col text-danger fs-6">
{{ field.errors }}
</div>
</div>
{% endif %}
</div>
{% if forloop.last and hidden_fields %}
<div class="col">{% for field in hidden_fields %}{{ field }}{% endfor %}</div>
{% endif %}
{% endfor %}
</div>
{# utils/templates/renderer/utils_datalist_javascript.html #}
<script>
(function() {
const ids = [
{% for id_ in datalist_ids %}'{{ id_|stringformat:"s" }}',{% endfor %}
];
const form = document.querySelector('#{{ form_id }}');
form.addEventListener('submit', (event) => {
// 関連するfieldの値を更新
for (const id_ of ids) {
const target = document.querySelector(`#${id_}`);
const datalist = target.getAttribute('list');
const options = document.querySelectorAll(`#${datalist} option`);
const selectedOption = Array.from(options).find((option) => option.label === target.value);
// 対応するオプションが見つかった場合は、書き換える
if (selectedOption) {
target.value = selectedOption.dataset.value;
}
}
});
})();
</script>
以上で、準備は完了です。
サンプルアプリの実装
今回は、本の著者とタイトルを紐づけて登録する場合を想定し、著者名を選択する部分にDatalist要素を使う構成とします。
作り込む前に、下記のコマンドを実行し、book
アプリの作成・INSTALLED_APPS
への追加を実施しておきましょう。
# ホスト環境での操作
docker exec -it django.custom-form bash
# コンテナ内での操作
python manage.py startapp book
mkdir -p templates/book
cd book
touch forms.py urls.py
exit # or press Ctrl + D
# config/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'django.forms',
'book.apps.BookConfig', # <-- 追加
]
サンプルアプリの実装5ステップ
また、サンプルアプリの実装結果は、GitHubの以下のパスに格納しているので、あわせてご覧ください。
https://github.com/yuruto-free/implement-custom-form-using-django/tree/add-book-app(v0.1.2)
Modelの定義
今回は、以下のようなモデルを定義しました。
# book/models.py
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
class Author(models.Model):
name = models.CharField(
max_length=64,
verbose_name=_('Name'),
help_text=_('Max length of this field is 64.'),
)
def get_header(self):
return [_('Name')]
def get_members(self):
return [self.name]
def get_update_url(self):
return reverse('book:update_author', kwargs={'pk': self.pk})
def get_delete_url(self):
return reverse('book:delete_author', kwargs={'pk': self.pk})
def __str__(self):
return self.name
class Book(models.Model):
author = models.ForeignKey(
Author,
verbose_name=_('Author'),
on_delete=models.CASCADE,
related_name='books',
)
title = models.CharField(
max_length=255,
verbose_name=_('Title'),
help_text=_('Max length of this field is 255.'),
)
def get_header(self):
return [_('Author'), _('Title')]
def get_members(self):
return [self.author.name, self.title]
def get_update_url(self):
return reverse('book:update_book', kwargs={'pk': self.pk})
def get_delete_url(self):
return reverse('book:delete_book', kwargs={'pk': self.pk})
def __str__(self):
return f'{self.title}({self.author})'
テンプレートファイルの共有化のため、URL取得用のメソッドも追加しています。

Formの定義
Formの定義は以下のようになります。
# book/forms.py
from django import forms
from django.utils.translation import gettext_lazy as _
from utils.forms import BaseDatalistModelForm
from utils.widgets import Datalist
from . import models
class _BaseFormWithCSS(BaseDatalistModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
_classes = field.widget.attrs.get('class', '')
field.widget.attrs['class'] = f'{_classes} form-control'
field.widget.attrs['placeholder'] = field.help_text
class AuthorForm(_BaseFormWithCSS):
class Meta:
model = models.Author
fields = ('name',)
class BookForm(_BaseFormWithCSS):
class Meta:
model = models.Book
fields = ('author', 'title')
widgets = {
'author': Datalist(attrs={
'id': 'author-id',
'use-dataset': True,
'class': 'form-control',
}),
}
# BaseDatalistModelForm
datalist_fields = ['author']
datalist_kwargs = {
'author': {
'label': _('Author'),
'queryset': models.Author.objects.all(),
},
}
BookForm
で定義しているように、Datalist要素を適用したい項目(今回は、author
)に対し、datalist_fields
とdatalist_kwargs
を設定します。
# BaseDatalistModelForm
datalist_fields = ['author']
datalist_kwargs = {
'author': {
'label': _('Author'),
'queryset': models.Author.objects.all(),
},
}
さらに、widget
の設定も必要になるため、従来通り設定を行います。
widget
は、先ほど作成したDatalist
クラスを用いることに注意してください。

Viewの定義
Viewは、CreateViewとUpdateViewで使いまわしができるよう、共通クラスを定義しています。
# book/views.py
from django.views.generic import (
ListView,
CreateView,
UpdateView,
DeleteView,
)
from django.urls import reverse_lazy, reverse
from . import models, forms
# ==========
# For Author
# ==========
class ListAuthor(ListView):
model = models.Author
template_name = 'book/author_list.html'
paginate_by = 10
context_object_name = 'authors'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['create_path'] = reverse('book:create_author')
return context
class _CommonAuthorView:
model = models.Author
form_class = forms.AuthorForm
template_name = 'book/author_form.html'
success_url = reverse_lazy('book:list_author')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['parent_path'] = self.success_url
return context
class CreateAuthor(_CommonAuthorView, CreateView):
pass
class UpdateAuthor(_CommonAuthorView, UpdateView):
pass
class DeleteAuthor(DeleteView):
raise_exception = True
http_method_names = ['post']
model = models.Author
success_url = reverse_lazy('book:list_author')
# ========
# For Book
# ========
class ListBook(ListView):
model = models.Book
template_name = 'book/book_list.html'
paginate_by = 10
context_object_name = 'books'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['create_path'] = reverse('book:create_book')
return context
class _CommonBookView:
model = models.Book
form_class = forms.BookForm
template_name = 'book/book_form.html'
success_url = reverse_lazy('book:list_book')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['parent_path'] = self.success_url
return context
class CreateBook(_CommonBookView, CreateView):
pass
class UpdateBook(_CommonBookView, UpdateView):
pass
class DeleteBook(DeleteView):
raise_exception = True
http_method_names = ['post']
model = models.Book
success_url = reverse_lazy('book:list_book')
こちらは、Djangoの流儀に従って定義しています。

Templateの定義
テンプレートも似たようなものが多くなるため、共通化して定義しました。
base.html
{# templates/base.html #}
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|stringformat:'s' }}">
<head>
{# Required meta tags #}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Custom Form using Django</title>
{# Font Awesome #}
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
{# Bootstrap 5.3 #}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
{% block header %}
{% endblock %}
</head>
<body class="d-flex flex-column vh-100 bg-body-tertiary">
<main class="w-100 mx-auto mb-auto">
<div class="container">
{# contents #}
{% block content %}
{% endblock %}
</div>
</main>
{# Popperjs and Bootstrap 5.3 javascript #}
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-fbbOQedDUMZZ5KreZpsbe1LCZPVmfTnH7ois6mU1QK+m14rQ1l2bGBq41eYeM/fS"></script>
{% block bodyjs %}
<script>
const g_registerDeleteModalEvent = (args={}) => {
const configs = {
targetCSS: args.hasOwnProperty('targetCSS') ? args.targetCSS : 'delete-target-record',
formID: args.hasOwnProperty('formID') ? args.formID : 'delete-form',
modalID: args.hasOwnProperty('modalID') ? args.modalID : 'delete-modal',
targetName: args.hasOwnProperty('targetName') ? args.targetName : 'target-name',
};
const deleteBtns = document.querySelectorAll(`.${configs.targetCSS}`);
const deleteForm = document.querySelector(`#${configs.formID}`);
const targetField = document.querySelector(`#${configs.targetName}`);
for (const btn of deleteBtns) {
btn.addEventListener('click', (event) => {
deleteForm.action = btn.dataset.url;
targetField.textContent = btn.dataset.name;
const modal = new bootstrap.Modal(`#${configs.modalID}`);
modal.show();
});
}
};
</script>
{% endblock %}
</body>
</html>
index.html
{# templates/index.html #}
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<h2>{% trans "Index" %}</h2>
<div class="row justify-content-center">
<div class="col">
<div class="row row-cols-1 row-cols-md-2 g-2">
<div class="col">
<a href="{% url 'book:list_author' %}" class="link-underline link-underline-opacity-0">
<div class="card">
<div class="card-body">
<p> class="card-title fs-3">{% trans "Author list" %}</p>
<p> class="card-text">{% trans "Show authors." %}</p>
</div>
</div>
</a>
</div>
<div class="col">
<a href="{% url 'book:list_book' %}" class="link-underline link-underline-opacity-0">
<div class="card">
<div class="card-body">
<p> class="card-title fs-3">{% trans "Book list" %}</p>
<p> class="card-text">{% trans "Show books." %}</p>
</div>
</div>
</a>
</div>
</div>
</div>
</div>
{% endblock %}
list.html
{# templates/list.html #}
{% load i18n %}
<h2>{{ title }}</h2>
<div class="row justify-content-center">
<div class="col">
<div class="row row-cols-1 g-2">
<div class="col">
<div class="row">
<div class="col-12 col-md-9">
<a
href="{{ create_path }}"
class="btn btn-primary w-100"
>
{% trans "Create record" %}
</a>
</div>
<div class="col-12 col-md-3">
<a
href="{% url 'index' %}"
class="btn btn-secondary w-100"
>
{% trans "Back to top page" %}
</a>
</div>
</div>
</div>
<div class="col">
{% if objects %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
{% with instance=objects|first %}
{% for header_name in instance.get_header %}
<th scope="col">{{ header_name }}</th>
{% endfor %}
{% endwith %}
<th colspan="2">{% trans "Operate" %}</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for instance in objects %}
<tr>
{% for target in instance.get_members %}
<td {% if forloop.first %}scope="row"{% endif %}>{{ target }}</td>
{% endfor %}
<td>
<a href="{{ instance.get_update_url }}" class="text-success link-underline link-underline-opacity-0">
<i class="fas fa-edit"></i>
</a>
</td>
<td>
<div
class="text-danger delete-target-record"
data-name="{{ instance|stringformat:'s' }}"
data-url="{{ instance.get_delete_url }}"
>
<i class="fas fa-trash"></i>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<span>{% trans "There is no records. Please add records from the above button." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="modal" id="delete-modal" tabindex="-1" aria-labelledby="deletion-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<p class="modal-title fs-5" id="deletion-label">{% trans "Confirmation of deletion" %}</p>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-danger"><strong>{% trans "May I really delete this record?" %}</strong></p>
<p>{% trans "target" %}: <span id="target-name" class="text-break"></span></p>
</div>
<div class="modal-footer">
<form method="POST" action="" id="delete-form" class="w-100">
{% csrf_token %}
<div class="row g-2">
<div class="col-12 col-md-6">
<button type="submit" class="btn btn-danger w-100">
{% trans "Accept" %}
</button>
</div>
<div class="col-12 col-md-6">
<button type="button" class="btn btn-secondary w-100" data-bs-dismiss="modal">
{% trans "Close" %}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
form.html
{# templates/form.html #}
{% load i18n %}
<h2>{{ title }}</h2>
<div class="row justify-content-center">
<div class="col">
<div class="card">
<div class="card-body">
<form method="POST" id="{{ form_id }}">
{% csrf_token %}
{{ form }}
<div class="mt-1 row row-cols-1 row-cols-md-2 g-2">
<div class="col">
<button>
type="submit"
class="btn btn-primary w-100"
>
{% trans "Register" %}
</button>
</div>
<div class="col">
<a>
href="{{ parent_path }}"
class="btn btn-secondary w-100"
>
{% trans "Cancel" %}
</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
author_list.html
{# templates/book/author_list.html #}
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
{% include 'list.html' with title=_("Author list") create_path=create_path objects=authors %}
{% endblock %}
{% block bodyjs %}
<script>
(function () {
document.addEventListener('DOMContentLoaded', g_registerDeleteModalEvent);
})();
</script>
{% endblock %}
author_form.html
{# templates/book/author_form.html #}
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
{% include 'form.html' with title=_("Author form") form_id='author-form' form=form parent_path=parent_path %}
{% endblock %}
{% block bodyjs %}
{% endblock %}
book_list.html
{# templates/book/book_list.html #}
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
{% include 'list.html' with title=_("Book list") create_path=create_path objects=books %}
{% endblock %}
{% block bodyjs %}
<script>
(function () {
document.addEventListener('DOMContentLoaded', g_registerDeleteModalEvent);
})();
</script>
{% endblock %}
book_form.html
このテンプレートのみ、BaseDatalistModelForm
で定義したメソッドを呼び出し、submit時に追加処理を行えるようにします。
{# templates/book/book_form.html #}
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
{% include 'form.html' with title=_("Book form") form_id='book-form' form=form parent_path=parent_path %}
{% endblock %}
{% block bodyjs %}
{# =========== #}
{# = 追加部分 = #}
{% include form.datalist_js_template_name with form_id='book-form' datalist_ids=form.datalist_ids %}
{# =========== #}
{% endblock %}
URLの定義
config
とbook
のそれぞれで、URLを定義すれば、実装は完了となります。
config/urls.py
# config/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import path, include
from django.views.generic import TemplateView
urlpatterns = [
path('admin/', admin.site.urls),
path('i18n/', include('django.conf.urls.i18n')),
] + i18n_patterns(
path('', TemplateView.as_view(template_name='index.html'), name='index'),
path('book/', include('book.urls')),
)
book/urls.py
# book/urls.py
from django.urls import path
from django.views.generic import RedirectView
from . import views
app_name = 'book'
urlpatterns = [
path('', RedirectView.as_view(pattern_name='index')),
# Author
path('list/authors', views.ListAuthor.as_view(), name='list_author'),
path('create/author', views.CreateAuthor.as_view(), name='create_author'),
path('update/author/', views.UpdateAuthor.as_view(), name='update_author'),
path('delete/author/', views.DeleteAuthor.as_view(), name='delete_author'),
# Book
path('list/books', views.ListBook.as_view(), name='list_book'),
path('create/book', views.CreateBook.as_view(), name='create_book'),
path('update/book/', views.UpdateBook.as_view(), name='update_book'),
path('delete/book/', views.DeleteBook.as_view(), name='delete_book'),
]
以上で、実装は完了となります。
動作確認
最後に、動作確認をしておきたいと思います。
まず、データベースのmigrationを行っておきましょう。
# ホスト環境での操作
docker exec -it django.custom-form bash
# コンテナ内での操作
python manage.py makemigrations
python manage.py migrate
exit # or press Ctrl + D
その後、Webブラウザで立ち上げたWebサーバにアクセスすると、以下のような画面が表示されると思います。

では、順番にアクセスしていき、動作確認を行いましょう。
Authorの追加
Indexページにて、「Author list」にアクセスすると、以下のような画面が表示されると思います。

画面上部の「Create record」を押下すると、下記のような画面に遷移することを確認してください。

後は、「Name」部分に著者名を記載し、「Register」を押下して、著者を追加していくだけです。
数名追加した結果は、以下のようになりました。

期待通り動作していますね。
Bookの追加
続いて、本題に関連するBook
側の処理を確認していきます。
Indexページに戻り、「Book list」にアクセスすると、以下のような画面が表示されると思います。

先ほど同様に「Create record」を押下してください。
その後、「Author」のところにカーソルを当てると、先ほど追加した著者一覧が表示されると思います。

追加した著者のうち、適当な一名を選択して、テキストボックスに選択した対象が入力されていることを確認してください。

最後に、Titleを入力後、「Register」を押下します。
問題がなければ、下記のように「Author list」のページにrecordが追加されているはずです。

以上で、今回やりたかったことが実現できました。
改訂箇所
追加時の動作は期待通り動いていますが、修正時(update時)は、前回設定した値が参照されなくなっていると思います。
このような場合、utils/forms.py
でfieldを作成する際に初期値を設定する必要があるはずです。
fieldのinitial属性にデータを与えればよいはずですが、まだ検証まで出来ておりません。

修正処理を機能として実現したい場合は、この点の改善も必要になります。
まとめ
今回は、DjangoでDatalist要素を扱う方法について解説しました。
今回のポイント3つ
- 既存のWidgetとFieldを活用し、Datalist用のWidgetとFieldを作成する必要がある。
- Widgetを自作する場合は、templateの構成もあわせて検討が必要になる。
- 外部キー(Foreign key)のfieldをDatalist要素で代替する場合、form送信時に一工夫する必要がある。
Djangoは多機能なフレームワークですが、拡張しやすい構成になっているので、必要な機能がある場合は、ご自身で実現してみてはどうでしょうか。