Rails Portfolio Project: Flash Cards

Posted by BeejLuig on March 1, 2017

Looking for new posts? Head over to my new blog at https://bjcant.dev

My latest project for the Flatiron School’s online verified program is complete! This is my most feature-filled application to date, and it was definitely the most fun to work on. Ok, let’s get into it!

Flash Cards

Flash Cards homepage

Flash Cards is a web application for viewing and managing sets of flash cards, dubbed “study sets.” A guest can see any study set and view user profiles. A logged in user can create manage their own study sets, organize study sets in folders, and copy other user’s study sets into their own collection. A user can also take advantage of the “study mode” feature.

Managing User Sessions with Omniauth and Devise

I opted to utilize Devise to manage registering new users and managing sessions. I only needed a user to login with an email and password, and the Devise defaults suited this nicely. Devise also plays well with Omniauth (kinda), which was nice because Omniauth was a requirement for this project. I used Omniauth to enable Google OAuth2 sign-ins. It would have been nice to authorize a single user via username/password OR Google OAuth, but it was a headache that I decided to avoid.

Logging in with Omniauth

Unfortunately in development mode, the client ID and secret key required to utilize Omniauth don’t like to stick around for very long. I used the Dot Env gem to hold my ENV variables. If you decide to use this, don’t forget to add your .env file to .gitignore. You don’t want strangers seeing those variables!

Models

There are four models for this application:

User

class User < ApplicationRecord
  has_many :folders
  has_and_belongs_to_many :study_sets
  has_many :flash_cards, through: :study_sets, source: :flash_cards
end

StudySet

class StudySet < ApplicationRecord
  has_and_belongs_to_many :studiers, class_name: "User"
  belongs_to :owner, class_name: "User"
  has_many :flash_cards
  has_and_belongs_to_many :folders

  validates :title, presence: true

  accepts_nested_attributes_for :flash_cards, allow_destroy: true
end

FlashCard

class FlashCard < ApplicationRecord
  belongs_to :study_set
  validates :term, :definition, presence: true
end

Folder

class Folder < ApplicationRecord
  belongs_to :user
  has_and_belongs_to_many :study_sets
  validates :name, :user_id, presence: true
end

Not too complicated. The most important relationship is the one between StudySet and User. If you look in the StudySet class, you will see this line:

belongs_to :owner, class_name: "User"

This is the relationship between a newly created instance of StudySet and the user who created it. There can be only one owner.

Before that line, you can see this:

has_and_belongs_to_many :studiers, class_name: "User"

Users can see and interact with any study set. At the bottom of every study set is a “Study Mode” button. Whenever a unique user presses that button on a study set, that user is added to the study set’s studiers array. This is why StudySet has many :studiers.

study mode button
Engage study mode!

A user should also know about which study sets he/she has studied. Users can then easily find recent study sets in their account without copying them. They are listed under “Recently Studied.”

user profile page

Controllers and routes

Besides the automatically generated Devise controller and ApplicationController, there are four controllers for this app. The first is the Users::OmniauthCallbacksController. This controller kicks in when a user attempts to login by pressing the GoogleOAuth2 link.

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController

  def google_oauth2
    @user = User.from_omniauth(request.env["omniauth.auth"])
    if @user.persisted?
      session[:user_id] = @user.id
      sign_in_and_redirect @user, :event => :authentication #this will throw if @user is not activated
      set_flash_message(:notice, :success, :kind => "Google OAuth2") if is_navigational_format?
    else
      session["devise.google_oauth2_data"] = request.env["omniauth.auth"]
      redirect_to new_user_registration_url
    end
  end

  def failure
    redirect_to root_path
  end

end

There are two actions here. One is for a successful callback, and the second is for a failure. If you are trying to use Omniauth with Devise, take a good look at those action methods. This is necessary for Omniauth to work with Devise, and it was sort of a pain to figure out. A devise sanitizer is also necessary to permit specific keys from the callback hash. This is what I have in my ApplicationController:

protected

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:account_update, keys: [:image])
end

This was necessary so I could pull the Google profile image from request.env["omniauth.auth"].

UsersController has one lowly action.

class UsersController < ApplicationController
  before_action :authenticate_user!, except: [:show]

  def show
    @user = User.find_by_id(params[:id])
    @study_sets = @user.study_sets
    @folders = @user.folders
  end
end

Devise takes care of the rest of the User-related actions!

Take note of the top line there:

before_action :authenticate_user!, except: [:show]. The authenticate_user! helper method is provided by Devise to clean up authentication logic. you can pass in an array of exceptions to allow guests to view certain pages. I have that line at the top of every controller. The only thing that changes is what is inside except: [].

The authenticate_user! helper at work

FoldersController and StudySetsController look pretty similar, and they are pretty long. So, instead of sharing both completely, I’m just going to explain the pattern.

