iT邦幫忙

DAY 22
0

為程式人寫的 Django Tutorial系列 第 22

Django Tutorial for Programmers: 22. Form sets

內容diff。今天很長。

其實這個內容跟本應該放在更前面,只是我**忘記了**,所以只好現在補講。昨天我們說要做讓使用者修改點餐的功能,就先延到明天。

我們現在可以在 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 等等)來產生會是更好的選擇。不過這就超出這個教學的範圍了。

上一篇
Django Tutorial for Programmers: 21. Extending CBVs
下一篇
Django Tutorial for Programmers: 23. 收尾
系列文
為程式人寫的 Django Tutorial30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言