The problem ============== Initially asked at [RORO](https://groups.google.com/forum/?hl=en&fromgroups=#!topic/rails-oceania/-SCmy1_bFsw) 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](http://jsfiddle.net/8ysR8/)) - 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](http://jsfiddle.net/3WEWr/)). 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: ```haml - # 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 ```ruby 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: ```coffeescript 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: """
""" 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: ```coffeescript describe "showsPopover", -> beforeEach -> setFixtures """ title 1 title 2 """ 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, "Shouldn't be hidden") 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, "Shouldn't be hidden") 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 = 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, "") popover().find("form").trigger("ajax:success") expect(visiblePopovers().length).toEqual 0 it "should not hide popover when expecting another event", -> openAndRespond(1, "") popover().find("form").trigger("ajax:success") expect(visiblePopovers().length).toEqual 1 ``` Now, provided there's appropriate action on the controller the job is done!