Better Nested Attributes in Rails with the Cocoon Gem
Bài đăng này đã không được cập nhật trong 8 năm
Trong bài này chúng ta sẽ cùng thảo luận về vấn đề làm thế nào để xây dựng forms sử dụng đặc tính Rails nested attributes. Tôi sẽ trình bày với bạn làm thế nào vận dụng nhiều các bản ghi quan hệ từ một single form và thiết lập đụng các models và controller để kich hoạt những tính năng này. Thật vậy, chúng ta sẽ thảo luận về các lỗi hay găp phải và chúng ta sẽ sử dụng gem Cocoon để làm cho form của chúng ta trở lên linh hoạt. Giải pháp này cho phép thêm và xóa bỏ nested fiedlds không đồng bằng việc cung cấp nhiều option và callbacks.
Building a Simple Form
Đầu tiên chúng ta sẽ tạo một ứng dụng mà không đi kèm test mặc định.
$ rails new NestedForms -T
Giả sử rằng, với app này, chúng ta muốn giữ lưu các places yêu thích và address của chúng. Ví dụ, một plasce có nhiều address, vì thế chúng ta sẽ mô tả nó sử dụng quan hệ:
$ rails g model Place title:string
$ rails g model Address city:string street:string place:belongs_to
$ rake db:migrate
Cần đảm bảo rằng các quan hệ này được cài đặt chính xác:
models/place.rb
[...]
has_many :addresses, dependent: :destroy
[...]
models/address.rb
[...]
belongs_to :place
[...]
Giờ tới controller: app/controllers/places_controller.rb
class PlacesController < ApplicationController
def index
@places = Place.all
end
def new
@place = Place.new
end
def create
@place = Place.new(place_params)
if @place.save
redirect_to root_path
else
render :new
end
end
private
def place_params
params.require(:place).permit(:title)
end
end
Thêm routes:
config/routes.rb
[...]
resources :places, only: [:new, :create, :edit, :update]
root to: 'places#index'
[...]
View root page:
views/places/index.html.erb
<h1>Places</h1>
<p><%= link_to 'Add place', new_place_path %></p>
<ul><%= render @places %></ul>
Chúng ta có render @places, chúng ta cần tạo partial tương ứng: views/places/_place.html.erb
<li>
<strong><%= place.title %></strong><br>
<% if place.addresses.any? %>
Addresses:
<ul>
<% place.addresses.each do |addr| %>
<li>
<%= addr.city %>, <%= addr.street %>
</li>
<% end %>
</ul>
<% end %>
</li>
tạo view places:
views/places/new.html.erb
<h1>Add place</h1>
<%= render 'form' %>
tạo partial form:
views/places/_form.html.erb
<%= render 'shared/errors', object: @place %>
<%= form_for @place do |f| %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<%= f.submit %>
<% end %>
tạo view để hiển thị lỗi:
views/shared/_errors.html.erb
<% if object.errors.any? %>
<div>
<strong>
<%= pluralize(object.errors.count, 'error') %> were found
</strong>
<ul>
<% object.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
Tuy vậy, về phía người dùng thì tôi thích thêm places của address ngay trên cùng một form thay vì tạo ra hai form riêng. Điều này cũng giúp chúng ta chỉ phải thao tác trên một controller. Đây là nơi nested attribute được sử dụng.
Adding Nested Attributes
Ý tưởng đằng sau nested attribute là khá đơn giản. Bạn có signle form nơi mà bạn có thể tạo một object với nhiều các bản ghi quan hệ. Tính năng này có thể được bổ sung thực sự nhanh chóng. Vì nó đòi hỏi sự thay đổi rất nhỏ ở controller và model, cũng như một số đánh dấu. Tất cả bắt đầu với việc bổ sung từ khóa dài: accepts_nested_attributes_for.
models/places.rb
[...]
accepts_nested_attributes_for :addresses
[...]
chúng ta đã thêm method accepts_nested_attributes_for vào model, trong controller chúng ta có thể thao tác với hàng loạt places thông qua cơ chế mass-assignment. Controller sẽ thay đổi như sau:
places_controller.rb
[...]
private
def place_params
params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street])
end
[...]
Khi bạn submit một form với nested field, params[:place] sẽ chứa 1 mảng dưới một key là :addresses_attributes, Mảng này sẽ mô tả mỗi địa chỉ được thêm vào database. Miễn là chúng ta sử dụng strong_params, những thuộc tính mới sẽ được permit. Giờ chung ta sẽ thêm nested form vào view: views/places/_form.html.erb
<%= form_for @place do |f| %>
<%= render 'shared/errors', object: @place %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<p><strong>Addresses:</strong></p>
<%= f.fields_for :addresses do |address| %>
<div>
<%= address.label :city %>
<%= address.text_field :city %>
<%= address.label :street %>
<%= address.text_field :street %>
</div>
<% end %>
</div>
<%= f.submit %>
<% end %>
Phương thức field_for, nó khá giống với phương thức form_for nhưng nó không cung cấp form_tag. Chú ý bên trong block chúng ta sử dụng biến cục bộ address - không được gọi nó f bời vì nó đã bao gồm builder cho form cha nó. Có một vấn đề, tuy vây. Khi bạn ghé thăm "New Places" page sẽ không nhìn thấy nested field, bởi vì chắc chắn thể hiện mới của places class không chứa nested addresses. Việc sửa chữa đơn giản, sẽ tạo một số address trực tiếp ngay trong controller:
places_controller.rb
[...]
def new
@place = Place.new
3.times { @place.addresses.build}
end
[...]
Đây không phải là một giải pháp tốt, sau này chúng ta sẽ thay thế nó.
Bây giờ bạn có thể khởi động server, vào trang new place và tạo place với nested address.
A Bit of Validation
Bây giờ, một user có thể tạo một places với một list address trống. Cái đó có lẽ là điều chúng ta không muốn. Để kiểm soát việc này, sử dụng reject_if lưa chọn chấp nhận một lambda hoăc :all_blank value, :all_blank sẽ loại bỏ một bản ghi nơi mà tất cả các thuộc tính trống. Tuy vậy, trong một số trường hợp, chúng ta chỉ muốn loại bỏ nếu một số thuộc tính trống. Vì vậy hãy sử dụng lambda:
models/place.rb
[...]
accepts_nested_attributes_for :addresses,
reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? }
[...]
Bây giờ bất cứ address nào nếu không có city hoặc streest sẽ không được lưu vào database.
Destroy ’em
Address có thể được thêm vào, nhưng không có cách nào để remove nó sau đó. Để giải quyết trường hợp này, accepts_nested_attributes_for được cung cấp một lựa chọn khác :
models/place.rb
[...]
accepts_nested_attributes_for :addresses, allow_destroy: true,
reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? }
[...]
Điều này có nghĩa là chúng ta có thể hủy bỏ các nested record. Để hủy một nested record thì thuộc tính _destroy được set value = 1, và tất nhiêu chúng ta cũng phải permit cho thuộc tính này .
places_controller.rb
[...]
private
def place_params
params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street, :_destroy])
end
[...]
Thêm checkbox để đánh dấu nested record để xóa:
views/places/_form.html.erb
[...]
<div>
<p><strong>Addresses:</strong></p>
<%= f.fields_for :addresses do |address| %>
<div>
<%= address.label :city %>
<%= address.text_field :city %>
<%= address.label :street %>
<%= address.text_field :street %>
<%= address.check_box :_destroy %>
</div>
<% end %>
</div>
[...]
Bây giờ trong controller có 2 action:
places_controller.rb
[...]
def edit
@place = Place.find_by(id: params[:id])
end
def update
@place = Place.find_by(id: params[:id])
if @place.update_attributes(place_params)
redirect_to root_path
else
render :edit
end
end
[...]
Trong các action này cũng không có gì đặc biệt. Ta config lại routes:
[...]
resources :places, only: [:new, :create, :edit, :update]
[...]
Ta thêm đường dẫn tới trang "Edit":
views/places/_place.html.erb
<li>
<strong><%= place.title %></strong> | <%= link_to 'Edit place', edit_place_path(place) %><br>
[...]
</li>
Giờ có thể test thử chắc năng destroy.
Making It Dynamic
Giờ form cơ bản đã xong, tuy nhiên sử dụng nó không được tiện lợi. Ví dụ không có cách nào có thể thêm nhiền hơn 3 address. Để làm được tính năng này thì phải làm nhiều việc, vì rails không hỗ trợ thêm nhiều field. Chúng ta có một giải pháp ở đây, sử dụng gem Cocoon và nó thật tuyệt vời. Cocoon tác động nested với javascript. Cho phép thêm hoặc xoa field môt cách linh động.
Để bắt đầu với gem Cocoon thật đơn giản. thêm gem mới:
Gemfile
[...]
gem "cocoon"
[...]
Và cài đặt nó:
$ bundle install
thêm vào file javascript mới:
javascripts/application.js
[...]
//= require cocoon
[...]
Giờ ta tách từng nested field ra thành từng phần riêng biệt:
views/places/_address_fields.html.erb
<div class="nested-fields">
<%= f.label :city %>
<%= f.text_field :city %>
<%= f.label :street %>
<%= f.text_field :street %>
<%= f.check_box :_destroy %>
<%= link_to_remove_association "remove address", f %>
</div>
ở đây ta thấy link_to_remove_association, Đây là một helper của Cocoon, tạo ra một liên kết mới không đồng bộ để xóa các associated record. Method nay chấp nhận 3 tham số:
- tiêu đề của link
- form object
- HTML options Bây giờ chúng ta sử dụng partial này dưới form:
views/places/_form.html.erb
<%= form_for @place do |f| %>
<%= render 'shared/errors', object: @place %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<p><strong>Addresses:</strong></p>
<div id="addresses">
<%= f.fields_for :addresses do |address| %>
<%= render 'address_fields', f: address %>
<% end %>
<div class="links">
<%= link_to_add_association 'add address', f, :addresses %>
</div>
</div>
</div>
<%= f.submit %>
<% end %>
ở đây chúng ta sử dụng một helper khác của Cocoon link_to_add_association. Nó sẽ tạo ra một liên kết động để có thể thêm nested field. Method này nhận đầu vào 4 tham số:
- nội dung của link(text)
- form builder (parent's form)
- tên của quan hệ
- HTML option Giờ thì lại hãy khởi động lại server và thử thao tác lại xem, chắc chán thuận tiện hơn.
Conclusion Ở trên chúng ta vừa thử tạo ra form nested cơ bản và đã thử dùng gem Cocoon. Còn một vài phần nữa nhưng tôi nghĩ nên dể dành cho phần khác, giờ chúng ta hãy cứ thực hành với các phần ở trên đã. Cảm ơn mọi người đã đọc bài của tôi.
Link tham khảo:
All rights reserved