Rails Refactoring Adventure: Javascript Assets

One of the things that has made Rails so popular is the ease and speed with which small teams can use it to spin up full-stack applications.

Unfortunately, I take this for granted and often prioritize that "ease and speed" over writing clean code (although of course I go back and refactor, as do we all). This is especially true of my client-side code. More often than not, I find myself throwing some quick and dirty jQuery into the asset pipeline and calling it a day.

Since Ruby is my first language, my client-side code is often low on the list of things to refactor.

In a recent project, thanks to the advice of one of my former teachers, I finally tackled some of that "quick and dirty" JavaScript––refactoring some repetitious and inelegant code by building a custom jQuery plugin and implementing Handlebars templates in the asset pipeline.

Since I don't think I'm the only Rubyist out there to tip-toe around writing clean client-side code, I thought I'd share my refactoring adventure here.

Let's begin by taking a look at the original code, and the feature that it supported.

Background

First, a little background on the app. We'll be refactoring the "new schedule form" feature of an app that allows instructors at the Flatiron School to create and deploy daily schedules to their students.

A schedule has many labs (think assignments), many objectives and many activities. A lab has a name and an objective has content. An activity is a bit more complex––it has a start time, an end time, a description and a boolean "reserve room" value. This last attribute is used by a Google calendar integration feature to determine whether or not a classroom should be booked on the class's Google calendar.

The form for a new schedule only provides fields for a certain number of labs, objectives and activities. We want our user to be able to add form fields for additional labs, objectives and activities, at the click of a button.

Something like this:

Dynamically Appending Form Fields to the DOM

In order to build out the above functionality, I first defined a series of Ajax event listeners to listen to the click of the add lab, objective and activity buttons. On the click of each button, the event listener functions would append the necessary HTML, adding a new form field to the DOM.

Let's take a look at this code, and then we'll discuss some of the code smells it's giving off.

// app/assets/javascripts/schedule_form.js
$(function () {
  addLabListener();
  addActivityListener();
  addObjectiveListener();
})

function addLabListener(){
  $("#add-lab").on("click", function(event){
    event.preventDefault();
    event.stopPropagation();
    var new_id_num = 0;
    var inputs = $("input[name^='schedule[labs_attributes]'");
    if (inputs.length > 0) {
      var last = inputs[inputs.length - 1];
      var last_id_num = last.id.split("_")[3]
      var new_id_num = parseInt(last_id_num) + 1
    }
    
    $("#add-labs").append('<br><label>Name</label><input class="form-control" type="text" name="schedule[labs_attributes][' + new_id_num + '][name]" id="schedule_labs_attributes_' + new_id_num + '_name"><br>')
  });
}

function addActivityListener(){
  $("#add-activity").on("click", function(event){
    event.preventDefault();
    event.stopPropagation();
    var new_id_num = 0;
    var inputs = $("input[name^='schedule[activities_attributes]'");
    if (inputs.length > 0) {
      var last = inputs[inputs.length - 1];
      var last_id_num = last.id.split("_")[3];
      var new_id_num = parseInt(last_id_num) + 1;
    }

    $("#add-activities").append('<label for="schedule_activities_attributes_"' + new_id_num + '_start_time">Start Time</label><input class="form-control" type="time" name="schedule[activities_attributes][' + new_id_num + '][start_time]" id="schedule_activities_attributes_' + new_id_num + '_start_time"><label for="schedule_activities_attributes_"' + new_id_num + '_end_time">End Time</label><input class="form-control" type="time" name="schedule[activities_attributes][' + new_id_num + '][end_time]" id="schedule_activities_attributes_' + new_id_num + '_end_time"><br><label for="schedule_activities_attributes_"' + new_id_num + '_description">Description</label><input class="form-control" type="text" name="schedule[activities_attributes][' + new_id_num + '][description]" id="schedule_activities_attributes_' + new_id_num + '_description"><br><label for="schedule_activities_attributes_"' + new_id_num + '_reserve_room">Reserve Room</label><input type="hidden" name="schedule[activities_attributes][' + new_id_num + '][reserve_room]" value="0"><input type="checkbox" value="1" name="schedule[activities_attributes][' + new_id_num + '][reserve_room]" id="schedule_activities_attributes_' + new_id_num + '][reserve_room]"><br><br>')
  });
}

