$30 off During Our Annual Pro Sale. View Details »

Bring Fun Back to JS: Refactoring Toward Ember

Bring Fun Back to JS: Refactoring Toward Ember

Presented at RailsConf 2014

You just wanted to add some nice interactive functionality to your Rails app. But then one jQuery plugin turns to three, add a dash of statefulness, some error handling, and suddenly you can't sleep at night.

We'll walk through using Ember Components to test-drive a refactor until your front-end code is understandable, usable, and extensible. Armed with TDD and components, you can start to get excited, not exasperated, when asked to add advanced client-side interactions to your website.

tehviking

April 25, 2014
Tweet

More Decks by tehviking

Other Decks in Programming

Transcript

  1. A journey into the heart of
    architecture
    Refactoring Toward
    Ember

    View Slide

  2. Engineering

    View Slide

  3. Architecture

    View Slide

  4. Craftsmanship

    View Slide

  5. Cloud
    gineering
    itecture
    smanship

    View Slide

  6. These are all things
    More specifically, they are all
    things this talk is not about

    View Slide

  7. Brandon Hays
    Cloudgineer

    View Slide

  8. Credentials
    YMCA basketball participant
    Beat Mario 3 (almost)
    Donut master
    Marathoner (Breaking Bad
    seasons 1-5)
    Karate Champ Champ
    Hacked the Gibson
    Can recite Back to the Future from
    memory
    3 years jazz, 2 years tap
    Creator of 0 frameworks
    My mom says I am cool
    Friends with @fivetanley
    Very nearly graduated high
    school

    View Slide

  9. We don’t live here

    View Slide

  10. Where we live
    A tale of two cities.
    Three cities, actually.
    And not really a tale.

    View Slide

  11. Salt Lake City

    View Slide

  12. Salt Lake City
    Salt Lake City

    View Slide

  13. We don’t live here.

    View Slide

  14. Austin

    View Slide

  15. We don’t even live
    here.

    View Slide

  16. We live here.

    View Slide

  17. Charming, right?

    View Slide

  18. Temporary made
    permanent

    View Slide

  19. Unskilled labor,
    Found materials

    View Slide

  20. No fire codes

    View Slide

  21. Refactoring Toward
    Ember
    A journey into the heart of
    architecture madness

    View Slide

  22. The Ball of Mud
    Pattern
    Throwaway Code
    Piecemeal Growth
    Result: Shantytown
    http://laputan.org/mud/

    View Slide

  23. How does this
    happen?
    “This should be easy”
    “Has to happen this week”
    “Proof of concept”

    View Slide

  24. How does this
    happen?
    Myth of the 2-week feature
    Under-experienced labor
    Prototypes in production

    View Slide

  25. A shantytown story
    How the feature that starts too
    small to design
    becomes too big to maintain

    View Slide

  26. View Slide

  27. View Slide

  28. I’m taking a stand.
    “Gif” is pronounced
    like “Github Gist”.

    View Slide

  29. * ce5486d - Initial commit
    * 96db2d4 - Basic app with auth
    * f97c86b - AJAX posting of body without URL
    The Sprinkling of JS

    View Slide

  30. The Sprinkling of JS

    View Slide

  31. Feature request:
    Submit a gif from main page

    View Slide

  32. // A single tumbleweed passes by
    js/main.js

    View Slide

  33. $(document).ready(function(){
    $('#toggle-post-dialog').on("click", function(e) {
    e.preventDefault();
    $('#share-section .button-area').hide('fade');
    $('#gif-post-dialog').show('blind');
    });
    });
    js/main.js

    View Slide

  34. Can it submit via AJAX?
    Oh! Thanks,
    but...

    View Slide

  35. $('button.gif-submit').on("click", function(e) {
    e.preventDefault();
    $(this).hide("fade");
    var url = $(this).attr('href');
    var body = $("#new-gif-body").val();
    $.ajax({
    type: "POST",
    dataType: "json",
    url: url,
    data: {
    gif_post: {
    body: body,
    user_id: currentUserId
    }
    }
    });
    });
    js/main.js

    View Slide

  36. Why does the new post not show
    up on the page?
    Terrific!
    Except...

    View Slide

  37. Oh goody, string concat into the DOM
    .done(function(data) {
    $('.button.gif-submit').show('fade');
    $('.share-gif-form').hide('fade');
    $("#new-gif-body").val("");
    $('#gif-post-dialog').append("New gif posted: " + post.url
    + "");
    var newRow = '' + username + '' + post.url + '' + body + '
    td>ShowEditmethod="delete" href="/gif_posts/' + post.id + '" rel="nofollow">Destroy';
    $('table.gif-list tbody').append(newRow);
    });

    View Slide

  38. Agile Your Code™
    That’s
    Cloudgineering™

    View Slide

  39. * ce5486d - Initial commit
    * 96db2d4 - Basic app with auth
    * f97c86b - AJAX posting of body without URL
    * a38f598 - Extract URL and display gif
    * 602fabb - add cancel button
    * 81e0f72 - auto-disable input with message without valid gif
    The Steady Rain of JS

    View Slide

  40. The Steady Rain of JS

    View Slide

  41. Hiya, sport! Got a minute?
    What if users want to cancel?

    View Slide

  42. $('#gif-post-dialog button.cancel-post').on("click", function(e) {
    e.preventDefault();
    $('button.gif-submit').show('fade');
    $('#share-section .button-area').show('fade');
    $("#new-gif-body").val("");
    $('#gif-post-dialog').hide('blind');
    });
    js/main.js

    View Slide

  43. Great job slugger, but...
    Users are submitting garbage, we
    should validate that client side.

    View Slide

  44. $('#new-gif-body').bind("input propertychange", function(e) {
    var parsedUrl = $(this).val().match(/(((ftp|https?):\/\/)(www\.)?|www\.)([\da-z-_
    \.]+)([a-z\.]{2,7})([\/\w\.-_\?\&]*)*\/?/);
    var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl[0]) : null;
    if (!!isGif) {
    $("#gif-post-dialog button.gif-submit").removeAttr("disabled");
    $('#gif-post-dialog .message').text("")
    $('#gif-post-dialog .message').hide()
    } else {
    $('#gif-post-dialog .message').show()
    $('#gif-post-dialog .message').text("There is no valid gif link in this post.")
    $("#gif-post-dialog button.gif-submit").attr("disabled", "disabled");
    }
    });
    js/main.js

    View Slide

  45. * ce5486d - Initial commit
    * 96db2d4 - Basic app with auth
    * f97c86b - AJAX posting of body without URL
    * a38f598 - Extract URL and display gif
    * 602fabb - add cancel button
    * 81e0f72 - auto-disable input with message without valid gif
    * 3389c4f - add character count and validation
    * 3eb1560 - restrict edit/delete privileges to current user's data
    * 997ed02 - handle network and validation errors
    * a3896fd - async delete post
    The Torrential
    Downpour of JS

    View Slide

  46. The Torrential
    Downpour of JS

    View Slide

  47. Fabulous! Just a couple
    more things.
    Gif preview
    Smart character count

    View Slide

  48. js/main.js
    if (!!isGif) {
    charCount = ($(this).val().length - parsedUrl[0].length)
    if (charCount <= 140) {
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    $('#gif-post-dialog .message').text("").removeClass("error").hide();
    $('#gif-post-dialog .gif-preview-container').html('src="' + parsedUrl[0] + '" width="90">')
    } else {
    $('#gif-post-dialog .message').show().addClass("validation-error").text("Your
    message is too long.");
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    }
    }
    $("#gif-post-dialog .character-count-number").text(charCount);

    View Slide

  49. Craftsmanship.

    View Slide

  50. Ship it.

    View Slide

  51. That’s
    Cloudgineering™
    Craftsmanship It.

    View Slide

  52. $(document).ready(function(){
    $('#toggle-post-dialog').on("click", function(e) {
    e.preventDefault();
    $("#new-gif-body").val("");
    $('#share-section .button-area').hide('fade');
    $('#gif-post-dialog').show('blind');
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    });
    $('#gif-post-dialog').on("click", 'a.cancel-post', function(e) {
    e.preventDefault();
    $('#share-section .button-area').show('fade');
    $("#new-gif-body").val("");
    $('#gif-post-dialog').hide('blind');
    });
    $('#new-gif-body').on("input propertychange", function(e) {
    var parsedUrl = $(this).val().match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/);
    var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null;
    var charCount
    if (!!isGif) {
    charCount = ($(this).val().length - parsedUrl[0].length)
    if (charCount <= 140) {
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    $('#gif-post-dialog .message').text("").removeClass("error").hide();
    $('#gif-post-dialog .gif-preview-container').html('')
    } else {
    $('#gif-post-dialog .message').show().addClass("validation-error").text("Your message is too long.");
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    }
    } else {
    charCount = $(this).val().length
    $('#gif-post-dialog .message').show().addClass("validation-error").text("There is no valid gif link in this post.")
    $('#gif-post-dialog .gif-preview').remove()
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    }
    $("#gif-post-dialog .character-count-number").text(charCount);
    });
    $('section.gif-list').on("click", "article a[data-gif-delete]", function(e) {
    e.preventDefault();
    console.log(e);
    var url = $(this).attr('href');
    var id = $(this).data('gifPostId');
    if(confirm('Are you sure you want to delete this post?')){
    $.ajax({
    type: "DELETE",
    dataType: "json",
    url: url
    }).done(function(data) {
    $('section.gif-list article[data-gif-post-id=' + id.toString() + ']').remove();
    });
    }
    });
    $('#gif-post-dialog').on("click", 'a.gif-submit', function(e) {
    e.preventDefault();
    var currentUserId = $('meta[name="current-user-id"]').attr("content");
    $(this).attr("disabled", "disabled");
    var url = $(this).attr('href');
    var body = $("#new-gif-body").val();
    var parsedUrl = body.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/);
    $.ajax({
    type: "POST",
    dataType: "json",
    url: url,
    data: {
    gif_post: {
    body: body,
    url: parsedUrl[0],
    }
    }
    }).done(function(data) {
    var post = data.gif_post;
    var url = post.url || "";
    var username = (!!post.user && !!post.user.username) ? post.user.username : "";
    var body = post.body || null;
    $('#gif-post-dialog .message').removeClass("error").text("");
    $('.share-gif-form').hide('fade');
    $("#gif-post-dialog .character-count-number").text("0");
    $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url);
    var newArticle = '' + body + 'DeleteShared by ' + username + 'Permalink';
    $('section.gif-list').prepend(newArticle);
    setTimeout(function() {
    $('#share-section .button-area').show('fade');
    $('#gif-post-dialog .message').hide().text("");
    $('#gif-post-dialog .gif-preview').remove()
    $('#gif-post-dialog').hide('blind');
    $('.share-gif-form').show();
    }, 5000);
    }).fail(function(data) {
    if (!data.responseText) {
    $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting your gif. Please wait and try again.");
    } else {
    $('#gif-post-dialog .message').show().addClass("error").text(data.responseJSON.errors.url[0]);
    }
    setTimeout(function() {
    $('#gif-post-dialog .message').removeClass("error").hide();
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    }, 5000);
    });
    });
    });
    js/main.js

    View Slide

  53. Code shape:
    Sack of hot garbage

    View Slide

  54. * ce5486d - Initial commit
    * 96db2d4 - Basic app with auth
    * f97c86b - AJAX posting of body without URL
    * a38f598 - Extract URL and display gif
    * 602fabb - add cancel button
    * 81e0f72 - auto-disable input with message without valid gif
    * 3389c4f - add character count and validation
    * 3eb1560 - restrict edit/delete privileges to current user's data
    * 997ed02 - handle network and validation errors
    * a3896fd - async delete post
    * lolnope - that’s it, I quit
    The Tsunami of JS

    View Slide

  55. The Tsunami of JS

    View Slide

  56. Excellent work!
    Add a
    “Like”
    button with a count,
    and we’
    re all set!

    View Slide

  57. View Slide

  58. Sweeping under the rug
    Reconstruction
    Block-by-block renovation (keeping it
    working)
    Quit your job
    Now what?
    http://laputan.org/mud/

    View Slide

  59. Black box: Sometimes OK
    Rewrite: Painful lessons about hidden
    functionality
    Refactor: Haaard
    Quit: Time to stop running from your
    problems, pal
    Now what?

    View Slide

  60. Exponential change cost
    Inconsistent behavior
    Long use == more errors
    Why refactor?

    View Slide

  61. Because you can’t
    sleep at night

    View Slide

  62. Make code more idiomatic
    Refactor to JS objects
    Path 1: DIY from
    scratch

    View Slide

  63. Use models to manage data &
    state
    Get the heck out of the DOM
    Path 2: Lean on a
    Framework

    View Slide

  64. Our goal:
    Get out of managing
    the DOM as fast as
    humanly possible

    View Slide

  65. 1. Wrap it
    2. Test it
    3. Identify Models
    4. Identify States
    5. Break it up
    5 steps

    View Slide

  66. Model layer
    Bindings to manage state
    Components
    Why Ember?

    View Slide

  67. 1. Rap it

    View Slide

  68. 1. Rap it
    1. Wrap it

    View Slide

  69. 1. Move code into Ember Component
    2. Move markup into handlebars
    template
    3. Insert into original DOM
    A sprinkling of Ember

    View Slide

  70. Code this bad goes to
    code jail

    View Slide

  71. App.GfNewPostComponent = Ember.Component.extend({
    layoutName: "components/gf-new-post",
    initLegacyCode: function() {
    // All old code goes in here, no changes to see
    $('#toggle-post-dialog').on("click", function(e) {
    ...
    });
    }.on("didInsertElement")
    });
    The Component
    js/components/gf-new-post.js

    View Slide




  72. Recently Shared Gifs



    Submit a Gif







    The Template
    js/templates/components/gf-new-post.hbs

    View Slide

  73. The Sprinkling
    js/components/gf-new-post.js
    $(document).ready(function() {
    $("#new-post-container").each(function(){
    var component = App.GfNewPostComponent.create();
    component.replaceIn(this);
    });
    });

    View Slide

  74. 2. Test it

    View Slide

  75. First test
    jspec/components/gf-new-post_spec.coffee
    describe 'new post component', ->
    beforeEach ->
    @component = App.GfNewPostComponent.create().append()
    @component.container = App.__container__
    afterEach ->
    @component.destroy()
    it "exists", ->
    expect(@component.get('element')).to.exist
    expect(@component.$('#gif-post-dialog')).to.exist
    $ karma run
    .
    Chrome 35.0.1887 (Mac OS X 10.8.5): Executed 1 of 1 SUCCESS (1.811 secs / 0.001 secs)

    View Slide

  76. Hand wave alert

    View Slide

  77. A note on JS & Ember
    testing

    View Slide

  78. It’s a “wild west”.

    View Slide

  79. Not this wild west

    View Slide

  80. This wild west

    View Slide

  81. If the wild west was so
    great, we’d still live
    there.

    View Slide

  82. The testing story is starting to
    mature (blogs, libraries)
    Opportunity to make a
    contribution
    The good news

    View Slide

  83. AJAX tests
    jspec/components/gf-new-post_spec.coffee
    beforeEach ->
    ic.ajax.defineFixture '/gif_posts',
    response:
    gif_post:
    id: "1"
    url: "http://blah.com/cool-gif.gif"
    user:
    username: "fakeuser"
    body: "thing: http://blah.com/cool-gif.gif"
    jqXHR: {}
    textStatus: 'success'

    View Slide

  84. More tests
    jspec/components/gf-new-post_spec.coffee
    describe "clicking submit with good response", ->
    beforeEach ->
    @component.$("a.gif-submit").click()
    it "shows success message and adds gif", ->
    expect(@component.$("#gif-dialog .message")).to.be.visible
    expect(@component.$("#gif-dialog .message")).to.have.class "success"
    expect(@component.$("#gif-dialog .message").text()).to.equal "New gif posted:
    http://blah.com/cool-gif.gif"
    $ karma run
    .......................
    Chrome 35.0.1887 (Mac OS X 10.8.5): Executed 23 of 23 SUCCESS (2.181 secs / 0.017
    secs)

    View Slide

  85. describe 'new post component', ->
    beforeEach ->
    @gifList = $('').appendTo("body");
    @component = App.GfNewPostComponent.create().append()
    @component.container = App.__container__
    afterEach ->
    @gifList.remove()
    @component.destroy()
    it "exists", ->
    expect(@component.get('element')).to.exist
    expect(@component.$('#gif-post-dialog')).to.exist
    it "doesn't show the dialog", ->
    expect(@component.$('#gif-post-dialog')).not.to.be.visible
    describe "clicking the show dialog button", ->
    beforeEach ->
    @component.$("#toggle-post-dialog").click()
    it "shows the dialog", ->
    expect(@component.$('#gif-post-dialog')).to.be.visible
    describe 'entering bad text into the gif box', ->
    beforeEach ->
    @component.$("textarea").val("thing: notagif.jpg")
    @component.$("textarea").trigger "input"
    it "leaves the save button disabled", ->
    expect(@component.$("a.gif-submit")).to.have.attr("disabled")
    it "counts the characters", ->
    expect(@component.$(".character-count-number").text()).to.equal "18"
    it "displays a client-side validation error", ->
    expect(@component.$(".message")).to.be.visible
    expect(@component.$(".message")).to.have.class("validation-error")
    expect(@component.$(".message").text()).to.match /no valid gif/
    it "does not display a preview section", ->
    expect(@component.$(".gif-preview")).not.to.exist
    describe 'putting too much text in the box', ->
    beforeEach ->
    @component.$("textarea").val("thing: http://blah.com/cool-gif.gif")
    @component.$("textarea").trigger "input"
    @component.$("textarea").val("thing: http://blah.com/cool-gif.gif This is a thing which is pretty cool except the text is too long.
    Maybe this is going to display a nice error message for people to enjoy")
    @component.$("textarea").trigger "input"
    it "disables the save button", ->
    expect(@component.$("a.gif-submit")).to.have.attr("disabled")
    it "counts the characters", ->
    expect(@component.$(".character-count-number").text()).to.equal "145"
    it "displays a client-side validation error", ->
    expect(@component.$(".message")).to.be.visible
    expect(@component.$(".message")).to.have.class("validation-error")
    expect(@component.$(".message").text()).to.match /too long/
    it "continues to display a preview section", ->
    expect(@component.$(".gif-preview")).to.exist
    describe 'entering good text into the gif box', ->
    beforeEach ->
    @component.$("textarea").val("thing: http://blah.com/cool-gif.gif")
    @component.$("textarea").trigger "input"
    it "enables the save button", ->
    expect(@component.$("a.gif-submit")).not.to.have.attr("disabled")
    it "counts characters, minus the gif input", ->
    expect(@component.$(".character-count-number").text()).to.equal "7"
    it "does not display an error message", ->
    expect(@component.$(".message")).not.to.be.visible
    expect(@component.$(".message")).not.to.have.class("validation-error")
    expect(@component.$(".message").text()).to.be.empty
    it "displays a preview image", ->
    expect(@component.$(".gif-preview")).to.exist
    expect(@component.$(".gif-preview img").attr("src")).to.equal "http://blah.com/cool-gif.gif"
    describe "clicking submit with good response", ->
    beforeEach ->
    @clock = sinon.useFakeTimers()
    @component.$("a.gif-submit").attr("href", "/gif_posts").click();
    ic.ajax.defineFixture '/gif_posts',
    response:
    gif_post:
    id: "1"
    url: "http://blah.com/cool-gif.gif"
    user:
    username: "fakeuser"
    body: "thing: http://blah.com/cool-gif.gif"
    jqXHR: {}
    textStatus: 'success'
    @component.$("a.gif-submit").click();
    afterEach ->
    @clock.restore()
    it "shows success message and adds gif", ->
    expect(@component.$("#gif-post-dialog .message")).to.be.visible
    expect(@component.$("#gif-post-dialog .message")).to.have.class "success"
    expect(@component.$("#gif-post-dialog .message").text()).to.equal "New gif posted: http://blah.com/cool-gif.gif"
    newGif = $("section.gif-list article.gif-entry")
    expect($(newGif[0]).find(".gif-entry-user").text()).to.equal "Shared by fakeuser"
    describe "after 5 seconds", ->
    beforeEach ->
    @clock.tick(5010)
    it "resets to initial state", ->
    expect(@component.$("#gif-post-dialog .message")).not.to.be.visible
    expect(@component.$("#gif-post-dialog .message")).not.to.have.class "success"
    expect(@component.$("#gif-post-dialog .message").text()).to.equal ""
    describe "deleting the gif with success response", ->
    beforeEach ->
    ic.ajax.defineFixture '/gif_posts/1',
    response: "yay"
    jqXHR: {}
    textStatus: 'success'
    sinon.stub(window, "confirm").returns(true)
    @newGif = $("section.gif-list article.gif-entry")
    @deleteButton = $(@newGif).find(".gif-entry-delete [data-gif-delete]")
    $(@deleteButton).trigger("click")
    afterEach ->
    window.confirm.restore()
    it "removes the gif", ->
    expect($("section.gif-list article.gif-entry")).not.to.exist
    describe "clicking submit with validation error", ->
    beforeEach ->
    @clock = sinon.useFakeTimers()
    @component.$("a.gif-submit").attr("href", "/gif_posts").click();
    ic.ajax.defineFixture '/gif_posts',
    responseText: "Validation fail"
    responseJSON:
    errors:
    url: ["LOL NOPE VALIDATION FAILZ"]
    jqXHR: {}
    textStatus: 'unprocessable entity'
    @component.$("a.gif-submit").click();
    it "shows an error message", ->
    expect(@component.$("#gif-post-dialog .message")).not.to.have.class "success"
    expect(@component.$("#gif-post-dialog .message")).to.have.class "error"
    expect(@component.$("#gif-post-dialog .message").text()).to.equal "LOL NOPE VALIDATION FAILZ"
    describe "after 5 seconds", ->
    beforeEach ->
    @clock.tick(5010)
    afterEach ->
    @clock.restore()
    it "resets to initial state", ->
    expect(@component.$("#gif-post-dialog .message")).not.to.be.visible
    expect(@component.$("#gif-post-dialog .message")).not.to.have.class "error"
    expect(@component.$("#gif-post-dialog .message").text()).to.equal ""
    describe "clicking submit with bad response", ->
    beforeEach ->
    @component.$("a.gif-submit").attr("href", "/gif_posts").click();
    ic.ajax.defineFixture '/gif_posts',
    response: "BARF"
    @component.$("a.gif-submit").click();
    it "shows an error message", ->
    expect(@component.$("#gif-post-dialog .message")).not.to.have.class "success"
    expect(@component.$("#gif-post-dialog .message")).to.have.class "error"
    expect(@component.$("#gif-post-dialog .message").text()).to.match /error posting/
    describe "clicking cancel", ->
    beforeEach ->
    @component.$("#gif-post-dialog a.cancel-post").click()
    it "closes the dialog", ->
    expect(@component.$('#gif-post-dialog')).not.to.be.visible
    it "clears the text input", ->
    expect(@component.$('textarea').val()).to.equal ""
    Fully tested
    jspec/components/gf-new-post_spec.coffee

    View Slide

  86. Go ahead, take a
    victory lap

    View Slide

  87. 3. Identify Models
    *May or may not match server MVC models

    View Slide

  88. $ karma run
    Chrome 35.0.1887 (Mac OS X 10.8.5) GifPost with a valid url parses the url from the
    body FAILED
    AssertionError: expected undefined to equal 'http://blah.com/cool-gif.gif'
    Unit tests!
    jspec/models/gif-post_spec.coffee
    describe "GifPost", ->
    beforeEach ->
    @gifPost = App.GifPost.create()
    describe "with a valid url", ->
    beforeEach ->
    fillIn "textarea#new-gif-body", "thing: http://blah.com/cool-gif.gif"
    it "parses the url from the body", ->
    expect(@gifPost.get("parsedUrl")).to.equal "http://blah.com/cool-gif.gif"

    View Slide

  89. Extract to model
    App.GifPost = Ember.Object.extend({
    regex: gifRegex(),
    body: "",
    parsedUrl: function() {
    var matches = this.get("body").match(this.get("regex"))
    return (matches && matches.length > 0) ? matches[0] : "";
    }.property("body"),
    });
    $ karma run
    ..............................
    Chrome 35.0.1887 (Mac OS X 10.8.5): Executed 30 of 29 SUCCESS (2.181 secs / 0.017
    secs)
    js/models/gif-post.js

    View Slide

  90. Add Handlebars


    {{textarea name="new-gif-post" class= "new-gif-input" value=gifPost.body}}


    Post

    {{gifPost.charCount}}

    js/templates/components/gf-new-post.hbs

    View Slide

  91. Adiós garbage!
    $('#new-gif-body').on("input propertychange", function(e) {
    var regex = gifRegex();
    var parsedUrl = $(this).val().match(regex);
    var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null;
    var charCount
    if (!!isGif) {
    charCount = ($(this).val().length - parsedUrl[0].length)
    if (charCount <= 140) {
    ...
    } else {
    charCount = $(this).val().length
    ...
    }
    $(".character-count-number").text(charCount);
    });
    js/components/gf-new-post.js

    View Slide

  92. Let the framework carry
    the load

    View Slide

  93. 4. Identify States

    View Slide

  94. State 1: initial

    View Slide

  95. State 2: editing

    View Slide

  96. State 3: loading

    View Slide

  97. State 4: error

    View Slide

  98. State 5: success

    View Slide

  99. Identify states
    js/components/gf-new-post.js
    App.GfNewPostComponent = Ember.Component.extend({
    ...
    classNameBindings: ["formState"],
    formState: "initial",
    // FORM-STATES: "initial", "editing", "loading", "failure", "success"
    ...

    View Slide

  100. - NO TOUCHING THE DOM!

    View Slide

  101. Change states
    js/components/gf-new-post.js
    showDialog: function() {
    this.set("formState", "editing");
    },
    cancel: function() {
    this.set("formState", "initial");
    this.set("gifPost", App.GifPost.create());
    },
    submit: function() {
    this.set("formState", "loading");
    this.get("gifPost").save();
    }

    View Slide

  102. 4. Break it up

    View Slide

  103. What’s left?
    js/components/gf-new-post.js
    initLegacyCode: function() {
    $('section.gif-list').on("click", "article a[data-gif-delete]", function(e) {
    e.preventDefault();
    var url = $(this).attr('href');
    var id = $(this).data('gifPostId');
    if(confirm('Are you sure you want to delete this post?')){
    ic.ajax({type: "DELETE", dataType: "json", url: url
    }).then(function(data) {
    $('section.gif-list article[data-gif-post-id=' + id.toString() +
    ']').remove();
    });
    }
    });
    }.on("didInsertElement"),

    View Slide

  104. What’s left?
    js/components/gf-new-post.js
    var newArticle = '' +
    gifPost.get("body") + 'DeleteShared by ' + gifPost.get("username") + 'Permalinkstyle="clear:both;">';
    $('section.gif-list').prepend(newArticle);

    View Slide

  105. The call() is coming
    var newArticle = '' +
    gifPost.get("body") + 'DeleteShared by ' + gifPost.get("username") + 'Permalinkstyle="clear:both;">';
    $('section.gif-list').prepend(newArticle);
    From inside the
    codebase

    View Slide

  106. Time for a 2nd
    component
    Manage post list
    Handle delete
    Final nail in coffin for direct DOM
    manipulation

    View Slide

  107. Makes AJAX requests & maps to
    model for you
    This actually buys us time
    Detour: Ember Data

    View Slide

  108. Ember Data
    js/models/gif-post.js
    App.GifPost = DS.Model.extend({
    /* PROPERTIES */
    regex: gifRegex(),
    body: DS.attr("string"),
    url: DS.attr("string"),
    username: DS.attr("string"),
    ...

    View Slide

  109. js/store.js
    // Overwrite ajax to be able to use nice ic-ajax fixtures
    App.ApplicationAdapter = DS.ActiveModelAdapter.extend({
    ajax: function(url, type, options) {
    options = this.ajaxOptions(url, type, options);
    return ic.ajax(options);
    }
    });
    Ember Data

    View Slide

  110. 2nd Component
    js/components/gf-list-posts.js
    App.GfListPostsComponent = Ember.Component.extend({
    layoutName: "components/gf-list-posts",
    gifPosts: null,
    actions: {
    delete: function(gifPost) {
    if (confirm("Really delete this lovely gif?")) {
    gifPost.destroyRecord().then(function(result){
    //message
    }, function(){
    gifPost.rollback();
    });
    }
    }
    }
    });

    View Slide

  111. 2nd Test Set
    jspec/components/gf-list-posts_spec.js
    describe "list posts component", ->
    beforeEach ->
    // fixture goes here
    @component = App.GfListPostsComponent.create
    container: App.__container__
    @component.appendTo("body")
    App.store.find("gifPost").then (result) =>
    @component.set "gifPosts", result
    afterEach ->
    @component.destroy()
    it "exists", ->
    expect(@component.get('element')).to.exist
    it "lists the gifs", ->
    expect(@component.$("article.gif-entry:first .gif-entry-
    user").text().trim()).to.equal "Shared by tehviking"

    View Slide

  112. 2nd Template
    js/templates/components/gf-list-posts.js
    {{#each gifPosts}}




    {{body}}

    Delete

    Shared by {{username}}

    {{/each}}

    View Slide

  113. 2nd Sprinkle
    js/templates/components/gf-list-posts.js
    $(document).ready(function() {
    if ($("#gif-posts-container").length) {
    App.store.find("gifPost").then(function(result) {
    $("#gif-posts-container").each(function(){
    var component = App.GfListPostsComponent.create({
    gifPosts: result
    });
    component.replaceIn(this);
    });
    });
    }
    });

    View Slide

  114. 2 components,
    shared data
    Both react to data changes
    DOM auto-updates in both

    View Slide

  115. View Slide

  116. We already bound state to a CSS
    class
    Let CSS transitions do the work
    CSS Animation

    View Slide

  117. CSS Animation
    js/templates/components/gf-list-posts.js
    .new-post-component {
    &.initial {
    }
    &.editing {
    }
    &.loading {
    }
    &.failure {
    }
    &.success {
    }
    &.is-invalid {
    }
    }

    View Slide

  118. CSS Animation
    stylesheets/gf-list-posts.scss
    .new-post-component {
    &.initial {
    .share-dialog-container {
    height: 0px;
    opacity: 0;
    }
    }
    .share-dialog-container {
    opacity: 1;
    height: 160px;
    -webkit-transition-property: height, opacity;
    transition-property: height, opacity;
    -webkit-transition-duration: 300ms, 200ms;
    transition-duration: 300ms, 200ms;
    }
    }

    View Slide

  119. DEMO

    View Slide

  120. No direct DOM
    manipulation

    View Slide

  121. data: {
    gif_post: {
    body: body,
    url: parsedUrl[0],
    }
    }
    }).done(function(data) {
    var post = data.gif_post;
    var url = post.url || "";
    var username = (!!post.user && !!post.user.username) ? post.user.username : "";
    var body = post.body || null;
    $('#gif-post-dialog .message').removeClass("error").text("");
    $('.share-gif-form').hide('fade');
    $("#gif-post-dialog .character-count-number").text("0");
    $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url);
    var newArticle = 'lass="framed" src="' + url + '">' + body + 'DeleteShared by ' + username + 'Permalinktyle="clear:both;">';
    $('section.gif-list').prepend(newArticle);
    setTimeout(function() {
    $('#share-section .button-area').show('fade');
    $('#gif-post-dialog .message').hide().text("");
    $('#gif-post-dialog .gif-preview').remove()
    $('#gif-post-dialog').hide('blind');
    $('.share-gif-form').show();
    }, 5000);
    }).fail(function(data) {
    if (!data.responseText) {
    $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting your gif. Please wait and try again.")
    } else {
    $('#gif-post-dialog .message').show().addClass("error").text(data.responseJSON.errors.url[0]);
    }
    setTimeout(function() {
    $('#gif-post-dialog .message').removeClass("error").hide();
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    }, 5000);

    View Slide

  122. Introducing: 512-bit
    jQCS encryption

    View Slide

  123. data: {
    gif_post: {
    body: body,
    url: parsedUrl[0],
    }
    }
    }).done(function(data) {
    var post = data.gif_post;
    var url = post.url || "";
    var username = (!!post.user && !!post.user.username) ? post.user.username : "";
    var body = post.body || null;
    $('#gif-post-dialog .message').removeClass("error").text("");
    $('.share-gif-form').hide('fade');
    $("#gif-post-dialog .character-count-number").text("0");
    $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url);
    var newArticle = 'lass="framed" src="' + url + '">' + body + 'DeleteShared by ' + username + 'Permalinktyle="clear:both;">';
    $('section.gif-list').prepend(newArticle);
    setTimeout(function() {
    $('#share-section .button-area').show('fade');
    $('#gif-post-dialog .message').hide().text("");
    $('#gif-post-dialog .gif-preview').remove()
    $('#gif-post-dialog').hide('blind');
    $('.share-gif-form').show();
    }, 5000);
    }).fail(function(data) {
    if (!data.responseText) {
    $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting your gif. Please wait and try again.")
    } else {
    $('#gif-post-dialog .message').show().addClass("error").text(data.responseJSON.errors.url[0]);
    }
    setTimeout(function() {
    $('#gif-post-dialog .message').removeClass("error").hide();
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    }, 5000);
    Now that’s
    Cloudgineering™

    View Slide

  124. // This computes the displayed message for all success/failure
    message: function(){
    var formState = this.get("formState")
    // On failure, delegate message to the object
    if (formState === "failure") {
    return this.get("gifPost.message");
    // On success, just scream real loud!
    } else if (formState === "success") {
    return "New gif posted: " + this.get("gifPost.parsedUrl")
    // If it's invalid but has a gif, it's too long:
    } else if (this.get("isInvalid") && !!this.get("gifPost.isGif")) {
    return "Your message is too long.";
    // Otherwise if it's not valid it's not a gif
    } else if (this.get("isInvalid")) {
    return "Please add a valid gif link to this post.";
    } else {
    return null;
    }
    }.property("formState", "isInvalid", "gifPost.isGif", "gifPost.message"),
    /* ACTIONS */
    // These actions are primarily about pushing state around.
    actions: {
    showDialog: function() {
    this.set("formState", "editing");
    },
    cancel: function() {
    this.set("formState", "initial");
    this.set("gifPost", App.store.createRecord("gifPost"));
    },
    submit: function() {
    controller = this;
    controller.set("formState", "loading");
    var gifPost = this.get("gifPost");
    gifPost.save().then(function(data) {
    // Success
    controller.set("formState", "success");
    controller.defer(function() {
    after

    View Slide

  125. $(document).ready(function(){
    $('#toggle-post-dialog').on("click", function(e) {
    e.preventDefault();
    $("#new-gif-body").val("");
    $('#share-section .button-area').hide('fade');
    $('#gif-post-dialog').show('blind');
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    });
    $('#gif-post-dialog').on("click", 'a.cancel-post', function(e) {
    e.preventDefault();
    $('#share-section .button-area').show('fade');
    $("#new-gif-body").val("");
    $('#gif-post-dialog').hide('blind');
    });
    $('#new-gif-body').on("input propertychange", function(e) {
    var parsedUrl = $(this).val().match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/);
    var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null;
    var charCount
    if (!!isGif) {
    charCount = ($(this).val().length - parsedUrl[0].length)
    if (charCount <= 140) {
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    $('#gif-post-dialog .message').text("").removeClass("error").hide();
    $('#gif-post-dialog .gif-preview-container').html('')
    } else {
    $('#gif-post-dialog .message').show().addClass("validation-error").text("Your message is too long.");
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    }
    } else {
    charCount = $(this).val().length
    $('#gif-post-dialog .message').show().addClass("validation-error").text("There is no valid gif link in this post.")
    $('#gif-post-dialog .gif-preview').remove()
    $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled");
    }
    $("#gif-post-dialog .character-count-number").text(charCount);
    });
    $('section.gif-list').on("click", "article a[data-gif-delete]", function(e) {
    e.preventDefault();
    console.log(e);
    var url = $(this).attr('href');
    var id = $(this).data('gifPostId');
    if(confirm('Are you sure you want to delete this post?')){
    $.ajax({
    type: "DELETE",
    dataType: "json",
    url: url
    }).done(function(data) {
    $('section.gif-list article[data-gif-post-id=' + id.toString() + ']').remove();
    });
    }
    });
    $('#gif-post-dialog').on("click", 'a.gif-submit', function(e) {
    e.preventDefault();
    var currentUserId = $('meta[name="current-user-id"]').attr("content");
    $(this).attr("disabled", "disabled");
    var url = $(this).attr('href');
    var body = $("#new-gif-body").val();
    var parsedUrl = body.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/);
    $.ajax({
    type: "POST",
    dataType: "json",
    url: url,
    data: {
    gif_post: {
    body: body,
    url: parsedUrl[0],
    }
    }
    }).done(function(data) {
    var post = data.gif_post;
    var url = post.url || "";
    var username = (!!post.user && !!post.user.username) ? post.user.username : "";
    var body = post.body || null;
    $('#gif-post-dialog .message').removeClass("error").text("");
    $('.share-gif-form').hide('fade');
    $("#gif-post-dialog .character-count-number").text("0");
    $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url);
    var newArticle = '' + body + 'DeleteShared by ' + username + 'Permalink';
    $('section.gif-list').prepend(newArticle);
    setTimeout(function() {
    $('#share-section .button-area').show('fade');
    $('#gif-post-dialog .message').hide().text("");
    $('#gif-post-dialog .gif-preview').remove()
    $('#gif-post-dialog').hide('blind');
    $('.share-gif-form').show();
    }, 5000);
    }).fail(function(data) {
    if (!data.responseText) {
    $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting your gif. Please wait and try again.");
    } else {
    $('#gif-post-dialog .message').show().addClass("error").text(data.responseJSON.errors.url[0]);
    }
    setTimeout(function() {
    $('#gif-post-dialog .message').removeClass("error").hide();
    $("#gif-post-dialog a.gif-submit").removeAttr("disabled");
    }, 5000);
    });
    });
    });
    before

    View Slide

  126. App.GfNewPostComponent = Ember.Component.extend({
    /* PROPERTIES */
    layoutName: "components/gf-new-post",
    gifPost: null,
    classNames: ["new-post-component"],
    classNameBindings: ["formState", "isInvalid"],
    formState: "initial",
    // FORM-STATES: "initial", "editing", "loading", "failure", "success"
    isValid: Ember.computed.alias("gifPost.isValid"),
    isInvalid: Ember.computed.not("isValid"),
    /* OBSERVERS */
    // This computes the displayed message for all success/failure
    message: function(){
    var formState = this.get("formState")
    // On failure, delegate message to the object
    if (formState === "failure") {
    return this.get("gifPost.message");
    // On success, just scream real loud!
    } else if (formState === "success") {
    return "New gif posted: " + this.get("gifPost.parsedUrl")
    // If it's invalid but has a gif, it's too long:
    } else if (this.get("isInvalid") && !!this.get("gifPost.isGif")) {
    return "Your message is too long.";
    // Otherwise if it's not valid it's not a gif
    } else if (this.get("isInvalid")) {
    return "Please add a valid gif link to this post.";
    } else {
    return null;
    }
    }.property("formState", "isInvalid", "gifPost.isGif", "gifPost.message"),
    /* ACTIONS */
    // These actions are primarily about pushing state around.
    actions: {
    showDialog: function() {
    this.set("formState", "editing");
    },
    cancel: function() {
    this.set("formState", "initial");
    this.set("gifPost", App.store.createRecord("gifPost"));
    },
    submit: function() {
    controller = this;
    controller.set("formState", "loading");
    var gifPost = this.get("gifPost");
    gifPost.save().then(function(data) {
    // Success
    controller.set("formState", "success");
    controller.defer(function() {
    controller.set("formState", "initial")
    }, 5000);
    // Failure
    }, function(data) {
    controller.set("formState", "failure");
    if (!!data.jqXHR && data.jqXHR.status == 422) {
    // 422: validation error
    controller.get("gifPost").set("message", data.jqXHR.responseJSON.errors.url[0]);
    } else {
    controller.get("gifPost").set("message", "There was an error posting your gif. Please wait and try again.")
    }
    controller.defer(function() {
    controller.set("formState", "editing")
    }, 5000);
    });
    }
    },
    /* FUNCTIONS */
    // Allow injectable setTimeout override for testing
    defer: function(callback, delay) {
    setTimeout(callback, delay);
    }
    });
    /* INITIALIZATION */
    $(document).ready(function() {
    $("#new-post-container").each(function(){
    var component = App.GfNewPostComponent.create({
    gifPost: App.store.createRecord("gifPost")
    });
    component.replaceIn(this);
    });
    });
    App.GfListPostsComponent = Ember.Component.extend({
    /* PROPERTIES */
    layoutName: "components/gf-list-posts",
    gifPosts: null,
    classNames: ["list-posts-component"],
    // We only want to show posts that are saved to the server
    persistedGifPosts: Ember.computed.filterBy("gifPosts", "isNew", false),
    sortedPosts: function() {
    // well this is a neat trick, sort in reverse ID order
    return this.get("persistedGifPosts").sortBy("id:desc");
    }.property("persistedGifPosts.@each"),
    /* ACTIONS */
    actions: {
    delete: function(gifPost) {
    if (confirm("Really delete this lovely gif?")) {
    gifPost.destroyRecord().then(function(result){
    //message?
    }, function(){
    gifPost.rollback();
    });
    }
    }
    }
    });
    /* INITIALIZATION */
    $(document).ready(function() {
    if ($("#gif-posts-container").length) {
    App.store.find("gifPost").then(function(result) {
    $("#gif-posts-container").each(function(){
    var component = App.GfListPostsComponent.create({
    gifPosts: result
    });
    component.replaceIn(this);
    });
    });
    }
    });
    App.GifPost = DS.Model.extend({
    /* PROPERTIES */
    body: DS.attr("string"),
    url: DS.attr("string"),
    username: DS.attr("string"),
    message: null,
    parsedUrl: function() {
    if (!!this.get("body")) {
    var matches = this.get("body").match(this.get("regex"))
    return (matches && matches.length > 0) ? matches[0] : "";
    }
    }.property("body"),
    isGif: function() {
    return /.gif$/.test(this.get("parsedUrl"))
    }.property("parsedUrl"),
    charCount: function() {
    if (this.get("isGif")) {
    return this.get("body.length") - this.get("parsedUrl.length")
    } else {
    return this.get("body.length")
    }
    }.property("body", "parsedUrl", "isGif"),
    isValid: function() {
    return !!(this.get("charCount") <= 140 && this.get("isGif"))
    }.property("charCount", "isGif"),
    permalink: function() {
    return "/gif_posts/" + this.get("id");
    }.property("id"),
    /* OBSERVERS */
    // Copy the computed parsedUrl into the canonical url to send to the server
    setUrl: function() {
    this.set("url", this.get("parsedUrl"));
    }.observes("parsedUrl"),
    /* MISC */
    // This property is at the end basically because it breaks Emacs auto-indent :/
    regex: function() {
    return /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/
    }.property()
    });
    after

    View Slide

  127. Where can you go
    from here?
    Just a couple steps from going full
    SPA
    Or keep series of components that
    share model data

    View Slide

  128. Why did we do all
    this?
    It was a sizable amount of work
    What even is my life

    View Slide

  129. How’s it coming?
    Once you finish that Like button,
    we can IPO and be millionaires!

    View Slide

  130. Oh yeah
    Want users to have great stuff
    Want to add value
    Want to enjoy working in our code

    View Slide

  131. Devs just wanna have
    fun

    View Slide

  132. “Fav” Feature
    jspec/components/gf-list-posts_spec.coffee
    describe "favoriting a post", ->
    beforeEach ->
    // Stub fixture data here
    @entry = @component.$("article.gif-entry:first")
    click $(@entry).find(".gif-entry-fav-link")
    it "favorites the post", ->
    expect($("article.gif-entry i.fav-star")).to.have.class "is-favorited"
    it "lists the favorite count", ->
    expect($("article.gif-entry .gif-entry-fav-count").text().trim()).to.equal "1"

    View Slide

  133. “Fav” Feature
    js/models/favorite.js
    App.Favorite = DS.Model.extend({
    userId: DS.attr("number"),
    gifPost: DS.belongsTo("gifPost")
    });

    View Slide

  134. “Fav” Feature
    js/components/gf-list-posts.js


    {{favorites.length}}





    View Slide

  135. “Fav” Feature
    js/components/gf-list-posts.js
    toggleFav: function(gifPost) {
    if (gifPost.get("isFavorited")) {
    var favId = gifPost.get("currentUserFavoriteId")
    App.store.find("favorite", favId).then(function(result){
    result.destroyRecord();
    });
    } else {
    fav = App.store.createRecord("favorite", {
    gifPost: gifPost
    });
    fav.save().then(function(result){
    // handle success
    }, function(){
    //handle failure
    fav.rollback();
    });
    }

    View Slide

  136. DEMO

    View Slide

  137. Fun to extend
    More filters
    Popular posts
    Sharing

    View Slide

  138. My
    conference
    story

    View Slide

  139. I want to hug every dev.
    Come say hi

    View Slide

  140. Thank you so much.
    Brandon Hays
    @tehviking
    http://frontside.io

    View Slide

  141. Image credits
    http://www.flickr.com/photos/davidyuweb/9369008677/
    http://www.flickr.com/photos/elescir/9833284434/
    http://www.flickr.com/photos/derekskey/5249580870/
    http://www.flickr.com/photos/111692634@N04/11407095913/
    http://www.flickr.com/photos/armandolobos/9360963587/
    http://upload.wikimedia.org/wikipedia/commons/9/9e/
    Rainstorm_over_Salt_Lake_City.jpg
    http://en.wikipedia.org/wiki/File:Sketch_of_Salt_Lake_1860.jpg
    http://www.flickr.com/photos/mommypants/3738076382
    http://www.flickr.com/photos/moontorch/9909941394/
    http://www.flickr.com/photos/cbnsp/6605969807/
    http://www.flickr.com/photos/33216291@N08/4152110541
    http://www.flickr.com/photos/schnappischnap/8969004201
    http://www.flickr.com/photos/intelfreepress/8294794328/
    http://www.flickr.com/photos/gsfc/6385412459/
    http://www.flickr.com/photos/uhdigital/8135187152/
    http://www.flickr.com/photos/perfectance/6965568618/
    http://www.flickr.com/photos/111692634@N04/11406975556/

    View Slide