結論
完成したコード:
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
を使用したフォームがあります。
class CreateWarriorForm ( Form ):
name = CharField ()
items = MultipleChoiceField ( widget = CheckboxSelectMultiple )
items
はItemオブジェクトでポピュレートされる必要があります。
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 ()
]
テンプレート:
{% 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
を使用したテンプレートパーシャルの設定
現在、問題があります:
return TemplateResponse ( request , "my_template.html" , context )
レスポンスにはページテンプレート全体が含まれています。そのテンプレートは、グローバルヘッダー、フッターなどが含まれてい
るbase.html
テンプレートを拡張しています。それらの部分を重複させたくありません。現在のページのコンテンツのみが必要です。要するに、{% block content %}
の内容が返されることを望みます。これを行うには、Luke Plantが作成したテンプレートパーシャルを返すためのデコレータ(django-render-block拡張機能を使用)を使用します。彼は、GitHubの彼のdjango-htmx-patterns リポジトリでデコレータの使用方法を示しています。
+ @for_htmx(use_block_from_params=True)
def create_warrior(request):
<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
パラメータが設定されていない場合、その dialog
で showModal()
を呼び出します。create-item
ボタンについてもまったく同じことを行います。さらに、close()
を呼び出すための閉じるボタンもあります。
さらなる利便性向上の変更として、ユーザーがダイアログウィンドウの外側 (バックドロップ) をクリックした場合にダイアログを閉じるようにします。ダイアログ要素内で、クリックイベントが発生したら、ダイアログウィンドウのサイズを測定し、is_in_me
変数をダイアログ内の座標に設定します。その後、クリックイベントがダイアログ内になければ、ダイアログで close()
を呼び出します。
ほぼ完成ですが、もう一歩進められます。ユーザーが新しいアイテムを作成すると、そのアイテムは MultipleSelectField
に追加されますが、ユーザーはそれを選択するためにチェックボックスをクリックする必要があります。少し手助けしてあげましょう。
< 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の問題に対する優れた解決策です。
まとめ
一連の動作の流れは以下の通りです。
ユーザーがページを開き、アイテム作成ボタンをクリックします。
hyperscript がダイアログを開きます。
ユーザーが {{ create_item_form }} を入力し、"create-item" という名前の送信ボタンをクリックします。
hyperscript がダイアログを閉じます。
HTMX が POST リクエストをビューに送信します。
ビューがフォームを処理し、POST リクエストの内容を使用して新しい CreateWarriorForm を作成してフォームの状態を維持します。
ビューは @for_htmx デコレータとテンプレート内の hx-vals のおかげで、{% block content %} の内容のみを含むレスポンスを返します。
HTMX は を取得し、レスポンス内容と置き換えます。
HTMX が新しいコンテンツの入れ替え処理を完了し、htmx:afterSettle イベントが発行されると、hyperscript は新しく追加されたチェックボックスに checked を設定します。
ユーザーがアイテムを追加後、"create-warrior" という名前の送信ボタンをクリックします。
再びフォームデータがビューに送信され、create-warrior アクションが処理され、{% block content %} の内容が新しいデータを含めて送信されます。
HTMX が入れ替えを行います。
記事とガイドに戻る