Complex Rails Forms with Nested Attributes
Rails cung cấp một cơ chế mạnh mẽ để dễ dàng tạo ra forms gọi là "nested attributes". Nó cho phép bạn phối hợp nhiều hơn một model trong forms của bạn trong khi vẫn giữ basic code pattern như bạn sử dụng với một model forms. Trong bài này tôi sẽ thể hiện một số cách khác nhau để sử dụng kĩ thuật ...
Rails cung cấp một cơ chế mạnh mẽ để dễ dàng tạo ra forms gọi là "nested attributes". Nó cho phép bạn phối hợp nhiều hơn một model trong forms của bạn trong khi vẫn giữ basic code pattern như bạn sử dụng với một model forms.
Trong bài này tôi sẽ thể hiện một số cách khác nhau để sử dụng kĩ thuật này. Tôi sẽ giả định rằng bạn đã quen thuộc với basic Rails forms. Chúng tá sẽ xây dựng sự phức tạp của form từng bước cho phép người dùng chỉnh sửa sở thích của họ.
The Base Form
Hãy bắt đầu với form cơ bản có thể chỉnh sửa một user. Tôi giả định bạn đã quen với mô hình này, vì vậy tôi sẽ không giải thích nữa. Chúng ta hãy bắt đầu:
Đầu tiên chúng ta sẽ tạo ra model user với chỉ một thuộc tính:
# app/models/user.rb class User < ActiveRecord::Base validates_presence_of :email end
Chúng ta sẽ tạo ra controller tương ứng cho model user.
# app/controllers/users_controller.rb class UsersController def new @user = User.new end def edit @user = User.find(params[:id]) end def create @user = User.new(params[:user]) if @user.save redirect_to @user else render :action => 'new' end end def update @user = User.find(params[:id]) if @user.update(params[:user]) redirect_to @user else render :action => 'edit' end end end
Base form của chúng ta được tạo ra từ Rails scaffolding:
# app/views/users/_form.html.erb <%= form_for(@user) do |f| %> <% if @user.errors.any? %> <div id="error_explanation"> <h2> <%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved: </h2> <ul> <% @user.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div> <%= f.label :email %> <%= f.text_field :email %> </div> <div class="actions"> <%= f.submit %> </div> <% end %>
Đó là nhưng bước cơ bản, giờ chúng ta sẽ đi sâu hơn xem sao.
Adding an Address
Chúng ta sẽ lưu Address ở một model khác, nhưng chúng ta muốn có thể edit Address trong cùng form như là một attribute của một user. Vì thế trong model ta sẽ thay đổi,
# app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_one :address accepts_nested_attributes_for :address end
và tạo ra model address:
# app/models/address.rb class Address < ActiveRecord::Base belongs_to :user validates_presence_of :city end
Hãy chú ý, trong model user chúng ta đã thêm "accepts_nested_attributes_for". Đây là phương thức cho phép bạn sửa đổi thể hiện của Address sử dụng cùng mass-asignment (Mass Assignment là 1 tính năng cực kì tiện lợi cho phép update 1 model – khi tạo mới hoặc thay đổi – bằng hash của Ruby) cơ sở trong User mà làm cho việc đó trở nên đơn giản.
accepts_nested_attributes_for thêm _attributes vào model cho phép bạn viết code như sau:
user = User.find(1) # Normal mass-assignment user.update(:email => 'new@example.com') # Creates or edits the address user.update(:address_attributes => {:city => 'Hobart'})
Bạn có thể thấy mà cách chúng ta mà chúng ta sẽ không phải thay đổi controller nếu chúng ta thiết lập form đúng, từ đó khi chỉnh sửa các thuộc tính address chúng ta có thể sử dụng hàm #update như là chúng ta làm với user's mail.
Để tạo ra form, chúng ta sử dụng fields_for. Đây là một phương thức mà chúng ta có thể làm nhiều thứ. Thay vì giải thích mọi thứ, tôi sẽ giới thiệt một số hành vi của mình thông qua một số ví dụ dưới đây.
Đầu tiên chúng ta có thể vượt qua fields_for một ký hiệu của tên quan hệ. Nói nghe thật phức tạp, nhưng nhìn vào đoạn code dưới đây thì sẽ hiểu:
# app/views/users/_form.html.erb # ... Form code from above omitted <%= f.fields_for :address do |ff| %> <div> <%= ff.label :city %> <%= ff.text_field :city %> </div> <% end %>
XIn lưu ý hãy thay tên biến cho fields_fỏ là ff thay vì f. Trong trường hợp cho quan hệ là has_one, logic là "nếu tồn tại address, hiển thị trường để chỉnh sửa thuộc tính city. Nếu không có địa chỉ thì không hiển thị bất kì trường nào". Ở đây chúng ta vấp phải một khối: Nếu một trường là ẩn khi nó không có địa chỉ, làm thế nào chúng ta có thể tạo address record?. Chúng ta muốn giải quyết vấn đề này trong tầng view. Chúng ta sẽ thiết lập các giá trị mặc định cho các đối tượng trong helper:
# app/helpers/form_helper.rb module FormHelper def setup_user(user) user.address ||= Address.new user end end
# app/views/users/_form.html.erb <%= form_for(setup_user(user)) do |f| %> ...
Bây giờ nếu người dùng không có address chúng ta tạo mới nhưng chưa lưu cái mà sẽ tồn tại cho tới khi form được submit.
Tất nhiên, nếu họ không có địa chỉ không có hành động là cần thiết (||= means “assign this value unless it already has a value”).
Adding Tasks
Một user có thể có nhiều tasks được giao. Đối với ví dụ này đơn giản là nhiệm vụ là có tên.
# app/models/task.rb class Task < ActiveRecord::Base belongs_to :user validates_presence_of :name end # app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_many :tasks accepts_nested_attributes_for :tasks, :allow_destroy => true, :reject_if => :all_blank end
Có 2 lựa chọn mới ở đây: allow_destroy and reject_if. Tôi sẽ giải thích đôi chút về 2 option này sau.
Như với address, chúng ta muốn form adding task này giống với form editing user. Chúng ta chỉ cần phải thiết lập accepts_nested_attributes_for, và thực hiện thêm 2 bước nữa: thêm vào chính xác fields_for, và thiết lập các giá trị mặc định
# app/views/users/_form.html.erb <h2>Tasks</h2> <%= f.fields_for :tasks do |ff| %> <div> <%= ff.label :name %> <%= ff.text_field :name %> <% if ff.object.persisted? %> <%= ff.check_box :_destroy %> <%= ff.label :_destroy, "Destroy" %> <% end %> </div> <% end %>
khi fields_for được cho tên của một quan hệ has_many, nó lặp qua tất cả các object trong collection và những kết quả đầu ra là một fields cho mỗi bản ghi. Vì vậy, một người dùng có 2 task, code ở trên sẽ tạo 2 text fields, mỗi cái cho 1 task.
Ngoài ra, với mỗi task đã tồn tại trong database, một checkbox sẽ được tạo ra mà map với thuộc tính _destroy. Đây là một attribute đặc biệt, nó được thêm vào bởi tùy chọn allow_destroy. Khi nó được thiết lập là True, bản ghi sẽ xóa chứ không phải sửa. mặc định hoạt động này bị vô hiệu hóa, vì vậy hãy ghi nhớ rõ để enable nó khi bạn cần.
Lưu ý các id của bất kỳ bản ghi nào được sinh ra tự động trong một field ẩn bởi fields_for, bạn không phải làm gì với nó.
Form đã được tạo sẽ cho phép chỉnh sửa và xóa các task đang tồn tại của người dùng, nhưng chưa có cách nào để thêm các task mới cho 1 người dùng mới chưa có task nào, fields_for sẽ không thấy mối quan hệ nào và không render ra trường nào. Như ở trên, để khắc phục điều này, chungs ta thêm các tasks mặc định cho người dùng trong view.
Có một vài cách khác nhau bạn có thể ap dụng cho các tác vụ UI, vi dụ như dùng javascript để linh động trong việc thêm bản ghi mới khi cần thiết. ví dụ dưới đây sẽ chọn một tác vụ đơn giản của việc thêm 3 bản ghi trống vào cuối danh sách mà có thể tùy chọn được filled in.
# app/helpers/form_helper.rb module FormHelper def setup_user(user) # ... code from above omitted 3.times { user.tasks.build } user end end
Field_for sẽ duyệt 3 bản ghi và tạo đầu vào cho chúng. Bây giờ một người dùng có ít hay nhiều task sẽ như thế nào không còn là vấn đề, sẽ luôn có 3 trường text trống cho task được thêm. Có một vấn đề ở đây là nếu một task trống được submit, theo default của Rails trước đây task này không hợp lệ vì có trường name trống và save fail, hoặc không thể filled in? nhưng thông thường thì không phải như những gì mình mong muốn. điều này có thể được tùy chỉnh bằng cách xác định reject_it để tuy chỉnh accepts_nested_attributes_for. Bạn có thể pass a lambda cái được đánh giá cho mỗi thuộc tính băm, trả về đúng nếu nó da reject, hoặc bạn có thể dùng :all_blank như đã nêu ở trên, nó tương đương với:
accepts_nested_attributes_for :tasks, :reject_if => proc {|attributes| attributes.all? {|k,v| v.blank?} }
More complicated relationships
Chúng ta có model internet quan hệ nhiều nhiều với user.
# app/models/interest.rb class Interest < ActiveRecord::Base has_many :interest_users validates_presence_of :name end
# app/models/interest_user.rb class InterestUser < ActiveRecord::Base belongs_to :user belongs_to :interest end
# app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_many :interest_users has_many :interests, :through => :interest_users accepts_nested_attributes_for :interest_users, :allow_destroy => true end
Chỉ có khái niệm mở rộng thêm ở đây là tùy chọn allow _ destroy, cái chúng ta sử dụng trong ví dụ trước. Như tên của nó, điều này cho phép chúng ta xóa bản ghi con ngoài việc tạo và sửa chúng. Nhớ lại theo mặc định, tác vụ này bị vô hiệu hóa, vì vậy chúng ta cần enable nó.
Như trước đây, sau khi thêm accepts_nested_attributes_for có thêm 2 bước để bổ sung checkbox interest cho form: thiết lập các giá trị mặc định thích hợp và dùng fields_for để tạo các trường form cần thiết. Chúng ta sẽ bắt đầu với bước thứ nhất:
# app/views/users/_form.html.erb <%= f.fields_for :interest_users do |ff| %> <div> <%= ff.check_box :_destroy, {:checked => ff.object.persisted?}, '0', '1' %> <%= ff.label :_destroy, ff.object.interest.name %> <%= ff.hidden_field :interest_id %> </div> <% end %>
Fields_for duyệt qua tất cả các đối tượng trong collection mà kết quả đầu ra và các trường cho mỗi bản ghi. Vì vậy, có hai lợi ích, các mã trên sẽ tạo ra hai check boxes, một cho mỗi interest.
Chúng ta biết rằng tùy chọn allow_destroy ở trên cho phép gửi một thuộc tính _destroy nếu đúng là flag object bị xóa. Vấn đề là đây là ngược lại các tác vụ mặc định check box: khi kiểm tra checkbox là uncheckedwe muốn _destroy là true, và khi nó được kiểm tra, chúng ta muốn giữ bản ghi quanh nó. Đó là những gì mà hai parametes cuối để check_box làm ('0' và '1'): thiết lập các giá trị checked và unchecked, biến đổi chúng từ mặc định của họ.
Trong khi chúng ta đang ở khu vực đó, cũng cần phải ghi đè logic mặc định rawng quyết định xem check box khởi tạo có được checked. This is what: kiểm tra => ff.object.persisted? -nếu các bảng ghi tồn tại trong cơ sở dữ liệu, sau đó người dùng chỉ định họ interests đến area đó, vì vậy box nên được kiểm tra. Lưu ý việc sử dụng ff.object để truy cập các bản ghi hiện tại trong vòng lặp. có thể sử dụng phương pháp này trong bất kỳ form_for hoặc fields_for để có được các đối tượng hiện tại.
Tôi đã nói về việc kiểm tra xem các bản ghi hiện tại có đang tồn tại hay không. Khi bạn load một người dùng ra khỏi cơ sở dữ liệu, tất nhiên tất cả các bản ghi sẽ vẫn tồn tại. Vấn đề chỉ là những interests vừa mới được lựa chọn sẽ được hiển thị và kiểm tra, trong khi thực tế có cần hiển thị tất cả các interests đã chọn hay không. Đây là lúc sử dụng method setup_user từ trước đó để cung cấp “default" bản ghi mới cho interests mà không phải tồn tại.
# app/helpers/form_helper module FormHelper def setup_user(user) user.address ||= Address.new (Interest.all - user.interests).each do |interest| user.interest_users.build(:interest => interest) end user.interest_users.sort_by! {|x| x.interest.name } user/tmp/clean-controllers.md.html end end
Đầu tiên đoạn code này tạo ra một bản ghi mới liên kết cho tất cả các interests mà người dùng hiện tại không có lựa chọn (Interest.all - user.interests), và sau đó sử dụng một in-place sort (! Sort_by) để đảm bảo rằng các check boxes luôn được hiển thị theo một thứ tự nhất quán. Nếu không có điều này, tất cả các bản ghi unchecked mới sẽ được nhóm lại ở dưới cùng của danh sách.
Parting Words
Nested attributes là một công nghệ mạnh mẽ để phát triển nhanh các forms phức tạp trong khi đang duy trì code của bạn gọn và đẹp. Field_for mang lại cho bạn nhiều các tùy chọn linh hoạt và phù hợp với các thuôc tính nested attribute pattern. - xem tai lieu – và bạn nên cố gắng giữ form của bạn tân dụng được lợi thế của accepts_nested_attributes_for mang đến cho bạn
Bài viết được dịch:
https://www.sitepoint.com/complex-rails-forms-with-nested-attributes/