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…
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.
I will try to explain the relevant parts on the interaction with turbo that will help you build an “inline CRUD”.
<%= turbo_frame_tag "new_product", target: "_top" %>
on the index page.data-turbo-frame="new_product"
to the “New product” link.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">
.
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
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
<%= turbo_frame_tag product %>
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.
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
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
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
.
Here I try 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 posts.