function addObjectiveListener(){
  $("#add-objective").on("click", function(event){
    event.preventDefault();
    event.stopPropagation();
    var new_id_num = 0;
    var inputs = $("input[name^='schedule[objectives_attributes]'");
    if (inputs.length > 0) {
      var last = inputs[inputs.length - 1];
      var last_id_num = last.id.split("_")[3];
      var new_id_num = parseInt(last_id_num) + 1;
    }

    $("#add-objectives").append('<br><label for="schedule_objectives_attributes_"' + new_id_num + '_content">Content</label><input class="form-control" type="text" name="schedule[objectives_attributes][' + new_id_num + '][content]" id="schedule_objectives_attributes_' + new_id_num + '_content"><br>')
  })
}
Code Smell No. 1: Violating Separation of Concerns

The first major code smell should jump out at you right away. Without even reading the code, we can see that huge and unwieldy snippets of HTMl are being appended to the DOM, especially in the addActivity function.

Our JavaScript assets are supposed to be a safe space for JavaScript, not enormous amounts of HTML. While I'm not adverse to appending or manipulating small HTML snippets directly in one of my JS files, this much HTML is unreasonable.

Not only are we polluting a file meant to define and organize JavaScript and jQuery with a ton of HTML, we're also creating unmanageable code. What happens when the form fields that we are appending to the DOM need to change in structure or content? Updating those super long strings of HTML will get confusing, fast.

Wouldn't it be great if we could extract this HTML to some other location, and simply render it here in our jQuery functions?

Lucky for us, we'll be able to do exactly that. First, let's check out our second code smell.

Code Smell No. 2: Repetition

If you take a closer look at our addLab, addObjective and addActivity functions, you'll notice that the code is almost exactly the same.

In all three functions we are:

  • Listening for the click of a particular button
  • Preventing event propagation
  • Identifying what array index to give the new form field in the context of our nested form
  • Dynamically structuring and appending the appropriate form field HTML to the DOM

Just two things really vary between the functions: the exact jQuery selector we are adding the event listener to, and the HTML we are appending to the DOM.

Wouldn't it be great if we could write one function that be attached to or invoked on any selector, and append the appropriate HTML dynamically? Something like $("whatever-button-I-want).addMore().

But how can we define a jQuery function that we can invoke directly on any selector? Well, we can define our very own, custom jQuery plugin.

Let's do it!

Defining a Custom jQuery Function

Before we actually build our plugin, let's take a closer look at how we'll be using it.

$(function () {
  $("#add-lab").addMore();
  $("#add-activity").addMore();
  $("#add-objective").addMore();
})

We'll define our addMore plugin such that it can be invoked on any of our "add form fields" buttons. The function will:

  1. Attach the event listener to listen for the click of the appropriate button.
  2. Dynamically determine, based on the element on which it is invoked, which HTML to append to the DOM.

Let's get started.

Plugin Definition

Defining a jQuery plugin is easy. All it takes is the following line:

$.fn.addMore = function(e) {
  // some code
}

Now that we've defined our plugin, let's code out the necessary functionality.

Plugin Functionality

The first thing we need to do is add our event listener:

$.fn.addMore = function(e) {
  this.on("click", function(event){
    event.stopPropagation;
    // some code
  });
}

Since our plugin will be invoked on the selectors that represent each button ("add lab", "add objective", and "add activity"), the this keyword, inside the plugin is bound to the button itself. So, we can dynamically attach a click-event listener to each of the elements on which we invoke the plugin function.

Now let's take a closer look at the code we need to refactor into this dynamic jQuery plugin. Here's the body of the addLabListener function from our original code:

var new_id_num = 0;
var inputs = $("input[name^='schedule[labs_attributes]'");
if (inputs.length > 0) {
  var last = inputs[inputs.length - 1];
  var last_id_num = last.id.split("_")[3]
  var new_id_num = parseInt(last_id_num) + 1
}
    
$("#add-labs").append('<br><label>Name</label><input class="form-control" type="text" name="schedule[labs_attributes][' + new_id_num + '][name]" id="schedule_labs_attributes_' + new_id_num + '_name"><br>')

Our view uses a nested form, meaning the form fields for the new labs, objectives and activities associated to our new schedule are nested under schedule. The form fields for a new lab, therefore, would look something like this:


