This card describes four variants, that add a more intuitive workflow when working with nested attributes in Rails + Unpoly:
- Without JS
- With HTML template and JS
- With HTML template and JS using dynamic Unpoly templates
- Adding Records via XHR and JS
Example
For the following examples we use a simple data model where a user has zero or more tasks.
class ExampleMigration < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :full_name
      t.timestamps
    end
    create_table :tasks do |t|
      t.string :title
      t.references :user
      t.timestamps
    end
  end
end
class Task < ApplicationRecord
  belongs_to :user
  validates :title, presence: true
end
class User < ApplicationRecord
  has_many :tasks
  accepts_nested_attributes_for :tasks, allow_destroy: true, reject_if: ->(attributes) { attributes['title'].blank? }
  validates :full_name, presence: true
end
Without JS
Back in times we used to add n-empty nested records and rejected them in case they were not filled.
resources :variant_1_users, only: [:edit, :update]
class Variant1UsersController < ApplicationController
  def edit
    load_user
    3.times { @user.tasks.build }
  end
  def update
    load_user
    @user.attributes = user_params
    if @user.save
      flash[:notice] = 'User saved successfully.'
      redirect_to(edit_variant_1_user_path(@user))
    else
      flash[:notice] = 'User could not be saved.'
      render :edit
    end
  end
  private
  def load_user
    @user = User.find(params[:id])
  end
  def user_params
    params.require(:user).permit(
      :full_name,
      tasks_attributes: [
        :id,
        :title,
        :_destroy,
      ]
    )
  end
end
- if flash[:notice]
  = flash[:notice]
%h1 Edit User
= form_with(model: @user, url: variant_1_user_path(@user)) do |form|
  - if @user.errors.any?
    %h2= pluralize(@user.errors.count, "error") + " prohibited this user from being saved:"
    %ul
      - @user.errors.full_messages.each do |message|
        %li= message
  = form.label :full_name
  = form.text_field :full_name
  %h3 Tasks
  = form.fields_for :tasks do |task_form|
    %div
      = task_form.label :title
      = task_form.text_field :title
      = task_form.check_box :_destroy
      = task_form.label :_destroy, "Remove task"
  = form.submit
Adding nested records via template HTML with JS
resources :variant_2_users, only: [:edit, :update]
class Variant2UsersController < ApplicationController
  def edit
    load_user
  end
  def update
    load_user
    @user.attributes = user_params
    if @user.save
      flash[:notice] = 'User saved successfully.'
      redirect_to(edit_variant_2_user_path(@user))
    else
      flash[:notice] = 'User could not be saved.'
      render :edit
    end
  end
  private
  def load_user
    @user = User.find(params[:id])
  end
  def user_params
    params.require(:user).permit(
      :full_name,
      tasks_attributes: [
        :id,
        :title,
        :_destroy,
      ]
    )
  end
end
- if flash[:notice]
  = flash[:notice]
%h1 Edit User
= form_with(model: @user, url: variant_2_user_path(@user)) do |form|
  - if @user.errors.any?
    %h2= pluralize(@user.errors.count, "error") + " prohibited this user from being saved:"
    %ul
      - @user.errors.full_messages.each do |message|
        %li= message
  = form.label :full_name
  = form.text_field :full_name
  %h3 Tasks
  .div(nested-records)
    = form.fields_for :tasks do |task_form|
      %div
        = task_form.label :title
        = task_form.text_field :title
        = task_form.check_box :_destroy
        = task_form.label :_destroy, "Remove task"
    = form.fields_for :tasks, Task.new do |task_form|
      %template(nested-records--template)
        = task_form.label :title
        = task_form.text_field :title
        = task_form.check_box :_destroy
        = task_form.label :_destroy, "Remove task"
    %button(nested-records--add type="button") Add task
    = form.submit
