Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TDD and the Big Ball of Mud

TDD and the Big Ball of Mud

Refactoring toward Ember presentation from Mountain West JS

tehviking

March 17, 2014
Tweet

More Decks by tehviking

Other Decks in Programming

Transcript

  1. A journey into the heart of
    architecture
    TDD vs. The Big
    Ball of Mud

    View full-size slide

  2. Architecture

    View full-size slide

  3. Craftsmanship

    View full-size slide

  4. Cloud
    gineering
    itecture
    smanship

    View full-size slide

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

    View full-size slide

  6. Brandon Hays
    Cloudgineer

    View full-size slide

  7. Credentials
    YMCA basketball
    participant
    Beat Mario 3 (almost)
    Donut master
    Author of 40 unreleased
    software projects
    Sandwich Artist
    Karate Champ Champ
    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 full-size slide

  8. We don’t live here

    View full-size slide

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

    View full-size slide

  10. Salt Lake City

    View full-size slide

  11. Salt Lake City
    Salt Lake City

    View full-size slide

  12. Salt Lake City
    We don’t live
    here.

    View full-size slide

  13. We don’t even
    live here.

    View full-size slide

  14. We live here.

    View full-size slide

  15. Charming,
    right?

    View full-size slide

  16. Temporary made
    permanent

    View full-size slide

  17. Unskilled labor,
    Found materials

    View full-size slide

  18. No fire codes

    View full-size slide

  19. TDD vs. The Big
    Ball of Mud
    A journey into the heart of
    architecture madness

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  24. Oh we are so not getting into pronunciation

    View full-size slide

  25. OK since you asked

    View full-size slide

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

    View full-size slide

  27. Feature request:
    Submit a gif from main
    page

    View full-size slide

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

    View full-size slide

  29. $(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 full-size slide

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

    View full-size slide

  31. $('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 full-size slide

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

    View full-size slide

  33. 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 + 'ShowEditdata-method="delete" href="/gif_posts/' + post.id + '"
    rel="nofollow">Destroy';
    $('table.gif-list tbody').append(newRow);
    });

    View full-size slide

  34. * 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 full-size slide

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

    View full-size slide

  36. $('#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 full-size slide

  37. That sure is swell,
    but...
    Users are submitting
    garbage, we should
    validate that client side.

    View full-size slide

  38. $('#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 full-size 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
    * 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 full-size slide

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

    View full-size slide

  41. 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('class="gif-preview">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 full-size slide

  42. $(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 full-size slide

  43. Code shape:
    Sack of hot
    garbage

    View full-size slide

  44. * 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 full-size slide

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

    View full-size slide

  46. That’s it, I’m
    outta here

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  50. Because you
    can’t sleep at
    night

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  56. 1. Rap it
    1. Wrap it

    View full-size slide

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

    View full-size slide

  58. Code this bad
    goes to code jail

    View full-size slide

  59. 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 full-size slide




  60. Recently Shared Gifs


    class="share-button">
    Submit a Gif







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

    View full-size slide

  61. 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 full-size slide

  62. 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

    View full-size slide

  63. 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 full-size slide

  64. Hand wave alert

    View full-size slide

  65. A note on JS &
    Ember testing

    View full-size slide

  66. It’s a “wild west”.

    View full-size slide

  67. Not this wild west

    View full-size slide

  68. This wild west

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  71. 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 full-size slide

  72. 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"

    View full-size slide

  73. 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 full-size slide

  74. 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 full-size slide

  75. Go ahead, take a
    victory lap

    View full-size slide

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

    View full-size slide

  77. Unit tests!
    jspec/models/gif-post_spec.coffee
    describe "GifPost", ->
    beforeEach ->
    @gifPost = App.GifPost.create()
    describe "with a valid url", ->
    beforeEach ->
    @gifPost.set("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 full-size slide

  78. $ 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 ->
    @gifPost.set("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 full-size slide

  79. 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"),
    });
    js/models/gif-post.js

    View full-size slide

  80. 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 full-size slide

  81. 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 full-size slide

  82. 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 full-size slide

  83. Let the framework
    carry the load

    View full-size slide

  84. 4. Identify States

    View full-size slide

  85. State 1: initial

    View full-size slide

  86. State 2: editing

    View full-size slide

  87. State 3: loading

    View full-size slide

  88. State 4: error

    View full-size slide

  89. State 5: success

    View full-size slide

  90. 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 full-size slide

  91. NO TOUCHING THE DOM!

    View full-size slide

  92. 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 full-size slide

  93. 4. Break it up

    View full-size slide

  94. 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 full-size slide

  95. What’s left?
    js/components/gf-new-post.js
    var newArticle = 'data-gif-post-id="' + gifPost.get("id") + '">' + gifPost.get("body") + 'post-id="' + gifPost.get("id") + '" href="/gif_posts/' +
    gifPost.get("id") + '" rel="nofollow">Deleteclass="gif-entry-user">Shared by ' + gifPost.get("username") +
    'Permalinkstyle="clear:both;">';
    $('section.gif-list').prepend(newArticle);

    View full-size slide

  96. The call is
    coming
    var newArticle = 'data-gif-post-id="' + gifPost.get("id") + '">' + gifPost.get("body") + 'post-id="' + gifPost.get("id") + '" href="/gif_posts/' +
    gifPost.get("id") + '" rel="nofollow">Deleteclass="gif-entry-user">Shared by ' + gifPost.get("username") +
    'Permalinkstyle="clear:both;">';
    $('section.gif-list').prepend(newArticle);
    From inside the
    codebase

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  99. 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 full-size slide

  100. 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 full-size slide

  101. 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 full-size slide

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




    {{body}}

    >Delete

    Shared by {{username}}

    {{/each}}

    View full-size slide

  103. 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 full-size slide

  104. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  108. 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 full-size slide

  109. No direct DOM
    manipulation

    View full-size slide

  110. }
    }
    }).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 + 'post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Deletes="gif-entry-user">Shared 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 yo
    Please wait and try again.");
    } else {
    $('#gif-post-
    og .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 full-size slide

  111. Introducing:
    512-bit jQCS
    encryption

    View full-size slide

  112. }
    }
    }).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 + 'post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Deletes="gif-entry-user">Shared 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 yo
    Please wait and try again.");
    } else {
    $('#gif-post-
    og .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 full-size slide

  113. 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() {
    after

    View full-size slide

  114. $(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 full-size slide

  115. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  120. Devs just wanna
    have fun

    View full-size slide

  121. “Fav” Feature
    js/handwave.js
    describe "favoriting a post", ->
    beforeEach ->
    // Fixture or stubbing here
    @component.send("favorite", @gifPost)
    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 full-size slide

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

    View full-size slide

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


    {{favorites.length}}





    View full-size slide

  124. “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 full-size slide

  125. Fun to extend
    More filters
    Popular posts
    Sharing

    View full-size slide

  126. My
    MountainWest
    story

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  129. 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 full-size slide