Skip to content

Commit

Permalink
Chapter 22
Browse files Browse the repository at this point in the history
  • Loading branch information
uranusjr committed Oct 20, 2014
1 parent 25c6f2b commit 98b734b
Show file tree
Hide file tree
Showing 5 changed files with 280 additions and 7 deletions.
204 changes: 204 additions & 0 deletions 22-formsets.md
@@ -0,0 +1,204 @@
其實這個內容跟本應該放在更前面,只是我**忘記了**,所以只好現在補講。昨天我們說要做讓使用者修改點餐的功能,就先延到明天。

我們現在可以在 admin 新增修改店家與菜單內容,但是在一般頁面中只能新增修改店家,不能修改菜單(雖然已經加入的會被列出)。我們可以像店家的表單一樣,把它獨立成一個 model form,但是這種做法有個問題:一次只能新增一個項目。要怎麼像 Django Admin 那樣,有個表格可以一次建立很多個項目?

答案就是 formsets。事實上 admin 裡的那個表格就是用它做的!顧名思義,一個 formset 就是「很多表單的集合體」。這有很多用途,例如一次讓使用者上傳很多圖片,或者更常見的,是用來處理一對多關係中的多個項目。

在這裡,我們想要建立指向某個 `Store` object 的 `MenuItem` model instances。所以我們可以使用內建的 factory method:

```python
# stores/views.py

from django.forms.models import inlineformset_factory
from .models import MenuItem

def store_update(request, pk):
# ...
MenuItemFormSet = inlineformset_factory(
parent_model=Store, model=MenuItem, extra=1,
)
menu_item_formset = MenuItemFormSet(instance=store)
return render(request, 'stores/store_update.html', {
'form': form, 'store': store, 'menu_item_formset': menu_item_formset,
})
```

之前已經看過 `modelform_factory`,這裡的用法類似,只是我們需要多指定一個 `parent_model` 參數,Django 才知道要用哪一個 foreign key 建立一對多關聯。[註 1]`instance` 指定 foreign key 指向的 parent instance,Django 就會自動幫你把關聯的物件預先取出,並根據 `extra` 的數值增加空白欄位。

在 template 中顯示這個 formset:

```html
{# stores/templates/stores/store_update.html #}

{# ... #}

{% crispy form %}
{% crispy menu_item_formset %} <!-- 新增這一行 -->
```

選一個店家,按「更新店家資訊」進去,應該會看到下面多出了可以填入菜單的欄位!不過這些欄位還沒有作用,因為它們沒有在 form tag 裡面,我們也還沒在 post method 實作儲存。

首先為 menu item formset 實作一個 helper,把我們不需要的東西清掉:

```python
# stores/forms.py

from django.forms.models import inlineformset_factory
from .models import MenuItem

BaseMenuItemFormSet = inlineformset_factory(
parent_model=Store, model=MenuItem, fields=('name', 'price',), extra=1
)

class MenuItemFormSet(BaseMenuItemFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False # 我們要自己包。
self.helper.disable_csrf = True # StoreForm 已經有 CSRF token,不需要重複產生。
```

`store_update` 改成下面這樣:

```python
from .forms import MenuItemFormSet

def store_update(request, pk):
try:
store = Store.objects.get(pk=pk)
except Store.DoesNotExist:
raise Http404

if request.method == 'POST':
form = StoreForm(request.POST, instance=store, submit_title='更新')

# 用 post data 建立 formset,並在其(與 store form)合法時儲存。
menu_item_formset = MenuItemFormSet(request.POST, instance=store)
if form.is_valid() and menu_item_formset.is_valid():
store = form.save()
menu_item_formset.save()
return redirect(store.get_absolute_url())
else:
# 移除 submit button 與 form tag。
form = StoreForm(instance=store, submit_title=None)
form.helper.form_tag = False

menu_item_formset = MenuItemFormSet(instance=store)

return render(request, 'stores/store_update.html', {
'form': form, 'store': store, 'menu_item_formset': menu_item_formset,
})
```

注意有註解的段落。接著在 template 中手動加上 form tag 與 submit button:

```html
{# stores/templates/stores/store_update.html #}

{# 替換 content block 的內容 #}
<form method="post">
{% crispy form %}
{% crispy menu_item_formset %}
<input type="submit" class="btn btn-primary" value="更新">
</form>
```

這樣結構就正確了!新增刪除幾個項目看看吧。

接著我們要實作 admin 裡面新增欄位的功能。在實作之前,我們必須先了解 formset 的運作原理。每個 formset 其實都分為兩個部分:

1. Management form,裡面包含四個欄位:
* Total forms,代表目前 formset 中有幾個 forms。
* Initial forms,代表「一開始」有幾個 forms。
* Minimum 與 maximum forms,代表最多與最少可以有幾個 forms。這可以在建立 formset 時指定,但我們這裡不管,就用預設值(最少 0 個,最多可以有數千個,應該夠用)。
2. Form 列表,包括原本已經存在的項目,以及 `extra` 參數指定的額外空白項目。表單數量會和 initial forms 的值相等。

所以我們可以這樣實作新增欄位按鈕:

1. 按下按鈕時,clone 一個 formset 中的 form。
2. 修改 form 中的 ID 與標籤,讓它能被 Django 識別。
3. 修改 total forms 參數,讓 Django 知道欄位數量有變。

這個動作的主要用意是讓我們可以自訂 menu item form 的格式;我們需要用一個 `div` 把每個單獨的 form 包起來,並為它加上 CSS class。

修改 `store_update.html`

```html
<!-- 引入 "static" tag -->
{% load staticfiles %}

<!-- 替換原本的 content block -->
{% block content %}
<form method="post">
{% crispy form %}

<!-- 手動一個一個產生 formset 中的 forms,並在它們外面包一層 div -->
{{ menu_item_formset.management_form }}
{% for form in menu_item_formset %}
<div class="menu-item form-group">
{% crispy form menu_item_formset.helper %}
</div>
{% endfor %}

<!-- 增加這行 -->
<a href="#" class="menu-item-add btn btn-default">新增菜單項目</a>
<input type="submit" class="btn btn-primary" value="更新">
</form>
{% endblock content %}

<!-- 加上這個 block -->
{% block js %}
{{ block.super }}
<script src="{% static 'stores/js/store_update.js' %}"></script>
{% endblock js %}
```

這裡注意到 `crispy` tag 其實可以接受第二個參數,動態在 template 中指定要用的 form helper。我們這裡讓所有 formset 中的 forms 都沿用 formset 的 helper。

最後建立 `stores/static/stores/js/store_update.js`,實作新增欄位:

```javascript
(function ($) {

$('.menu-item-add').click(function (e) {
e.preventDefault();

var lastElement = $('.menu-item:last');
var totalForms = $('#id_menu_items-TOTAL_FORMS');
var total = totalForms.val();

var newElement = lastElement.clone(true);
newElement.find(':input').each(function() {
var name = $(this).attr('name').replace(
'-' + (total - 1) + '-',
'-' + total + '-'
);
var id = 'id_' + name;
$(this).attr({'name': name, 'id': id}).val('').removeAttr('checked');
});
newElement.find('label').each(function() {
$(this).attr('for', $(this).attr('for').replace(
'-' + (total - 1) + '-',
'-' + total + '-'
));
});

totalForms.val(total + 1);
newElement.insertAfter(lastElement);
});

})(jQuery);
```

這段 JavaScript 會找到 formset 中的最後一個項目(所以我們前面要用 div 把每個 form 包起來,這裡才能方便使用)clone 一份,修改其中欄位的 ID 與 name,把原本的值清除,把它 insert 到最後面,再修改 total forms 欄位值。這看起來實在非常麻煩,幸好除非你對 form 做了什麼奇怪的實情,否則這個 script 基本上可以一直沿用下去,只要修改 `lastElement``totalForms` 的 selector 就好了。[註 2]

重新整理看看!原本的 delete checkbox 應該會被紅色的 delete 刪除按鈕取代,而「更新」旁邊也多了一個「新增」按鈕,可以用來動態新增欄位。

終於完成了!下週我們會回到正常進度。


---

註 1:你或許會問,如果有不止一個 foreign key 指向同一個 model,Django 要怎麼知道應該使用哪一個?為了避免這種狀況,其實 `inlineformset_factory` 可以接受一個叫 `fk_name` 的參數,讓你指定欄位名稱。但在這裡 `MenuItem` 只有一個 foreign key 指向 `Store`,所以不需要指定這個參數,Django 會自動偵測。

