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).
Following "widgets":
- Remote dropdown.
- Hide the dropdown.
- Posting to server when link is clicked.
- Replace HTML on the page with a server response.
- Remove the post from the list with animation.
NOTE: This is the most complicated widget presented here. Skip over the CoffeeScript if you don't care.
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
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!
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.
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.
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.
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)!
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')
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"
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