Unobtrusive JqGrid on Rails à la 2dconcept, but with Searchlogic

JqGrid makes our tablework easier on the web, but the original solution is obtrusive. Let’s see how we can polish it.

First, we have to install 2dc_jqgrid, and install it, according to 2dconcept’s document. I didn’t want to install squirrel, because I already added searchlogic, but since squirrel has will_paginate’s functionality built in, we have to deal with it ourselves.

Then, let’s pick an already written CRUD, let’s call ‘users’, which is tested from the UI side (eg. have a test with Webrat / Selenium / anything similar). This is important: making an unobtrusive page needs testable functionality.

First, let’s replace the original table with JqGrid:

index.html template (this is haml, but I think it’s readable):

%table#users
  %tr
    %th= User.human_attribute_name(:username)
    %th= User.human_attribute_name(:email)
    %th= User.human_attribute_name(:tel)
  - @users.each do |user|
    %tr
      %td= h(user.username)
      %td= h(user.email)
      %td= h(user.tel)
      %td
        = link_to t(:show), user
        = link_to t(:edit), edit_user_path(user)
  %tr
    %td{:cols => "5"}= will_paginate
:ruby
  haml_concat jqgrid(t(".title"), "asdf", "/users",
    [
      { :field => "id", :label => "ID", :width => 35, :resizable => false },
      { :field => "username", :label => User.human_attribute_name(:username), :width => 100 },
      { :field => "email", :label => User.human_attribute_name(:email), :width => 140 },
      { :field => "company", :label => User.human_attribute_name(:company), :width => 140 },
      { :field => "tel", :label => User.human_attribute_name(:tel), :width => 80, :align => "center" } ],
    { :height => 220, :selection_handler => "users_select_row", :direct_selection => true })
%p#newuserlink= link_to t(".new"), new_user_path
#userform.popup

It seems height setting was needed because of some browsers. Selection_handler and direct_selection setting are for future extensions. <div id=”userform” class=”popup”>…</div> will be your popup edit box.

So far so good, let’s give JqGrid some data:

UsersController#index:

  def index
    @users = User
    @@index_columns ||= [:id, :username, :email, :company, :telephone, :mobile]

    if params[:_search].present?
      if params[:_search] == "true"
        filters = {}
        @@index_columns.each do |param|
          filters[("#{param}_like").to_sym] = "%#{params[param]}%" if params[param].present?
        end
        @users = User.search(filters)
      end
      if params[:sidx].present?
        @users = if params[:sord] == "desc"
          @users.public_send("descend_by_#{params[:sidx]}")
        else
          @users.public_send("ascend_by_#{params[:sidx]}")
        end
      end
    end
    @users = @users.paginate(:page => params[:page], :per_page => params[:rows] || 10)

    respond_to do |format|
      format.html
      format.json do
        render :json => @users.to_jqgrid_json(
          @@index_columns, params[:page], params[:rows], @users.total_entries
        )
      end
    end
  end

As you can see, we kept html output as is, but we added extra functionality jqgrid search and filter. It’s a bit more verbose than with Squirrel, but that’s alright. Now your page should turn the original table to a grid if you have javascript enabled. Let’s handle our first action: open an in-page popup for editing an entry:

index.html template, annotated:

For first, tell jQuery to ask for Javascript responses to ajax requests:

jQuery.ajaxSetup({
       'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript")}
})

Then, register events when the page is loaded. Start with New User link in the bottom. Note that inject_userform_submit is called after the response is arrived: we can’t register the edit form’s submit event until it’s loaded, we have to inject the code after we get it loaded.

    $(function() {
      $("#newuserlink").click(function() {
        $.get("/users/new", null, inject_userform_submit, "script");
        return false;
      })

This is the meat of our popup: I use jQuery Tools for that.

      $(".popup").overlay({
        expose: {
          color: '#fff',
          loadSpeed: 100,
          opacity: 0.5
        }
      })
    })

Let’s handle JqGrid’s row selection callback too, just like #new:

    users_select_row = function(id) {
      $.get("/users/"+id+"/edit", null, inject_userform_submit, "script")
    }

Last but not least replace the popup edit/new form’s submit to handle things in Javascript, and make this popup opened:

    inject_userform_submit = function() {
      $("#userform form").submit(function() {
        $.post($(this).attr("action"), $(this).serialize(), null, "script");
        return false;
      })
      $(".popup").overlay().load()
    }

Now we have requests, let’s create responses. I really hope everybody puts the form genrator in ‘form’ partial, and we can reuse it:

new.js.erb:

$("#userform").html("<%= escape_javascript(render 'form') %>")

edit.js.erb:

