A Tutorial for a Ruby on Rails Application


Introduction

It’s often difficult to choose a good movie to watch or food to order. This application, called “Choose For Me”, aims to solve this problem. The primary objective of the application is to create a Rails app that utilizes best practices for creating such an app. Most of the effort in this application is put into making sure the code is as dry (Don’t Repeat Yourself) as possible.

Set-up

Setting up a new Rails application is quite easy. Use the command rails new choose_for_me (make sure you have all the required dependencies already installed, like Ruby).

Now, navigate to the choose_for_me directory and run the rails server or rails s command to start the server. If you followed these steps properly, you will be greeted with the default Rails page after visiting http://localhost:3000.

Structure

In this application, structure is the most important aspect. Here, we will use the Model View Controller (MVC) architecture, which is common in Rails applications.

GitHub: https://github.com/Anx450z/choose_for_me_learn

Models

Rails uses the Model View Controller (MVC) architecture. For this application, we will use two models: a User model and a Topic model.

User model

This model is used for handling users for the application. It will have user login and authentication functionality. For this, we will use a gem called ‘devise’. To install Devise, use the command bundle add devise and rails generate devise:install. Now, run the command to generate the model and associated views for the user: rails generate devise User. After that, run rails db:migrate. Restart the application.

Navigate to /app/controllers/application_controller.rb and add this line in the ApplicationController class:

before_action :authenticate_user!

This will enable authentication before any action.

Topic model

The Topic model will interact with the topic controller and the database. We will also create relationships with the User model. A Topic will include a title, rating, and a type (e.g., movie or book).

Creating Model and Database

Rails will generate a model with the command rails generate scaffold Topic title:string rating:float type:string. This command will also create a migration to create a table with columns for title, rating, type (along with created_at and updated_at by default), and associated controllers and views. Now, run rails db:migrate to run the newly created migration.

Remember, we have a column named type. It will be used for storing inheritance. We will discuss why and how in the Populating Database section.

Creating Relationships

Currently, there is no relationship between User and Topic. Let’s create a relationship between them. A user can have many topics (like movies, books, and food), and likewise, each topic can have many users. For this type of relationship, we will use the has_many through association. For this to work, we need to create an inner join between these two tables at the database level. We can do that by creating a new migration: rails generate migration create_user_topics_join_table.

This will create a new migration file in /db/migrate. Now, update the change method with the following:

def change
  create_join_table :users, :topics do |t|
    t.index [:user_id, :topic_id]
    t.index [:topic_id, :user_id]
  end
end

Now, run rails db:migrate. This will create a new table in the database with the name topics_users with two columns, user_id and topic_id. This table will be used for our associations.

We also need a new model through which we can have a relationship between users and topics. In /app/models, create a new file rejection.rb with the following content:

class Rejection < ApplicationRecord
  belongs_to :user
  belongs_to :topic
end

To create the association, navigate to /app/models/topic.rb file. Add the following to the Topic class:

class Topic < ApplicationRecord
  has_many :rejections
  has_many :users, through: :rejections
end

Now, head to the user.rb file in /app/models/user.rb and add the association in the User class:

class User < ApplicationRecord
  has_many :rejections
  has_many :topics, through: :rejections
end

Try visiting http://localhost:3000/topics (you might need to sign up/log in). This route was created by the scaffold (check routes.rb in /config/routes.rb).

Populating Database

Currently, our database is empty. To resolve this, we need to add some data for testing purposes.

Manually adding data

Using the rails console command or rails c, we can add some data to the database manually. Let’s create a new user first:

user = User.new(email: "[email protected]", password: "password")
user.save

To check your newly created user, enter the command User.all. You can see all the users present in the user database.

Similarly, we can add data to the topics database:

newMovie = Topic.new(title: "The Shawshank Redemption", rating: 9.3, type: "Topic::Movie")
newMovie.save

You might encounter an error stating that “The single-table inheritance mechanism failed to locate the subclass: ‘Topic::Movie’”. Let’s fix this by creating the subclass. Navigate to /app/models and create a new folder topic. Inside the topic folder, create a new file movie.rb. Write the following code into the class:

class Topic::Movie < Topic
end

This creates the required subclass that inherits from the Topic class.

Now, re-run the command:

newMovie = Topic.new(title: "The Shawshank Redemption", rating: 9.3, type: "Topic::Movie")
newMovie.save

newMovie can be saved now. Check the saved data by using Topic.all.

In the same way, we can add subclasses for other topics as well (like books, food, etc.).

Seeding data

Instead of feeding data manually, we could write a program that feeds the data into the database. This process is called seeding. We can seed users or topics databases; for this application, seeding topics is enough.

Navigate to /db/seeds.rb. Here, we can write our program that will seed the data:

25.times do
  Topic.create(title: Faker::Movie.title, rating: rand(7.0..10.0), type: "Topic::Movie")
end

This program will create 25 entries into the database topics. However, we want to have different movie titles and ratings in our database. For this, we can use the Faker gem. Run bundle add faker to add the dependency.

Now, let’s update our seeds.rb file:

25.times do
  Topic.create(title: Faker::Movie.title, rating: rand(7.0..10.0), type: "Topic::Movie")
end

25.times do
  Topic.create(title: Faker::Book.title, rating: rand(7.0..10.0), type: "Topic::Book")
end

25.times do
  Topic.create(title: Faker::Food.dish, rating: rand(7.0..10.0), type: "Topic::Food")
end

Similarly, we can create loops for Food and Books. (Refer to the Faker gem’s GitHub for more details.)

Now, run the rails db:seed command to run the seed program.

We can check the database in the rails console. Run Topic.all to see all the data entries.

Routes

Navigate to /config/routes.rb. Let’s update the routes to show the URL localhost:3000/<type>/topics, where type can be movies, books, or foods:

scope '/:type' do
  resources :topics, only: [:index]
end

Scopes are used in routes to change the resources path. Using the above scope will create routes like localhost:3000/movies/topics for the type that is movies.

Updating controller, view, and routes

Adding scope

Head to the rails console and check all the topics where the type is movie. We can do this by using Topic.all.where(type: 'Topic::Movie'). However, we can shorten this by using scopes. Scopes are custom queries that you define inside your Rails models with the scope method.

In /app/models/topic.rb, add the following scopes:

scope :movies, -> { where(type: 'Topic::Movie') }
scope :books, -> { where(type: 'Topic::Book') }
scope :foods, -> { where(type: 'Topic::Food') }

After doing this, restart the rails console. To check movies, we can do this by simply writing Topic.movies. This step is optional for this project, but it’s good to know such functionality exists.

Topic helper

We will use a helper to provide useful methods. We will add a method that takes a parameter (type) and returns “Topic::{topic type}”.

In /app/helpers/topics_helper.rb, add the following method:

def topic_type(type)
  "Topic::#{type.singularize.titleize}"
end

Updating controller

Navigate to /app/controllers/topics_controller.rb. First, we need to modify our index action. Instead of displaying every entry, we need to filter the required type (e.g., movies).

def index
  @topic = current_user.randomize(topic_type(params[:type]))
end

We will use our custom method randomize and pass the topic type, which we defined as a private method previously. We will call the randomize method on the current user. Hence, we need to define the method in the user.rb model.

Updating model

Navigate to /app/models/user.rb. Here, we need to get all the options of a specific type. We have stored those options into the all_options variable. All the options the user has rejected will be stored into the rejected_options variable (here we are using the association). Then, we need to get all the options excluding the rejected options and store them in the options variable.

def randomize(topic_type)
  all_options = Topic.where(type: topic_type)
  rejected_options = topics
  options = all_options - rejected_options

  if options.any?
    @option = options.sample
    rejected_options << @option
  else
    rejected_options = []
    @option = all_options.sample
  end

  @option
end

Finally, if we get any values inside options, we can get a random option from it and store it into the @option variable. Then, the same option is assumed as rejected, as we don’t want it to be suggested to the user again. After saving, we can return the @option variable back to the controller.

Otherwise, if there are no options (as the user has rejected all of them), we want to reset the suggestions. So, we could delete all the values inside rejected_options and just provide the user with the first suggestion again.

Updating view

To show the single option rather than the whole list, we need to update the view as well.

Navigate to /app/views/topics/index.html.erb. And update the file as follows:

<h1><%= params[:type].pluralize.titleize %></h1>

<%= render partial: 'topic', object: @topic %>

With this, we are pretty much done with the base of the application.

Front-end

Let’s work on some front-end aspects. We will be using Tailwind CSS and the power of partials in Rails.

Installing Tailwind

Use the command bundle add tailwindcss-rails, then rails tailwindcss:install. Now, shut down the server and start it with the bin/dev command instead of rails server.

TailwindCSS

Learning Tailwind CSS is out of the scope for this project. Just use the application.tailwind.css and update /app/assets/stylesheets/application.tailwind.css.

Also, by default, there will be styles applied to the <main> tag in /app/views/layouts/application.html.erb.

History

Let’s add a new feature called “History” where the user’s previous suggestions will be stored with an option to clear the history. For this, we need to update our routes.rb:

resources :topics, only: [:index] do
  get :clear_history, on: :collection
end

Check the routes, and we can see there is a new route, clear_history_topics. We will update the index.html.erb in /app/views/topics to show the history and some other UI features.

We are going to use the partial _history.html.erb to render the history component here:

<h1><%= params[:type].pluralize.titleize %></h1>

<%= render partial: 'topic', object: @topic %>
<%= render partial: 'history' %>

Partials

We need to update our _topic.html.erb partial to show one topic suggestion with stars as ratings:

<div class="my-4 border p-4 rounded-md">
  <h3 class="text-xl font-bold"><%= topic.title %></h3>
  <p class="text-gray-600">Rating: <%= render partial: 'layouts/star', collection: (1..topic.rating.round).to_a, as: :rating %></p>
</div>

Now, we need to create a _star.html.erb partial to show the star ratings. Navigate to app/views/layouts and create a new file _star.html.erb:

<span class="text-yellow-500">&#9733;</span>

Finally, we will create the _history.html.erb partial as well. In /app/views/layouts, create a new file _history.html.erb:

<% if current_user.topics.count > 1 %>
  <div class="my-4 p-4 border rounded-md">
    <h3 class="text-lg font-bold mb-2">History</h3>
    <% current_user.topics[0..-2].each do |topic| %>
      <%= render partial: 'topic', object: topic %>
    <% end %>
    <%= button_to 'Clear History', clear_history_topics_path, method: :get, class: 'bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded' %>
  </div>
<% end %>

Since we are adding the current option to the rejection, it will show up in the history as well. To remedy that, we loop till the second last index. And if there are no history entries, then we might not want to show the history at all. The first line accomplishes that by making sure to show the history only when at least two options are present, as one option is the current suggestion we want to ignore.

Update controller

We need to add a method to handle the “Clear History” button:

def clear_history
  current_user.rejections.destroy_all
  redirect_to topics_path(type: topic_type(params[:type]))
end

The above method will delete the relationships and redirect the user to the topic path.

There you go, A RoR web Application that includes a lot of concept for a beginner! If you have any doubts post them into the comment section.