註 2:如果你仔細看產生的 HTML,會發現我們沒有改到所有元件的 IDs,只改了 input 欄位。這樣就足夠讓 Django 正確反應,不過如果你有 CSS 或 JavaScript 需求,必須讓所有的元件都被正確修改,或許用 JavaScript template engine(Mustache、Underscore.js 的範本、或者 Handlebars 等等)來產生會是更好的選擇。不過這就超出這個教學的範圍了。
17 changes: 16 additions & 1 deletion lunch/lunch/stores/forms.py
@@ -1,5 +1,6 @@
from django import forms
from .models import Store
from django.forms.models import inlineformset_factory
from .models import Store, MenuItem
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit

Expand All @@ -15,3 +16,17 @@ def __init__(self, *args, submit_title='Submit', **kwargs):
self.helper = FormHelper()
if submit_title:
self.helper.add_input(Submit('submit', submit_title))


BaseMenuItemFormSet = inlineformset_factory(
parent_model=Store, model=MenuItem, fields=('name', 'price',), extra=1
)


class MenuItemFormSet(BaseMenuItemFormSet):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
30 changes: 30 additions & 0 deletions lunch/lunch/stores/static/stores/js/store_update.js
@@ -0,0 +1,30 @@
(function ($) {

$('.menu-item-add').click(function (e) {
e.preventDefault();

var lastElement = $('.menu-item:last');
var totalForms = $('#id_menu_items-TOTAL_FORMS');
var total = totalForms.val();

var newElement = lastElement.clone(true);
newElement.find(':input').each(function() {
var name = $(this).attr('name').replace(
'-' + (total - 1) + '-',
'-' + total + '-'
);
var id = 'id_' + name;
$(this).attr({'name': name, 'id': id}).val('').removeAttr('checked');
});
newElement.find('label').each(function() {
$(this).attr('for', $(this).attr('for').replace(
'-' + (total - 1) + '-',
'-' + total + '-'
));
});

totalForms.val(total + 1);
newElement.insertAfter(lastElement);
});

})(jQuery);
24 changes: 22 additions & 2 deletions lunch/lunch/stores/templates/stores/store_update.html
@@ -1,11 +1,31 @@
{% extends 'stores/base.html' %}

{% load crispy_forms_tags %}
{% load staticfiles crispy_forms_tags %}


{% block title %}更新 {{ store.name }} | {{ block.super }}{% endblock title %}


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

{% crispy form %}

{{ menu_item_formset.management_form }}
{% for form in menu_item_formset %}
<div class="menu-item form-group">
{% crispy form menu_item_formset.helper %}
</div>
{% endfor %}

<a href="#" class="menu-item-add btn btn-default">新增菜單項目</a>
<input type="submit" class="btn btn-primary" value="更新">

</form>
{% endblock content %}


{% block js %}
{{ block.super }}
<script src="{% static 'stores/js/store_update.js' %}"></script>
{% endblock js %}
12 changes: 8 additions & 4 deletions lunch/lunch/stores/views.py
Expand Up @@ -4,8 +4,8 @@
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from events.forms import EventForm
from .forms import StoreForm, MenuItemFormSet
from .models import Store
from .forms import StoreForm


def store_list(request):
Expand Down Expand Up @@ -46,13 +46,17 @@ def store_update(request, pk):
raise Http404
if request.method == 'POST':
form = StoreForm(request.POST, instance=store)
if form.is_valid():
menu_item_formset = MenuItemFormSet(request.POST, instance=store)
if form.is_valid() and menu_item_formset.is_valid():
store = form.save()
menu_item_formset.save()
return redirect(store.get_absolute_url())
else:
form = StoreForm(instance=store, submit_title='更新')
form = StoreForm(instance=store, submit_title=None)
form.helper.form_tag = False
menu_item_formset = MenuItemFormSet(instance=store)
return render(request, 'stores/store_update.html', {
'form': form, 'store': store,
'form': form, 'store': store, 'menu_item_formset': menu_item_formset,
})


Expand Down

0 comments on commit 98b734b

Please sign in to comment.