Sử dụng Nested Attributes với Gem Cocoon
Bài đăng này đã không được cập nhật trong 7 năm
Trong bài này chúng ta sẽ thảo luận về việc xây dựng form có sử dụng nested attributes
. Làm sao có thể để vận dụng kết hợp nhiều associated record từ một single form và thiết lập các model và controller để sử dụng tính năng này. Cũng như, chúng ta sẽ thảo luận các lỗi phổ biến và các tính năng mở rộng form tuỳ vào việc làm nó linh động hơn với việc sử dụng gem Cocoon
. Phương pháp này cho phép thêm và xoá nested fields không đồng bộ và cung cấp rất nhiều lựa chọn cho người dùng và callbacks.
Xây dựng một Simle Form
Đối với demo này, tôi sẽ sử dụng Rails 5 nhưng vẫn có thể dùng Rails 3 và 4.
Hãy tạo ra một ứng dụng mà không có bộ test mặc định:
$ rails new NestedForms -T
Giả sử rằng, với app này, chúng ta muốn lưu lại các địa điểm yêu thích và địa chỉ của chúng. Ví dụ, nếu bạn enter vào địa điểm “Cafe” thì sẽ có một loạt các địa chỉ cafe chúng ta ưa thích. Điều này có nghĩa rằng một nơi có thể có nhiều địa chỉ, vậy chúng ta sẽ mô tả nó như sau:
$ rails g model Place title:string
$ rails g model Address city:string street:string place:belongs_to
$ rake db:migrate
Hãy chắc rằng các liên kết đó được thiết lập chính xác:
models/place.rb
has_many :addresses, dependent: :destroy
models/address.rb
belongs_to :place
Hiện tại code là một PlacesController cơ bản:
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'
Bây giờ, xem root page:
views/places/index.html.erb
<h1>Places</h1>
<p><%= link_to 'Add place', new_place_path %></p>
<ul><%= render @places %></ul>
Đã thêm render @places, chúng ta cũng cần có partial:
_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 cho create place:
views/places/new.html.erb
<h1>Add place</h1>
<%= render '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 %>
Với kinh nghiệm của một người dùng, để tốt hơn tôi muốn cho thêm các địa chỉ của place trên cùng một form hơn là code thêm một form riêng biệt. Điều này cũng sẽ tiết kiệm cho ta từ việc code thêm một controller cho các địa chỉ.
Thêm Nested Attributes
Ý tưởng đằng sau nested attributes
tương đối đơn giản. Bạn có một single form nơi bạn có thể tạo ra object cùng với các associated record. Tính năng này sẽ được thêm rất nhanh, kèm theo các điều chỉnh nhỏ tới controller and model.
Tất cả bắt đầu bằng việc thêm phương thức accepts_nested_attributes_for
:
models/places.rb
accepts_nested_attributes_for :addresses
Trong controller:
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 fields, params[:place]
sẽ bao gồm một array với key là :addresses_attributes
. Array này mô tả mỗi địa chỉ được thêm vào trong database. Như chúng ta đang dùng strong_params, các attributes mới này phải được cho phép một cách rõ ràng.
Giờ chúng ta 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 %>
Với phương thức fields_for
, bạn có lẽ đã đoán được việc thêm các nested fields
. Nó hơi giống với form_for nhưng nó không cung cấp thẻ form chính nó. Chú ý rằng, bên trong block, tôi đang sử dụng một biến local address
mới.
Khi bạn vào trang “New Place", bạn sẽ không thấy bất kỳ nested fields nào, bởi vì chắc chắn Place chưa build bất kì các địa chỉ nested nào trong controller:
places_controller.rb
def new
@place = Place.new
3.times { @place.addresses.build}
end
Tuy nhiên đây chưa phải là giải pháp tốt nhất và chúng ta sẽ giải quyết nó sau.
Bạn giờ có thể khởi động lại server, chuyển tới trang “New place”, và thử tạo ra một place với vài nested addresses. Tuy nhiên, những điều đó không phải lúc nào cũng trơn tru, phải không? Nếu bạn đang dùng Rails 5, giống tôi, bạn sẽ nhìn thấy lỗi lạ “Addresses place must exist” ngăn cản form được submit. Sự xuất hiện này là một bug chính trong Rails 5 liên hệ tới belongs_to_required_by_default
được thiết lập là true. Thiết lập này có nghĩa là associated record được mặc định là có. Bạn có thể thiết lập lại là false.
Có cách khác nữa để fix ở đây là dùng inverse_of
:
models/place.rb
has_many :addresses, dependent: :destroy, inverse_of: :place
Validation
Hiện tại, người dùng có thể tạo một place với một list các địa chỉ rỗng, cái mà có lẽ bạn không muốn. Để kiểm soát hành vi này, dùng reject_if
để bỏ qua các record có attributes là rỗng.
models/place.rb
accepts_nested_attributes_for :addresses,
reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? }
Các địa chỉ không có city hoặc street sẽ không được lưu trong database.
Destroy
Các địa chỉ đã có thể thêm, nhưng không có cách nào để xoá chúng sau đó. Để khắc phục vấn đề này, cung cấp thêm option khác cho phương thức accepts_nested_attributes_for
:
models/place.rb
private
def place_params
params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street, :_destroy])
end
Thêm một checkbox đánh dấu nested record để xoá:
_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>
Thêm action trong controller:
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
Thêm trong routes:
config/routes.rb
resources :places, only: [:new, :create, :edit, :update]
Link "Edit":
_views/places/place.html.erb
<li>
<strong><%= place.title %></strong> | <%= link_to 'Edit place', edit_place_path(place) %><br>
[...]
</li>
Sự linh động
Về cơ bản nested form đã xong, tuy nhiên, nó không tiện để sử dụng. Ví dụ, không có cách nào để thêm nhiều hơn 3 địa chỉ. Rails không hỗ trợ tính năng này. May mắn cho chúng ta, đã có sẵn giải pháp đó chính là sử dụng gem Cocoon
. Cocoon hỗ trợ nested form với JavaScript, cho phép thêm hoặc xoá linh động hơn.
Thêm gem:
Gemfile
gem "cocoon"
Cài đặt:
$ bundle install
javascripts/application.js
//= require cocoon
Lưu ý rằng Cocoon requires jQuery. Giờ thêm partial:
_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>
link_to_remove_association
là phương thức hỗ trợ của Cocoon. Phương thức này tạo ra một liên kết xoá mới.
Lưu ý: class nested-fields
là bắt buộc để“remove address”.
Chúng ta dùng partial trong 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 đang sử dụng phương thức link_to_add_association
của Cocoon. Render một link để tự động thêm nested fields.
Giờ mở bất kỳ place có sẵn, click các checkboxes gần các địa chỉ bạn muốn xoá và submit form.
Cocoon’s Callbacks
Cuối cùng, tôi muốn nói về việc làm thế nào để thiết lập Cocoon callback. Có 4 loại:
- cocoon:before-insert
- cocoon:after-insert
- cocoon:before-remove
- cocoon:after-remove
Với cocoon:before-insert
bạn có thể làm các nested fields xuất hiện một cách linh hoạt:
javascripts/global.coffee
jQuery(document).on 'turbolinks:load', ->
addresses = $('#addresses')
addresses.on 'cocoon:before-insert', (e, el_to_add) ->
el_to_add.fadeIn(1000)
Cũng như tôi đang dùng Turbolink 5, chúng ta đang nghe sự kiện turbolinks:load
. Nếu bạn không thích Turbolinks, dòng đầu tiên sẽ đơn giản hơn:
javascripts/global.coffee
jQuery ->
Thêm require:
javascripts/application.js
//= require global
Với callback cocoon:after-insert
, ví dụ chúng ta sẽ highlight những địa chỉ đã được thêm. Thư viện jQueryUI library sẽ thêm hiệu ứng “Highlight”.
Thêm gem mới :
Gemfile
gem 'jquery-ui-rails'
Cài đặt:
$ bundle install
Require thêm các file js mới:
javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require jquery-ui/effect-highlight
//= require cocoon
//= require global
//= require turbolinks
Bây giờ chúng ta có thể sử dụng callback này:
javascripts/global.coffee
addresses.on 'cocoon:after-insert', (e, added_el) ->
added_el.effect('highlight', {}, 500)
Để tự động xoá, ta dùng callback cocoon:before-remove
.
javascripts/global.coffee
addresses.on 'cocoon:before-remove', (e, el_to_remove) ->
$(this).data('remove-timeout', 1000)
el_to_remove.fadeOut(1000)
Cuối cùng, để hiện ra số lượng record và tự động thay đổi số lượng khi tạo hoặc xoá:
_views/places/form.html.erb
<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>
<p class="count">Total: <span><%= @place.addresses.count %></span></p>
</div>
</div>
Thêm một hàm đơn giản để tự động update số lượng:
javascripts/global.coffee
jQuery(document).on 'turbolinks:load', ->
addresses = $('#addresses')
count = addresses.find('.count > span')
recount = -> count.text addresses.find('.nested-fields').size()
Chúng ta có một file js hoàn chỉnh như sau:
javascripts/global.coffee
jQuery(document).on 'turbolinks:load', ->
addresses = $('#addresses')
count = addresses.find('.count > span')
recount = -> count.text addresses.find('.nested-fields').size()
addresses.on 'cocoon:before-insert', (e, el_to_add) ->
el_to_add.fadeIn(1000)
addresses.on 'cocoon:after-insert', (e, added_el) ->
added_el.effect('highlight', {}, 500)
recount()
addresses.on 'cocoon:before-remove', (e, el_to_remove) ->
$(this).data('remove-timeout', 1000)
el_to_remove.fadeOut(1000)
addresses.on 'cocoon:after-remove', (e, removed_el) ->
recount()
Giới hạn
Bạn có thể ngạc nhiên khi nó có khả năng để giới hạn số lượng của nested record. Phương pháp accepts_nested_attributes_for hỗ trợ :limit, chỉ định số lượng tối đa của associated records có thể thực hiện.
Tuy nhiên Cocoon không hỗ trợ giới hạn của các bản ghi nested tại thời điểm viết bài này.
Kết luận
Bài này chúng ta đã thảo luận việc dùng nested attributes trong Rails. Chúng ta đã tạo một form cơ bản cho phép người dùng thêm, sửa, và xoá các bản ghi. Sau đó chúng ta tích hợp gem Cocoon và khả năng linh động của form khi dùng jQuery.
Cocoon có thêm rất nhiều các lựa chọn hay cho việc cá nhân hoá. Hy vọng, bài này viết sẽ hữu ích với bạn. Thank you!
Tài liệu dịch: https://www.sitepoint.com/better-nested-attributes-in-rails-with-the-cocoon-gem/
All rights reserved