<input name='schedule[labs_attributes][0]'>
<input name='schedule[labs_attributes][1]'>
<input name='schedule[labs_attributes][2]'>

The form fields for new objectives and activities are almost identical. The only difference is that labs_attributes would be swapped out for objectives_attributes or activities_attributes.

So, our original addLabListener function counts the number of input elements whose name attributes begin with schedule[lab_attributes]. This way, we know which array index to assign to the new lab form field we are appending to the form.

All three of our original event listener functions have similar lines of code:

addLabListener()

var inputs = $("input[name^='schedule[labs_attributes]'");
if (inputs.length > 0) {
  var last = inputs[inputs.length - 1];
  var last_id_num = last.id.split("_")[3]
  var new_id_num = parseInt(last_id_num) + 1
}

addObjectiveListener()

var new_id_num = 0;
var inputs =   
 $("input[name^='schedule[objectives_attributes]'");

if (inputs.length > 0) {
  var last = inputs[inputs.length - 1];
  var last_id_num = last.id.split("_")[3];
  var new_id_num = parseInt(last_id_num) + 1;
}

addActivityListener()

var new_id_num = 0;
var inputs = 
 $("input[name^='schedule[activities_attributes]'");
if (inputs.length > 0) {
  var last = inputs[inputs.length - 1];
  var last_id_num = last.id.split("_")[3];
  var new_id_num = parseInt(last_id_num) + 1;
}

The only difference among these three snippets is the element-specific jQuery selector: $("input[name^='schedule[activities_attributes]'");

In order to abstract away this repetition and re-write this code into our dynamic jQuery plugin, we need a way to refer to these selectors dynamically.

We'll achieve this by assigning a set of data attributes to each "add " button. We'll grab the value of the data attribute within our jQuery plugin, and use that value to grab the information we need about the appropriate set of form fields.

First things first, let's update our buttons with those data attributes:

app/views/schedules/new.html.erb:

<button id="add-lab" class="btn btn-primary form-btn" data-add-type=lab>+ lab</button>
<button id="add-objective" class="btn btn-primary form-btn" data-add-type=objective>+ objective</button>
<button id="add-activity" class="btn btn-primary form-btn" data-add-type=activity>+ activity</button>

We'll also add a data attribute to each lab, objective and activity field to make it easier to track the position, or index, of each new form field in our nested form. Let's take a brief look at the fields for new labs as an example.

<div id="lab-fields">
  <%= f.fields_for :labs, schedule.labs do |lab_form|%>
      <%= lab_form.label :name %>
      <%= lab_form.text_field :name, class: "form-control", 
        data: {lab_position: lab_form.index} %>
  <%end%>
</div>

Note the data attribute we've added to the lab_form.text_field. This will render the following HTML:

<input type="text" name="schedule[labs_attributes][0]" data-lab-position="0">

<input type="text" name="schedule[labs_attributes][1]" data-lab-position="1">

<input type="text" name="schedule[labs_attributes][2]" data-lab-position="2">

...

This way, if we want to know the last index number of the labs_attributes collection of form fields, we can use our data-lab-position selector.

The same data attributes have been added to the objectives and activities fields, the only difference being that those attributes are named data-objective-position and data-activity-position respectively.

Now that our data attributes are all set up, let's return to our plugin.

We'll use the data-add-type attribute that we added to each "add " button to identify the type of form fields we need (i.e. lab, objective or activity:

$.fn.addMore = function(e) {
  this.on("click", function(event){
    event.stopPropagation();
    var addType =  $(this).data('add-type');
    ...
  });
};

Then, we'll use this addType to figure out what the index element number of our new form field should be:

$.fn.addMore = function(e) {
  this.on("click", function(event){
    event.stopPropagation();
    var addType =  $(this).data('add-type');
    var newIdNum = 0;
    if ($('[data-' + addType + '-position]').length >= 1) {
      var newIdNum = $('[data-' + addType + '-
        position]').last().data()[addType + "Position"] + 1;
    }
  });
};

Now that we've calculated the index number that we'll need to construct our new form field, we're ready to append that new form field to the DOM!

But wait, how will we know which HTML to append? And how will we know where to append it?

We'll answer the second question first. Each set of form fields (fields for labs, objectives, and activities) are wrapped in a div with a specific id. For example:

