Rails 8 app with(out) Hotwire and TailwindCSS, used for the From Static to Reactive workshop.
Install all the dependencies before the workshop - it'll save you a lot of time! (./bin/setup
)
Highly recommend adding hotwire-dev-tools to your browser.
./bin/dev
During the workshop we'll transform a static food ordering Rails app into a reactive one using all parts of Hotwire. Make sure (again!) to check out this repo before the workshop starts and install all the dependencies.
Open the network tab in your browser's dev tools and check the requests being made when you hover over various elements on the page.
Go the /products
page.
The trash icon is not working correctly. Check it out yourself:
- Add a dish to your order
- Hover over the trash icon
- Refresh the page
It shouldn't clear the entire cart when hovered over. Fix it so that it only clears it when clicked.
Take a look at the app/views/products/_sidebar.html.erb
. Specifically at the link to orders_clear_path
.
π‘ Solution
Add the data-turbo="false"
to the link to keep the existing behaviour.
git checkout ex1_done
Once again, at the /products
page, notice the heart icon next to the dish name. You can click it to make it a favourite.
Currently it reloads the page. It's not good UX.
Let's improve the UX by introducing turbo frame to the like button.
You'll find the like button at app/views/products/_product.html.erb
.
Tip --> <%= turbo_frame_tag dom_id(product) do %>
π‘ Hint
Wrap the content of `app/views/products/_product.html.erb` in a turbo frame. Click the like button. No reload!But... Check the Order
button now.
π‘ Hint
Add `data-turbo-frame="_top"` to the `a` element redirecting to `add_to_order_product_path`.Add functionality to confirm the deletion of a dish from the order.
Go to the app/views/products/_sidebar.html.erb
file. Look for the button_to
helper with remove_item_order_path
.
Tip --> data-turbo-confirm
π‘ Solution
Add `data: { turbo_confirm = "your_confirmation_prompt" }` to the `button_to` helper.Now, use the turbo frame to stop the page from reloading when clearing the order.
Go to the app/views/products/_sidebar.html.erb
file.
π‘ Hint
Wrap the content of sidebar in a turbo frame. But be careful! Sidebar is sticky and it should still be after the change :)Now it works great. But... the scroll position is not preserved. Let's fix that by enabling morphing.
Go to the app/views/layouts/application.html.erb
file.
Uncomment the link with <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
π‘ Solution
`git checkout ex4_sidebar`When you clear the whole order, the page still reloads. How to fix that?
π‘ Hint
In the first exercise we added `data-turbo="false"` to the link that clears the order. We should change it to `data-turbo-prefetch="false"` instead.π‘ Solution
`git checkout ex5_clear_order_works`Click the Go to checkout
button. What happens?
π‘ Solution
Add `data-turbo-frame="_top"` to the `a` element redirecting to `checkout_path`. `git checkout ex6_done`Currently when restaurant workers want to go through order history, they have to reload the page to see the next page of orders. Let's improve that.
Go to the app/views/restaurant/index.html.erb
file. Use <%= turbo_frame_tag ...
to wrap the orders table.
π‘ Hint
You wrapped the table but it didn't work? Try extending the turbo frame to include the pagination links as well.Alternatively you can add data-turbo-frame
to the pagination links.
Solution at git checkout ex7_done
Currently when moving orders through preparation process the page is reloaded. Our goal is to improve the user experience by using turbo frames.
Go to the app/views/restaurant/index.html.erb
file. Use <%= turbo_frame_tag ...
to wrap the kitchen kanban-like view.
π‘ Hint
Solution at `git checkout ex8_done`Before our last change it was possible to edit an order address in separate page. We broke that by introducing the turbo frame tag. No worries. We'll fix that and improve UX by introducing inline editing of order address.
Step 1
Go to the `app/views/restaurant/_order.html.erb` file. Wrap it in a turbo frame tag.git checkout ex9_step_1
Step 2
Go to the `app/views/restaurant/edit_order.html.erb` file. Wrap it in a turbo frame tag.git checkout ex9_step_2
Step 3
Go to the `app/controllers/restaurant_controller.rb` file. Instead of redirecting to `show_order_path`, redirect to `restaurant_index_path`.git checkout ex9_step_3
Step 4
But what happened to moving orders between states in the kanban view? It stopped working!Why?
Frame is the answer.
git checkout ex9_step_4
Currently when removing a dish from the order, the number of items in the cart icon is not updated. Let's fix that.
We have to do 3 steps:
- Add unique dom id to total quantity number that is displayed on the top of the cart icon (
app/views/layouts/application.html.erb
) - In the
app/controllers/orders_controller.rb
changeredirect_to
torespond_to
withformat.turbo_stream
- Last but not least, we have to fix the bug with order items not being removed from the order itself. To do that, we have to:
3.1. Add unique dom id to the order item in the
app/views/products/_sidebar.html.erb
file. 3.2. Add theturbo_stream.remove(@order_item)
instruction to therespond_to
in controller
Step 1
```erb ... ```git checkout ex10_step_1
Step 2
```ruby respond_to do |format| format.html { redirect_to products_path } format.turbo_stream do render turbo_stream: [ turbo_stream.update("total-quantity", @order.total_quantity), ] end end ```git checkout ex10_step_2
Step 3
Into the `respond_to` block add: `turbo_stream.remove(@order_item)`git checkout ex10_step_3
Currently when an order is moved in the kitchen dashboard from one state to another, the full page is rendered.
Step 1
In the `app/views/restaurant/index.html.erb` file add ids to all three columns. Use: * new-orders * preparing-orders * done-ordersThose have to be the same as in the OrderHelper#status_to_kitchen_kanban_id
git checkout ex11_step_1
Step 2
Prepare `advance_order.turbo_stream.erb` file in the `app/views/restaurant` folder. Our goal is to remove the order from the old column and add it to the new one.<%= turbo_stream.prepend(status_to_kitchen_kanban_id(@order.status)) do %>
<%= render 'restaurant/order', order: @order %>
<% end %>
<%= turbo_stream.remove(@order) %>
git checkout ex11_step_2
Step 3
Adjust the controller: def advance_order
@order = Order.find(params[:id])
@order.advance_order!
respond_to do |format|
format.html { redirect_to restaurant_index_path }
format.turbo_stream
end
end
git checkout ex11_step_3
Step 4
It works well but the UI is kind of clunky after the change. There are multiple ways to fix that. We suggest moving the `git checkout ex11_step_4
Step 5
Fix the error that occurs in browsers dev console when moving the order from the last column.<% if status_to_kitchen_kanban_id(@order.status) %>
<%= turbo_stream.prepend(status_to_kitchen_kanban_id(@order.status)) do %>
<%= render 'restaurant/order', order: @order %>
<% end %>
<% end %>
git checkout ex11_step_5
Our goal is to refresh the status page of an order (app/views/orders/show.html.erb
) without reloading the page for
hungry user waiting for their food.
Step 1
In the `app/views/orders/_status.html.erb` file add a turbo frame tag (or div with an id) around the order progress partial.In order to receive streams at specific view we have to subscribe to the channel.
<%= turbo_stream_from order-progress %>
git checkout ex12_step1
Step 2
Now our goal is to broadcast the streams when the order status is changed.We have to do that in two places.
In both app/jobs/simulate_order_process_job.rb
and app/controllers/orders_controller.rb
files add following code:
Turbo::StreamsChannel.broadcast_update_to(
"order-progress",
target: "order-progress-#{order_id}",
partial: "orders/progress",
locals: { status: status }
)
Adjust the code in the controller, it doesn't use status
.
git checkout ex12_step2
Our goal is to refresh the restaurant dashboard without reloading the page.
At the app/views/restaurant/index.html
we want to refresh the total daily income, chart and add new orders
to the kanban board.
Let's start with the chart. How to refresh the chart?
We need to introduce one modification at the index page.
Step 1
Extract the chart code to a partial.git checkout ex13_step_1
After that we need to subscribe to changes.
Step 2
`<%= turbo_stream_from "restaurant" %>`git checkout ex13_step_2
Let's prepare the total income. How to refresh it?
Step 3
`git checkout ex13_step_3`Now finally let's broadcast changes. Use SimulateOrderProcessJob
to do that.
To calculate daily_income use following code:
if status == "paid"
daily_income = Order
.includes(:order_items)
.includes(:products)
.billed
.where("orders.created_at >= ?", 6.days.ago.beginning_of_day)
.group_by_day("orders.created_at")
.sum("order_items.quantity * products.price")
# perhaps something else will go here?
end
Now it is time to broadcast that value and update the chart as well!
Step 4
```ruby Turbo::StreamsChannel.broadcast_replace_to("restaurant", target: "chart", partial: "restaurant/chart", locals: { daily_income: }) Turbo::StreamsChannel.broadcast_update_to("restaurant", target: "total-income-today", content: daily_income.to_a.last.last) ```git checkout ex13_step_4
The final step is to prepend the order to the kanban view "new-orders" using broadcasts.
Step 5
git checkout ex13_step_5
We have a search form in the products index. It doesn't submit when we type, and it's not very reactive - let's fix that.
Step 1
Wrap results in a turbo frame. `app/views/products/index.html.erb`Check how it works now.
git checkout ex14_step1
Step 2
Add `data: { turbo_frame: "results" }` to the search form `app/views/products/_search.html.erb`git checkout ex14_step2
Step 3
Create new stimulus controller `rails g stimulus search`. Next add it to the search form `app/views/products/_search.html.erb` `data-controller="search"` Next add console.log to the controller `app/javascript/controllers/search_controller.js` `connect()` so we can see when it's connectedgit checkout ex14_step3
Step 4
Let's add a new method to the controller `app/javascript/controllers/search_controller.js` `submit()`, and import debounce from `debounce` npm package.In the connect method add this.submit = debounce(this.submit, 300)
which will debounce the submit method by 300ms.
submit() should call this.element.requestSubmit()
finally, add data: { action: "input->search#submit" }
to the input field app/views/products/_search.html.erb
git checkout ex14_step4
Step 5
Let's handle the url change. Notice the url is not updating when we type.
Add data: { turbo_action: "advance" }
to the form app/views/products/_search.html.erb
git checkout ex14_step5
Time to fight the biggest enemy of all: the modal.
Step 1
Add a `modal` turbo frame to the `app/layouts/application.html.erb` file.git checkout ex15_step1
Step 2
Let's display the `add_to_order_product_path` in the new modal frame by adding a `data-turbo-frame="modal"` attribute to the link. Also let's wrap our add to order form in a turbo frame in `app/views/products/add_to_order.html.erb` file.git checkout ex15_step2
Step 3
We've prepared a modal viewcomponent and stimulus controller, but it's not connected to the modal frame yet.Let's have a look inside the viewcomponent first and then use it inside app/views/products/add_to_order.html.erb
file by replacing the existing modal frame with <%= render TurboModalComponent.new(title: "Add to order") do %>
git checkout ex15_step3
Step 4
Fix the close button by adding `this.element.close()` to the stimulus controller.git checkout ex15_step4
Step 5
Nice! Now we have a working modal! But there's one more thing to do. The numbers of items & the order itself are not updating.Let's fix that by adding a turbo_stream
to the orders controller.
format.html { redirect_to products_path }
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("total-quantity", @order.total_quantity),
turbo_stream.update("modal", ""),
turbo_stream.update("sidebar-container", partial: "products/sidebar", locals: { order: @order })
]
end
end
One more thing is to wrap the sidebar in another element with an id sidebar-container
so we can reach it with the stream.
This way we can update it without reloading the page.
git checkout ex15_step5
We hope you enjoyed the workshop! There are many more things to explore in Hotwire within this app so feel free to experiment!
If you have any questions, please feel free to ask.
Cheers!