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

6fd16b1b6a307ca583526e2ec4dab52d?s=128

tehviking

March 17, 2014
Tweet

Transcript

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

    Big Ball of Mud
  2. Engineering

  3. Architecture

  4. Craftsmanship

  5. Cloud

  6. Cloud gineering itecture smanship

  7. These are all things More specifically, they are all things

    this talk is not about
  8. Brandon Hays Cloudgineer

  9. 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
  10. We don’t live here

  11. Where we live A tale of two cities. Three cities,

    actually. And not really a tale.
  12. Salt Lake City

  13. Salt Lake City Salt Lake City

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

  15. Austin

  16. We don’t even live here.

  17. We live here.

  18. Charming, right?

  19. Temporary made permanent

  20. Unskilled labor, Found materials

  21. No fire codes

  22. TDD vs. The Big Ball of Mud A journey into

    the heart of architecture madness
  23. The Ball of Mud Pattern Throwaway Code Piecemeal Growth Result:

    Shantytown http://laputan.org/mud/
  24. How does this happen? “This should be easy” “Has to

    happen this week” “Proof of concept”
  25. How does this happen? Myth of the 2-week feature Under-experienced

    labor Prototypes in production
  26. A shantytown story How the feature that starts too small

    to design becomes too big to maintain
  27. Oh we are so not getting into pronunciation

  28. OK since you asked

  29. * ce5486d - Initial commit * 96db2d4 - Basic app

    with auth * f97c86b - AJAX posting of body without URL The Sprinkling of JS
  30. Feature request: Submit a gif from main page

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

  32. $(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
  33. Oh! Thanks, but... Can it submit via AJAX?

  34. $('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
  35. Terrific! Except... Why does the new post not show up

    on the page?
  36. 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("<p class='success-text'>New gif posted: " + post.url + "</p>"); var newRow = '<td>' + username + '</td><td>' + post.url + '</td><td>' + body + '</td><td><a href="/gif_posts/' + post.id + '">Show</a></td><td><a href="/gif_posts/' + post.id + '/edit">Edit</a></td><td><a data-confirm="Are you sure?" data-method="delete" href="/gif_posts/' + post.id + '" rel="nofollow">Destroy</a></td>'; $('table.gif-list tbody').append(newRow); });
  37. * 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
  38. Hiya, sport! Got a minute? What if users want to

    cancel?
  39. $('#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
  40. That sure is swell, but... Users are submitting garbage, we

    should validate that client side.
  41. $('#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
  42. * 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
  43. Fabulous! Just a couple more things. Gif preview Smart character

    count
  44. 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('<div class="gif-preview"><img src="' + parsedUrl[0] + '" width="90"></div>') } 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);
  45. $(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('<div class="gif-preview"><img src="' + parsedUrl[0] + '" width="90"></div>') } 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 = '<article class="gif-entry" data-gif-entry data-gif-post-id="' + post.id + '"><div class="gif-entry-image"><img class="framed" src="' + url + '"></div><div class="gif-entry-body">' + body + '</div><div class="gif-entry-delete"><a class="btn btn-danger"data-gif-delete data-gif-post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Delete</a></div><div class="gif-entry-user">Shared by ' + username + '</div><div class="gif-entry-permalink"><a href="/gif_posts/' + post.id + '">Permalink</a></div><div style="clear:both;"></div></article>'; $('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
  46. Code shape: Sack of hot garbage

  47. * 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
  48. Excellent work! Add a “Like” button with a count, and

    we’ re all set!
  49. That’s it, I’m outta here

  50. Sweeping under the rug Reconstruction Block-by-block renovation (keeping it working)

    Quit your job Now what? http://laputan.org/mud/
  51. Black box: Sometimes OK Rewrite: Painful lessons about hidden functionality

    Refactor: Haaard Quit: Time to stop running from your problems, pal Now what?
  52. Exponential change cost Inconsistent behavior Long use == more errors

    Why refactor?
  53. Because you can’t sleep at night

  54. Make code more idiomatic Refactor to JS objects Path 1:

    DIY from scratch
  55. Use models to manage data & state Get the heck

    out of the DOM Path 2: Lean on a Framework
  56. Our goal: Get out of managing the DOM as fast

    as humanly possible
  57. 1.Wrap it 2.Test it 3.Identify Models 4.Identify States 5.Break it

    up 5 steps
  58. Model layer Bindings to manage state Components Why Ember?

  59. 1. Rap it

  60. 1. Rap it 1. Wrap it

  61. 1.Move code into Ember Component 2.Move markup into handlebars template

    3.Insert into original DOM A sprinkling of Ember
  62. Code this bad goes to code jail

  63. 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
  64. <div class="row headline"> <div class="col-md-8 col-sm-8 col-md-offset-2"> <h1> <span>Recently Shared

    Gifs</span> <span id="share-section"> <div class="button-area"> <a class="btn btn-success" id="toggle-post-dialog" class="share-button"> Submit a Gif </a> </div> </span> </h1> </div> </div> <!-- Also no changes here --> The Template js/templates/components/gf-new-post.hbs
  65. The Sprinkling js/components/gf-new-post.js $(document).ready(function() { $("#new-post-container").each(function(){ var component = App.GfNewPostComponent.create();

    component.replaceIn(this); }); });
  66. 2. Test it

  67. 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
  68. 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)
  69. Hand wave alert

  70. A note on JS & Ember testing

  71. It’s a “wild west”.

  72. Not this wild west

  73. This wild west

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

    there.
  75. The testing story is starting to mature (blogs, libraries) Opportunity

    to make a contribution The good news
  76. 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'
  77. 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"
  78. 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)
  79. describe 'new post component', -> beforeEach -> @gifList = $('<section

    class="gif-list"></section>').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
  80. Go ahead, take a victory lap

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

    models
  82. 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"
  83. $ 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"
  84. 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
  85. 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
  86. Add Handlebars <div id="gif-post-dialog"> <div class="gif-input-container"> {{textarea name="new-gif-post" class= "new-gif-input"

    value=gifPost.body}} </div> <a {{action "submit"}} class="btn btn-primary gif-submit"> Post </a> <p class="character-count">{{gifPost.charCount}}</p> </div> js/templates/components/gf-new-post.hbs
  87. 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
  88. Let the framework carry the load

  89. 4. Identify States

  90. State 1: initial

  91. State 2: editing

  92. State 3: loading

  93. State 4: error

  94. State 5: success

  95. Identify states js/components/gf-new-post.js App.GfNewPostComponent = Ember.Component.extend({ ... classNameBindings: ["formState"], formState:

    "initial", // FORM-STATES: "initial", "editing", "loading", "failure", "success" ...
  96. NO TOUCHING THE DOM!

  97. 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(); }
  98. 4. Break it up

  99. 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"),
  100. What’s left? js/components/gf-new-post.js var newArticle = '<article class="gif-entry" data-gif-entry data-gif-post-id="'

    + gifPost.get("id") + '"><div class="gif- entry-image"><img class="framed" src="' + gifPost.get("parsedUrl") + '"></div><div class="gif-entry- body">' + gifPost.get("body") + '</div><div class="gif-entry- delete"><a class="btn btn-danger"data-gif-delete data-gif- post-id="' + gifPost.get("id") + '" href="/gif_posts/' + gifPost.get("id") + '" rel="nofollow">Delete</a></div><div class="gif-entry-user">Shared by ' + gifPost.get("username") + '</div><div class="gif-entry-permalink"><a href="/gif_posts/' + gifPost.get("id") + '">Permalink</a></div><div style="clear:both;"></div></article>'; $('section.gif-list').prepend(newArticle);
  101. The call is coming var newArticle = '<article class="gif-entry" data-gif-entry

    data-gif-post-id="' + gifPost.get("id") + '"><div class="gif- entry-image"><img class="framed" src="' + gifPost.get("parsedUrl") + '"></div><div class="gif-entry- body">' + gifPost.get("body") + '</div><div class="gif-entry- delete"><a class="btn btn-danger"data-gif-delete data-gif- post-id="' + gifPost.get("id") + '" href="/gif_posts/' + gifPost.get("id") + '" rel="nofollow">Delete</a></div><div class="gif-entry-user">Shared by ' + gifPost.get("username") + '</div><div class="gif-entry-permalink"><a href="/gif_posts/' + gifPost.get("id") + '">Permalink</a></div><div style="clear:both;"></div></article>'; $('section.gif-list').prepend(newArticle); From inside the codebase
  102. Time for a 2nd component Manage post list Handle delete

    Final nail in coffin for direct DOM manipulation
  103. Makes AJAX requests & maps to model for you This

    actually buys us time Detour: Ember Data
  104. 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"), ...
  105. 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
  106. 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(); }); } } } });
  107. 2nd Template js/templates/components/gf-list-posts.js {{#each gifPosts}} <article class="gif-entry"> <div class="gif-entry-image"> <img

    class="framed" {{bind-attr src=.parsedUrl}}> </div> <div class="gif-entry-body">{{body}}</div> <div class="gif-entry-delete"> <a class="btn btn-danger" {{action "delete" gifPost}} >Delete</a> </div> <div class="gif-entry-user">Shared by {{username}}</div> </article> {{/each}}
  108. 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); }); }); } });
  109. 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"
  110. 2 components, shared data Both react to data changes DOM

    auto-updates in both
  111. None
  112. We already bound state to a CSS class Let CSS

    transitions do the work CSS Animation
  113. CSS Animation js/templates/components/gf-list-posts.js .new-post-component { &.initial { } &.editing {

    } &.loading { } &.failure { } &.success { } &.is-invalid { } }
  114. 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; } }
  115. DEMO

  116. No direct DOM manipulation

  117. } } }).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 = '<article class="gif-entry" data-gif-entry data-gif-post-id="' + post.id + div class="gif-entry-image"><img class="framed" src="' + url + '"></div><div class="gif-entry- ">' + body + '</div><div class="gif-entry-delete"><a class="btn btn-danger"data-gif-delete data post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Delete</a></div><div s="gif-entry-user">Shared by ' + username + '</div><div class="gif-entry-permalink"><a href="/ posts/' + post.id + '">Permalink</a></div><div style="clear:both;"></div></article>'; $('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);
  118. Introducing: 512-bit jQCS encryption

  119. } } }).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 = '<article class="gif-entry" data-gif-entry data-gif-post-id="' + post.id + div class="gif-entry-image"><img class="framed" src="' + url + '"></div><div class="gif-entry- ">' + body + '</div><div class="gif-entry-delete"><a class="btn btn-danger"data-gif-delete data post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Delete</a></div><div s="gif-entry-user">Shared by ' + username + '</div><div class="gif-entry-permalink"><a href="/ posts/' + post.id + '">Permalink</a></div><div style="clear:both;"></div></article>'; $('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™
  120. 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
  121. $(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('<div class="gif-preview"><img src="' + parsedUrl[0] + '" width="90"></div>') } 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 = '<article class="gif-entry" data-gif-entry data-gif-post-id="' + post.id + '"><div class="gif-entry-image"><img class="framed" src="' + url + '"></div><div class="gif-entry-body">' + body + '</div><div class="gif-entry-delete"><a class="btn btn-danger"data-gif-delete data-gif-post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Delete</a></div><div class="gif-entry-user">Shared by ' + username + '</div><div class="gif-entry-permalink"><a href="/gif_posts/' + post.id + '">Permalink</a></div><div style="clear:both;"></div></article>'; $('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
  122. 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
  123. Where can you go from here? Just a couple steps

    from going full SPA Or keep series of components that share model data
  124. Why did we do all this? It was a sizable

    amount of work What even is my life
  125. How’s it coming? Once you finish that Like button, we

    can IPO and be millionaires!
  126. Oh yeah Want users to have great stuff Want to

    add value Want to enjoy working in our code
  127. Devs just wanna have fun

  128. “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"
  129. “Fav” Feature js/models/favorite.js App.Favorite = DS.Model.extend({ userId: DS.attr("number"), gifPost: DS.belongsTo("gifPost")

    });
  130. “Fav” Feature js/components/gf-list-posts.js <div class="gif-entry-fav"> <span class="gif-entry-fav-count"> {{favorites.length}} </span> <a

    class="gif-entry-fav-link" {{action "toggleFav" this}}> <i {{bind-attr class="isFavorited :fav-star"}}></i> </a> </div>
  131. “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(); }); }
  132. DEMO

  133. Fun to extend More filters Popular posts Sharing

  134. My MountainWest story

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

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

  137. 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/