As mentioned before, user authentication starts at the top, using the authenticate_user! helper method. For some actions, like #create, #update and #destroy, it is necessary to ensure that the current user is the same as the user attached to the model being manipulated. I created a couple helpers in ApplicationController for that.

  def user_verified?
    params[:user_id].to_i == current_user.id
  end

  def whoops
    flash[:notice] = "Whoops! You can't go there."
    redirect_to user_path(current_user)
  end

The general controller logic looks like this:

def create
 if user_verified?
  #some code 
 else 
  whoops
 end
end

Nice and simple! Nothing too crazy there.

User verification

Now, I want to point out a few extra actions in StudySetsController.

Let’s start with #sort. In a study set show page, there is a drop-down select with an option to sort flash cards alphabetically. On change, the sort option triggers the #sort action, pushing the option value to the params hash.

  def sort
    @study_set = StudySet.find_by_id(params[:id])
    @sort ||= params[:sort]
    if params[:sort] == "Alphabetical"
      @flash_cards = @study_set.flash_cards.sort_by {|fs| fs.term }
    else
      @flash_cards = @study_set.flash_cards
    end
    render :show
  end
Not sorted
unsorted flash cards
Sorted!
sorted flash cards

Then we have the #copy action. If a user is viewing a study set show page created by another user, a “Copy this” link appears in the middle of the page. When clicked, the #copy action is triggered.

  def copy
    @study_set = StudySet.find_by_id(params[:id])
    @study_set.make_copy(current_user)
    redirect_to user_path(current_user)
  end

make_copy(current_user) is a helper method I added in the StudySet model. It looks like this:

  def make_copy(user)
    copy = self.dup
    self.flash_cards.each do |flash_card|
      copy.flash_cards << flash_card.dup
    end
    user.study_sets << copy
    copy.owner = user
    user.save
    copy.save
  end

The #dup method duplicates all of the attributes of an object. In the case of StudySet, I need to dup the study_set instance and all of the flash_cards belonging to study_set.

copy this
Copy this

Lastly, we have the #study_mode action. This is attached to the button I mentioned earlier. For this button, I wanted a different authentication behavior.

  def study_mode
    @study_set = StudySet.find_by_id(params[:id])
    if !current_user
      @flash_cards = @study_set.flash_cards
      flash[:alert] = "You must be signed in to use this feature!"
      redirect_to user_study_set_path(@study_set)
    else
      @study_set.add_studier(current_user)
      @flash_cards = @study_set.flash_cards
      @study_mode = true
      render :show
    end
  end

If a guest presses the button, the page will reload with a flash alert telling them to sign in. A logged-in user will successfully enter study mode. The add_studiers helper is called, and the :show page reloads, this time displaying an alternate view.

Study mode engage, for real this time!

Here is the #add_studiers(user) method

  def add_studier(user)
    if !self.studiers.include?(user)
      self.studiers << user
      self.save
    end
  end

The method checks to make sure the current user has not already been added to this study_set’s studiers array. If current_user is unique, it is pushed in.

Search feature

Ok, so there is one more thing I want to talk about because it was a feature that I assumed would be more difficult to implement than it actually was. That feature is the search form. On the home page is a search form. This searches StudySet.all with a class-level query on the :title and :description attributes. Here is the search-form:

<div class="search-bar">
  <%= form_tag(root_path, method: "get", id: "search-form") do %>
    <div class="input-group">
      <%= text_field_tag :search, params[:search], class: "form-control" %>
      <span class="input-group-btn">
        <%= submit_tag "Search", name: nil, class: "btn btn-default"%>
      </span>
    </div><!-- /input-group -->
  <%end%>
</div><!-- /.search-bar -->

This form basically just sends the inputted text to the params hash in params[:search]. Then, in study_sets#index:

  def index
    @study_sets = StudySet.all
    if params[:search]
      @study_sets = StudySet.search(params[:search]).order("created_at DESC")
    else
      @study_sets = StudySet.all.order("created_at DESC")
    end
  end

There is a check for the existence of params[:search]. If there are search params, the @study_sets instance variable is set to the results of the StudySet.search(params[:search]) query. This class-level query looks like this:

  def self.search(search)
    where("title LIKE ? OR description LIKE ?", "%#{search}%", "%#{search}%")
  end

And that’s it! The results of the search will be visible on the page. If the results are empty, the page will show a message and a “back” link.

Searching on the homepage

Views

I’m actually not going to get into the view yet. I will be updating this application for my next project (JavaScript and jQuery). It will make more sense to write about the front-end then. In the meantime, I’ll let the images and videos above speak for themselves.

Here is the full demo! I found a mistake towards the end of the video. The mistake is fixed, the video is not ;)