$("#userform").html("<%= escape_javascript(render 'form') %>")

create.js.erb:

<% if flash.has_key?(:notice) %>
  $("#flashes").html('<div class="flash notice"><%= escape_javascript(flash.delete(:notice)) %></div>')
  $("#users").trigger("reloadGrid")
  $(".popup").overlay().close()
<% else %>
  $("#flashes").html('<div class="flash error"><%= escape_javascript(flash.delete(:error)) %></div>')
  $("#userform").html("<%= escape_javascript(render 'form') %>")
<% end %>

You can make it more elegant if you have another flash div if you re-render the form.

Then, we have to get UsersController#create and UsersController#update behave differently:

UsersController#create:

    ...
    if @user.save
      flash[:notice] = t('users.create.success')
      respond_to do |format|
        format.html { redirect_to users_path }
        format.js
      end
    else
      flash.now[:error] = t('users.create.failure')
      respond_to do |format|
        format.html { render "new" }
        format.js
      end
    end
    ...

UsersController#update:

  def update
    @user = User.find(params[:id])
    if @user.update_attributes(params[:user])
      flash[:notice] = t('users.update.success')
      respond_to do |format|
        format.html { redirect_to users_path }
        format.js { render "create" }
      end
    else
      respond_to do |format|
        format.html { render "edit" }
        format.js { render "create" }
      end
    end
  end

Then, of course, you have to do some CSS magic to make it nice:

.popup {
  width: 408px;
  display: none;
  border: 2px solid #666;
  background-color: #324; }

Or, in SASS:

.popup
  :width 408px
  :display none
  :border 2px solid #666
  :background-color #324

We have achieved our goals:

  • the original code is still working (run the tests again, you can measure this)
  • the previous one vice-versa: our tests are still greens
  • we used our old gems (searchlogic, will_paginate)
  • the code is unobtrusive, everything works fine if JavaScript is not enabled

Enjoy!

  • http://openid-provider.appspot.com/register@phlegx.com Phlegx

    Hi!

    Nice work. Some suggestions:

    - What is your used Rails Version?
    I use 2.3.8 and i have to change the method public_send to send to get it to work.

    - If i search on a column (e.g. username) AND i order the the username column, i get an error. I cannot search AND order a column at the same time. Can you help me please? I'm very newbie in Rails.

    Cheers
    phlegx

  • julian7

    I already switched to Rails3, and it doesn't check params[:sidx] (it should be one of the columns). Anyways, in rails3, arel does a very nice job, and searchlogic is not needed anymore.

    Search and order by should work in the same time, however it could depend on your database backend. Which one you use?

  • http://openid-provider.appspot.com/register@phlegx.com Phlegx

    Hi!

    How looks the code for rails3? So, you have uninstalled searchlogic?

    bye
    phlegx

  • julian7

    Yep, searchlogic is not in my current stack. This is how a recent users#index looks like:
    https://gist.github.com/794700

  • http://openid-provider.appspot.com/register@phlegx.com Phlegx

    Ohh nice! I test it today. If it works then you are THE BEST!
    The code looks simple and clear but what is ActiveSupport::Memoizable?!
    How can i do to put the :o rder_by, :sort_index, :sort_order methods to the application controller? Because I have the jqgrid on all index views and so I have to add all this methods to every controller.

    Whit this script is it possible to search across model attributes in relations with e.g. the model user that shows the jqgrid on index:

    @@index_columns ||= [:act, :id, :firstname, :lastname, :'region.name', :'residence.name']

    So, :'region.name' and :'residence.name' are two models in relation with the model user.

    bye

  • julian7

    Memoizable just caches the result of your method, it's more like def method() @_method ||= … end. You can put these methods to another controller (may I suggest using another class which inherits ActiveController, just to be able to exclude these methods if they're not needed), however you won't be able to use @@index_columns, because it would be propagated to all other objects. You'll have to use class methods to do so:

    class SortableController < ApplicationController
    protected
    def order_by … end
    def sort_index
    self.class.index_columns.include?(params[:sidx]) ? params[:sidx] : nil
    end
    def sort_order
    params[:sord] == 'desc' ? 'desc' : 'asc'
    end
    end

    and in your controller:

    class WhateverController < SortableController
    def self.index_columns
    @@index_columns ||= [:act, :id, :firstname, :lastname, :"region.name", :"residence.name"]
    end

    end

    The catch is @@index_columns will be native only for WhateverController, and it won't be propagated to SortableController.

  • http://openid-provider.appspot.com/register@phlegx.com Phlegx

    Ok, nice!

    >> With this script is it possible to search across relations? Example user.residence.name

    Do I have to make joins?