+2

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 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:

https://www.sitepoint.com/better-nested-attributes-in-rails-with-the-cocoon-gem/?utm_source=sitepoint&utm_medium=relatedsidebar&utm_term=ruby

https://github.com/nathanvda/cocoon


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí