What we will build in this chapter
We will almost finalize our quote editor in this chapter by adding line items to the line item dates we created in the previous chapter. Those line items will have a name
, an optional description
, a unit_price
, and a quantity
.
While this chapter is about another CRUD controller, it will come with some interesting challenges as we will have a lot of nested Turbo Frames. We will also discuss how to preserve the state of our Quotes#show
page when performing CRUD operations on both the LineItemDate
and the LineItem
models.
Before we start coding, let's look at how our final quote editor will work. Let's create a quote and then navigate to the Quotes#show
page. Let's then create a few line item dates and a few line items to have a solid grasp of what our final product should look like.
Once we have a good understanding of how the final quote editor behaves, let's start, as always, by making our CRUD controller on the LineItem
model work without Turbo Frames and Turbo Streams. We will add Turbo Rails features later once our controller works properly.
First, let's make a few sketches of how our application will behave without Turbo Frames and Turbo Streams. When we visit the Quotes#show
page, we now have line item dates on our quote. As each line item date will be able to have multiple line items, each line item date card will have a "Add item" link to add line items for this specific line item date:
On the Quotes#show
page, we should be able to add line items to any of the line item dates present on the quote. As we are first building the CRUD without Turbo, clicking on the "Add item" link for a line item date will take us to the LineItems#new
page, where we can add a line item for this specific line item date.
If we click on the "Add item" link for the second line item date, here is a sketch of the page we can expect:
If we submit a valid form, we will be redirected to the Quotes#show
page with the new line item added:
If we decide to update the line item we just created, we can click on the "Edit" link for this line item to navigate to the LineItems#edit
page:
If we submit a valid form, we will be redirected to the Quotes#show
page with the line item updated:
Last but not least, we can delete a line item by clicking on the "Delete" link for this line item. The line item is then removed from the list.
Now that the requirements are clear, it's time to start coding!
Creating the model
Let's start by creating a model named LineItem
. This model will have five fields:
- a reference to the line item date it belongs to
- a name
- an optional description
- a unit price
- a quantity
We add a reference to the LineItemDate
model because each line item belongs to a line item date, and each line item date has many line items. Let's generate the migration:
bin/rails generate model LineItem \
line_item_date:references \
name:string \
description:text \
quantity:integer \
unit_price:decimal{10-2}
Before running the rails db:migrate
command, we must add constraints to the name
, quantity
, and unit_price
fields as we want them to always be present on each record. We can enforce this validation at the database level thanks to the null: false
constraint.
The final migration should look like this:
# db/migrate/XXXXXXXXXXXXXX_create_line_items.rb
class CreateLineItems < ActiveRecord::Migration[7.0]
def change
create_table :line_items do |t|
t.references :line_item_date, null: false, foreign_key: true
t.string :name, null: false
t.text :description
t.integer :quantity, null: false
t.decimal :unit_price, precision: 10, scale: 2, null: false
t.timestamps
end
end
end
Now that our migration is ready, we can run it:
bin/rails db:migrate
Let's add the associations and the corresponding validations on the LineItem
model:
# app/models/line_item.rb
class LineItem < ApplicationRecord
belongs_to :line_item_date
validates :name, presence: true
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :unit_price, presence: true, numericality: { greater_than: 0 }
delegate :quote, to: :line_item_date
end
The validations on the model enforce that:
- The
name
,quantity
, andunit_price
fields must be present - The
unit_price
andquantity
fields must be greater than zero - The
quantity
must be an integer
We also delegate the quote
method to the LineItem#line_item_date
method. That way, the two following lines are equivalent:
line_item.line_item_date.quote
line_item.quote
Now that our LineItem
model is completed, let's add the has_many
association on the LineItemDate
model:
# app/models/line_item_date.rb
class LineItemDate < ApplicationRecord
has_many :line_items, dependent: :destroy
# All the previous code...
end
Our model layer is now complete! Let's next work on the routes.
Adding routes for line items
We want to perform all the seven CRUD actions on the LineItem
model except two of them:
- We won't need the
LineItem#index
action as all line items will already be present on theQuotes#show
page. - We won't need the
LineItem#show
action as it would make no sense for us to view a single line item. We always want to see the quote as a whole.
Those two exceptions are reflected in the routes file below:
# config/routes.rb
Rails.application.routes.draw do
# All the previous routes
resources :quotes do
resources :line_item_dates, except: [:index, :show] do
resources :line_items, except: [:index, :show]
end
end
end
Now that our routes are ready, it's time to start designing our application with fake data!
Designing line items
The line item dates of the Quotes#show
page currently don't have any line items. Let's fix that by adding some fake data to our fixtures.
Let's imagine that the quote editor we are building is for a corporate events software. As events can span multiple dates, our quotes will have multiple dates and each will have multiple items! In our fixture file, we might want to add a room where the guests can have a meeting and a meal. Let's add those items in the fixtures file:
# test/fixtures/line_items.yml
room_today:
line_item_date: today
name: Meeting room
description: A cosy meeting room for 10 people
quantity: 1
unit_price: 1000
catering_today:
line_item_date: today
name: Meal tray
description: Our delicious meal tray
quantity: 10
unit_price: 25
room_next_week:
line_item_date: next_week
name: Meeting room
description: A cosy meeting room for 10 people
quantity: 1
unit_price: 1000
catering_next_week:
line_item_date: next_week
name: Meal tray
description: Our delicious meal tray
quantity: 10
unit_price: 25
We can now seed the database again by running the bin/rails db:seed
command. Those seeds will enable us to design our quote editor with fake data. Let's now open the application on the Quotes#show
page for the "First quote". We want to add two elements to each line item date on the page:
- The collection of line items for this date
- The link to add new line items for this date
Let's add those elements by completing the partial for a single line item date:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<div class="line-item-date__header">
<!-- All the previous code -->
</div>
<div class="line-item-date__body">
<div class="line-item line-item--header">
<div class="line-item__name">Article</div>
<div class="line-item__quantity">Quantity</div>
<div class="line-item__price">Price</div>
<div class="line-item__actions"></div>
</div>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
class: "btn btn--primary" %>
</div>
</div>
</div>
<% end %>
To render each line item, we now have to create a partial to display a single line item:
<%# app/views/line_items/_line_item.html.erb %>
<div class="line-item">
<div class="line-item__name">
<%= line_item.name %>
<div class="line-item__description">
<%= simple_format line_item.description %>
</div>
</div>
<div class="line-item__quantity-price">
<%= line_item.quantity %>
×
<%= number_to_currency line_item.unit_price %>
</div>
<div class="line-item__quantity">
<%= line_item.quantity %>
</div>
<div class="line-item__price">
<%= number_to_currency line_item.unit_price %>
</div>
<div class="line-item__actions">
<%= button_to "Delete",
[quote, line_item_date, line_item],
method: :delete,
class: "btn btn--light" %>
<%= link_to "Edit",
[:edit, quote, line_item_date, line_item],
class: "btn btn--light" %>
</div>
</div>
The simple_format
helper is often useful to render text that was typed into a textarea. For example, let's imagine a user typed the following text in the description field for a line item:
- Appetizer
- Main course
- Dessert
- A glass of wine
The HTML generated by the simple_format
helper in our view will be:
<p>
- Appetizers
<br>
- Main course
<br>
- Dessert
<br>
- A glass of wine
</p>
As we can see, the formatting our user wanted when he filled his text area is preserved thanks to the line breaks <br>
. If we remove the simple_format
helper, the text will be displayed on a single line which is not what we want here.
The .line-item__quantity
, .line-item__price
, and .line-item__quantity-price
CSS classes might seem a bit redundant, but we will display the two first CSS classes only when the screen size is above our tabletAndUp
breakpoint, and we will display the last CSS class only on mobile.
Now that we have the HTML markup let's add a little bit of CSS to make our design a bit nicer. The first thing we have to do is finalize our .line-item-date
component that we started in the previous chapter by adding the .line-item-date__body
and .line-item-date__footer
elements:
// app/assets/stylesheets/components/_line_item_date.scss
.line-item-date {
// All the previous code
&__body {
border-radius: var(--border-radius);
background-color: var(--color-white);
box-shadow: var(--shadow-small);
margin-top: var(--space-xs);
padding: var(--space-xxs);
padding-top: 0;
@include media(tabletAndUp) {
padding: var(--space-m);
}
}
&__footer {
border: dashed 2px var(--color-light);
border-radius: var(--border-radius);
text-align: center;
padding: var(--space-xxs);
@include media(tabletAndUp) {
padding: var(--space-m);
}
}
}
Now that our .line-item-date
CSS component is complete let's spend some time designing each individual line item. We are going to write a lot of CSS here as we will create:
- Our
.line-item
base component to design a single line item - A
.line-item--header
modifier to design the header row of a collection of line items - A
.line-item--form
modifier to design the form to create and update a line item
All those components will be responsive on mobile and tablets, and larger screens thanks to our tabletAndUp
breakpoint. Let's dive into the code:
// app/assets/stylesheets/components/_line_item.scss
.line-item {
display: flex;
align-items: start;
flex-wrap: wrap;
background-color: var(--color-white);
gap: var(--space-xs);
margin-bottom: var(--space-s);
padding: var(--space-xs);
border-radius: var(--border-radius);
> * {
margin-bottom: 0;
}
&__name {
flex: 1 1 100%;
font-weight: bold;
@include media(tabletAndUp) {
flex: 1 1 0;
}
}
&__description {
flex-basis: 100%;
max-width: 100%;
color: var(--color-text-muted);
font-weight: normal;
font-size: var(--font-size-s);
}
&__quantity-price {
flex: 0 0 auto;
align-self: flex-end;
justify-self: flex-end;
order: 3;
font-weight: bold;
@include media(tabletAndUp) {
display: none;
}
}
&__quantity {
flex: 1;
display: none;
@include media(tabletAndUp) {
display: revert;
flex: 0 0 7rem;
}
}
&__price {
flex: 1;
display: none;
@include media(tabletAndUp) {
display: revert;
flex: 0 0 9rem;
}
}
&__actions {
display: flex;
gap: var(--space-xs);
order: 2;
flex: 1 1 auto;
@include media(tabletAndUp) {
order: revert;
flex: 0 0 10rem;
}
}
&--form {
box-shadow: var(--shadow-small);
.line-item__quantity,
.line-item__price {
display: block;
}
.line-item__description {
order: 2;
}
}
&--header {
display: none;
background-color: var(--color-light);
margin-bottom: var(--space-s);
@include media(tabletAndUp) {
display: flex;
}
& > * {
font-size: var(--font-size-s);
font-weight: bold ;
letter-spacing: 1px;
text-transform: uppercase;
}
}
}
Let's not forget to import this new file inside our manifest file:
// app/assets/stylesheets/application.sass.scss
// All the previous code
@import "components/line_item";
That was a lot of CSS, but everything should look nice now! If we open the browser, we should see that our design is good enough!
Before moving to the next section, let's notice that we currently have a performance problem. Even though it is a bit outside of the scope of this tutorial, it's important to explain what happens here. If we inspect the logs of our Rails server when visiting the Quotes#show
page, we will see an N+1 query issue here:
...
SELECT "line_items".* FROM "line_items" WHERE "line_items"."line_item_date_id" = $1
...
SELECT "line_items".* FROM "line_items" WHERE "line_items"."line_item_date_id" = $1
...
In the extract from the logs above, we are querying the line_items
table twice because we have two line item dates, but we would query it n times if we had n line item dates. This is because each time we render a new line item date, we also perform a request to retrieve the associated line items because of this line:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
A good rule of thumb for performance is that we should query a database table only once per request-response cycle.
To avoid the N+1 query issue, we need to load the collection of line items for each line item date in advance. Let's do it in the QuotesController#show
action:
# app/controllers/quotes_controller.rb
class QuotesController < ApplicationController
# All the previous code...
def show
@line_item_dates = @quote.line_item_dates.includes(:line_items).ordered
end
# All the previous code...
end
With this includes
added, we should notice in the logs that we now only query the line_items
table once to display the page:
SELECT "line_items".* FROM "line_items" WHERE "line_items"."line_item_date_id" IN ($1, $2)
With that performance issue solved, it is now time to create our LineItemsController
and make it work!
Our standard CRUD controller
Creating line items without Turbo
Now that our database schema, model, routes, markup, and design are done, it's time to start working on the controller. As mentioned in the introduction, we will first build a standard controller without Turbo Frames and Turbo Streams; we will add them later.
Our controller will contain all the seven actions of the CRUD except the #index
and the #show
actions. Let's start by making the #new
and #create
actions work:
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
before_action :set_quote
before_action :set_line_item_date
def new
@line_item = @line_item_date.line_items.build
end
def create
@line_item = @line_item_date.line_items.build(line_item_params)
if @line_item.save
redirect_to quote_path(@quote), notice: "Item was successfully created."
else
render :new, status: :unprocessable_entity
end
end
private
def line_item_params
params.require(:line_item).permit(:name, :description, :quantity, :unit_price)
end
def set_quote
@quote = current_company.quotes.find(params[:quote_id])
end
def set_line_item_date
@line_item_date = @quote.line_item_dates.find(params[:line_item_date_id])
end
end
Our controller is very standard and should already work, but we are missing the line_items/new.html.erb
view and the line_items/_form.html.erb
partial. Let's add those two files to our application:
<%# app/views/line_items/new.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>New item for <%= l(@line_item_date.date, format: :long) %></h1>
</div>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
</main>
We don't need a fancy design for our LineItems#new
page as we will use Turbo later to extract the form from the page and insert it in the Quotes#show
page. However, it should still be usable for people using legacy browsers that don't support Turbo. Let's add the markup for our form:
<%# app/views/line_items/_form.html.erb %>
<%= simple_form_for [quote, line_item_date, line_item],
html: { class: "form line-item line-item--form" } do |f| %>
<%= form_error_notification(line_item) %>
<%= f.input :name,
wrapper_html: { class: "line-item__name" },
input_html: { autofocus: true } %>
<%= f.input :quantity,
wrapper_html: { class: "line-item__quantity" } %>
<%= f.input :unit_price,
wrapper_html: { class: "line-item__price" } %>
<%= f.input :description,
wrapper_html: { class: "line-item__description" } %>
<div class="line-item__actions">
<%= link_to "Cancel", quote_path(quote), class: "btn btn--light" %>
<%= f.submit class: "btn btn--secondary" %>
</div>
<% end %>
In this form, we reuse the form_error_notification
helper that we created in the last chapter! We also reuse the .line-item
CSS class in combination with the .line-item--form
modifier to style the form.
Let's test in our browser. It does not work as expected! The line item date card disappears, and we have the following error in the browser's console:
Response has no matching <turbo-frame id="line_item_date_123456"> element
This is because our "Add item" link is already nested within a Turbo Frame we added in the previous chapter as described in the sketch below:
Because of those Turbo Frames, Turbo will intercept all clicks on links and form submissions within those Turbo Frames and expect a Turbo Frame of the same id in the response. We first want to make our CRUD work without Turbo Frames and Turbo Streams.
To prevent Turbo from intercepting our clicks on links and form submissions, we will use the data-turbo-frame="_top"
data attribute as explained in the first chapter about Turbo Frames. Let's add this data attribute to our "Add item" link:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<!-- All the previous code -->
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
data: { turbo_frame: "_top" },
class: "btn btn--primary" %>
</div>
<!-- All the previous code -->
Let's also anticipate and add the same data attribute to the "Edit" link and the "Delete" form on the line item partial as we will have the same issue there:
<%# app/views/line_items/_line_item.html.erb %>
<!-- All the previous code -->
<div class="line-item__actions">
<%= button_to "Delete",
[quote, line_item_date, line_item],
method: :delete,
form: { data: { turbo_frame: "_top" } },
class: "btn btn--light" %>
<%= link_to "Edit",
[:edit, quote, line_item_date, line_item],
data: { turbo_frame: "_top" },
class: "btn btn--light" %>
</div>
<!-- All the previous code -->
Let's now test our #new
and #create
action in the browser. Everything now works as expected!
Let's just take a few seconds to fill the translations file with the text we want for the labels, placeholders, and submit buttons:
# config/locales/simple_form.en.yml
en:
simple_form:
placeholders:
quote:
name: Name of your quote
line_item:
name: Name of your item
description: Description (optional)
quantity: 1
unit_price: $100.00
labels:
quote:
name: Name
line_item:
name: Name
description: Description
quantity: Quantity
unit_price: Unit price
line_item_date:
date: Date
helpers:
submit:
quote:
create: Create quote
update: Update quote
line_item:
create: Create item
update: Update item
line_item_date:
create: Create date
update: Update date
With that file completed, the text of the submit button will be "Create date" when we create a LineItemDate
and "Update date" when we update a LineItemDate
.
Updating line items without Turbo
Now that our #new
and #create
actions are working let's do the same work for the #edit
and #update
actions. Let's start with the controller:
class LineItemsController < ApplicationController
before_action :set_quote
before_action :set_line_item_date
before_action :set_line_item, only: [:edit, :update, :destroy]
# All the previous code
def edit
end
def update
if @line_item.update(line_item_params)
redirect_to quote_path(@quote), notice: "Item was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
private
# All the previous code
def set_line_item
@line_item = @line_item_date.line_items.find(params[:id])
end
end
We know that we will need the set_line_item
callback for the #destroy
action as well, so we can anticipate and add it to the list of actions requiring this callback.
Now that our #edit
and #update
actions are implemented, let's add the LineItems#edit
view to be able to test in the browser:
<%# app/views/line_items/edit.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit item</h1>
</div>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
</main>
As we can notice, the LineItems#edit
view is very similar to the LineItems#new
view. Only the title changes. As we already built the form in the previous section, we are ready to experiment in the browser. Everything works as expected; only one more action to go!
Deleting line items without Turbo
The #destroy
action is the simplest of all five as it doesn't require a view. We only need to delete the line item and then redirect to the Quotes#show
page:
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
# All the previous code
def destroy
@line_item.destroy
redirect_to quote_path(@quote), notice: "Item was successfully destroyed."
end
# All the previous code
end
Let's test it in our browser, and it works as expected!
Our CRUD controller is now working, but we now want all interactions to happen on the same page. Thanks to the power of Turbo, it will only require a few lines of code to slice our page into pieces that can be updated independently.
Adding Turbo Frames and Turbo Streams
Creating line items with Turbo
Now that our CRUD controller is working as expected, it's time to improve the user experience so that all the interactions happen on Quotes#show
page.
To be clear on the requirements, let's first sketch the desired behavior. From now on, we will zoom in sketches on a single line item date for them to remain readable.
When a user visits the Quotes#show
page and clicks on the "Add item" button for a specific date, we want the form to appear on the Quotes#show
page right above the "Add item" button we just clicked on. We will do this with Turbo Frames, of course! To make it work, we have to connect the "Add item" link to an empty Turbo Frame thanks to the data-turbo-frame
data attribute.
For Turbo to properly replace the Turbo Frame on the Quotes#show
page, the Turbo Frame on the LineItems#new
page must have the same id.
We notice that the Turbo Frame ids are longer than in the previous chapters. Turbo Frames must have unique ids on the page to work properly. As we have multiple dates on the page, if the id for the empty Turbo Frame was only new_line_item
, or the id for the list of line items was only line_items
, we would have multiple Turbo Frames with the same id.
Let's explain why Turbo Frames on the same page must have different ids. If we did like in previous chapters, our create.turbo_stream.erb
view would look like this:
<%# app/views/line_items/create.turbo_stream.erb %>
<%= turbo_stream.update LineItem.new, "" %>
<%= turbo_stream.append "line_items", @line_item %>
If there are multiple line item dates on the quote, then there would be the new_line_item
and the line_items
ids multiple times on the Quotes#show
page. How could Turbo guess what to do if there are multiple times the same id? Our created line item would probably be appended in the list of line items of the wrong date!
A good convention is to prefix the ids we would normally have by the dom_id
of the parent resource to solve this issue. That way, we are sure our ids are unique.
For Turbo to work properly, we need a Turbo Frame of the same id on the LineItems#new
page:
With those Turbo Frames in place, when a user clicks on the "New item" button, Turbo will successfully replace the empty Turbo Frame on the Quotes#show
page with the Turbo Frame containing the form on the LineItems#new
page:
When the user submits the form, we want the created line item to be appended to the list of line items for this specific date:
Now that the requirements are clear, it should only take a few lines of code to make it real, thanks to the power of Turbo Frames and Turbo Streams!
Let's start working on the first part: making the form appear on the Quotes#show
page when a user clicks on the "Add item" button. On each line item date, let's add an empty Turbo Frame and connect the "Add date" button to it:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<!-- All the previous code -->
<div class="line-item-date__body">
<div class="line-item line-item--header">
<!-- All the previous code -->
</div>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<%= turbo_frame_tag dom_id(LineItem.new, dom_id(line_item_date)) %>
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
data: { turbo_frame: dom_id(LineItem.new, dom_id(line_item_date)) },
class: "btn btn--primary" %>
</div>
</div>
</div>
<% end %>
As mentioned above, for nested resources, we want to prefix the dom_id
of the resource with the dom_id
of the parent. The dom_id
helper takes an optional prefix as a second argument. We could use the dom_id
helper to follow our convention:
line_item_date = LineItemDate.find(1)
dom_id(LineItem.new, dom_id(line_item_date))
# => line_item_date_1_new_line_item
This approach works fine, but it is hard to read. It also has an edge case:
dom_id("line_items", dom_id(line_item_date))
# This does not return "line_item_date_1_line_items"
# It raises an error as "line_items" does not respond to `#to_key`
# and so can't be transformed into a dom_id
Instead of relying on the dom_id
helper directly, let's create a helper to make our ids easier to generate/read and ensure all developers in our team will use the same convention:
# app/helpers/application_helper.rb
module ApplicationHelper
# All the previous code
def nested_dom_id(*args)
args.map { |arg| arg.respond_to?(:to_key) ? dom_id(arg) : arg }.join("_")
end
end
With this helper in place, it is much easier to generate and read our dom_ids
:
line_item_date = LineItemDate.find(1)
nested_dom_id(line_item_date, LineItem.new)
# => line_item_date_1_new_line_item
nested_dom_id(line_item_date, "line_items")
# => line_item_date_1_line_items
Let's just update the view to use our new convention:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<!-- All the previous code -->
<div class="line-item-date__body">
<div class="line-item line-item--header">
<!-- All the previous code -->
</div>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<%= turbo_frame_tag nested_dom_id(line_item_date, LineItem.new) %>
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
data: { turbo_frame: nested_dom_id(line_item_date, LineItem.new) },
class: "btn btn--primary" %>
</div>
</div>
</div>
<% end %>
Now that our Turbo Frames have the expected ids on the Quotes#show
page, we need to have matching Turbo Frames on the LineItems#new
page for Turbo to swap the frames. Let's wrap our form in a Turbo Frame tag:
<%# app/views/line_items/new.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>New item for <%= l(@line_item_date.date, format: :long) %></h1>
</div>
<%= turbo_frame_tag nested_dom_id(@line_item_date, LineItem.new) do %>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
<% end %>
</main>
Let's experiment in the browser. When clicking on the "Add item" button the form should appear at the expected position for this date!
As in the previous chapters, when we submit an invalid form, the errors appear on the page as expected.
We have to give Turbo more precise instructions when submitting a valid form thanks to a Turbo Stream view. We want to perform two actions:
- Remove the form we just submitted from the DOM
- Append the created line item to the list of line items for this specific date
Let's edit our LineItemsController#create
action to respond to the turbo_stream
format:
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
# All the previous code...
def create
@line_item = @line_item_date.line_items.build(line_item_params)
if @line_item.save
respond_to do |format|
format.html { redirect_to quote_path(@quote), notice: "Item was successfully created." }
format.turbo_stream { flash.now[:notice] = "Item was successfully created." }
end
else
render :new, status: :unprocessable_entity
end
end
# All the previous code...
end
Let's create our view that will perform the two actions that we want:
<%# app/views/line_items/create.turbo_stream.erb %>
<%# Step 1: empty the Turbo Frame containing the form %>
<%= turbo_stream.update nested_dom_id(@line_item_date, LineItem.new), "" %>
<%# Step 2: append the created line item to the list %>
<%= turbo_stream.append nested_dom_id(@line_item_date, "line_items") do %>
<%= render @line_item, quote: @quote, line_item_date: @line_item_date %>
<% end %>
<%= render_turbo_stream_flash_messages %>
The last thing we need to do is to add a Turbo Frame to wrap the list of line items for each specific date:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<!-- All the previous code -->
<%= turbo_frame_tag nested_dom_id(line_item_date, "line_items") do %>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<% end %>
<!-- All the previous code -->
Let's test it in our browser, and everything should work as expected. That was a lot of work! We are almost there. The #edit
, #update
, and #destroy
actions will be easier to implement now that nearly everything is in place.
Updating line items with Turbo
Like we did for the #new
and #create
actions, we want to make the #edit
and #update
actions for a quote happen on the Quotes#show
page. We already have most of the Turbo Frames we need, but we are going to need each line item also to be wrapped inside a Turbo Frame as described in the sketch below:
When clicking on the "Edit" link for the second line item of our sketch that is within a Turbo Frame of id line_item_2
, Turbo expects to find a Turbo Frame with the same id on the LineItems#edit
page as described in the sketch below:
With those Turbo Frames in place, Turbo will be able to replace the line item with the form from the LineItems#edit
page when clicking on the "Edit" link of a line item:
When submitting the form, we want the form to be replaced with the final quote:
Now that the requirements are clear, it's time to start coding. The first part of the job is to make the edit form successfully replace the HTML of line items on the Quotes#show
page. To do this, let's wrap every item in a Turbo Frame:
<%# app/views/line_items/_line_item.html.erb %>
<%= turbo_frame_tag line_item do %>
<div class="line-item">
<!-- All the previous code -->
</div>
<% end %>
We also need to remove the data-turbo-frame="_top"
data attribute from the "Edit" link:
<%# app/views/line_items/_line_item.html.erb %>
<!-- All the previous code -->
<%= link_to "Edit",
[:edit, quote, line_item_date, line_item],
class: "btn btn--light" %>
<!-- All the previous code -->
Now that we wrapped our line items in Turbo Frames, we need to wrap the form in the LineItems#edit
page in a Turbo Frame as well:
<%# app/views/line_items/edit.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit item</h1>
</div>
<%= turbo_frame_tag @line_item do %>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
<% end %>
</main>
With this Turbo Frame in place, we can test in the browser. When clicking on the "Edit" button on a line item, the form successfully replaces the line item on the Quotes#show
page.
If we submit an invalid form, everything already works as expected. If we submit a valid form, the line item date is successfully replaced but we miss the flash message. To solve this, we will need a Turbo Stream view. Let's first enable our controller to render a Turbo Stream view:
# app/controllers/line_items_controller.rb
def update
if @line_item.update(line_item_params)
respond_to do |format|
format.html { redirect_to quote_path(@quote), notice: "Item was successfully updated." }
format.turbo_stream { flash.now[:notice] = "Item was successfully updated." }
end
else
render :edit, status: :unprocessable_entity
end
end
Let's now create the update.turbo_stream.erb
view to replace the line item form with the line item partial and render the flash message:
<%# app/views/line_items/update.turbo_stream.erb %>
<%= turbo_stream.replace @line_item do %>
<%= render @line_item, quote: @quote, line_item_date: @line_item_date %>
<% end %>
<%= render_turbo_stream_flash_messages %>
Let's test it in the browser; everything should work as expected!
Destroying line items with Turbo
The last feature we need is the ability to remove line item dates from our quote. To do this, we first have to support the Turbo Stream format in the #destroy
action in the controller:
# app/controllers/line_items_controller.rb
def destroy
@line_item.destroy
respond_to do |format|
format.html { redirect_to quote_path(@quote), notice: "Date was successfully destroyed." }
format.turbo_stream { flash.now[:notice] = "Date was successfully destroyed." }
end
end
In the view, we only have to remove the line item and render the flash message:
<%# app/views/line_items/destroy.turbo_stream.erb %>
<%= turbo_stream.remove @line_item %>
<%= render_turbo_stream_flash_messages %>
Let's not forget to remove the data-turbo-frame="_top"
data attribute from the "Delete" button:
<%# app/views/line_items/_line_item.html.erb %>
<!-- All the previous code -->
<%= button_to "Delete",
[quote, line_item_date, line_item],
method: :delete,
class: "btn btn--light" %>
<!-- All the previous code -->
We can finally test in our browser that everything works as expected. The behavior is almost exactly the same as the one we had for quotes!
Editing line item dates with Turbo
The actions for our line items are now complete! However, we have a small issue: the whole line item date card is replaced by the edit form when clicking on the "Edit" link for a line item date. We would like only the card's header contaning the date to be replaced.
Let's wrap only the header of the line item date card inside another Turbo Frame with a unique id by prefixing its dom_id
with "edit":
For Turbo to be able to replace the Turbo Frame, we need a Turbo Frame with the same id on the LineItemDates#edit
page:
Now, when clicking on the "Edit" button for this specific date, Turbo will only replace the header of the line item date card:
Now that the requirements are clear, it's time to start coding. Let's first start by adding the Turbo Frame with the "edit" prefix to the line item date partial:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<%= turbo_frame_tag dom_id(line_item_date, :edit) do %>
<div class="line-item-date__header">
<!-- All the previous code -->
</div>
<% end %>
<div class="line-item-date__body">
<!-- All the previous code -->
</div>
</div>
<% end %>
We also need to add the "edit" prefix to the LineItemDates#edit
page:
<%# app/views/line_item_dates/edit.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit date</h1>
</div>
<%= turbo_frame_tag dom_id(@line_item_date, :edit) do %>
<%= render "form", quote: @quote, line_item_date: @line_item_date %>
<% end %>
</main>
Let's test it in the browser. Now when clicking on the edit link for a line item date, only the card's header is replaced by the form from the LineItemDates#edit
page.
Preserving state with Turbo Rails
Until now, we managed to preserve the state of our application all the time by making pieces of our page really independent. However, there is a small glitch in our application right.
To demonstrate our state issue, let's navigate on the Quotes#show
page for the first quote and open a few line item forms for the first line item date by clicking on the "Edit" button for those line items. Let's then update the first line item date. The forms are now closed again!
This is because to keep our dates in ascending order, we completely remove the line item date card from the DOM and then re-attach it at the correct position in the list. Of course, if we completely remove and re-attach the line item date, we will lose the state of the line items within this date as the partial is rendered with all forms closed by default.
Here we reached the limits of what we can do with Turbo Rails without writing any custom JavaScript. If we wanted to preserve the state of the application on the Quotes#show
page when updating a line item date, we would have two solutions:
- Don't reorder the items when using the Turbo Stream format
- Reorder items in the front-end with a Stimulus controller
Even if this is a minor glitch, knowing the limitations of what Turbo can do on its own is important! In this tutorial, we will simply ignore this glitch.
Testing our code with system tests
Our work wouldn't be complete if we didn't add a few tests. We should always write at least system tests to ensure the happy path is covered. If we make a mistake, we can correct it before pushing our code into production.
Let's add a system test file to test the happy path of the CRUD on our line items:
# test/system/line_items_test.rb
require "application_system_test_case"
class LineItemSystemTest < ApplicationSystemTestCase
include ActionView::Helpers::NumberHelper
setup do
login_as users(:accountant)
@quote = quotes(:first)
@line_item_date = line_item_dates(:today)
@line_item = line_items(:room_today)
visit quote_path(@quote)
end
test "Creating a new line item" do
assert_selector "h1", text: "First quote"
within "##{dom_id(@line_item_date)}" do
click_on "Add item", match: :first
end
assert_selector "h1", text: "First quote"
fill_in "Name", with: "Animation"
fill_in "Quantity", with: 1
fill_in "Unit price", with: 1234
click_on "Create item"
assert_selector "h1", text: "First quote"
assert_text "Animation"
assert_text number_to_currency(1234)
end
test "Updating a line item" do
assert_selector "h1", text: "First quote"
within "##{dom_id(@line_item)}" do
click_on "Edit"
end
assert_selector "h1", text: "First quote"
fill_in "Name", with: "Capybara article"
fill_in "Unit price", with: 1234
click_on "Update item"
assert_text "Capybara article"
assert_text number_to_currency(1234)
end
test "Destroying a line item" do
within "##{dom_id(@line_item_date)}" do
assert_text @line_item.name
end
within "##{dom_id(@line_item)}" do
click_on "Delete"
end
within "##{dom_id(@line_item_date)}" do
assert_no_text @line_item.name
end
end
end
If we run the bin/rails test:all
command, we will notice that we have two previous tests to fix. As we have too many "Edit" and "Delete" links with the same name, Capybara won't know which one to click on and raise a Capybara::Ambiguous
error.
To fix that issue, we have to be more specific with the ids we use in our within
blocks:
# test/system/line_item_dates_test.rb
# All the previous code
test "Updating a line item date" do
assert_selector "h1", text: "First quote"
within id: dom_id(@line_item_date, :edit) do
click_on "Edit"
end
assert_selector "h1", text: "First quote"
fill_in "Date", with: Date.current + 1.day
click_on "Update date"
assert_text I18n.l(Date.current + 1.day, format: :long)
end
test "Destroying a line item date" do
assert_text I18n.l(Date.current, format: :long)
accept_confirm do
within id: dom_id(@line_item_date, :edit) do
click_on "Delete"
end
end
assert_no_text I18n.l(Date.current, format: :long)
end
# All the previous code
Let's run all the tests with bin/rails test:all
command, and they should now all be green!
Wrap up
In this chapter, we almost finalized our quote editor. We learned how to manage nested Turbo Frames and keep our code readable thanks to naming conventions on Turbo Frames!
In the next chapter, we will completely finalize our quote editor! See you there!
← previous next →