Статьи

Лучшие вложенные атрибуты в Rails с самоцветом Cocoon

Кокон

В этой статье мы собираемся обсудить, как создавать более сложные формы, используя функцию вложенных атрибутов Rails. Я покажу вам, как управлять несколькими связанными записями из одной формы и правильно настроить модели и контроллер для включения этой функции. Кроме того, мы собираемся обсудить типичные подводные камни и усовершенствовать нашу форму, чтобы сделать ее более динамичной, используя драгоценный камень под названием Cocoon . Это решение позволяет добавлять и удалять вложенные поля асинхронно, предоставляя множество опций настройки и обратных вызовов.

Исходный код доступен на GitHub .

Демо-приложение доступно на сайте pointpoint-nested-forms.herokuapp.com .

Создание простой формы

Для этой демонстрации я буду использовать Rails 5, но большинство описанных концепций могут быть применены к Rails 3 и 4.

Создайте новое приложение без набора тестов по умолчанию:

$ rails new NestedForms -T 

Предположим, что с помощью этого приложения мы хотим отслеживать наши любимые места и их адреса. Например, если мы введем «Кафе» как место вместе с кучей адресов наших любимых кафе. Это означает, что в одном месте может быть много адресов, поэтому мы опишем его с помощью ассоциаций:

 $ rails g model Place title:string $ rails g model Address city:string street:string place:belongs_to $ rake db:migrate 

Убедитесь, что ассоциации установлены правильно:

модели / place.rb

 [...] has_many :addresses, dependent: :destroy [...] 

модели / address.rb

 [...] belongs_to :place [...] 

Теперь PlacesController базовый PlacesController (тот, который будет править ими всеми …):

приложение / контроллеры / 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 

Добавьте маршруты:

конфиг / routes.rb

 [...] resources :places, only: [:new, :create, :edit, :update] root to: 'places#index' [...] 

Теперь просмотр для корневой страницы:

просмотры / места / index.html.erb

 <h1>Places</h1> <p><%= link_to 'Add place', new_place_path %></p> <ul><%= render @places %></ul> 

После добавления render @places нам также понадобится соответствующий render @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> 

Вид для создания мест:

просмотры / места / new.html.erb

 <h1>Add place</h1> <%= render 'form' %> 

Включая форму:

просмотры / места / _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 %> 

Вот еще один фрагмент для отображения ошибок:

просмотров / общий / _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 %> 

Все идет нормально. Однако, с точки зрения лучшего взаимодействия с пользователем, я хотел бы разрешить добавлять адреса мест на одной странице, а не кодировать отдельную форму. Это также избавит нас от необходимости кодировать дополнительный контроллер для управления адресами. Вот где в игру вступают вложенные атрибуты.

Добавление вложенных атрибутов

Идея вложенных атрибутов довольно проста. У вас есть одна форма, в которой вы можете создать объект вместе со связанными с ним записями. Эта функция может быть добавлена ​​очень быстро, так как требует очень небольших модификаций контроллера и модели, а также некоторой разметки.

Все начинается с добавления длинного имени метода acceptpts_nested_attributes_for :

модели / places.rb

 [...] accepts_nested_attributes_for :addresses [...] 

Добавив этот метод, мы теперь можем манипулировать адресами через механизм массового назначения мест. Контроллер также требует некоторых изменений:

places_controller.rb

 [...] private def place_params params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street]) end [...] 

Когда вы отправляете форму с вложенными полями, params[:place] будет содержать массив под ключом :addresses_attributes . Этот массив описывает каждый адрес, который будет добавлен в базу данных. Пока мы используем strong_params, эти новые атрибуты должны быть явно разрешены.

Теперь добавьте вложенную форму в представление:

просмотры / места / _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 %> 

Метод fields_for , как вы уже догадались, добавляет вложенные поля. Он очень похож на метод form_for но не предоставляет сам тег form . Обратите внимание, что внутри блока я использую новый address локальной переменной – не называйте его f потому что он уже содержит конструктор для родительской формы.

Однако есть проблема. Когда вы посещаете страницу «Новое место», вы не увидите вложенных полей, поскольку очевидно, что новый экземпляр класса Place не содержит вложенных адресов. Простое исправление, как предлагается в документации Rails, заключается в создании пары адресов непосредственно в контроллере:

places_controller.rb

 [...] def new @place = Place.new 3.times { @place.addresses.build} end [...] 

Действительно, это не лучшее решение, и мы избавимся от него позже.

Теперь вы можете загрузить сервер, перейти на страницу «Новое место» и попытаться создать место с несколькими вложенными адресами. Однако, вещи не всегда могут идти так гладко, верно? Если вы используете Rails 5.0, как и я, вы увидите довольно странную ошибку «Адрес должен существовать», препятствующий отправке формы. Это кажется основной ошибкой в Rails 5, которая связана с новой опцией belongs_to_required_by_default установленной в true . Этот параметр означает, что связанная запись должна присутствовать по умолчанию. Чтобы глобально отказаться от этого поведения, вы можете либо установить Rails.application.config.active_record.belongs_to_required_by_default в значение false (внутри файла инициализатора new_framework_defaults.rb ) или предоставить optional: true опцию optional: true для метода belongs_to .

Другое предлагаемое исправление включает использование опции inverse_of :

модели / place.rb

 [...] has_many :addresses, dependent: :destroy, inverse_of: :place [...] 

Эта ошибка должна быть исправлена ​​в Rails 5.1.

Немного проверки

В настоящее время пользователь может создать место со списком пустых адресов, что, вероятно, не то, что вы хотите. Чтобы управлять этим поведением, используйте параметр reject_if который принимает либо лямбда, либо значение :all_blank . :all_blank отклонит запись, в которой все атрибуты будут пустыми. Однако в нашем случае мы хотим отклонить, если какой-либо атрибут пуст, поэтому давайте придерживаться лямбда-выражения:

модели / place.rb

 [...] accepts_nested_attributes_for :addresses, reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? } [...] 

Теперь любой адрес без города или улицы не будет сохранен в базе данных.

Уничтожь их

Теперь можно добавлять адреса, но позже их невозможно удалить. Чтобы решить эту проблему, предоставьте еще один параметр для метода accepts_nested_attributes_for :

модели / place.rb

 [...] accepts_nested_attributes_for :addresses, allow_destroy: true, reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? } [...] 

Это просто означает, что теперь вложенные записи могут быть уничтожены. Чтобы уничтожить вложенную запись, поле _destroy должно быть установлено с истинным значением (то есть 1, «1», true или «true»). Это новое поле также должно быть разрешено:

places_controller.rb

 [...] private def place_params params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street, :_destroy]) end [...] 

Добавьте флажок, чтобы пометить вложенные записи для удаления:

просмотры / места / _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> [...] 

Теперь запишите два новых действия контроллера:

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 [...] 

Обратите внимание, что сами действия не требуют особых изменений, что действительно здорово.

Добавьте еще два маршрута:

конфиг / routes.rb

 [...] resources :places, only: [:new, :create, :edit, :update] [...] 

И представьте ссылку «Изменить»:

просмотры / места / _place.html.erb

 <li> <strong><%= place.title %></strong> | <%= link_to 'Edit place', edit_place_path(place) %><br> [...] </li> 

Теперь откройте любое существующее место, установите флажки рядом с адресами, которые вы хотите уничтожить, и отправьте форму!

Делать это динамичным

Базовая вложенная форма завершена, однако ее использование не очень удобно. Например, нет способа добавить более трех адресов. Добавление этой функции требует больше работы, потому что Rails не поддерживает динамическое добавление полей из коробки. К счастью для нас, уже есть решение. Это называется Cocoon, и это потрясающе. Cocoon поддерживает вложенные формы с помощью JavaScript, что позволяет динамически добавлять или удалять файлы. Cocoon предоставляет и другие настройки. Более того, он не зависит от компоновщика форм и, следовательно, работает либо с базовыми компоновщиками форм Rails, либо с такими решениями, как SimpleForm или Formtastic.

Начать работу с Cocoon просто. Добавьте новый драгоценный камень:

Gemfile

 [...] gem "cocoon" [...] 

И установить это:

 $ bundle install 

Затем подключите новый файл JavaScript:

JavaScripts / application.js

 [...] //= require cocoon [...] 

Обратите внимание, что Cocoon требует наличия jQuery. Теперь извлеките вложенные поля в отдельный фрагмент:

просмотры / места / _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 . Этот помощник, как следует из названия, создает новую ссылку, которая асинхронно удаляет связанную запись. Этот метод принимает три аргумента (третий необязательный):

  • Текст для отображения в ссылке
  • Объект формы
  • link_to HTML (аналогичные тем, которые передаются в link_to )

Обратите внимание, что класс nested-fields необходим для работы ссылки «удалить адрес».

Теперь нам нужно использовать это частичное внутри формы:

просмотры / места / _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 %> 

