Updated: Unpoly + Nested attributes in Rails: A short overview of different approaches
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
- ```
- ----
- 
- ----