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

Posted . Visible to the public. Auto-destruct in 60 days

With the new unpoly client side templates Show archive.org snapshot (available since 3.10) there's another way to substitute the ids of inserted nested attribute forms which I added to this card.

Changes

  • -This card describes two variants, that add a more intuitive workflow when working with nested attributes in Rails + Unpoly.
  • +This card describes four variants, that add a more intuitive workflow when working with nested attributes in Rails + Unpoly:
  • +
  • +1. [Without JS](#section-js)
  • +2. [With HTML template and JS](#section-adding-nested-records-via-template-html-js)
  • +3. [With HTML template and JS using dynamic Unpoly templates](#section-adding-nested-records-via-template-html-js-dynamic)
  • +4. [Adding Records via XHR and JS](#section-adding-nested-records-via-xhr-js)
  • # Example
  • For the following examples we use a simple data model where a user has zero or more tasks.
  • ```ruby
  • 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
  • ```
  • ```ruby
  • class Task < ApplicationRecord
  • belongs_to :user
  • validates :title, presence: true
  • end
  • ```
  • ```ruby
  • 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.
  • ```ruby
  • resources :variant_1_users, only: [:edit, :update]
  • ```
  • ```ruby
  • 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
  • ```
  • ```haml
  • - 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](/makandra/620664/attachments/32479)
  • ---
  • ## Adding nested records via template HTML with JS
  • ```ruby
  • resources :variant_2_users, only: [:edit, :update]
  • ```
  • ```ruby
  • 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
  • ```
  • ```haml
  • - 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
  • ```
  • ```js
  • 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](/makandra/620664/attachments/32481)
  • ----
  • +## Adding nested records via template HTML with JS using dynamic Unpoly templates
  • +This approach uses unpoly's support for [templates with dynamic variable substition](https://unpoly.com/templates#dynamic) available since [Unpoly version 3.10.0](https://unpoly.com/changes/3.10.0)
  • +
  • +```haml
  • +- 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.
  • +
  • +```js
  • +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
  • ```ruby
  • resources :variant_3_users, only: [:edit, :update]
  • ```
  • ```ruby
  • 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
  • ```
  • ```haml
  • - 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](/makandra/620664/attachments/32482)
  • ----
Daniel Schulz
Last edit
Daniel Schulz
License
Source code in this card is licensed under the MIT License.
Posted by Daniel Schulz to makandra dev (2025-04-25 12:24)