Benito Serna Tips and tools for Ruby on Rails developers

Inline CRUD with rails and hotwire

January 11, 2022

This is a guide to help you build “inline CRUDs” with rails and hotwire, where you create and edit records in the same page.

Like in the next video…

The example app

You can find the code of the app in the video on: github/bhserna/inline_crud_hotwire

To explain how you can add this behavior to your app, I will use the app from the video as an example.

If you need help to extrapolate the example for your use case, you can ask a question in the comments section of this post, I will try to answer there.

Inline CRUD interactions

I will try to explain the relevant parts on the interaction with turbo that will help you build an “inline CRUD”.

Show the “new product” form in the same page

  1. Add a <%= turbo_frame_tag "new_product", target: "_top" %> on the index page.
  2. Add data-turbo-frame="new_product" to the “New product” link.
  3. On the new template wrap the form on a <%= turbo_frame_tag @product do %>...<% end %>
<%=# products/index.html.erb %>
<%= turbo_frame_tag "new_product", target: "_top" %>
<%=# products/index.html.erb %>
<%= link_to "New product", new_product_path, data: { turbo_frame: "new_product" } %>
<%=# products/new.html.erb %>
<%= turbo_frame_tag @product do %>
  <%= render "form", product: @product %>
<% end %>

The <%= turbo_frame_tag "new_product", target: "_top" %> on the index page will be converted to a <turbo-frame id="new_product" target="_top">. It will set the place for the “new product” form.

The id is new_product because it will help us to latter use the method turbo_frame_tag @product where @product is Product.new, then rails will turn this into the string "new_product".

The target="_top" is there to target the whole page by default and in that way when the product is successfully created you can redirect to the index page and turbo will update the whole page.

The data-turbo-frame="new_product" attribute on the “New product” link. Tells turbo to put the content of the matching turbo frame from the server response on the <turbo-frame id="new_product"> that is on the current page.

This is why on the new.html.erb template the form is inside <%= turbo_frame_tag @product do %>, that will be transformed by rails to <turbo-frame id="new_product">.

Show the product after it is created

Redirect to the index page.

If the turbo frame has the attribute target="_top" to target the whole page by default you can redirect to the index page on the controller and turbo will update the whole page.

def create
  @product = Product.new(product_params)

  if @product.save
    redirect_to products_path
  else
    #...
  end
end

Show the form errors on the “new product” form

Update the form with a turbo_stream.

If the turbo frame has the attribute target="_top" to target the whole page by default you can’t render new.html.erb because then turbo will update the whole page with the response.

That’s why you need to create a new template new.turbo_stream.erb with:

<%= turbo_stream.update @product do %>
  <%= render "form", product: @product %>
<% end %>

Then Rails will change the <%= turbo_stream.update @product do %> to <turbo-stream action="update" target="new_product"> because the @product was not saved and its dom id representation is still "new_product".

In the controller you can respond with render :new, status: :unprocessable_entity and as the request was a TURBO_STREAM request rails will use the turbo_stream template by default.

def create
  @product = Product.new(product_params)

  if @product.save
    #...
  else
    render :new, status: :unprocessable_entity
  end
end

Show the “edit product” form on the item’s place

  1. Wrap the list item in a <%= turbo_frame_tag product %>
  2. On the edit template wrap the form on a <%= turbo_frame_tag @product do %>...<% end %>

Wrapping the list item in a <%= turbo_frame_tag product %>, creates a <turbo-frame id="product_#{product.id}">.

<%= turbo_frame_tag product do %>
  <div class="product-card">
    ...
    <div class="product-actions">
      <%= link_to "Edit", edit_product_path(product) %>
      ...
    </div>
  </div>
<% end %>

This will tell turbo to capture all navigation within the frame by expecting any followed link or form submission to return a response including a matching frame tag.

Wrapping the product edition form, on a <%= turbo_frame_tag @product do %>...<% end %> will create the matching <turbo-frame id="product_#{product.id}">.

<%=# products/edit.html.erb %>
<%= turbo_frame_tag @product do %>
  <%= render "form", product: @product %>
<% end %>

Then turbo will take the content from the matching <turbo-frame> the response, and update the content of the current <turbo-frame> on the page.

Show the product after it is updated

Redirect to the index page.

As the form was inside the <turbo-frame id="product_#{product.id}"> and the index page has a matching <turbo-frame id="product_#{product.id}">, turbo will take that content and update the form with the regular list item from the index page.

def update
  @product = Product.find(params[:id])

  if @product.update(product_params)
    redirect_to products_path
  else
    #...
  end
end

Show the form errors on the “edit product” form

Render the edit.html.erb template with an error status.

When you submit a form, tubo will expect a redirect, but if you return an error status, turbo will process the response. So if you respond with render :edit, status: :unprocessable_entity turbo will take the content from the matching <turbo-frame id="product_#{product.id}">, and update the page.

def update
  @product = Product.find(params[:id])

  if @product.update(product_params)
    #...
  else
    render :edit, status: :unprocessable_entity
  end
end

Delete the product

Redirect to the index page.

As the delete button, that is really an html form, was inside the <turbo-frame id="product_#{product.id}"> and the index page (after the item was deleted), will not have a matching <turbo-frame id="product_#{product.id}">, turbo will remove the content from <turbo-frame>.

Note that it will not remove the turbo frame from the page, it will just remove its content. If you want to remove the turbo frame you will need to respond with a turbo-stream to remove the turbo-frame.

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.