Skip to content

Leveraging javascript in Rails: the easy way

Patrick Bolger edited this page Aug 31, 2017 · 24 revisions

This is a case study of the use of javascript in a rails app, fully exploiting the capabilities provided by Rails helpers and server-side JS processing. This uses some of the capabilities defined in the rails guide Working with JavaScript in Rails.

The "SHF" app has the concept of a membership application ("application"), submitted by a user ("applicant") who wants to become a member of the organization. An organization Admin then reviews the application.

In some situations, the Admin may decide that she needs more information added to the application, and moves the application to ask applicant for info status. As part of that, the Admin identifies what kind of information is needed - this can be done by selecting a pre-defined description of the required information (reason-for-waiting), or by entering free-form text which describes the required information (custom-waiting-reason).

In the screenshot below, the Admin has clicked the ask applicant for info button (moving it to that status). This results in a POST action to the controller (to set the new state), followed by a rendering of the application-review view, which now displays a subform for specifying the required information:

waiting for applicant

Then, the Admin selects "Other (enter the reason)" as the reason-for-waiting. At that point, a text-entry field (labeled "other reason") appears, and the Admin enters a description of the required information (custom-waiting-reason):

custom waiting reason

When the Admin leaves the text-entry field, the information is sent to the server.

We will first look at the "before" code, where the javascript-to-server integration was not fully leveraging the rails capabilities, to see how this is implemented. Then, we'll look at the "after" code - which has been converted to more fully leverage rails capabilities.

Before Conversion

View (partial /views/membership_applications/_reason_waiting.html.haml)

.row
  .container
    .reason-waiting-information
      = label_tag :member_app_waiting_reasons,
                  t('membership_applications.need_info.reason_title')

      - collection = AdminOnly::MemberAppWaitingReason.all.to_a
      - collection << AdminOnly::MemberAppWaitingReason
                      .new(id: -1,
                      "name_#{I18n.locale.to_s}": "#{@other_waiting_reason_text}")

      - selected = ! @membership_application.custom_reason_text.blank? ? -1 :
                     @membership_application.member_app_waiting_reasons_id
      = select_tag(:member_app_waiting_reasons,
                   options_from_collection_for_select(collection,
                                                      :id, reason_name_method,
                                                      selected),
                   { include_blank: t('membership_applications.need_info.select_a_reason'),
                     class: 'reason-waiting-list' })

.row
  .container
    #other-text-field
      = label_tag :custom_reason_text,
                  t('membership_applications.need_info.other_reason_label')
      = text_field_tag :custom_reason_text,
                       @membership_application.custom_reason_text


:javascript

  var reasons_list = $('#member_app_waiting_reasons');  // the select HTML element (list)
  var custom_text_info = $('#other-text-field');
  var custom_text_field = $('#custom_reason_text');

  // this option is added to the list of reasons and is only used so we can
  // show/hide the custom reason text field
  var other_reason_text = "#{@other_waiting_reason_text}";
  var other_reason_option = document.createElement("option");
  other_reason_option.text = other_reason_text;
  other_reason_option.value = -1;  // some value that will not be in the database or use by Rails



  function selected_is_customOtherReason() {
    return $('#member_app_waiting_reasons option:selected').text() === other_reason_option.text;
  }


  // send the reason selected to the server unless the 'custom/other' was selected
  function changed_reason() {
     // do not send a request if the reason selected is the
     // "Other/custom... enter text" reason.   Wait until the text field
     // with the custom reason is entered to send that data

    if (!selected_is_customOtherReason()) {
      custom_text_field.val('');
      send_updated_reason( #{@membership_application.id},
        $('#member_app_waiting_reasons').find('option:selected').val() , null );
    }
    hideOrShowCustomReasonElements();

   }


  // Set the waiting_reason to nil because we instead have the info in the custom text field
  function changed_custom_text() {
    send_updated_reason( #{@membership_application.id},
                         null , custom_text_field.val() );
    custom_text_info.show();
   }


  var hideOrShowCustomReasonElements = function() {

    if ( custom_text_field.val() || selected_is_customOtherReason()) {
      custom_text_info.show();
      reasons_list.value = other_reason_option.value; // make sure this option is selected; important when first displaying the view
    }
    else {
        // clear out any custom reason if the selected reason is not the custom reason
        $('#custom_reason_text').val("");
        custom_text_info.hide();
    }

  };


  // Send information to the server
  //  no need to do anything if it was successful
  var send_updated_reason = function(app_id, reason_id, custom_text) {

    $.ajax({
      url: "#{membership_application_path}",
      type: "PUT",
      data: { membership_application: { member_app_waiting_reasons_id: reason_id,
        custom_reason_text: custom_text },
        id: app_id }
    }).fail(function(evt, xhr, status, err) {
      alert( "#{I18n.t('membership_applications.update.error')}: " +
             'Status: ' + evt.statusText );
    });

  };


  var initialize = (function() {
    // reasons_list.options.add(other_reason_option);  // add the other_reason to the list
    hideOrShowCustomReasonElements();
    $('#member_app_waiting_reasons').on('change', changed_reason);
    $('#custom_reason_text').on('change', changed_custom_text)
  });

  $(document).ready(initialize);

The first section of this code (before javascript:) sets up a select field and a text-entry field - the former for selecting the reason-for-waiting, and the latter for entering custom-waiting-reason.

