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">★</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.