Djangoで`<dialog>`をHTMXとhyperscriptを使ってフォームを更新する方法

結論

完成したコード:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# forms.py
class CreateWarriorForm(Form):
    name = CharField()
    items = MultipleChoiceField(widget=CheckboxSelectMultiple)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["items"].choices = [
            (choice.id, choice)
            for choice in Item.objects.all().order_by("id")
            if Item.objects.all().exists()
        ]

class CreateItemForm(Form):
    # いくつかのフォームの内容

# views.py
from utils import for_htmx

@for_htmx(use_block_from_params=True)
def create_warrior(request):
    create_warrior_form = CreateWarriorForm()
    create_item_form = CreateItemForm()
    if request.method == "POST"
        if "create-warrior" in request.POST:
            form = CreateWarriorForm(request.POST)
            if form.is_valid():
                warrior = Warrior.objects.create_warrior(request)
                messages.success(request, f"{ warrior }が正常に作成されました。")
        if "create-item" in request.POST:
            form = CreateItemForm(request.POST)
            if form.is_valid()
                item = Item.objects.create_item(request)
                messages.success(request, f"{ item }が正常に作成されました。")
            create_warrior_form = CreateWarriorForm(request.POST) # フォームの状態を維持する
    context = {"create_warrior_form": create_warrior_form, "create_item_form": create_item_form}
    return TemplateResponse(request, "my_template.html", context)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<!-- my_template.html -->
{% extends 'base.html' %}

{% block content %}
<div id="content-wrapper">
  <!-- novalidateを使用して、{{ create_warrior_form }}のデータの有効性に関係なくcreate-itemアクションを実行できるようにします -->
  <form action="." method="post" novalidate
        hx-post="."
        hx-target="#content-wrapper"
        hx-ext="morph"
        hx-swap="morph:outerHTML"
        hx-vals='{"use_block": "content"}'>
    <div _="on htmx:afterSettle from #content-wrapper 
                 set input to the last <input/> in me then add @checked to the input">
      {{ room_create_form }}
    </div>
    <button id="open-dialog-button" type="button"
            _="on click set dialog to the next <dialog/>
                  if dialog does not match @open
                    call dialog.showModal()
                  end">
      Create Item
    </button>
    <button name="create-warrior" type="submit">Submit</button>
    <dialog _="on click
                     measure me
                 set is_in_me to result.top    <= event.clientY              and
                                 event.clientY <= result.top + result.height and
                                 result.left   <= event.clientX              and
                                 event.clientX <= result.left + result.width
                 if is_in_me == false call me.close()">
      <button id="close-dialog-button" type="button"
              _="on click set dialog to the closest <dialog/>
                          if the dialog matches @open 
                                    call dialog.close() 
                                end">
        <svg fill="none"
             viewBox="0 0 24 24"
             stroke-width="1.5"
             stroke="currentColor"
             aria-hidden="true">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
      {{ create_item_form }}
      <button name="create-item" type="button"
              _="on click set dialog to the next <dialog/>
                    if dialog does not match @open
                      call dialog.showModal()
                    end">
        Submit
      </button>
    </dialog>
  </form>
</div>
{% endblock %}

問題: Djangoでオブジェクトを作成する際の直感的でないUI/UX

オブジェクトを作成するページがあります。そのページには、MultipleChoiceFieldを使用したフォームがあります。

1
2
3
class CreateWarriorForm(Form):
    name = CharField()
    items = MultipleChoiceField(widget=CheckboxSelectMultiple)

itemsはItemオブジェクトでポピュレートされる必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class CreateWarriorForm(Form):
    name = CharField()
    items = MultipleChoiceField(widget=CheckboxSelectMultiple)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["items"].choices = [
            (choice.id, choice)
            for choice in Item.objects.all().order_by("id")
            if Item.objects.all().exists()
        ]

テンプレート:

1
2
3
4
5
6
7
{% block content %}
<div id="content-wrapper">
    <form>
        {{ create_warrior_form }}
    </form>
</div>
{% endblock %}

ただし、MultipleChoiceFieldに希望する選択肢がない場合(Warriorに与えたいItemが存在しない場合)、完全に新しいページを作成してこれらのアイテムを追加し、ユーザーにWarriorを作成する前にItemを作成させることができます。そのワークフローは少し分かりにくいです。

解決策:インラインオブジェクトの作成

<dialog>を使用して、ユーザーがインラインでアイテムを作成できるようにして分かりやすくします。

<dialog>の追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{% block content %}
<div id="content-wrapper">
    <form>
        {{ create_warrior_form }}
        <button name="create-warrior" type="submit">Submit</button>
        <dialog>
            {{ create_item_form }}
            <button name="create-item" type="button">Submit</button>
        </dialog>
    </form>
</div>
{% endblock %}

リクエストオブジェクトに存在します。リクエスト内のどのボタンのnameがあるかに基づいて異なるアクションを実行できます。

<dialog>要素は、一般的にポップアップまたはモーダルと呼ばれるものを作成します。現在のページを背景としてオーバーレイし、ダイアログのinnerHTML(中央)を表示します。

<dialog>のUIは、DOM内の場所に関係なく基本的に同じように機能します。ただし、それと{{ create_warrior_form }}を含む<form>内に残る必要があります。ビューを見ると理解しやすくなります。

ビューの作成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def create_warrior(request):
    create_warrior_form = CreateWarriorForm()
    create_item_form = CreateItemForm()
    if request.method == "POST"
        if "create-warrior" in request.POST:
            form = CreateWarriorForm(request.POST)
            if form.is_valid():
                warrior = Warrior.objects.create_warrior(request)
                messages.success(request, f"{ warrior }が正常に作成されました。")
        if "create-item" in request.POST:
            form = CreateItemForm(request.POST)
            if form.is_valid()
                item = Item.objects.create_item(request)
                messages.success(request, f"{ item }が正常に作成されました。")
            create_warrior_form = CreateWarriorForm(request.POST) # フォームの状態を維持する
    context = {"create_warrior_form": create_warrior_form, "create_item_form": create_item_form}
    return TemplateResponse(request, "my_template.html", context)

ユーザーがname="create-item"のボタンをクリックした場合、CreateItemFormが検証されますが、CreateWarriorFormのデータは検証されません。フォームの残りの入力が不完全または無効でも、CreateItemFormのデータが有効であれば、このアクションは成功します。POSTリクエストには{{ create_warrior_form }}のデータが含まれているため、フォームの状態を維持するために新しいCreateWarriorFormを作成します。これにより、<form>に小さな変更が必要になりますが、次にHTMXの部分を追加するときにそれを行うことができます。

HTMXの追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{% block content %}
<div id="content-wrapper">
    <!-- novalidateを使用して、{{ create_warrior_form }}のデータの有効性に関係なくcreate-itemアクションを実行できるようにします -->
    <form action="." method="post" novalidate
          hx-post="."
          hx-target="#content-wrapper"
          hx-ext="morph"
          hx-swap="morph:outerHTML">
        {{ create_warrior_form }}
        <button name="create-warrior" type="submit">Submit</button>
        <dialog>
            {{ create_item_form }}
            <button name="create-item" type="button">Submit</button>
        </dialog>
    </form>
</div>
{% endblock %}

まず、すべての必要な属性を持つフォームを設定します。すべてのことにHTMXを使用しており、ユーザーがJavaScriptをオンにしていることを前提としていますが、フォームをブラウザが期待通りに動作させると、予期しない結果を防ぎます。

次に、hx-postをエンドポイント(現在のページ)を指すように設定します。 hx-targetは、ターゲットを交換の対象として設定します。ここでは、content-wrapperというIDを持つ要素が対象です。 hx-swapは、ターゲットのouterHTMLを交換するように設定されています。

@for_htmxを使用したテンプレートパーシャルの設定

現在、問題があります:

1
    return TemplateResponse(request, "my_template.html", context)

レスポンスにはページテンプレート全体が含まれています。そのテンプレートは、グローバルヘッダー、フッターなどが含まれてい

base.htmlテンプレートを拡張しています。それらの部分を重複させたくありません。現在のページのコンテンツのみが必要です。要するに、{% block content %}の内容が返されることを望みます。これを行うには、Luke Plantが作成したテンプレートパーシャルを返すためのデコレータ(django-render-block拡張機能を使用)を使用します。彼は、GitHubの彼のdjango-htmx-patternsリポジトリでデコレータの使用方法を示しています。

1
2
+ @for_htmx(use_block_from_params=True)
  def create_warrior(request):
1
2
3
4
5
6
    <form action="." method="post" novalidate
          hx-post="."
          hx-target="#content-wrapper"
          hx-ext="morph"
          hx-swap="morph:outerHTML"
+         hx-vals='{"use_block": "content"}'>

この小さな変更により、ビューはテンプレート内のcontentブロックをレスポンスに返します。

ダイアログの開閉にhyperscriptを追加

MDNドキュメントの<dialog>要素は、ダイアログを開くためのJavaScriptとダイアログを閉じるための必要なスクリプトを示しています(冗談じゃねーよ、馬鹿コラ)。基本的に、ダイアログでshowModal()close()を使用します。純粋なJavaScriptを使用する代わりに、hyperscriptを使用できます。動作の局所性の原則に従うと、ボタンの機能はすべて<button>要素自体を見るだけで簡単に理解できるようになります。

まず、ボタン:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{% block content %}
<div id="content-wrapper">
    <!-- novalidateを使用して、{{ create_warrior_form }}のデータの有効性に関係なくcreate-itemアクションを実行できるようにします -->
    <form action="." method="post" novalidate
          hx-post="."
          hx-target="#content-wrapper"
          hx-ext="morph"
          hx-swap="morph:outerHTML"
          hx-vals='{"use_block": "content"}'>
        {{ create_warrior_form }}
+       <button id="open-dialog-button" type="button"
+                _="on click set dialog to the next <dialog/>
+                      if dialog does not match @open
+                        call dialog.showModal()
+                      end">
+           Create Item
+       </button>
        <button name="create-warrior" type="submit">Submit</button>
+       <dialog _="on click
+                     measure me
+                     set is_in_me to result.top    <= event.clientY              and
+                                     event.clientY <= result.top + result.height and
+                                     result.left   <= event.clientX              and
+                                     event.clientX <= result.left + result.width
+                     if is_in_me == false call me.close()">
+           <button id="close-dialog-button" type="button"
+                    _="on click set dialog to the closest <dialog/>
                           if the dialog matches @open 
                               call dialog.close() 
                           end">
+              <svg fill="none"
+                   viewBox="0 0 24 24"
+                   stroke-width="1.5"
+                   stroke="currentColor"
+                   aria-hidden="true">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
+              </svg>
+            </button>
            {{ create_item_form }}
            <button name="create-item" type="button"
                    _="on click set dialog to the next <dialog/>
+                      if dialog does not match @open
+                        call dialog.showModal()
+                      end">
                Submit
            </button>
        </dialog>
    </form>
</div>
{% endblock %}

hyperscriptは分かりやすいと思います。

まず、open-dialog-button について説明します。最初に、dialog というローカル変数を DOM 内の次の dialog 要素に設定します。その dialog 要素に open パラメータが設定されていない場合、その dialogshowModal() を呼び出します。create-item ボタンについてもまったく同じことを行います。さらに、close() を呼び出すための閉じるボタンもあります。

さらなる利便性向上の変更として、ユーザーがダイアログウィンドウの外側 (バックドロップ) をクリックした場合にダイアログを閉じるようにします。ダイアログ要素内で、クリックイベントが発生したら、ダイアログウィンドウのサイズを測定し、is_in_me 変数をダイアログ内の座標に設定します。その後、クリックイベントがダイアログ内になければ、ダイアログで close() を呼び出します。

ほぼ完成ですが、もう一歩進められます。ユーザーが新しいアイテムを作成すると、そのアイテムは MultipleSelectField に追加されますが、ユーザーはそれを選択するためにチェックボックスをクリックする必要があります。少し手助けしてあげましょう。

1
2
3
4
<div _="on htmx:afterSettle from #content-wrapper 
           set input to the last <input/> in me then add @checked to the input">
    {{ room_create_form }}
</div>

HTMXとhyperscriptは、両方ともイベントとよく動作するように設計されており、HTMXはたくさんのイベントを発射しますhtmx:afterSettleは、HTMXがコンテンツの交換を完全に終了したときに呼び出されるイベントです。 HTMXが完全に交換を終了すると、hyperscriptは新しく作成された<input type="checkbox">checkedを設定します。

以上です。Djangoでオブジェクトを作成する際の分かりにくいUI/UXの問題に対する優れた解決策です。

まとめ

一連の動作の流れは以下の通りです。

  1. ユーザーがページを開き、アイテム作成ボタンをクリックします。
  2. hyperscript がダイアログを開きます。
  3. ユーザーが {{ create_item_form }} を入力し、"create-item" という名前の送信ボタンをクリックします。
  4. hyperscript がダイアログを閉じます。
  5. HTMX が POST リクエストをビューに送信します。
  6. ビューがフォームを処理し、POST リクエストの内容を使用して新しい CreateWarriorForm を作成してフォームの状態を維持します。
  7. ビューは @for_htmx デコレータとテンプレート内の hx-vals のおかげで、{% block content %} の内容のみを含むレスポンスを返します。
  8. HTMX は
    を取得し、レスポンス内容と置き換えます。
  9. HTMX が新しいコンテンツの入れ替え処理を完了し、htmx:afterSettle イベントが発行されると、hyperscript は新しく追加されたチェックボックスに checked を設定します。
  10. ユーザーがアイテムを追加後、"create-warrior" という名前の送信ボタンをクリックします。
  11. 再びフォームデータがビューに送信され、create-warrior アクションが処理され、{% block content %} の内容が新しいデータを含めて送信されます。
  12. HTMX が入れ替えを行います。
Profile Photo

Author

Added

2024年2月7日

Technologies
HTMX HTMX
hyperscript hyperscript
Django Django