<div id="lab-fields">
  <%= f.fields_for :labs, schedule.labs do |lab_form|%>
      <%= lab_form.label :name %>
      <%= lab_form.text_field :name, class: "form-control", data: {lab_position: lab_form.index} %>
  <%end%>
</div>

The objectives and activities fields are wrapped in divs with ids of objective-fields and activity-fields respectively. So, it will be easy enough to dynamically append some HTML to the appropriate div:

$.fn.addMore = function(e) {
  this.on("click", function(event){
    event.stopPropagation();
    var addType =  $(this).data('add-type');
    var newIdNum = 0;
    if ($('[data-' + addType + '-position]').length >= 1) {
      var newIdNum = $('[data-' + addType + '-
        position]').last().data()[addType + "Position"] + 1;
    }
    $("#" + addType + "-fields").append(<some HTML>);
  })
};

Now we're ready for the final step––dynamically appending large strings of HTML.

For this, we'll be using Handlebars to template the HTML form fields, and render them dynamically in our plugin.

Using Handlebars in the Asset Pipeline

We'll need to do a few things to get set up.

First, we need to add the Handlebars Assets gem to our Gemfile and bundle install.

gem 'handlebars_assets'

Then, we're create a directory, templates inside app/assets/javascripts and require the templates directory, as well as Handlebars itself, in our manifest:

// app/assets/javascripts/application.js

//= require handlebars
//= require_tree ./templates

Now, let's create a template for each of the three types form fields we'll need to append to the DOM.

├── app
│   ├── assets
        ├── javascripts
            ├── application.js
            ├── schedule_form.js
            ├── templates
                ├── add-activity.hbs
                ├── add-lab.hbs
                └── add-objective.hbs

Let's take a look at just one of these templates, the add-lab.hbs template:

< label > Name < /label >
< input class="form-control" type="text" 
  name="schedule[labs_attributes][{{newIdNum}}][name]" 
  id="schedule_labs_attributes_{{newIdNum}}_name" data-
  {{addType}}-position={{newIdNum}}>

Notice that we're using handlebars expressions to inject the necessary variables, newIdNum and addType, which will be dynamically calculated in our jQuery plugin.

Here's how we'll render the templates:

HandlebarsTemplates['add-' + addType]({newIdNum: newIdNum, addType: addType})

Here, we are doing a few things:

  • Dynamically rendering the correct template by interpolating the template file name with our addType variable.
  • Passing in the newIdNum and addType variables, which are used in the templates.

Let's put it all together and take a look at our beautiful refactor!

$(function () {
  $("#add-lab").addMore();
  $("#add-activity").addMore();
  $("#add-objective").addMore();
})

$.fn.addMore = function(e) {
  this.on("click", function(event){
    event.stopPropagation();
    var addType =  $(this).data('add-type');
    var newIdNum = 0;
    if ($('[data-' + addType + '-position]').length >= 1) {
      var newIdNum = $('[data-' + addType + '-
       position]').last().data()[addType + "Position"] + 1;
    }
    $("#add-" + addType + "s")
      .append(HandlebarsTemplates['add-' + addType](  
        {newIdNum: newIdNum, addType: addType}));
  })
};

Conclusion

Let's recap before you go. We:

  • Defined our own jQuery plugin.
  • Refactored our HTML form to use data attributes as jQuery selectors, instead of CSS.
  • Used those data attributes in our jQuery plugin to dynamically append the appropriate HTML to the form.
  • Incorporated Handlebars templates into our asset pipeline, in order to template and render HTML in our jQuery plugin.

So, while this blog post illustrated a specific example, I think there are a few take-aways.

  • When faced with a number of nearly identical jQuery functions, consider making your very own jQuery plugin.
  • If you find yourself dealing with unwieldy HTML strings inside your JS assets, Handlebars may be the way to go.
  • Use data attributes, instead of CSS selectors, to identify the DOM elements you want to work with. This has two advantages. First, we can define more dynamic jQuery functions, by grabbing the values of various data attributes. Second, our jQuery is no longer tied to our CSS. We (and other developers collaborating on our project) can change the styling without worrying about breaking our JavaScript.

I hope some of the patterns demonstrated here can help other JavaScript-adverse Rubyists stop shying away from JS refactors in Rails. Thanks for reading!

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus