Are you tired of toy app tutorials? Are you looking to build a PWA in Ruby on Rails, just like Basecamp or Hey? How about both?
Follow along as we build the HOTWire Hacker News Progressive Webapp, or HHNPWA. This will build on the lessons from the Hacker News Progressive Web App, and will leverage all the enhancements that come from Turbo. In the end, you can compare the two, and decide for yourself what works.
Setting up the app
Ruby and Rails versions
Check your Ruby and Rails versions:
$ ruby -v
ruby 2.7.2p137 (2020-10-01 revision 5445e04352)
$ rails -v
Rails 6.1.0
Create the Rails app
Create the Rails project. This will use Postgresql and HOTWIRE, so we’ll include these directives.
$ rails new HHNPWA --database=postgresql
Setup postgres DB
You’ll need to create a Postgres user:
$ createuser --createdb --login -P hhnpwa
Enter whatever password you’d like. Enter those details in config/database.yml
.
development:
<<: *default
database: HHNPWA_development
username: hhnpwa
password: hhnpwa
Then create the database.
$ rails db:create
$ rails db:migrate
Setup TailwindCSS
This tutorial will use Tailwind CSS to style to page.
$ yarn add tailwindcss postcss autoprefixer
$ npx tailwindcss init
Add the tailwindcss initializer to postcss.config.js
:
module.exports = {
plugins: [
require("postcss-import"),
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009",
},
stage: 3,
}),
require("tailwindcss")("tailwind.config.js"),
],
};
Create a stylesheet for webpacker at app/javascript/stylesheets/application.scss
and add the tailwind directives:
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
And add the css import at the bottom of app/javascript/packs/application.js
:
require("../stylesheets/application.scss");
And, finally, add the style_sheet_pack
tag for the css, perhaps below the Javascript:
<%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
If you don’t add any classes, you actually won’t see any CSS loaded. You can test that everything is working by adding the first controller, app/controllers/tops_controller.rb
:
class TopsController < ApplicationController
end
Adding a view at app/views/tops/show.html.erb
:
<div class="bg-red-500 w-3/4 mx-auto p-4">
<p class="text-white text-2xl">Hello, HHNPWA</p>
</div>
And add a route to tops#show
in routes.rb:
Rails.application.routes.draw do
root "tops#show"
end
Adding Turbo
Replace Turbolinks with Turbo in the Gemfile
:
gem 'turbo-rails'
Then install it, and add the Turbo JavaScript library:
$ ./bin/bundle install
$ ./bin/yarn remove turbolinks
$ ./bin/yarn add @hotwired/turbo-rails
Remove turbolinks from app/javascript/packs/application.js
and add Turbo. The whole file should look like this:
import Rails from "@rails/ujs";
import * as ActiveStorage from "@rails/activestorage";
import "channels";
import { Turbo, cable } from "@hotwired/turbo-rails";
Rails.start();
ActiveStorage.start();
require("../stylesheets/application.scss");
Add HTTP.rb
Add http.rb to your Gemfile, and run bundle install
:
gem 'http'
Loading Top News Stories
We’ll start by loading the top 30 stories. This will show how Turbo can lazy load items. This is a good example of needing to call out to a third part API, and not blocking the full page load.
The TopsController
will load the top stories from the HNPWA api, and then load the top 30 stories on the page. This is will be converted to a Turbo stream later, but for now, the initial HTTP call will be in the controller. Here is app/controllers/tops_controller.rb
:
class TopsController < ApplicationController
def show
top_stories_json = JSON.parse HTTP.get("https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty").to_s
@stories = top_stories_json[0..29]
end
end
Each story will be lazy loaded through a turbo-frame element in app/views/tops/show.html.erb
:
<div class=" w-3/4 mx-auto p-4">
<p class="bg-red-600 text-white text-2xl py-1 px-3">Top Stories</p>
<div class="grid grid-cols-3 gap-4">
<% @stories.each_with_index do |hn_story_id, top_news_location| %>
<turbo-frame id="hn_story_id_<%= hn_story_id %>" src="<%= item_path(hn_story_id) %>">
<div class="border rounded-sm">
Loading Item <%= top_news_location + 1 %>
</div>
</turbo-frame>
<% end %>
</div>
</div>
This means that the app needs an ItemsController
to load the data from the HN API. The controller will load the item’s JSON and template it on the server side. Update routes.rb with the routing for items:
resources :items
Here is app/controllers/items_controller.rb
:
class ItemsController < ApplicationController
def show
@hn_story_id = params[:id]
@item = JSON.parse HTTP.get("https://hacker-news.firebaseio.com/v0/item/#{@hn_story_id}.json?print=pretty").to_s
end
end
And here is app/views/items/show.html.erb
:
<turbo-frame id="hn_story_id_<%= @hn_story_id %>">
<div class="border rounded-sm flex flex-col">
<header class="border flex justify-between">
<span class="rounded-full py-1 px-3 bg-red-600 text-white"><%= @item['score'] %></span>
<p class="py-1 px-3">
<% if @item['type'] == 'job' %>
job
<% end %>
<% if @item['url'] %>
<%= URI.parse( @item['url'] ).host %>
<% end %>
</p>
</header>
<article class="border flex flex-col">
<p class="py-1 px-3 flex-grow">
<% if @item['url'].nil? %>
<%= link_to @item['title'], item_path(@item['id']) %>
<% else %>
<%= link_to @item['title'], @item['url'], { target: '_blank', rel: 'noopener' } %>
<% end %>
</p>
<p class="py-1 px-3">
<em><%= DateTime.strptime("#{@item['time']}",'%s').strftime('%c') %></em>
</p>
</article>
<% unless @item['type'] == 'job' %>
<footer class="border flex justify-between">
<p class="py-1 px-3">
<%= link_to pluralize(@item['descendants'], 'comment'), item_path(@item['id']) %>
</p>
<p class="py-1 px-3">
<a href="/user/<%= @item['by'] %>"><%= @item['by'] %></a>
</p>
</footer>
<% end %>
</div>
</turbo-frame>
The key component matching the lazy loaded turbo-frame
with the turbo-frame
coming from the server is the id: <turbo-frame id="hn_story_id_<%= @hn_story_id %>">
. Since these ids match, Turbo will slot the HTML into the correct place.
HHNPWA vs. HNPWA
The biggest change in this version so far is that it feels more like a SPA than the HNPWA. The page loads with just a skeleton, and then items are loaded in as fast as possible. Since everything comes from the server, this negates the need for having to template the JSON on the client side. One improvement will be linking up streaming, and caching on the server side, so the page loads with data, potentially stale, and then updates them in real time.
Subscribe for updates as this project takes shape. You can see all the code at Github: https://github.com/OnRailsBlog/hhnpwa
Other HOTWire Tutorials
- HOTWire HNPWA #1: Setting up for Top Stories
- Turbo Streaming Top Items – HOTWire HNPWA #2
- Paginating Top Items – HOTWire HNPWA #3
- Russian Doll Caching – Building HOTWire HNPWA #4
- Lazy Loading Lots of Comments HOTWire Tutorial #5
11 comments on “HOTWire HNPWA #1: Setting up for Top Stories”
I think in the step: Setup TailwindCSS, after running command: “yarn add tailwindcss postcss autoprefixer” it should list command: “rails webpacker:install” before running “npx tailwindcss init”
I just ran the
rails new
, and webpacker is installed in 6.1. Was there a particular error you came across?Also in step Create the Rails app: may be the app name should be hhnpwa.
Thanks, I’ve fixed the typo.
[…] HOTWire HNPWA #1: Setting up for Top Stories […]
[…] HOTWire HNPWA #1: Setting up for Top Stories […]
[…] HOTWire HNPWA #1: Setting up for Top Stories […]
[…] HOTWire HNPWA Tutorial #1: Setting up for Top Stories […]
[…] HOTWire HNPWA Tutorial #1: Setting up for Top Stories […]
Great post, thank you.
On that note, I had some trouble installing tailwind, this solved the issue for me on rails 7:
https://tailwindcss.com/docs/guides/ruby-on-rails
For it to compile, run `bin/dev` and open https://localhost:3000
Thanks for the tutorials. Something is wrong with the CSS
Enter whatever password you’d like. Enter those details in config/database.yml.
If you look above, the filename is hidden. Maybe a dark/light theme issue?