Posted On

For a recent client project, I was tasked with building a form that would need to asynchronously update the options on display according to the user's selections. With this post, I'm going to put together a small test app that showcases the same techniques in the hope that it will help someone in their own work. Before we get started, I'm going to assume you have some familiarity with Rails and can start an app from nothing and write database migrations, etc.

I'm really into video games so I put together an app that allows you to enter a game's name, publisher, release date, and rate it. Multiple users can rate a game by clicking on some star icons and submitting a form. To do this, we start with two models: game and rating.

Here are their entries in my database:

  create_table "games", force: true do |t|
    t.string   "name"
    t.string   "publisher"
    t.string   "release_date"
    t.datetime "created_at"
    t.datetime "updated_at"

  create_table "ratings", force: true do |t|
    t.integer  "game_id"
    t.integer  "rating"
    t.datetime "created_at"
    t.datetime "updated_at"

  add_index "ratings", ["game_id"], name: "index_ratings_on_game_id", using: :btree

Pretty straightforward, right? In the models, Game has_many :ratings while Rating belongs_to :game. I left the controller for Game as scaffolded, but RatingsController was written with only what I needed to get by. But we'll get to that later. First, we'll build the form to rate a game.

= simple_form_for(@rating) do |f|
  = f.error_notification

    = f.association :game, collection: Game.all, include_blank: 'Select a game...'
    = f.hidden_field :rating

    = render 'game_info'

    = f.button :submit

We utilize SimpleForm for easy Rails forms, but it should be simple enough to do this with any kind of form. This particular form has just one input the user can interact with, the one to select which game they're wanting to rate.

The hidden field is there so that we can submit what rating the user decides on after we retrieve it with some Javascript. If this code looks a little simpler than you're used to seeing, it might be because this is using HAML, not regular HTML.

By now you've probably noticed that partial I'm rendering there called game_info. This is where we are going to use Ajax to give the user more info on the game and display the rating stars without having to completely refresh the page. That partial looks like this:

- unless @game.blank?
    %li= @game.publisher
    %li= @game.release_date

    %i.fa.fa-star{data: {rating: 1}}
    %i.fa.fa-star{data: {rating: 2}}
    %i.fa.fa-star{data: {rating: 3}}
    %i.fa.fa-star{data: {rating: 4}}
    %i.fa.fa-star{data: {rating: 5}}

I'm using FontAwesome star icons with data attributes here, but there are other solutions you could look into.

So now let's dig into the controller code we're going to need to make this work. In addition to the typical new and create actions, you'll need to write a custom action. I called mine game_info:

def game_info
    @game = Game.find(params[:game_id].to_i)

    render json: {
     content: render_to_string({
         partial: 'game_info',
         layout: nil

@game is set here to enable us to display the game's info in the partial and in the render json block, we are telling it which partial to use when this action is accessed. Layout is set to nil so that we don't try to pull in anything other than the code in our partial.

We've got our models, controllers, and views ready to go. All that's left is the Javascript and a small line added to our routes.

I wrote the code for this post in Coffeescript but the techniques apply to Javascript all the same. Here's the entire bit of code and I'll explain what it's doing in pieces after:

class @NewRating

  constructor: ->
    $('#rating_game_id').change @reloadGameInfo

  setRating: =>
    $('.fa-star').each (index, element) =>
      $el = $(element)
      $ ->
        $el.siblings().removeClass 'filled'
        $el.addClass 'filled'
        $el.prevAll().addClass 'filled'
        rating = $ 'rating'

  getGameID: =>

  reloadGameInfo: =>
    $.getJSON('/game_info?game_id=' + @getGameID(), (result) =>
      $('.game-info').html result.content

The constructor is what user action we're looking for. In this case, we want to watch for when #rating_game_id (the select box in our form) is changed. When it changes, the function reloadGameInfo is called. Nestled inside reloadGameInfo is the chewy Ajax center this post has been leading up to.

The documentation for getJSON will do a much better job explaining how it works than I ever could, but within it we are attempting to access that custom controller action we wrote earlier. It is also setting the game_id parameters according to the game that is selected in the form. You can easily see how that's being done by getGameID.

After the controller action is accessed, it looks for our div with the class .game-info in our form and asynchronously refreshes the content rendered there. From there, we call the function setRating to enable a user to set their rating for the game by clicking on the star icons. For each star that is clicked on, I remove the class filled from all of them and then add it back on to the one that was clicked and the others leading up to it. This is to give the user some colored feedback to show them what rating they'll be submitting. After that, I'm setting the variable rating to that star's data-rating value and then setting the hidden field in our form that that value.

Before this all will work however, we'll need this line in our routes:

get :game_info, :to => 'ratings#game_info'

And now we need just one last thing. The code to initialize that Coffeescript that we wrote. I opted to just add this to my form page:

  window.newRating = new NewRating();

And that's all there is to it! Here's what my form looks like in action:
A short gif of my form at work Pretty simple and from here, you should be able to use this technique to make your forms more dynamic from here on out.

If you're interested in the source code used in this post, you can find that right here: