HOTWire HNPWA #1: Setting up for Top Stories

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

7 thoughts on “HOTWire HNPWA #1: Setting up for Top Stories

  1. 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”

Leave a Reply