public
Last active

Unobtrusive and testable AJAX in Rails 3

  • Download Gist
readme.md
Markdown

The problem

Initially asked at RORO but I will elaborate with an example.

What we need to do is the following:

  • we list a set of items (let's say posts) and want to show a drop-down menu next to each one (quick example)
  • when the drop-down arrow is clicked the menu is loaded from the server.

Now, the menu will content a few normal links, but will also have one interesting: Hide (or Unhide depending on the status of the item).

The Hide functionality will have to do the following:

  • When clicked the menu is hidden.
  • The server is notified about the action (POST to hide).
  • The item is removed with an animation.
  • The alert is is added with the link to the hidden items (like so).

What we need

Following "widgets":

  1. Remote dropdown.
  2. Hide the dropdown.
  3. Posting to server when link is clicked.
  4. Replace HTML on the page with a server response.
  5. Remove the post from the list with animation.

Step 1. The remote dropdown

NOTE: This is the most complicated widget presented here. Skip over the CoffeeScript if you don't care.

HTML

We can use Twitter Bootsrtap's Popover for this to have the ability to render any content.

Desired HTML:

- # The buttons section:

.btn-toolbar
  .btn-group
    %a.btn.btn-info{href: post_path(post)}
      %i.icon-list.icon-white
      = t '.read'

    -# This anchor is what we want
    %a.btn{href: controls_post_path(post), 'data-shows-popover' => 'remote'}
      %i.icon-cog
      %span.caret

And the related view spec

require 'spec_helper'

describe "posts/_controls" do
  subject { render partial: "posts/controls",  locals: {post: post} }
  let(:post) { stub_model(Post) }

  it { should have_selector "a", href: controls_post_path(post), 'data-shows-popover' => 'remote' }
end

# routing assumed

JavaScript

The HTML above doesn't do much at all. This is the full implementation of the appropriate widget for it:

showPopover = (el) ->
  trigger = $(el)
  if not trigger.hasClass("popover-inited")
    hideAllPopovers() unless trigger.closest('.popover').length # only hide if trigger is not within another popover
    trigger.addClass("popover-inited")
    # Opening first time
    initialiserName = trigger.data("shows-popover")
    initialiser = popoverInitialisers[initialiserName]
    throw "Don't know how to open popover on element #{el.toString()}" unless initialiser
    options =
      placement: 'bottom'
      trigger: 'manual'
      template: """
        <div class='popover widget-show-popover wide-popover'>
          <div class='arrow'></div>
          <div class='popover-inner'>
            <h3 class='popover-title'></h3>
            <div class='popover-content'><div></div></div>
          </div>
        </div>
      """
    initialiser(options, trigger)
  else
    hideAllPopovers(trigger)
    trigger.popover('toggle')
    trigger.trigger 'shown'


popoverInitialisers =
  remote: (options, trigger) ->
    url = trigger.prop('href')
    trigger.animate(opacity: 0.3)
    $.get url, (response) ->
      trigger.animate(opacity: 1, 'fast')
      options.title = trigger.prop('title') or trigger.text()
      options.content = response
      trigger.popover(options)
      trigger.popover('show')
      trigger.trigger 'shown'


hideAllPopovers = (trigger) ->
  # Do not remove elements to let other events be handled and just hide it
  $popoverElement = trigger?.data('popover')?.$element
  $(".widget-show-popover").not($popoverElement).removeClass("in").hide() # `in` - so that boostrap can toggle it

App.Widgets.showsPopover =
  selector: "[data-shows-popover]"

  init: ->
    $('body').off '.shows-popover'
    $('body').on 'click.shows-popover.widgets-api', @selector, (e) ->
      e.preventDefault()
      e.stopPropagation() # So that it isn't hiddent straight away
      showPopover this

    $('body').on 'click.shows-popover.widgets-api', (e) ->
    # Only hide when clicking outside of a popover
      $target = $(e.target)
      withinPopover = $target.data('shows-popover-action') != 'hide' and $target.closest('.popover').length > 0
      isInDOM = $target.closest('html').length > 0
      isModal = $target.closest('.modal').length > 0
      isModal = $target.closest('.modal').length > 0
      hideAllPopovers() if isInDOM and not withinPopover and not isModal
      true


    $('body').on 'ajax:success.shows-popover.widgets-api', 'form', (e) ->
      # hide popovers if form has been successfully submitted
      hideAllPopovers() if $(this).data("dismiss-popover-on") == 'success'

and a spec:

describe "showsPopover", ->

  beforeEach ->
    setFixtures """
      <a id='remoteTrigger1' href='/remote/url' data-shows-popover='remote'>title 1</a>
      <a id='remoteTrigger2' href='/remote/second' data-shows-popover='remote'>title 2</a>
    """


  afterEach ->
    $(".popover").remove()

  popover = -> visiblePopovers().first()
  remoteTrigger = (number=1)-> $("#remoteTrigger#{number}")

  openAndRespond = (number, content) ->
    remoteTrigger(number).click()
    request = mostRecentAjaxRequest()
    request.response
      status: 200
      contentType: 'text/html'
      responseText: content

  visiblePopovers = -> $(".widget-show-popover.in:visible")

  it "should apply custom css class", ->
    openAndRespond(1, "Hi there")
    expect( popover() ).toHaveClass("wide-popover")

  it "should show the remote content from the popover", ->
    openAndRespond(1, "Hi there")

    expect(visiblePopovers().text()).toContain "Hi there"
    expect(visiblePopovers().text()).toContain "title 1"

  it "should hide the popover when clicked somewhere on a page", ->
    openAndRespond(1, "Hi there")
    $('body').click()
    expect(visiblePopovers().length).toEqual 0

  it "should not hide the popover when clicked within a popover", ->
    openAndRespond(1, "<strong>Shouldn't be hidden</strong>")
    popover().find("strong").click()
    expect(visiblePopovers().length).toEqual 1

  it "should hide the popover when clicked within a popover but trigger wants to hide it", ->
    openAndRespond(1, "<strong data-shows-popover-action='hide'>Shouldn't be hidden</strong>")
    popover().find("strong").click()
    expect(visiblePopovers().length).toEqual 0

  it "should not hide the popover when clicked element is removed from DOM (click, replace html)", ->
    openAndRespond(1, "<button><strong>Replacing</strong></button>")
    button = popover().find("button")
    replacing = popover().find("strong")
    button.click -> button.html("New")
    replacing.click()
    expect( visiblePopovers().length ).toEqual 1

  it "should hide the popover that is left without the related trigger", ->
    openAndRespond(1, "Orphan")
    remoteTrigger().remove()
    $("body").click()
    expect( visiblePopovers().length ).toEqual 0

  it "should hide existing popover when opening a second one", ->
    openAndRespond(1, "First")
    openAndRespond(2, "Second")
    expect(visiblePopovers().length).toEqual 1

  it "should not hide existing popover when opening a second one which is within opened popover", ->
    openAndRespond(1, "First")
    remoteTrigger(2).appendTo '.popover-content'
    openAndRespond(2, "Second within first")
    expect(visiblePopovers().length).toEqual 2

  it "should hide existing popover when reopening a second one", ->
    openAndRespond(1, "First")
    openAndRespond(2, "Second")
    remoteTrigger(1).click() # Open 1st, Close 2nd
    expect(visiblePopovers().text()).toContain "First"
    expect(visiblePopovers().text()).not.toContain "Second"

  it "should trigger 'shown' event when opening", ->
    spyOnEvent '#remoteTrigger1', 'shown'
    openAndRespond(1, "Whatever")
    expect('shown').toHaveBeenTriggeredOn '#remoteTrigger1'

  describe "dismissing popover on ajax", ->
    it "should hide popover on ajax:success", ->
      openAndRespond(1, "<form data-dismiss-popover-on='success'> </form>")
      popover().find("form").trigger("ajax:success")
      expect(visiblePopovers().length).toEqual 0

    it "should not hide popover when expecting another event", ->
      openAndRespond(1, "<form data-dismiss-popover-on='other'> </form>")
      popover().find("form").trigger("ajax:success")
      expect(visiblePopovers().length).toEqual 1

Now, provided there's appropriate action on the controller the job is done!

Step 2. Hide the dropdow

Is already part of the Step 1 above. But originally was added at a much later stage. And that was easy since there's a test suite for the widget.

To close the popover from inside the popover we just need to add data-shows-popover-action='hidden' (see the code of the widget above). So we're done here.

Step 3. Posting to server when link is clicked

Don't invent the wheel here. We already have the data-remote=true from Rails. So we're done with it, except I want to add a spec for that drop down:

# something like
it { should have_selector "a", "data-remote" => "true", "data-method" => "post" }

At this stage we will have the following:

- # The buttons section:

.btn-toolbar
  .btn-group
    %a.btn.btn-info{href: post_path(post)}
      %i.icon-list.icon-white
      = t '.read'

    -# This anchor is what we want
    %a.btn{href: controls_post_path(post), 'data-shows-popover' => 'remote'}
      %i.icon-cog
      %span.caret
- # The content of the remote
    %a{'href'                    => hidden_posts_path(post),
       'data-remote'                => 'true',
       'data-method'                => 'post',
       'data-shows-popover-action'  => 'hide'}
      %i.icon-eye-open
      = t('.hide')

So far so good.

Step 4. Replace HTML on the page with a server response

HTML

How I want to do it is by adding marking what to update when the AJAX request was received. Something like 'data-replace-on-success' => '.messages-area'.

Thus we update the snippet above with:

- # The content of the remote
    %a{'href'                    => hidden_posts_path(post),
       'data-remote'                => 'true',
       'data-method'                => 'post',
       'data-shows-popover-action'  => 'hide',
       'data-replace-on-success'  => '.messages-area'}       
      %i.icon-eye-open
      = t('.hide')

I won't provide the spec since it is pretty obvious. NOTE: It is important for me to write the specs against all those data attributes. It will alert me if something will go wrong. Also it's too easy not to do that.

JavaScript

And we also add appropriate widgets with a few tests:

App.Widgets.replaceOnSuccess =
  selector: "[data-replace-on-success]"
  init: ->
    $("body").off ".replace-on-success"
    $("body").on "ajax:success.replace-on-success", this.selector, (e, data) ->
      replaceTarget  = $(this).data("replace-on-success")
      $replaceTarget = $(replaceTarget)
      $replaceTarget.filter(":not(:first)").remove()
      $replaceTarget.first().replaceWith data

with a basic spec:

describe "replaceOnSuccess", ->
  beforeEach ->
    setFixtures """
      <div id='replace'>
        <div class='replace-inner'>1st</div>
        <div class='replace-inner'>2nd</div>
      </div>
      <form data-replace-on-success='.replace-inner'></form>
    """

  replace      = -> $("#replace")
  form         = -> $("form")

  it "should replace target element with new data on ajax:success", ->
    form().trigger('ajax:success', ['new data'])
    expect(replace().text().trim()).toEqual 'new data'

And now we will see the message too (provided the controller renders appropriate content of course)!

Step 5. Remove the post from the list with animation

HTML

Also when the link is clicked, we want to have a nice "disappear" animation. Definitely want to do it ASAP so user doesn't wait for the response and only then the animation kicks in.

So we'll cheat here a bit and will start the animation as soon as any AJAX request is sent from the element. I want to do something like 'data-remove-on-ajax' => 'how_to_find: what_to_find.

Thus our "Hide" link will look like:

- # The content of the remote
    %a{'href'                    => hidden_posts_path(post),
       'data-remote'                => 'true',
       'data-method'                => 'post',
       'data-shows-popover-action'  => 'hide',
       'data-replace-on-success'  => '.messages-area',
       'data-remove-on-ajax'  => "closest: .post"}
      %i.icon-eye-open
      = t('.hide')

JavaScript

And the related widget is pretty simple:

App.Widgets.removeOnAjax =
  selector: "[data-remove-on-ajax]"
  init: ->
    $("body").off ".remove-on-ajax"
    $("body").on "ajax:beforeSend.remove-on-ajax", this.selector, (e) ->
      attr  = $(this).data "remove-on-ajax"
      [selectorFn, selectorVal] = attr.split(":")
      if not selectorVal
        # There's no colon -assuming global selector
        $elements = $(selectorFn.trim())
      else
        # "closest: selector"
        # TODO: This potentialy may be confused with "selector:first"
        $elements = $(this)[selectorFn.trim()](selectorVal.trim())
      $elements.slideUp -> $elements.remove()

with the spec

describe "removeOnAjax", ->
  beforeEach ->
    setFixtures """
      <div id='root'>
        <button id="btn1" data-remove-on-ajax="next: .me" />


        <div class='me'>Me</div>
        <div>Him</div>
      </div>

      <button id="btn2" data-remove-on-ajax=".me" />
    """

  it "should remove element on ajax with custom finder method", ->
    $("#btn1").trigger('ajax:beforeSend')
    text = $("#root").text()
    expect(text).toContain "Him"
    expect(text).not.toContain "Me"

  it "should remove element on ajax with global selector", ->
    $("#btn2").trigger('ajax:beforeSend')
    text = $("#root").text()
    expect(text).toContain "Him"
    expect(text).not.toContain "Me"

Summary

At this stage we are done plus we have a few reusable widgets that we can apply consistently everywhere.

Any frameworks will complicate it too much. I personally have only 1 fully-blown client-side page where I use AngularJS. But that's an exception and that page has as separate set JS file loaded.

A few points:

  • spec the views and data-atributes of your "widgets"
  • spec all the JS widgets
  • the JS specs for widgets don't have to be very comprehensive as long as your target of benefit/effort is matched

Please sign in to comment on this gist.

Something went wrong with that request. Please try again.