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 (
POSTto hide). - The item is removed with an animation.
- The alert is is added with the link to the hidden items (like so).
So we need to have the following "widgets":
- Remote dropdown.
- Posting to server when link is clicked.
- Hide the dropdown.
- Replace HTML on the page with a server response.
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 assumedThe 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 1Now, provided there's appropriate action on the controller the job is done!