Unpoly + Nested attributes in Rails: A short overview of different approaches

Updated . Posted . Visible to the public. Repeats.

This card describes two variants, that add a more intuitive workflow when working with nested attributes in Rails + Unpoly.

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

Image


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

Image


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

Image


Last edit
Emanuel
License
Source code in this card is licensed under the MIT License.
Posted by Emanuel to makandra dev (2024-05-15 07:08)