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
- ```
- ---
- 
- ---
- ## 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))
- })
- ```
- ----
- 
- ----
- +## 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
- ```
- ----
- 
- ----
Posted by Daniel Schulz to makandra dev (2025-04-25 12:24)