Узнайте больше о ruby с нашим учебным пособием Понимание собственных классов в Ruby и понимание их важности для SitePoint.
Rails предоставляет мощный механизм для простого создания расширенных форм, называемых «вложенными атрибутами». Он позволяет вам комбинировать более одной модели в ваших формах, сохраняя тот же базовый шаблон кода, который вы используете с простыми формами одной модели.
В этой статье я покажу несколько разных способов использования этой техники. Я предполагаю, что вы знакомы с базовыми формами Rails, которые создаются командами скаффолдинга. Мы будем шаг за шагом создавать сложную форму, которая позволит пользователю редактировать свои предпочтения. Наш домен — это некоммерческая система управления, в которой волонтеры (пользователи) имеют области знаний и задачи, которые им были поручены.
Базовая форма
Давайте начнем с базовой формы, которая может редактировать пользователя. Я предполагаю, что вы знакомы с этим шаблоном, поэтому я не буду его объяснять. Я представляю это здесь только потому, что остальная часть статьи будет построена поверх этого кода.
Сначала простая пользовательская модель с одним атрибутом:
# app/models/user.rb class User < ActiveRecord::Base validates_presence_of :email end
Мы будем использовать один и тот же контроллер для всей этой статьи. В этом прелесть вложенных атрибутов — нам не нужно менять код нашего контроллера!
# 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
Наша базовая форма — это именно то, что генерируется скаффолдами Rails:
# 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 %>
С этим из пути, давайте погрузимся в!
Добавление адреса
Мы храним адресную запись пользователя в отдельной модели, но мы хотим иметь возможность редактировать адрес в той же форме, что и другие атрибуты пользователя.
# app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_one :address accepts_nested_attributes_for :address end # app/models/address.rb class Address < ActiveRecord::Base belongs_to :user validates_presence_of :city end
Обратите внимание на добавление accepts_nested_attributes_for
к модели User
. Этот метод позволяет вам изменять экземпляры Address
используя те же средства массового назначения для пользователя, что делает простые формы такими тривиальными. accepts_nested_attributes_for
добавляет метод записи _attributes
к модели, который позволяет вам писать код следующим образом:
user = User.find(1) # Normal mass-assignment user.update(:email => '[email protected]') # Creates or edits the address user.update(:address_attributes => {:city => 'Hobart'})
Вы можете видеть, как нам не придется изменять код нашего контроллера, если мы правильно #update
нашу форму, поскольку для редактирования атрибутов адреса вы используете тот же метод #update
что и для редактирования электронной почты пользователя.
Для создания формы мы будем использовать метод fields_for
builder. Это сложный метод, который может делать много вещей. Вместо того, чтобы объяснять все это заранее, я расскажу о некоторых из его поведений на следующих примерах.
Прежде всего, вы можете передать fields_for
символ имени отношения, и из этого отношения будет интуитивно понятно, как он должен отображать поля. Я знаю, это звучит сложно, но следующий фрагмент кода должен прояснить ситуацию:
# 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 %>
Обратите внимание на измененное имя переменной для fields_for
ff
а не f
. В этом случае для отношения has_one
логика такова: «если адрес существует, покажите поле для редактирования атрибута города. В противном случае, если адреса нет, не показывать никаких полей ». Здесь мы сталкиваемся с нашим первым камнем преткновения: если поля скрыты, когда адреса нет, как мы можем создать запись адреса в первую очередь? Поскольку это проблема вида (отображаем ли мы поля или нет?), Мы хотим решить эту проблему в слое вида. Мы делаем это, устанавливая значения по умолчанию для объекта формы в помощнике:
# 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| %> ...
Теперь, если у пользователя нет адреса, мы создаем новый несохраненный, который будет сохранен при отправке формы. Конечно, если у них есть адрес, никаких действий не требуется ( ||=
означает «назначить это значение, если оно уже не имеет значения»).
Попробуйте это, и вы увидите, что рельсы даже правильно накапливаются и отображают ошибки для дочернего объекта. Это довольно опрятно.
Добавление задач
Пользователь может иметь много назначенных ему задач. В этом примере у задачи просто есть имя.
# 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
Здесь есть две новые опции: allow_destroy и reject_if . Я объясню их чуть позже, когда они станут актуальными.
Как и в случае с адресом, мы хотим, чтобы задачи назначались в той же форме, что и редактирование пользователя. Мы только что настроили accepts_nested_attributes_for
, и осталось сделать два шага: добавить правильные fields_for
и установить значения по умолчанию.
# 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 %>
Когда для fields_for
задано имя a, которое имеет много связей, оно выполняет итерацию по каждому объекту в этой коллекции и выводит данные поля один раз для каждой записи. Таким образом, для пользователя, у которого есть две задачи, приведенный выше код создаст два текстовых поля, по одному для каждой задачи.
Кроме того, для каждой задачи, которая сохраняется в базе данных, создается флажок, который сопоставляется с атрибутом _destroy
. Это специальный атрибут, который добавляется параметром allow_destroy . Когда установлено значение true, запись будет удалена, а не отредактирована. Это поведение по умолчанию отключено, поэтому не забудьте явно включить его, если вам это нужно.
Обратите внимание, что идентификатор любых сохраняемых записей автоматически добавляется в скрытое поле с помощью fields_for
, вам не нужно делать это самостоятельно (хотя если вам это необходимо по какой-либо причине, fields_for
достаточно умен, чтобы не добавлять его снова.) Просмотр источник на сгенерированном HTML, чтобы увидеть для себя.
Форма, которую мы создали, позволит нам редактировать и удалять существующие задачи для пользователя, но в настоящее время нет способа добавить новые задачи, поскольку для нового пользователя без задач fields_for
увидит пустое отношение и не отобразит никаких полей. Как и выше, мы исправляем это, добавляя новые задачи по умолчанию для пользователя в представлении.
Существует ряд различных вариантов поведения пользовательского интерфейса, таких как использование javascript для динамического добавления новых записей по мере необходимости. Для этого примера мы выберем простое поведение, добавив три пустые записи в конец списка, которые можно заполнить.
# app/helpers/form_helper.rb module FormHelper def setup_user(user) # ... code from above omitted 3.times { user.tasks.build } user end end
fields_for
будет перебирать эти три записи и создавать входные данные для них. Теперь независимо от того, сколько у пользователя задач или мало, всегда будет три пустых текстовых поля для добавления новых задач. Однако здесь есть проблема: если пустое задание отправлено, это новая запись, которая недопустима (пустое имя) и должна привести к сбою сохранения, или она никогда не была заполнена? По умолчанию Rails предполагает первое, но часто это не то, что нужно. Это поведение можно настроить, указав параметр reject_if для accepts_nested_attributes_for
. Вы можете передать лямбду, которая оценивается для каждого хеша атрибутов, возвращая true, если он должен быть отклонен, или вы можете использовать ярлык :all_blank
как у нас выше, что эквивалентно:
accepts_nested_attributes_for :tasks, :reject_if => proc {|attributes| attributes.all? {|k,v| v.blank?} }
Более сложные отношения
Для этого приложения мы хотим, чтобы пользователи указывали, в каких областях нашей некоммерческой организации они заинтересованы в помощи, например, стук в администрацию или дверь. Это моделируется отношениями «многие ко многим» между пользователями и интересами.
# 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
Единственная дополнительная концепция, добавленная здесь, это опция allow_destroy
, которую мы использовали в предыдущем примере. Как следует из названия, это позволяет нам уничтожать дочерние записи в дополнение к их созданию и редактированию. Напомним, что по умолчанию это поведение отключено, поэтому нам нужно явно включить его.
Как и раньше, после добавления accepts_nested_attributes_for
есть еще два шага для добавления флажков интереса к нашей форме: установка соответствующих значений по умолчанию и использование fields_for
для создания необходимых полей формы. Давайте начнем с первого:
# 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
дано имя, имеет много отношений, он выполняет итерацию по каждому объекту в этой коллекции и выводит данные поля один раз для каждой записи. Таким образом, для пользователя с двумя интересами приведенный выше код создаст два флажка, по одному для каждого интереса.
Мы знаем, что указанная выше опция allow_destroy позволяет нам отправлять специальный атрибут _destroy
который, если true, _destroy
объект. Проблема в том, что это обратное поведение флажка по умолчанию: когда флажок снят, мы хотим, чтобы _destroy
был true, а когда он установлен, мы хотим сохранить запись. Это то, что делают последние два параметра для check_box
(‘0’ и ‘1’): соответственно устанавливают проверенные и непроверенные значения, удаляя их из значений по умолчанию.
Пока мы находимся в этой области, нам также необходимо переопределить логику по умолчанию, которая решает, установлен ли флажок изначально. Это что :checked => ff.object.persisted?
делает — если запись существует в базе данных, то пользователь указал, что он заинтересован в этой области, поэтому необходимо установить флажок. Обратите внимание на использование ff.object
для доступа к текущей записи в цикле. Вы можете использовать этот метод внутри любого form_for
или fields_for
чтобы получить текущий объект.
Я говорил о проверке, сохраняется ли текущая запись или нет. Когда вы загружаете пользователя из базы данных, конечно, все записи по интересам будут сохранены. Проблема в том, что только те интересы, которые уже выбраны, будут показаны и проверены, тогда как на самом деле нам нужно показать все интересы, независимо от того, были ли они выбраны в прошлом. Именно здесь мы используем наш метод setup_user
который использовался ранее для предоставления новых записей «по умолчанию» для интересов, которые не сохраняются.
# 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
Сначала этот код создает новую запись объединения для всех интересов, которые в данный момент пользователь не выбрал ( Interest.all - user.interests
), а затем использует сортировку на месте ( sort_by!
), Чтобы гарантировать, что флажки всегда отображаются в последовательном порядке. Без этого все новые непроверенные записи будут сгруппированы внизу списка.
Прощальные слова
Вложенные атрибуты — это мощный метод для быстрой разработки сложных форм, сохраняя при этом ваш код красивым и аккуратным fields_for
дает вам большую гибкость и возможности для соответствия fields_for
вложенных атрибутов — см. документацию — и вы всегда должны пытаться структурировать свои формы, чтобы воспользоваться преимуществами поведения, которое accepts_nested_attributes_for
. Выходя за рамки этой статьи, просто прикосновение магии javascript, поддерживающей динамическое создание новых вложенных записей, может сделать ваши формы действительно выдающимися.
Вы можете скачать полный код этой статьи на github, чтобы поиграть с ним. Дайте нам знать, как вы идете в комментариях.
Узнайте больше о ruby с нашим учебным пособием Понимание собственных классов в Ruby и понимание их важности для SitePoint.