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

Updated . Posted . Visible to the public. Repeats.

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

  1. Without JS
  2. With HTML template and JS
  3. With HTML template and JS using dynamic Unpoly templates
  4. 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

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 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-target can correctly insert new clones at the end via the up-target="[nested-records]:after" selector
  • up-document specifies 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-for allows setting the ids of created fields via child-index which 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

Image


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