Sử dụng Nested Attributes với Gem Cocoon

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