Creating In-Place-Editing of a single attribute of a model using Turbo Frames (not using a gem such as Best_In_Place as it requires jQuery and is not working well with Rails 7) This implemenation is using ONLY turboframes.
To accomplish this I followed this tutorial: https://nts.strzibny.name/single-attribute-in-place-editing-turbo/ (written in January 2022)
The tutorial does not match Ruby 3.2.0, Rails 7.0.4 perfectly and needs a one variable adjustment on the show page to work.
Unfortunately, there is no validation feedback currently in this tutorials method as the turbo_frame form implemented does not have it included.
Question: how to properly add validation feedback and routing of errors? (preferably a turbo_frames only solution)
Summary of tutorial:
create new app and scaffold one model: User name:string
changes to UsersController (a new action on the controller to edit a single attribute, and adding edit_name to before_action list)
before_action :set_user, only: %i[ show edit edit_name update destroy ]
# GET /users/1/edit_name
def edit_name
end
add to routes.rb (a new route for editing a single specific attribute)
resources :users do
member do
get 'edit_name'
end
end
create view/users/edit_name.html.erb (a new view page to support editing a specific attribute, (here a name)).
<%= turbo_frame_tag "name_#{user.id}" do %>
<%= form_with model: #user, url: user_path(#user) do |form| %>
<%= form.text_field :name %>
<%= form.submit "Save" %>
<% end %>
<% end %>
additions on _user.html.erb file (the link to the created turbo frame form edit_name.html.erb)
<%= turbo_frame_tag "name_#{user.id}" do %>
Name: <%= link_to #user.name, edit_name_user_path(#user) %>
<% end %>
Upon starter the app server I get errors about #user being nil:Class.
In order to get the tutorial to work I have to change the _user.html.erb file to use a local variable for user in the link.
edited again _user.html.erb (changing instance variable #user to local variable user)
<%= turbo_frame_tag "name_#{user.id}" do %>
Name: <%= link_to user.name, edit_name_user_path(user) %>
<% end %>
With this change, the tutorial works, allowing single attribute in place editing through turbo frames! But no model validation feedback is implemented.
Below, I attempt to deal with validation, first adding validation to models/user.rb
class User < ApplicationRecord
validates :name, presence: true
validates :name, comparison: { other_than: "Jason" }
end
PROPOSED SOLUTION:
CREATE a new turbo_stream file for editing errors that pop up (it has an error in the turbo_frame tag that it is targeting, it needs to be able to target any parent turboframe where the single attribute edit was initiated)
<%= turbo_stream.replace"name_#{#user.id}" do %>
<%= form_with model: #user, url: user_path(#user) do |form| %>
<% if #user.errors.any? %>
<div style="color: red">
<h2><%= pluralize(#user.errors.count, "error") %> prohibited this user from being saved:</h2>
<ul>
<% #user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<% if #user.errors[:name].any? %>
<%= form.label :name, style: "display: block" %> <%= form.text_field :name %>
<% end %>
<% if #user.errors[:active].any? %>
<%= form.label :active, style: "display: block" %> <%= form.check_box :active %>
<% end %>
<%= form.submit "Save" %>
<% end %>
<% end %>
and edit the UsersController.rb update method to deal with turbo stream errors
# PATCH/PUT /users/1 or /users/1.json
def update
respond_to do |format|
if #user.update(user_params)
format.html { redirect_to user_url(#user), notice: "User was successfully updated." }
format.json { render :show, status: :ok, location: #user }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: #user.errors, status: :unprocessable_entity }
format.turbo_stream do
if #user.errors[:name].any?
#user.name = nil #so that it does not repopulate the form with the bad data
if #user.errors[:active].any?
#user.active = nil
end
render :edit_errors, status: :unprocessable_entity
end
end
end
end
This all works except for after entering a succesful edit on the form produced after an invalid entry, it renders the show for that entry only, rather than all of them.
What would be a 'dry'er method of doing all of this? (and how do I target updating just the one frame from the turbo stream so that only the one field gets updated after success on validation)?
Philosophically, is any of this worth it now compared to just using jQuery and the Gem Best_In_Place??? Seems like the number of changes are piling up and the code will get ugly if supporting such functionality across multiple attributes?
Since the initial issue is resolved, I'll just add some other ways you can do this. It's gonna be a little more work to do this yourself and you won't have all the functionality that some gem could give you. On the other hand, it's a lot less code and you have full control over everything. Besides, if you just need to have this one field to be editable, installing a gem and jquery is too much overhead.
Setup:
# rails v7.0.4.2
$ rails new hello_edit_in_place -c tailwind
$ cd hello_edit_in_place
$ bin/rails g scaffold User email first_name last_name --skip-timestamps
$ bin/rails db:migrate
$ bin/rails runner "User.create(email: 'admin#localhost', first_name: 'super', last_name: 'admin')"
$ open http://localhost:3000/users
$ bin/dev
class User < ApplicationRecord
validates :email, presence: true, length: {minimum: 3}
end
Turbo Frame
I'll just modify the default form and won't touch the controller as a quick example:
# app/views/users/_form.html.erb
# NOTE: this lets you render this partial and pass a local `:attribute` or
# get attribute from url params.
<% if attribute ||= params[:attribute] %>
<%= turbo_frame_tag dom_id(user, attribute) do %>
# NOTE: send `attrtibute` back in case of validation error, so this page
# can be rendered again with params[:attribute] set.
# V
<%= form_with model: user, url: user_path(user, attribute:) do |f| %>
<%= f.text_field attribute %>
# NOTE: show validation errors
<%= safe_join user.errors.full_messages_for(attribute), tag.br %>
<%= f.submit "save" %>
<% end %>
<% end %>
<% else %>
# original form here
<% end %>
# app/views/users/_user.html.erb
# NOTE: there is no need to have the whole set up for each individual
# attribute
<% user.attribute_names.reject{|a| a =~ /^(id|something_else)$/}.each do |attribute| %>
<%= tag.div attribute, class: "mt-4 block mb-1 font-medium" %> # tag.div - so that i can keep rb syntax highlight for stackoverflow
<%= turbo_frame_tag dom_id(user, attribute) do %>
<%= link_to edit_user_path(user, attribute:) do %>
<%= user.public_send(attribute).presence || "—".html_safe %>
<% end %>
<% end %>
<% end %>
That's it, every attribute is rendered, is editable and email shows validation errors. Also because all turbo_frame_tags have a unique id, everything works with multiple users on the index page.
Turbo Stream
You can also use turbo_stream to have more flexibility and make it even more dynamic, but it's a bit more of a set up. Also, add ability to edit full name in place, with first_name and last_name fields together:
# config/routes.rb
# NOTE: to not mess with default actions, add new routes
resources :users do
member do
get "edit_attribute/:attribute", action: :edit_attribute, as: :edit_attribute
patch "update_attribute/:attribute", action: :update_attribute, as: :update_attribute
end
end
# app/views/users/_user.html.erb
# Renders user attributes.
# Required locals: user.
<%= render "attribute", user:, attribute: :email %>
<%= render "attribute", user:, attribute: :name %>
# app/views/users/_attribute.html.erb
# Renders editable attribute.
# Required locals: attribute, user.
<%= tag.div id: dom_id(user, attribute) do %>
<%= tag.div attribute, class: "mt-4 block mb-1 font-medium" %>
# NOTE: to make a GET turbo_stream request vvvvvvvvvvvvvvvvvvvvvvvvvv
<%= link_to edit_attribute_user_path(user, attribute:), data: {turbo_stream: true} do %>
# far from perfect, but gotta start somewhere
<% if user.attribute_names.include? attribute.to_s %>
<%= user.public_send(attribute) %>
<% else %>
# if user doesn't have provided attribute, try to render a partial
<%= render attribute.to_s, user: %>
<% end %>
<% end %>
<% end %>
# app/views/users/_name.html.erb
# Renders custom editable attribute value.
# Required locals: user.
<%= user.first_name %>
<%= user.last_name %>
# app/views/users/_edit_attribute.html.erb
# Renders editable attribute form.
# Required locals: attribute, user.
<%= form_with model: user, url: update_attribute_user_path(user, attribute:) do |f| %>
<% if user.attribute_names.include? attribute.to_s %>
<%= f.text_field attribute %>
<% else %>
# NOTE: same as before but with `_fields` suffix,
# so this requires `name_fields` partial.
<%= render "#{attribute}_fields", f: %>
<% end %>
<%= f.submit "save" %>
<% end %>
# app/views/users/_name_fields.html.erb
# Renders custom attribute form fields.
# Requires locals:
# f - form builder.
<%= f.text_field :first_name %>
<%= f.text_field :last_name %>
# app/controllers/users_controller.rb
# GET /users/:id/edit_attribute/:attribute
def edit_attribute
attribute = params[:attribute]
respond_to do |format|
format.turbo_stream do
# render form
render turbo_stream: turbo_stream.update(
helpers.dom_id(user, attribute),
partial: "edit_attribute",
locals: {user:, attribute:}
)
end
end
end
# PATCH /users/:id/update_attribute/:attribute
def update_attribute
attribute = params[:attribute]
attribute_id = helpers.dom_id(user, attribute)
respond_to do |format|
if user.update(user_params)
format.turbo_stream do
# render updated attribute
render turbo_stream: turbo_stream.replace(
attribute_id,
partial: "attribute",
locals: {user:, attribute:}
)
end
else
format.turbo_stream do
# render errors
render turbo_stream: turbo_stream.append(
attribute_id,
html: (
helpers.tag.div id: "#{attribute_id}_errors" do
# FIXME: doesn't render `first_name` `last_name` errors
helpers.safe_join user.errors.full_messages_for(attribute), helpers.tag.br
end
)
)
end
end
end
end
private
def user
#user ||= User.find(params[:id])
end
In my Rails 5 app, Doctor/Admin have to create a Caregiver with linked Patient (new object CaregiverPatient represents that). During this process, inside the registrants_controller#new, Doctor/Admin search for a Patient (which is done by Ransack gem) and from the result he/she push Add Patient button to add Patient to initialized CaregiverPatient object from caregiver_patient#new and display #patient.full_name inside the form.
In order not to lose already filled form fields, everything must be done asynchronously. I've tried to do so with AJAX, below my code:
registrant_controller.rb
def new
#registrant = Registrant.new
#patient = Registrant.find(session[:patient_id_to_add_caregiver]) if session[:patient_id_to_add_caregiver]
end
registrants/_new_form.html.erb
<%= form_for #registrant do |f| %>
<%= f.fields_for :registration do |registration| %>
<% if #patient %>
<div id="add-patient"></div>
<% else %>
<%= required_text_field_group registration, #registrant, :registrant_id, "Patient Added", {}, { disabled: true } %>
<% end %>
<% end %>
# some other logic (...)
# patient search result
<% #registrants do |registrant| %>
<%= link_to 'Add Patient', new_registrant_link_path(registrant), remote: true %>
<% end %>
Add Patient button triggers caregiver_patients#new action:
#caregiver_patient_controller.rb
class CaregiverPatientsController < ApplicationController
# new_registrant_link_path
def new
session[:patient_id_to_add_caregiver] = params[:registrant_id].to_i
respond_to do |format|
format.html { redirect_to request.referer }
format.js { render action: 'patient_to_caregiver', notice: 'Patient Added' }
end
end
end
AJAX request is made:
#views/caregiver_patients/patient_to_caregiver.js.erb
$("#add-patient").html("<%= escape_javascript(render :partial => 'registrants/add_patient') %>");
views/registrants/_add_patient.html.erb
Patient Added: <%= #patient.full_name %>
But surprisingly gets an error:
ActionView::Template::Error (undefined method `full_name' for nil:NilClass):
1: Patient Added: <%= #patient.full_name %>
Which probably means that registrants_controller#new was not refreshed under the hood somehow. What have I missed?
I have an edit form that is not a devise form (i have a devise edit on a different view) to edit a users details. However the form inputs only appear if the data is already there. So a user is unable to add new details to the form, as the inputs don't appear at all.
Is this happening as i'm not using a devise form view?
This is the code in my own edit file:
<%= render "devise/registrations/details", f: f, resource: #resource, addressable: #resource, default_location: nil %>
Then in the devise/registration/details i have this:
<%= f.simple_fields_for :address do |fields| %>
<%= render "address/fields", fields: fields, addressable: addressable, resource: resource %>
<% end %>
However, i think the inputs are not showing up as fields are blank in the iteration. However these fields are showing up in the actual devise/edit file, even if they are blank, just not in my new one.
When using fields_for and simple_fields_for (which is basically just a pimped up version of the former) you have to "seed" the inputs in order for them to appear.
Consider this simplified plain rails example:
class UsersController < ApplicationController
def new
#user = User.new
#user.build_address
end
# ...
end
<%= simple_form_for(#user) do |f| %>
<%= f.simple_fields_for(:address) do |af| %>
<%= af.input :street %>
<% end %>
<% end %>
If we remove the line #user.build_address there will be no nested inputs. Thats because simple_fields_for calls the method #address on #user and creates inputs for the record. If the association is one or many to many it would iterate through the association. If the method returns nil or empty there are no inputs to create - it would be like calling form_for with nil.
You can also pass a second argument to fields_for to manually specify the record object:
<%= simple_form_for(#user) do |f| %>
<%= f.simple_fields_for(:address, #user.address || #user.build_address) do |af| %>
<%= af.input :street %>
<% end %>
<% end %>
In Devise you can do this by passing a block to super or by overriding #build_resource.
class MyRegistationsController < Devise::RegistrationsController
def new
super { |resource| resource.build_address }
end
end
I've generated a scaffold, let's call it scaffold test.
Within that scaffold, I've got a _form.html.erb thats being render for action's :new => :create and :edit => :update
Rails does a lot of magic sometimes and I cannot figure out how the form_for knows how to call the proper :action when pressing submit between :new and :edit
Scaffolded Form
<%= form_for(#test) do |f| %>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
vs.
Un-scaffolded Form
<% form_for #test :url => {:action => "new"}, :method => "post" do |f| %>
<%= f.submit %>
<% end %>
Edit template
<h1>Editing test</h1>
<%= render 'form' %>
New template
<h1>New test</h1>
<%= render 'form' %>
As you can see theres no difference between the forms
How can both templates render the same form but use different actions?
It checks #test.persisted? If it is persisted then it is an edit form. If it isn't, it is a new form.
It checks if the record is new or not.
#test.new_record? # if true then create action else update action
If the #test instance variable is instantiated via the Test.new class method, then the create method is executed. If #test is an instance of Test that exists in the database, the update method is executed.
In other words:
# app/controllers/tests_controller.rb
def new
#test = Test.new
end
<%= form_for(#test) |do| %> yields a block that is sent to the create controller method.
If, instead:
# app/controllers/tests_controller.rb
def edit
#test = Test.find(params[:id])
end
<%= form_for(#test) |do| %> yields a block that is sent to the update controller method.
UPDATE:
The precise function that Rails uses to recognize whether or not a record is new is the persisted? method.
I have tried to set up a separate section of my app using a subdirectory called controlpanel to manage various parts of the site.
I've set up the namespace in my routes.rb
map.namespace :controlpanel do |submap|
submap.resources :pages
# other controllers
end
And placed the controller and views into the relevant subdirectories.
Controlpanel::PagesController
def new
#page = Page.new
end
def create
if #page = Page.create_with_author(current_user, params[:page])
flash[:notice] = 'Page was successfully created.'
redirect_to ([:controlpanel, #page])
else
render :action => 'new'
end
end
Using this mixed in class method
def create_with_author(author, params)
created = new(params)
created.author = author
if created.save
created
end
end
And the view (controlpanel/pages/new.html.erb renders a partial called _form
<%= render :partial => 'form' %>
Which is as follows:
<% semantic_form_for([:controlpanel, #page]) do |form| %>
<% form.inputs do %>
<%= form.input :title %>
<%= form.input :body %>
<% end %>
<%= form.buttons %>
<% end %>
If I fill in the form correctly, it works as expected, redirecting me to the new page, however, if I leave fields blank, violating the validation constraints, I get the following error:
RuntimeError in Controlpanel/pages#create
Showing app/views/controlpanel/pages/_form.html.erb where line #1 raised:
Called id for nil, which would mistakenly be 4 -- if you really wanted the id of nil, use object_id
Can anyone see what is going wrong?
I'm using the formtastic plugin to create the form, but it still happens if I use a regular form.
Any advice greatly appreciated.
Thanks.
Given that the create action is called and new is rendered, Page.create must evaluate to nil.
You probably want to pass params[:page] to create.