How to edit a single attribute inline with Turbo Frame and Trubo Stream with validation feedback?

1.4k views Asked by At

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?

1

There are 1 answers

1
Alex On BEST ANSWER

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 || "&mdash;".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