So, what's going on here? At a high level:

  1. On page load, callback functions are associated with the select and text-entry fields in the view, triggered by the "change" event. Also, the custom information description is either hidden or shown, depending on whether a custom description has been assigned to this application.

  2. When the Admin selects a reason for waiting for information, the selected reason is sent to the server unless it is "Other (enter the reason)", in which case the text-entry field is made visible.

  3. When the Admin enters the custom-waiting-reason, and exits the text-entry field (thereby triggering an "change" event), the entered text is sent to the server.

  4. Sending the reason-for-waiting, and custom-waiting-reason is handled by an AJAX call to the server, which puts up an error message if the call fails or times out.

Controller (membership_applications_controller.rb, action update)

The controller action simply saves the updated information (sent via the AJAX call) and returns 'ok' status or an error status and message if there is an exception. This is the action method:

def update
  if @membership_application.update(membership_application_params)
    if new_file_uploaded params
      check_and_mark_if_ready_for_review params['membership_application'] if 
                            params.fetch('membership_application', false)

      respond_to do |format|
        format.js do
          head :ok   # just let the receiver know everything is OK. no need to render anything
        end
        format.html do
          helpers.flash_message(:notice, t('.success'))
          render :show
        end
      end
    else
      update_error(t('.error'))
    end
  else
    update_error(t('.error'))
  end
end

This is relatively simple - which is not surprising, since most of the "heavy lifting" is being done by the embedded javascript.

After Conversion

In order to reduce the amount of javascript code, and corresponding complexity, we have converted the code above to exploit rails' JS integration capabilities. This is explained by examining the changed code.

View (partial /views/membership_applications/_reason_waiting.html.haml)

#reason-waiting
  .row
    .container
      .reason-waiting-information
        = label_tag :member_app_waiting_reasons, t('membership_applications.need_info.reason_title')
        - selected = selected_reason_value(@membership_application, @other_waiting_reason_value)
        = select_tag(:member_app_waiting_reasons,
                     options_from_collection_for_select(reasons_collection(@other_waiting_reason_value, 
                                                         @other_waiting_reason_text),
                                                         :id,
                                                         reason_name_method,
                                                         selected),
                         { include_blank: t('membership_applications.need_info.select_a_reason'),
                           class: 'reason-waiting-list',
                           data: { remote: true,
                                   url: membership_application_path(@membership_application),
                                   method: 'put' } })

  .row
    .container
      #other-text-field{ style: "display: #{selected == @other_waiting_reason_value ? nil : 'none'}" }
        = label_tag :custom_reason_text, t('membership_applications.need_info.other_reason_label')
        = text_field_tag :custom_reason_text, @membership_application.custom_reason_text,
                    { data: { remote: true,
                              url: membership_application_path(@membership_application),
                              method: 'put' } }

:javascript

  $('#member_app_waiting_reasons').on('ajax:success', function (e, data) {
    if (data === "#{@other_waiting_reason_value}") {
      $('#other-text-field').show();
    } else {
      $('#other-text-field').hide();
    }
  });

For our purposes here, there are two changes of note between this converted code and the original code above.

  1. The select and text-entry fields have an added hash that looks like this:
data: { remote: true, 
        url: membership_application_path(@membership_application),
        method: 'put' } 

As explained here, these added data attributes cause an AJAX call to the specified URL, using the PUT HTTP method, when the field changes value (for the select field, when a selection is made, and for the text-entry field, when the focus exits the field - these both trigger the "change" event, thus triggering the AJAX call mechanism).

Note that the value of the specific input field is included in the standard params hash that is available to the controller action.

  1. There is some residual embedded javascript remaining in the view file. This is triggered by the "ajax:success" event, and simply toggles the visibility of the custom-waiting text, as appropriate. In general, it is preferred to not have any javascript included in a view file. However, in this case, it is appropriate because the JS logic here is using a controller instance variable.

Controller (membership_applications_controller.rb, action update)

The revised controller action now looks like this:

def update
  if request.xhr?

    if params[:member_app_waiting_reasons] && params[:member_app_waiting_reasons] != 
                                              "#{@other_waiting_reason_value}"
      @membership_application.update(member_app_waiting_reasons_id: params[:member_app_waiting_reasons],
                                     custom_reason_text: nil)
      head :ok
    else
      render plain: "#{@other_waiting_reason_value}"
    end

    if params[:custom_reason_text]
      @membership_application.update(custom_reason_text: params[:custom_reason_text],
                                     member_app_waiting_reasons_id: nil)
      head :ok
    end

  elsif @membership_application.update(membership_application_params)

    if new_file_uploaded params

      check_and_mark_if_ready_for_review params['membership_application'] if    
         params.fetch('membership_application', false)

      respond_to do |format|
        format.js do
          head :ok   # just let the receiver know everything is OK. no need to render anything
        end
        format.html do
          helpers.flash_message(:notice, t('.success'))
          render :show
        end
      end
    else
      update_error(t('.error'))
    end
  else
    update_error(t('.error'))
  end
end

In comparison to the prior version of this method, you'll see that there is some added complexity due to required logic that was previously handled by javascript. Generally, this logic has to determine if the user has selected "Other (enter the reason)" as the reason-for-waiting, or if the user has entered text in the custom-waiting-reason text field, in order to update the DB correctly.

Conclusion

The converted code has the following advantages over the original code:

  1. The amount of javascript required is a fraction of that for the original code.
  2. The original javascript was relatively complex. Some complexity has now been added to the controller action, but this is more straight-forward to handle in Ruby and is a natural extension of the original controller logic.
  3. The need to set up and execute an AJAX call within client-side javascript is completely negated by the ability to specify the data-remote attribute for UI input elements and forms. Rails even includes the element's value(s) in the params hash that is handed off to the controller.
  4. After the controller logic is completed, we can execute a callback on the "ajax:success" event, in which we can update DOM elements as needed. (We could also add special handling for failure.)
Clone this wiki locally