Rails Nesteds Forms с динамическими полями - PullRequest
1 голос
/ 02 октября 2019

Предположим, у меня есть таблица рецептов, в которой есть поле для имени. Форма будет выглядеть примерно так:

<%= form_with(model: recipe, local: true) do |form| %>
  <%= form.text_field :name %>
  <%= form.submit %>
<% end %>

, но если рецепт связан с ингредиентами (который содержит поля имени) через промежуточную таблицу с количеством ингредиента. как я должен сделать форму, чтобы создать рецепт, выбрать ингредиент и ввести количество ингредиента. А также есть возможность генерировать больше полей, если рецепт содержит более одного ингредиента. Все это в одной форме. Примерно так:

<%= form_with(model: recipe, local: true) do |form| %>

  <%= form.text_field :name %>

  (select field to choose an ingredient)

  (field for recipe_ingredient to ingress the amount of the ingredient)

  (button to generate more fields for other ingredients)

  <%= form.submit %>
<% end %>

версия рельсов: 5.2.2

Ответы [ 2 ]

2 голосов
/ 02 октября 2019

Это одна из самых сложных вещей в rails, потому что она требует построения формы на сервере и последующего редактирования формы в браузере с помощью js, когда пользователь добавляет или удаляет поля. Я попытаюсь показать простой минимальный рабочий пример.

Давайте сначала соберем форму на сервере с помощью fields_for , которая позволит вам добавить вложенные поля для модели, которая accepts_nested_fields_for является одним из ееотношения. В вашем случае вам нужно будет вложить вашу форму дважды (сначала для Recipe 's Dose s, а затем для каждого Dose' * Ingredient). Пользователи на самом деле не увидят модель Dose, поскольку она есть только в качестве промежуточной таблицы.

Допустим, вы настроили свое приложение так:

rails g scaffold Ingredient name:string

rails g scaffold Recipe name:string

rails g scaffold Dose ingredient:references recipe:references amount:decimal

Затем добавьтеэто для Recipe модели:

has_many :doses
has_many :ingredients, through: :doses

accepts_nested_attributes_for :doses, allow_destroy: true

И это для Dose модели:

accepts_nested_attributes_for :ingredient

Теперь отредактируйте файл app/views/recipes/_form.html.erb и добавьте поля для Dose

<%= form.fields_for :doses do |dose_form| %>
  <div class="field">
    <%= dose_form.label :_destroy %>
    <%= dose_form.check_box :_destroy %>
  </div>
  <div class="field">
    <%= dose_form.label :ingredient_id %>
    <%= dose_form.select :ingredient_id, @ingredients %>
  </div>
  <div class="field">
    <%= dose_form.label :amount %>
    <%= dose_form.number_field :amount %>
  </div>
<% end %>

Это мало что даст, поскольку fields_for будет генерировать код внутри своего блока только в том случае, если взаимосвязь заполнена. Итак, давайте заполним отношение recipe doses в действиях new и edit файла app/controllers/recipes_controller.rb. Пока мы там, давайте добавим все ингредиенты в нашу переменную @ingredients и разрешим наши вложенные атрибуты в хеш strong_parameters permitted.

def new
  @recipe = Recipe.new
  @ingredients = Ingredient.all.pluck(:name, :id)
  1.times{ @recipe.doses.build } 
end

def edit
  @ingredients = Ingredient.all.pluck(:name, :id)
end
...

def recipe_params
  params.require(:recipe).permit(:name, doses_attributes: [:id, :ingredient_id, :amount, :_destroy])
end

Вы можете создать столькодозы, как вы хотите, и как только мы настроим часть JS, мы можем «построить» их на переднем конце. 1 пока подойдет, просто чтобы показать наши поля доз.

Запустите миграцию и запустите сервер и создайте несколько ингредиентов , тогда вы увидите их в раскрывающихся списках при создании нового recipe

Теперь у вас есть рабочее решение с вложенными полями, но мы должны построить дозы в бэкэнде и отправить обработанную форму в браузер с заданным количеством встроенных доз. Давайте добавим несколько js, чтобы позволить пользователям создавать и уничтожать вложенные поля на лету.

Уничтожить поля легко, поскольку у нас уже есть все настройки. Нам просто нужно скрыть поле, если установлен флажок _destroy. Для этого давайте установим Stimulus

bundle exec rails webpacker:install:stimulus

И давайте создадим новый контроллер стимулов в app/javascript/controllers/fields_for_controller.js

import {Controller} from "stimulus"
export default class extends Controller {
  static targets = ["fields"]

  hide(e){
    e.target.closest("[data-target='fields-for.fields']").style = "display: none;"
  }
}

И обновим наш app/views/recipes/_form.html.erb для использования контроллера:

<div data-controller="fields-for">
  <%= form.fields_for :doses do |dose_form| %>
    <div data-target="fields-for.fields">
      <div class="field">
        <%= dose_form.label :_destroy %>
        <%= dose_form.check_box :_destroy, {data: {action: "fields-for#hide"}} %>
      </div>
      <div class="field">
        <%= dose_form.label :ingredient_id %>
        <%= dose_form.select :ingredient_id, @ingredients %>
      </div>
      <div class="field">
        <%= dose_form.label :amount %>
        <%= dose_form.number_field :amount %>
      </div>
    </div>
  <% end %>
</div>

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

Теперь давайте посмотрим на генерируемый html nested_fields, чтобы получить представление о том, как мы можем позволить пользователям добавлять и удалять их:

<div data-target="fields-for.fields">
      <div>
        <label for="recipe_doses_attributes_0__destroy">Destroy</label>
        <input name="recipe[doses_attributes][0][_destroy]" type="hidden" value="0" /><input data-action="fields-for#hide" type="checkbox" value="1" name="recipe[doses_attributes][0][_destroy]" id="recipe_doses_attributes_0__destroy" />
      </div>
      <div class="field">
        <label for="recipe_doses_attributes_0_ingredient_id">Ingredient</label>
        <select name="recipe[doses_attributes][0][ingredient_id]" id="recipe_doses_attributes_0_ingredient_id"><option value="1">first ingredient</option>
<option selected="selected" value="2">second ingredient</option>
<option value="3">second ingredient</option></select>
      </div>
      <div class="field">
        <label for="recipe_doses_attributes_0_amount">Amount</label>
        <input type="number" value="2.0" name="recipe[doses_attributes][0][amount]" id="recipe_doses_attributes_0_amount" />
      </div>
    </div>
<input type="hidden" value="3" name="recipe[doses_attributes][0][id]" id="recipe_doses_attributes_0_id" />

Интересный бит - recipe[doses_attributes][0][ingredient_id], в частности [0] получается fields_for присваивает инкрементальный child_index каждому из построенных doses. Бэкэнд использует этот child_index, чтобы знать, какие дочерние элементы нужно удалить или какие атрибуты обновить для какого дочернего элемента.

Так что теперь ответ ясен, нам просто нужно вставить тот же <div> fields_for, созданный иустановите child_index этой новой вставленной <div> на более высокое значение, чем наивысшее значение ранее в форме. Помните, что это только index, а не id, что означает, что мы можем установить его на очень большое число, так как Rails будет использовать его только для хранения атрибутов вложенных полей в одной группе и фактически назначать id s, когдасохранение записей.

Так что теперь мы должны сделать два выбора:

  1. Где получить инкрементальный индекс от
  2. Где получить fields_for <div> от

Для первого варианта обычным ответом является просто получить текущее время и использовать его как child_index

Для второго обычным способом является перемещение блока html в его собственныйчастично в app/views/doses/_fields.html.erb, затем визуализируйте этот блок дважды внутри формы в app/bies/recipes/_form.htm.erb. Оказавшись внутри цикла form.fields_for. И во второй раз внутри атрибута данных button, где мы создаем новую форму, просто чтобы сгенерировать одну field_for:

<div data-controller="fields-for">
  <%= form.fields_for :doses do |dose_form| %>
    <%= render "doses/fields", dose_form: dose_form %>
  <% end %>
  <%= button_tag("Add Dose", {data: { action: "fields-for#add", fields: form.fields_for(:doses, Dose.new, child_index:"new_field"){|dose_form| render("doses/fields", dose_form: dose_form)}}}) %>
</div>

Затем используйте js, чтобы получить частичное из тега данных кнопки, обновитьchild_index и вставьте обновленный html в форму. Поскольку кнопка уже имеет data-action='fields-for#add', нам просто нужно добавить действие добавления к нашему app/javascript/controllers/fields_for_controller.js

add(e){
  e.preventDefault()
  e.target.insertAdjacentHTML('beforebegin', e.target.dataset.fields.replace(/new_field/g, new Date().getTime()))
}

Теперь нам не нужно строить doses заранее. Использовать гем для этого намного проще, но его преимущество в том, что вы можете настроить его точно так, как вам нужно, и он не добавляет в ваше приложение никакого кода, который не нужен.

Также мне пришло в голову, что Portion было бы лучшим именем для Dose

0 голосов
/ 03 октября 2019

Альтернативным вариантом будет использование драгоценного камня, такого как https://github.com/schneems/wicked

Таким образом, вы можете создать многошаговую форму и получить данные в форме, основанной на «предыдущем шаге».

Кроме того, прекрасная жемчужина для выполнения чего-либо вложенного - https://github.com/nathanvda/cocoon

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...