const NESTED_ID = /(?<=_attributes_)(\d+)/
const NESTED_NAME = /(?<=_attributes]\[)(\d+)/
function cloneTemplate(template) {
  const uid = new Date().valueOf()
  const clone = template.content.cloneNode(true)
  const anchor = document.createElement('div')
  for (let element of clone.querySelectorAll('[id]')) { element.id = element.id.replace(NESTED_ID, uid) }
  for (let element of clone.querySelectorAll('[for]')) { element.htmlFor = element.htmlFor.replace(NESTED_ID, uid) }
  for (let element of clone.querySelectorAll('[name]')) { element.name = element.name.replace(NESTED_NAME, uid) }
  template.insertAdjacentElement('beforebegin', anchor)
  anchor.appendChild(clone)
}
up.compiler('[nested-records]', function(container) {
  const template = container.querySelector('[nested-records--template]')
  const addButton = container.querySelector('[nested-records--add]')
  addButton.addEventListener('click', () => cloneTemplate(template))
})
Adding nested records via template HTML with JS using dynamic Unpoly templates
This approach uses unpoly's support for templates with dynamic variable substition Show archive.org snapshot available since Unpoly version 3.10.0 Show archive.org snapshot
- if flash[:notice]
  = flash[:notice]
%h1 Edit User
= form_with(model: @user, url: variant_2_user_path(@user)) do |form|
  - if @user.errors.any?
    %h2= pluralize(@user.errors.count, "error") + " prohibited this user from being saved:"
    %ul
      - @user.errors.full_messages.each do |message|
        %li= message
  = form.label :full_name
  = form.text_field :full_name
  %h3 Tasks
  .div(nested-records)
    = form.fields_for :tasks do |task_form|
      %div
        = task_form.label :title
        = task_form.text_field :title
        = task_form.check_box :_destroy
        = task_form.label :_destroy, "Remove task"
    = form.fields_for :tasks, Task.new, child_index: "NESTED_RECORD_ID_PLACEHOLDER" do |task_form|
      %template(nested-records--template)
        %div(nested-records)
          %div
              = task_form.label :title
              = task_form.text_field :title
              = task_form.check_box :_destroy
              = task_form.label :_destroy, "Remove task"
  %a(up-target="[nested-records]:after" up-document="[nested-records--template]" role="button" href="#") Add task
  = form.submit
Compared to the previous version, some changes are necessary:
- the nested records need to be in their own container so that up-targetcan correctly insert new clones at the end via theup-target="[nested-records]:after"selector
- 
up-documentspecifies the template to be cloned
- the template needs to contain the targeted container element ([nested-records]) so that unpoly can correctly place it
- 
form.fields-forallows setting the ids of created fields viachild-indexwhich we set to a placeholder value (NESTED_RECORD_ID_PLACEHOLDER)
The last part to make this work is a custom unpoly template "engine" which replaces these placeholder values with a unique id during cloning.
up.on('up:template:clone', '[nested-records--template]', (event) => {
  const template = event.target.innerHTML
  const result = template.replaceAll('NESTED_RECORD_ID_PLACEHOLDER', new Date().valueOf())
  event.nodes = up.element.createNodesFromHTML(result)
})
Adding nested records via XHR with JS
resources :variant_3_users, only: [:edit, :update]
class Variant3UsersController < ApplicationController
  def edit
    load_user
  end
  def update
    load_user
    @user.attributes = user_params
    if up.validate?
      render :edit
    elsif @user.save
      flash[:notice] = 'User saved successfully.'
      redirect_to(edit_variant_3_user_path(@user))
    else
      flash[:notice] = 'User could not be saved.'
      render :edit
    end
  end
  private
  def load_user
    @user = User.find(params[:id])
  end
  def user_params
    params.require(:user).permit(
      :full_name,
      tasks_attributes: [
        :id,
        :title,
        :_destroy,
      ]
    )
  end
end
- if flash[:notice]
  = flash[:notice]
%h1 Edit User
= form_with(model: @user, url: variant_3_user_path(@user)) do |form|
  - if @user.errors.any?
    %h2= pluralize(@user.errors.count, "error") + " prohibited this user from being saved:"
    %ul
      - @user.errors.full_messages.each do |message|
        %li= message
  = form.label :full_name
  = form.text_field :full_name
  %h3 Tasks
  #task-fields
    - @user.tasks.build
    = form.fields_for :tasks do |task_form|
      %div
        = task_form.label :title
        = task_form.text_field :title, 'up-validate' => '#task-fields'
        = task_form.check_box :_destroy
        = task_form.label :_destroy, "Remove task"
  = form.submit
Posted by Emanuel to makandra dev (2024-05-15 07:08)