This card describes four variants, that add a more intuitive workflow when working with nested attributes in Rails + Unpoly:
- Without JS
- With HTML template and JS
- With HTML template and JS using dynamic Unpoly templates
- 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
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))
})
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 theup-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 viachild-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
Posted by Emanuel to makandra dev (2024-05-15 07:08)