A chalk figure kneeling to repair (possibly) a radio.
How Hard Can it Be?

I’ve been building a piece of Rails courseware, something to let people work through blog posts, videos and lessons I’ve written at their own speed. First I set up a simple Rails 6 app, then I prettied it up a bit.

Now it’s time to do something interesting with the actual lessons, which I’ve been calling “Topics.”

I’m an engineer, so I’m itching to add too much structure on top of this. Instead let’s go the other way: what’s the smallest thing I can do here and feel good about it?

What’s It Need?

The front page of RailsCasts, showing a list of videos
We’re starting with a little styling and no content yet.

I want a thumbnail for each Topic. I’m not sure each individual step needs it. Each one is going to need a name and a little text description. A lesson using external resources (e.g. blog posts) isn’t going to stay fresh forever, so it’ll need normal Rails timestamps so I can tell when it’s updated.

Eventually I’m going to want to refer to individual steps inside a Topic — users will want to check off pieces as they go along on a to-do list, for instance. So that means steps in the topic will need to be database objects as well.

I’d like comments, but that can be Disqus - easy enough.

What Migrations are Those?

Originally topics had a JSON-serialised field called “steps”. I think that’s getting replaced with JSON-serialised metadata and a set of step objects using an association. Let’s make a migration happen:

class AddStepsToTopics < ActiveRecord::Migration[6.0]
  def change
    create_table "steps" do |t|
        t.string  :name, size: 255
        t.integer :topic_id
        t.integer :order
        t.string  :url,  size: 4096
        t.string  :type, size: 16
        t.json    :extra_data

        t.timestamps

        t.index [:topic_id, :order]
    end

    # If we had useful data, this would need to save it. But we don't.
    remove_column :topics, :steps, :json

    # Now there's one called "extra_data" for metadata that doesn't necessarily get a column
    add_column :topics, :extra_data, :json

    add_column :topics, :name, :string, size: 1024
    add_column :topics, :thumbnail_url, :string, size: 4096
    add_column :topics, :comment_url, :string, size: 4096
  end
end

Mostly this is straightforward. The “extra_data” fields give me somewhere to stuff random tags for type-dependent miscellany - do I want to add an “estimated reading time” only for blog posts? Or some kind of a separate embed URL for unrecognised video hosts? This is where I can put stuff like that. If it turns out that I need a given item a lot it can wind up as a real column eventually, especially if I’d ever need to index on it.

Of course, for a very long time there won’t be enough topics for indexing to matter. But it’s good to think about these things when it’s easy.

A step has a topic_id so it can use “belongs_to”, and has an “order” so we can return the steps in the right order when querying them. I also added a “has_many” in Topic and a new Step model with a “belongs_to”. With those, the old code in the controller doesn’t break, though everything now has zero steps since we dropped the old data.

What’s In a Topic?

For this part, we’re going to need some content - interesting topics that can be built out. But we can start with name, thumbnail and description. I mocked up a couple of quick topics and put their thumbnails into public/img — this is, again, routing around a perfectly good asset system that Rails uses, but I feel okay doing that for speed when a system is likely to be temporary and not suffer from doing it “wrong.” These images will change rarely or never and are purely decorative. And we’ll need a whole different system when/if we start adding topics to a running application server.

The way I’m currently displaying it is wrong, including using a table. Time to rewrite the view.

I also need to fix the two manually-created database entries — set them up with names, descriptions and thumbnails.

You can see the full diff here, but here’s the heart and soul of the new Topics index view.

<% @topics.each do |topic| %>
<div class="topic-row">
  <div class="topic-thumbnail">
    <img src="<%= topic.thumbnail_url %>"
         width="200" height="125"
         alt="<%= topic.name %>" />
  </div>

  <div class="topic-text">
    <p class="text-muted">
        <%= topic.updated_at.strftime("%Y-%m-%d %H:%M") %>
        - XX comments</p>

    <h3><%= topic.name %></h3>

    <p>
      <%= topic.desc %>
    </p>

    <a href="/topics/show?id=<%= topic.id %>">
        <button type="button"
                class="btn btn-info">Have a Look</button></a>
    <hr/>
  </div>
</div>
<div class="clearfix"></div>
<% end %>

This isn’t perfect. The name text isn’t pretty, the comments text is a dummy placeholder, and I’m not enchanted with “Have a Look” as the button text. But it’s a whole lot better. Here, have a look:

A new topics view, showing the thumbnails and full descriptions.

It’s starting to look a bit like a site you stumbled across on the internet, right? And relatively few pieces look like I Photoshopped them over a real app. The “logged in as” text is probably the only thing that looks like it just shouldn’t be there. I have plans for that, but it may take me a few more blog posts to get there.

What About My Step Count?

That’s tolerable for topics. What about steps? You may have noticed that the “Have a Look” button takes you to a single-topic “show” action, so that’s a start. But right now the “show” action just tells you what source file it’s in.

So let’s write a show action. There’s a bit of CSS that you can see in the full commit, but here’s the HTML and Erb:

<div class="container">
  <h1><%= @topic.name %></h1>

  <% steps = @topic.get_steps %>
  <% if steps.empty? %>
    <p>No steps! Something is wrong with this topic!</p>
  <% else %>
    <% steps.each do |step| %>

        <div class="step-row">
          <h3><%= step.name %></h3>

          <p>
            <a href="<%= step.url %>" target="_blank">🔗 External link 🔗</a>
          </p>

          <div class="done-select-group">
              <label for="<%= step.id %>-done-select">Done?</label>
              <select class="done-select" id="<%= step.id %>-done-select" value="Not Implemented..." disabled="disabled">
                <option>Not Implemented...</option>
                <option>Not Done</option>
                <option>Skip</option>
                <option>Done</option>
              </select>
          </div>
        </div>
    <% end %>

  <% end %>
</div>

(I kind of love the fact that Ruby allows UTF-8 in source files so you can just include emojis in your text and it’s totally supported.)

This is the simplest topic that could possibly work. It just has an external link for the step and a to-be-implemented selector for whether you’ve finished that step. Eventually it will make far more sense to have embedded videos, embedded blog posts where possible and generally a much prettier topic. In fact, looking at this has me convinced that we’ll need step descriptions at a minimum.

Also, we can’t yet mark steps as “done.” That has to be done for each step/user combination and we don’t have any tables for that yet.

I’ve been keeping in mind that I’d like to be able to page-cache these topics - and the logged-in user and whether each step is done make that difficult. I think those things are going to become a combination of cookies and AJAX so that the basic page is 100% identical for each user. And that’s complexity that gets tied up with Webpacker, Turbolinks and a few other things. So for now, let’s just assume the page will be different per-user.

What do we have before we do that?

A new single-topic view showing the steps as external links and a dropdown.

I Like It, But I Can’t Mark It Done

That’s not bad. Let’s let users mark steps as done, though. We’ll need a database table to store that information. I like the idea that there may be more different “all done”-type descriptions — at least “not done” and “done” and “skipped,” but I like the idea that there could be others in the future. Maybe “urgent” to get more frequent reminders? But it’s cheap to make the done-ness be an integer instead of a boolean, so I’ll do that. And I’d like users to be able to set a text note to themselves on each step.

I’ll make a table of per-user per-step rows called “userstepitems”. If we wind up needing to store anything else by user/step combo, well, now we have a possible place for it. But for now “is it done?” is the only thing we need.

class AddUserStepItems < ActiveRecord::Migration[6.0]
  def change
    create_table "user_step_items" do |t|
        t.integer :doneness
        t.string  :note
        t.integer :user_id
        t.integer :step_id

        t.index [:user_id, :step_id]
    end
  end
end

We’ll also add a UserStepItem model that belongs_to User and Step, and we’ll say User and Step each has_many of UserStepItem. The index is set up around querying by User or by User-and-Step, so right now step.userstepitems would be expensive. But I don’t think we’ll be using it for awhile, and indices are easy to add later.

I’ll also move the “login” message to a slightly prettier up-top location (here’s the full diff).

We’ll need to map between integers and text for “Done”. I’m going to do that in a simple way for now:

class UserStepItem < ApplicationRecord
    belongs_to :user
    belongs_to :step

    DONE_MAP = {
        0 => "Not Done",
        1 => "Skip",
        2 => "Done",
    }

    def self.done_values
        DONE_MAP.values
    end

    def self.done_as_text(int_value)
        DONE_MAP[int_value]
    end

    def done_as_text
        UserStepItem.done_as_text(self.doneness)
    end
end

Then with just a bit of messing about we can get the view to display doneness properly even if it doesn’t set it yet:

# app/views/topics/show.html.erb
<div class="container">
  <h1><%= @topic.name %></h1>

  <% steps = @topic.get_steps %>
  <% if steps.empty? %>
    <p>No steps! Something is wrong with this topic!</p>
  <% else %>
    <% steps.each do |step| %>
        <div class="step-row">
            <h3><%= step.name %></h3>

            <p>
                <a href="<%= step.url %>"
                   target="_blank">🔗 External link 🔗</a>
            </p>

            <% if current_user %>
                <% user_step_item = UserStepItem.
                        where(user_id: current_user.id,
                              step_id: step.id).first
                    doneness = user_step_item ?
                        user_step_item.done_as_text :
                        UserStepItem.done_as_text(0)
                %>
                <div class="done-select-group">
                    <label for="<%= step.id %>-done-select">Done?</label>
                    <%= select_tag("#{step.id}-done-select",
                         options_for_select(UserStepItem.done_values,
                                            doneness),
                                            class: "done-select",
                                            data: { step_id: step.id }) %>
                </div>
            <% else %>
                <div class="done-select-group">
                    <label for="<%= step.id %>-done-select">Done?</label>
                    <select class="done-select" disabled="disabled"
                            id="<%= step.id %>-done-select">
                        <option value="">Not Logged In</option>
                    </select>
                </div>
            <% end %>
        </div>
    <% end %>
  <% end %>
</div>

I tested this by creating a UserStepItem manually and, yes, it displays properly. It’s also possible to select items in the select but only if you’re logged in. Next it needs to update.

