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.

Revisions

  1. dnagir revised this gist Nov 27, 2012. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -479,4 +479,4 @@ A few points:

    - spec the views and data-atributes of your "widgets"
    - spec all the JS widgets
    - the JS specs for widgets doesn't have to be very comprehensive as long as your target of benefit/effort is matched
    - the JS specs for widgets don't have to be very comprehensive as long as your target of benefit/effort is matched
  2. dnagir revised this gist Nov 23, 2012. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -293,7 +293,7 @@ At this stage we will have the following:
    %i.icon-list.icon-white
    = t '.read'
    -# This anchor is what we want
    -# This anchor is what we want
    %a.btn{href: controls_post_path(post), 'data-shows-popover' => 'remote'}
    %i.icon-cog
    %span.caret
  3. dnagir revised this gist Nov 23, 2012. 1 changed file with 0 additions and 1 deletion.
    1 change: 0 additions & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -68,7 +68,6 @@ 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

  4. dnagir revised this gist Nov 23, 2012. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -52,7 +52,7 @@ Desired HTML:
    %i.icon-list.icon-white
    = t '.read'
    -# This anchor is what we want
    -# This anchor is what we want
    %a.btn{href: controls_post_path(post), 'data-shows-popover' => 'remote'}
    %i.icon-cog
    %span.caret
  5. dnagir revised this gist Nov 23, 2012. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -35,6 +35,8 @@ Following "widgets":
    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.
  6. dnagir revised this gist Nov 23, 2012. 1 changed file with 4 additions and 4 deletions.
    8 changes: 4 additions & 4 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -13,10 +13,10 @@ Now, the menu will content a few normal links, but will also have one interestin

    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/)).
    - 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](http://jsfiddle.net/3WEWr/)).


    What we need
  7. dnagir revised this gist Nov 23, 2012. 1 changed file with 100 additions and 1 deletion.
    101 changes: 100 additions & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -379,4 +379,103 @@ describe "replaceOnSuccess", ->
    expect(replace().text().trim()).toEqual 'new data'
    ```

    And now we will see the message too!
    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:


    ```haml
    - # 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:


    ```coffeescript
    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

    ```coffeescript
    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 doesn't have to be very comprehensive as long as your target of benefit/effort is matched
  8. dnagir revised this gist Nov 23, 2012. 1 changed file with 70 additions and 2 deletions.
    72 changes: 70 additions & 2 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -28,6 +28,7 @@ Following "widgets":
    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.



    @@ -303,12 +304,79 @@ At this stage we will have the following:
    %a{'href' => hidden_posts_path(post),
    'data-remote' => 'true',
    'data-method' => 'post',
    'data-shows-popover-action' => 'hide',
    'data-shows-popover-action' => 'hide'}
    %i.icon-eye-open
    = t('.hide_from_me')
    = 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:

    ```haml
    - # 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:

    ```coffeescript
    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:

    ```coffeescript
    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!
  9. dnagir revised this gist Nov 23, 2012. 1 changed file with 47 additions and 2 deletions.
    49 changes: 47 additions & 2 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -25,8 +25,8 @@ What we need
    Following "widgets":

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


    @@ -267,3 +267,48 @@ 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:

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

    At this stage we will have the following:

    ```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
    ```

    ```haml
    - # 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_from_me')
    ```

    So far so good.


  10. dnagir revised this gist Nov 23, 2012. 1 changed file with 15 additions and 3 deletions.
    18 changes: 15 additions & 3 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -19,10 +19,10 @@ The Hide functionality will have to do the following:
    4. The alert is is added with the link to the hidden items (like [so](http://jsfiddle.net/3WEWr/)).


    Start with the HTML
    What we need
    ==========

    So we need to have the following "widgets":
    Following "widgets":

    1. Remote dropdown.
    2. Posting to server when link is clicked.
    @@ -254,4 +254,16 @@ describe "showsPopover", ->
    expect(visiblePopovers().length).toEqual 1
    ```

    Now, provided there's appropriate action on the controller the job is done!
    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.

  11. dnagir revised this gist Nov 23, 2012. 1 changed file with 185 additions and 2 deletions.
    187 changes: 185 additions & 2 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -1,4 +1,3 @@

    The problem
    ==============

    @@ -71,4 +70,188 @@ describe "posts/_controls" do
    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: """
    <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:

    ```coffeescript
    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!
  12. dnagir created this gist Nov 23, 2012.
    74 changes: 74 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,74 @@

    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
    ```