Better Nested Attributes in Rails with the Cocoon Gem
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 ...
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.