Здесь мы используем второго помощника Кокон – link_to_add_association . Он отображает ссылку для динамического добавления вложенных полей с использованием части, которую мы кодировали минуту назад. Этот метод принимает четыре параметра (четвертый необязательный):

  • Текст для отображения в ссылке
  • Конструктор форм (родительская форма, а не вложенная!)
  • Наименование ассоциации
  • Варианты HTML. Эти параметры аналогичны тем, link_to принимает link_to , однако есть некоторые специальные параметры (например, где визуализировать вложенные поля или какая часть использовать), поэтому обязательно просмотрите документы

Вот и все! Загрузите сервер и попробуйте добавить и удалить адреса мест. Это очень удобно сейчас, не так ли?

Обратные звонки Кокон

Последнее, что я собираюсь показать вам сегодня, это как настроить обратные вызовы Cocoon. Их четыре:

  • cocoon:before-insert
  • cocoon:after-insert
  • cocoon:before-remove
  • cocoon:after-remove

С cocoon:before-insert вы можете анимировать внешний вид вложенных полей. Давайте закодируем это в новом файле CoffeeScript:

JavaScripts / global.coffee

 jQuery(document).on 'turbolinks:load', -> addresses = $('#addresses') addresses.on 'cocoon:before-insert', (e, el_to_add) -> el_to_add.fadeIn(1000) 

Пока я использую Turbolinks 5 , мы слушаем событие turbolinks:load . Если вы по какой-то причине предпочитаете держаться подальше от Turbolinks, первая строка будет намного проще:

JavaScripts / global.coffee

 jQuery -> 

Требуется этот файл:

JavaScripts / application.js

 [...] //= require global [...] 

Внутри cocoon:after-insert обратного вызова вы можете, например, выделить добавленные поля. В библиотеке jQueryUI есть куча эффектов, из которых я могу выбрать – я собираюсь использовать эффект «Highlight» в этой демонстрации.

Добавьте новый драгоценный камень:

Gemfile

 gem 'jquery-ui-rails' 

Установите это:

 $ bundle install 

Требуется новый файл JS (обратите внимание на правильный порядок):

JavaScripts / application.js

 //= require jquery //= require jquery_ujs //= require jquery-ui/effect-highlight //= require cocoon //= require global //= require turbolinks 

Теперь используйте этот новый эффект:

JavaScripts / global.coffee

 addresses.on 'cocoon:after-insert', (e, added_el) -> added_el.effect('highlight', {}, 500) 

Чтобы оживить удаление элемента, используйте функцию обратного вызова 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) 

$(this).data('remove-timeout', 1000) говорит, что Cocoon задержит удаление элемента на 1 секунду – этого вполне достаточно для выполнения анимации.

Наконец, давайте покажем, сколько вложенных записей было добавлено, и изменим их количество динамически. Добавьте новый блок .count :

просмотры / места / _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> [...] 

Далее запишем простую функцию recount которая собирается изменить счетчик:

JavaScripts / global.coffee

 jQuery(document).on 'turbolinks:load', -> addresses = $('#addresses') count = addresses.find('.count > span') recount = -> count.text addresses.find('.nested-fields').size() [...] 

Наконец, обновите обратный вызов cocoon:after-insert и добавьте новый с именем cocoon:after-remove . Финальная версия скрипта представлена ​​ниже:

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() 

Предельное?

Вы можете задаться вопросом, можно ли каким-то образом ограничить количество вложенных записей. Метод accepts_nested_attributes_for поддерживает :limit который указывает максимальное количество связанных записей, которые могут быть обработаны. Он может быть снабжен целым числом, процедурой или символом, указывающим на метод (и процедура, и метод должны возвращать целое число).

Однако Cocoon не поддерживает ограничение вложенных записей на момент написания этой статьи. Было обсуждение этого вопроса, но автор не считает его основной особенностью. Тем не менее, существует открытый запрос на добавление, добавляющий эту функциональность, который может быть объединен в будущем.

Вывод

В этой статье мы обсудили использование вложенных атрибутов в Rails. Мы создали базовую вложенную форму, позволяющую пользователям добавлять, редактировать и уничтожать связанные записи. Позже мы интегрировали самоцвет Cocoon и добавили форму в jQuery, сделав ее динамичной.

У Cocoon есть еще много опций, доступных для настройки, поэтому обязательно просмотрите его документы . Надеюсь, эта статья была вам полезна. Как всегда, я благодарю вас за то, что вы остались со мной и до скорой встречи!