Webpack

Now it’s finally time to see how Webpack and Rails 6 get along. I’ll need a handler for these selectors to do some JS magic.

In Rails 6, Webpack wants “packs” (JS modules) rather than old-style raw JS functions. Having read a bit about this I have trouble figuring out where to put things like “ready” functions or setting up handlers. Presumably they shouldn’t go in the module code. Of course, “ready” functions are dubious in a Turbolinks world anyway, but we still have to set up handlers, including the one we’ll need for these selectors.

There’s a whole Javascript in Rails guide, though it doesn’t appear to address that question. Before packs I might have set up handlers in application.js or another JS file it called. It appears that now I set up a new module (I’m calling it steps.js) and it gets executed if I include the pack tag… directly from the view? That works, though it feels weird and inelegant.

A bit of console.log testing does show that it loads when the view requires and not otherwise, and that inline code like console.log is executed when it’s loaded. So I can use it to set up DOM handlers in a view-dependent way. They’ll still be loaded after navigating to other pages (because Turbolinks) but they’ll only load when needed. That’s kind of neat, and arguably a better way to do it than Rails 5 and before.

Tying It All Together

We’re going to need a controller action to change the “done” value with an AJAX request: “rails g controller steps update_done”. I then changed the action to a POST in config/routes.rb. There’s probably some way to get the generator to do that…

You can see the complete diff, but here are the important bits.

I’ve added a simple controller action to allow updating the done-ness of a step:

class StepsController < ApplicationController
  # A quiet little AJAX action to update the done-ness of a step for a user
  def update_done
    user_id = current_user.id
    step_id = params[:step_id]
    if user_id && step_id
        usi = UserStepItem.find_or_create_by(user_id: current_user.id, step_id: step_id)
        usi.doneness = params[:done]
        usi.note = params[:note]
        saved = usi.save
    end

    if saved
        render plain: "OK", layout: nil, status: 200
    else
        render plain: "Not Found", layout: nil, :status => 404
    end
  end
end

Note that it uses the current logged-in user rather than a parameter for user ID, so this should be safe from anybody but a logged-in user sabotaging themselves (which would be fine.)

We’ll need a couple of modifications to the “show” view. At the top we’ll need to add “<%= javascriptpacktag ‘steps’ %>” to make sure it gets the Steps pack (more on that soon). And then for a step for a currently logged-in user:

<% if current_user %>
    <% user_step_item = UserStepItem.where(user_id: current_user.id, step_id: step.id).first
       doneness = user_step_item ? user_step_item.doneness : 0
    %>
    <div class="done-select-group">
        <div class="select-error-box error" id="select-error-box-<%= step.id %>"></div>
        <label for="<%= step.id %>-done-select">Done?</label>
        <%= select_tag("#{step.id}-done-select", options_for_select(UserStepItem.done_selector, doneness), class: "done-select", data: { step_id: step.id }) %>
    </div>
<% else %>

I’m now generating the selector a little differently - the text is the English word/phrase like “Done” or “Skipped”, but the value is the integer for simplicity. Sometimes we wind up going back and forth a bit…

And then finally, the Javascript:

// app/javascript/packs/steps.js
$("body").on("change", "select.done-select", function() {
    var changed_elt = $(this);
    var step_id = changed_elt.data("step-id");

    // Disable the selector until the request finishes
    changed_elt.prop('disabled', true);

    $.ajax("/steps/update_done", {
        method: 'post',
        data: {
            step_id: step_id,
            done: this.value,
            note: "..."
        },
        success: function() {
            $("#select-error-box-" + step_id).text("");
            changed_elt.prop('disabled', false);
        },
        error: function() {
            $("#select-error-box-" + step_id).text("(Server error)");
            changed_elt.prop('disabled', false);
        }
    });
});

This is the entire pack file (steps.js). The only fancy bit is that it disables the selector on change, then re-enables it after the server has handled the AJAX action (or gotten an error.)

And with that, RubyMadScience has all the fancy functionality of a basic TODO list.

What’s Left to Do?

The app is nearly a full-featured list already. What’s left to do?

Many things.

It would be great to have better steps - more interesting text, embedded video and text and so on. Just in general, there’s a lot of polish and aesthetic work here.

But the vital things to fix before it’s a usable MVP are these:

  • Email reminders
  • Deploy it
  • Actually write the real-world topic for at least one interesting class

Once those things are done, RubyMadScience is doing something that could make somebody’s life a little better. It can replace the email classes I have with something better.

There are many other interesting things I could add. But with those two, there’s a basic workable MVP. It can help somebody.

Have you been enjoying this series? It’s not done yet. Email and real topics are also going to be blog posts, and likely more afterward.

If you want to follow along, I recommend signing up for my email list below. If email lists aren’t your thing, there’s an RSS feed of posts at the top or there’s the rubymadscience tag, which includes not-yet-published posts in the series…

(There will also be some “offstage” work — changes that aren’t interesting enough to write up step-by-step. But you can always watch the repo if you enjoy that sort of thing.)

How far did we get along the way? This far:

The single-topic view of steps with external links and task completion.