Skip to content

Instantly share code, notes, and snippets.

@dnagir
Created November 23, 2012 02:27
Show Gist options
  • Select an option

  • Save dnagir/4133761 to your computer and use it in GitHub Desktop.

Select an option

Save dnagir/4133761 to your computer and use it in GitHub Desktop.
Unobtrusive and testable AJAX in Rails 3

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:

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

Start with the HTML

So we need to have the following "widgets":

  1. Remote dropdown.
  2. Posting to server when link is clicked.
  3. Hide the dropdown.
  4. Replace HTML on the page with a server response.

Step 1. The remote dropdown

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)}']") }
  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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment