Pro Yearly is on sale from $80 to $50! »

Bring Fun Back to JS: Refactoring Toward Ember

Bring Fun Back to JS: Refactoring Toward Ember

Presented at RailsConf 2014

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

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

6fd16b1b6a307ca583526e2ec4dab52d?s=128

tehviking

April 25, 2014
Tweet

Transcript

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

  2. Engineering

  3. Architecture

  4. Craftsmanship

  5. Cloud gineering itecture smanship

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

    this talk is not about
  7. Brandon Hays Cloudgineer

  8. Credentials YMCA basketball participant Beat Mario 3 (almost) Donut master

    Marathoner (Breaking Bad seasons 1-5) Karate Champ Champ Hacked the Gibson Can recite Back to the Future from memory 3 years jazz, 2 years tap Creator of 0 frameworks My mom says I am cool Friends with @fivetanley Very nearly graduated high school
  9. We don’t live here

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

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

  12. Salt Lake City Salt Lake City

  13. We don’t live here.

  14. Austin

  15. We don’t even live here.

  16. We live here.

  17. Charming, right?

  18. Temporary made permanent

  19. Unskilled labor, Found materials

  20. No fire codes

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

    madness
  22. The Ball of Mud Pattern Throwaway Code Piecemeal Growth Result:

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

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

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

    to design becomes too big to maintain
  26. None
  27. None
  28. I’m taking a stand. “Gif” is pronounced like “Github Gist”.

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

    with auth * f97c86b - AJAX posting of body without URL The Sprinkling of JS
  30. The Sprinkling of JS

  31. Feature request: Submit a gif from main page

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

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

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

  35. $('button.gif-submit').on("click", function(e) { e.preventDefault(); $(this).hide("fade"); var url = $(this).attr('href'); var

    body = $("#new-gif-body").val(); $.ajax({ type: "POST", dataType: "json", url: url, data: { gif_post: { body: body, user_id: currentUserId } } }); }); js/main.js
  36. Why does the new post not show up on the

    page? Terrific! Except...
  37. Oh goody, string concat into the DOM .done(function(data) { $('.button.gif-submit').show('fade');

    $('.share-gif-form').hide('fade'); $("#new-gif-body").val(""); $('#gif-post-dialog').append("<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); });
  38. Agile Your Code™ That’s Cloudgineering™

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

    with auth * f97c86b - AJAX posting of body without URL * a38f598 - Extract URL and display gif * 602fabb - add cancel button * 81e0f72 - auto-disable input with message without valid gif The Steady Rain of JS
  40. The Steady Rain of JS

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

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

    }); js/main.js
  43. Great job slugger, but... Users are submitting garbage, we should

    validate that client side.
  44. $('#new-gif-body').bind("input propertychange", function(e) { var parsedUrl = $(this).val().match(/(((ftp|https?):\/\/)(www\.)?|www\.)([\da-z-_ \.]+)([a-z\.]{2,7})([\/\w\.-_\?\&]*)*\/?/); var

    isGif = !!parsedUrl ? /.gif$/.test(parsedUrl[0]) : null; if (!!isGif) { $("#gif-post-dialog button.gif-submit").removeAttr("disabled"); $('#gif-post-dialog .message').text("") $('#gif-post-dialog .message').hide() } else { $('#gif-post-dialog .message').show() $('#gif-post-dialog .message').text("There is no valid gif link in this post.") $("#gif-post-dialog button.gif-submit").attr("disabled", "disabled"); } }); js/main.js
  45. * ce5486d - Initial commit * 96db2d4 - Basic app

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

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

    count
  48. js/main.js if (!!isGif) { charCount = ($(this).val().length - parsedUrl[0].length) if

    (charCount <= 140) { $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); $('#gif-post-dialog .message').text("").removeClass("error").hide(); $('#gif-post-dialog .gif-preview-container').html('<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);
  49. Craftsmanship.

  50. Ship it.

  51. That’s Cloudgineering™ Craftsmanship It.

  52. $(document).ready(function(){ $('#toggle-post-dialog').on("click", function(e) { e.preventDefault(); $("#new-gif-body").val(""); $('#share-section .button-area').hide('fade'); $('#gif-post-dialog').show('blind'); $("#gif-post-dialog

    a.gif-submit").attr("disabled", "disabled"); }); $('#gif-post-dialog').on("click", 'a.cancel-post', function(e) { e.preventDefault(); $('#share-section .button-area').show('fade'); $("#new-gif-body").val(""); $('#gif-post-dialog').hide('blind'); }); $('#new-gif-body').on("input propertychange", function(e) { var parsedUrl = $(this).val().match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/); var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null; var charCount if (!!isGif) { charCount = ($(this).val().length - parsedUrl[0].length) if (charCount <= 140) { $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); $('#gif-post-dialog .message').text("").removeClass("error").hide(); $('#gif-post-dialog .gif-preview-container').html('<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
  53. Code shape: Sack of hot garbage

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

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

  56. Excellent work! Add a “Like” button with a count, and

    we’ re all set!
  57. None
  58. Sweeping under the rug Reconstruction Block-by-block renovation (keeping it working)

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

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

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

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

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

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

    as humanly possible
  65. 1. Wrap it 2. Test it 3. Identify Models 4.

    Identify States 5. Break it up 5 steps
  66. Model layer Bindings to manage state Components Why Ember?

  67. 1. Rap it

  68. 1. Rap it 1. Wrap it

  69. 1. Move code into Ember Component 2. Move markup into

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

  71. App.GfNewPostComponent = Ember.Component.extend({ layoutName: "components/gf-new-post", initLegacyCode: function() { // All

    old code goes in here, no changes to see $('#toggle-post-dialog').on("click", function(e) { ... }); }.on("didInsertElement") }); The Component js/components/gf-new-post.js
  72. <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
  73. The Sprinkling js/components/gf-new-post.js $(document).ready(function() { $("#new-post-container").each(function(){ var component = App.GfNewPostComponent.create();

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

  75. First test jspec/components/gf-new-post_spec.coffee describe 'new post component', -> beforeEach ->

    @component = App.GfNewPostComponent.create().append() @component.container = App.__container__ afterEach -> @component.destroy() it "exists", -> expect(@component.get('element')).to.exist expect(@component.$('#gif-post-dialog')).to.exist $ karma run . Chrome 35.0.1887 (Mac OS X 10.8.5): Executed 1 of 1 SUCCESS (1.811 secs / 0.001 secs)
  76. Hand wave alert

  77. A note on JS & Ember testing

  78. It’s a “wild west”.

  79. Not this wild west

  80. This wild west

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

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

    to make a contribution The good news
  83. AJAX tests jspec/components/gf-new-post_spec.coffee beforeEach -> ic.ajax.defineFixture '/gif_posts', response: gif_post: id:

    "1" url: "http://blah.com/cool-gif.gif" user: username: "fakeuser" body: "thing: http://blah.com/cool-gif.gif" jqXHR: {} textStatus: 'success'
  84. More tests jspec/components/gf-new-post_spec.coffee describe "clicking submit with good response", ->

    beforeEach -> @component.$("a.gif-submit").click() it "shows success message and adds gif", -> expect(@component.$("#gif-dialog .message")).to.be.visible expect(@component.$("#gif-dialog .message")).to.have.class "success" expect(@component.$("#gif-dialog .message").text()).to.equal "New gif posted: http://blah.com/cool-gif.gif" $ karma run ....................... Chrome 35.0.1887 (Mac OS X 10.8.5): Executed 23 of 23 SUCCESS (2.181 secs / 0.017 secs)
  85. 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
  86. Go ahead, take a victory lap

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

    models
  88. $ karma run Chrome 35.0.1887 (Mac OS X 10.8.5) GifPost

    with a valid url parses the url from the body FAILED AssertionError: expected undefined to equal 'http://blah.com/cool-gif.gif' Unit tests! jspec/models/gif-post_spec.coffee describe "GifPost", -> beforeEach -> @gifPost = App.GifPost.create() describe "with a valid url", -> beforeEach -> fillIn "textarea#new-gif-body", "thing: http://blah.com/cool-gif.gif" it "parses the url from the body", -> expect(@gifPost.get("parsedUrl")).to.equal "http://blah.com/cool-gif.gif"
  89. Extract to model App.GifPost = Ember.Object.extend({ regex: gifRegex(), body: "",

    parsedUrl: function() { var matches = this.get("body").match(this.get("regex")) return (matches && matches.length > 0) ? matches[0] : ""; }.property("body"), }); $ karma run .............................. Chrome 35.0.1887 (Mac OS X 10.8.5): Executed 30 of 29 SUCCESS (2.181 secs / 0.017 secs) js/models/gif-post.js
  90. 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
  91. Adiós garbage! $('#new-gif-body').on("input propertychange", function(e) { var regex = gifRegex();

    var parsedUrl = $(this).val().match(regex); var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null; var charCount if (!!isGif) { charCount = ($(this).val().length - parsedUrl[0].length) if (charCount <= 140) { ... } else { charCount = $(this).val().length ... } $(".character-count-number").text(charCount); }); js/components/gf-new-post.js
  92. Let the framework carry the load

  93. 4. Identify States

  94. State 1: initial

  95. State 2: editing

  96. State 3: loading

  97. State 4: error

  98. State 5: success

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

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

  101. Change states js/components/gf-new-post.js showDialog: function() { this.set("formState", "editing"); }, cancel:

    function() { this.set("formState", "initial"); this.set("gifPost", App.GifPost.create()); }, submit: function() { this.set("formState", "loading"); this.get("gifPost").save(); }
  102. 4. Break it up

  103. What’s left? js/components/gf-new-post.js initLegacyCode: function() { $('section.gif-list').on("click", "article a[data-gif-delete]", function(e)

    { e.preventDefault(); var url = $(this).attr('href'); var id = $(this).data('gifPostId'); if(confirm('Are you sure you want to delete this post?')){ ic.ajax({type: "DELETE", dataType: "json", url: url }).then(function(data) { $('section.gif-list article[data-gif-post-id=' + id.toString() + ']').remove(); }); } }); }.on("didInsertElement"),
  104. 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);
  105. 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
  106. Time for a 2nd component Manage post list Handle delete

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

    actually buys us time Detour: Ember Data
  108. Ember Data js/models/gif-post.js App.GifPost = DS.Model.extend({ /* PROPERTIES */ regex:

    gifRegex(), body: DS.attr("string"), url: DS.attr("string"), username: DS.attr("string"), ...
  109. js/store.js // Overwrite ajax to be able to use nice

    ic-ajax fixtures App.ApplicationAdapter = DS.ActiveModelAdapter.extend({ ajax: function(url, type, options) { options = this.ajaxOptions(url, type, options); return ic.ajax(options); } }); Ember Data
  110. 2nd Component js/components/gf-list-posts.js App.GfListPostsComponent = Ember.Component.extend({ layoutName: "components/gf-list-posts", gifPosts: null,

    actions: { delete: function(gifPost) { if (confirm("Really delete this lovely gif?")) { gifPost.destroyRecord().then(function(result){ //message }, function(){ gifPost.rollback(); }); } } } });
  111. 2nd Test Set jspec/components/gf-list-posts_spec.js describe "list posts component", -> beforeEach

    -> // fixture goes here @component = App.GfListPostsComponent.create container: App.__container__ @component.appendTo("body") App.store.find("gifPost").then (result) => @component.set "gifPosts", result afterEach -> @component.destroy() it "exists", -> expect(@component.get('element')).to.exist it "lists the gifs", -> expect(@component.$("article.gif-entry:first .gif-entry- user").text().trim()).to.equal "Shared by tehviking"
  112. 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}}
  113. 2nd Sprinkle js/templates/components/gf-list-posts.js $(document).ready(function() { if ($("#gif-posts-container").length) { App.store.find("gifPost").then(function(result) {

    $("#gif-posts-container").each(function(){ var component = App.GfListPostsComponent.create({ gifPosts: result }); component.replaceIn(this); }); }); } });
  114. 2 components, shared data Both react to data changes DOM

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

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

    } &.loading { } &.failure { } &.success { } &.is-invalid { } }
  118. CSS Animation stylesheets/gf-list-posts.scss .new-post-component { &.initial { .share-dialog-container { height:

    0px; opacity: 0; } } .share-dialog-container { opacity: 1; height: 160px; -webkit-transition-property: height, opacity; transition-property: height, opacity; -webkit-transition-duration: 300ms, 200ms; transition-duration: 300ms, 200ms; } }
  119. DEMO

  120. No direct DOM manipulation

  121. data: { gif_post: { body: body, url: parsedUrl[0], } }

    }).done(function(data) { var post = data.gif_post; var url = post.url || ""; var username = (!!post.user && !!post.user.username) ? post.user.username : ""; var body = post.body || null; $('#gif-post-dialog .message').removeClass("error").text(""); $('.share-gif-form').hide('fade'); $("#gif-post-dialog .character-count-number").text("0"); $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url); var newArticle = '<article class="gif-entry" data-gif-entry data-gif-post-id="' + post.id + '"><div class="gif-entry-image"><img lass="framed" src="' + url + '"></div><div class="gif-entry-body">' + body + '</div><div class="gif-entry-delete"><a class="btn btn- anger"data-gif-delete data-gif-post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Delete</a></div><div class=" ntry-user">Shared by ' + username + '</div><div class="gif-entry-permalink"><a href="/gif_posts/' + post.id + '">Permalink</a></div><d tyle="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);
  122. Introducing: 512-bit jQCS encryption

  123. data: { gif_post: { body: body, url: parsedUrl[0], } }

    }).done(function(data) { var post = data.gif_post; var url = post.url || ""; var username = (!!post.user && !!post.user.username) ? post.user.username : ""; var body = post.body || null; $('#gif-post-dialog .message').removeClass("error").text(""); $('.share-gif-form').hide('fade'); $("#gif-post-dialog .character-count-number").text("0"); $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url); var newArticle = '<article class="gif-entry" data-gif-entry data-gif-post-id="' + post.id + '"><div class="gif-entry-image"><img lass="framed" src="' + url + '"></div><div class="gif-entry-body">' + body + '</div><div class="gif-entry-delete"><a class="btn btn- anger"data-gif-delete data-gif-post-id="' + post.id + '" href="/gif_posts/' + post.id + '" rel="nofollow">Delete</a></div><div class=" ntry-user">Shared by ' + username + '</div><div class="gif-entry-permalink"><a href="/gif_posts/' + post.id + '">Permalink</a></div><d tyle="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); Now that’s Cloudgineering™
  124. // This computes the displayed message for all success/failure message:

    function(){ var formState = this.get("formState") // On failure, delegate message to the object if (formState === "failure") { return this.get("gifPost.message"); // On success, just scream real loud! } else if (formState === "success") { return "New gif posted: " + this.get("gifPost.parsedUrl") // If it's invalid but has a gif, it's too long: } else if (this.get("isInvalid") && !!this.get("gifPost.isGif")) { return "Your message is too long."; // Otherwise if it's not valid it's not a gif } else if (this.get("isInvalid")) { return "Please add a valid gif link to this post."; } else { return null; } }.property("formState", "isInvalid", "gifPost.isGif", "gifPost.message"), /* ACTIONS */ // These actions are primarily about pushing state around. actions: { showDialog: function() { this.set("formState", "editing"); }, cancel: function() { this.set("formState", "initial"); this.set("gifPost", App.store.createRecord("gifPost")); }, submit: function() { controller = this; controller.set("formState", "loading"); var gifPost = this.get("gifPost"); gifPost.save().then(function(data) { // Success controller.set("formState", "success"); controller.defer(function() { after
  125. $(document).ready(function(){ $('#toggle-post-dialog').on("click", function(e) { e.preventDefault(); $("#new-gif-body").val(""); $('#share-section .button-area').hide('fade'); $('#gif-post-dialog').show('blind'); $("#gif-post-dialog

    a.gif-submit").attr("disabled", "disabled"); }); $('#gif-post-dialog').on("click", 'a.cancel-post', function(e) { e.preventDefault(); $('#share-section .button-area').show('fade'); $("#new-gif-body").val(""); $('#gif-post-dialog').hide('blind'); }); $('#new-gif-body').on("input propertychange", function(e) { var parsedUrl = $(this).val().match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/); var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null; var charCount if (!!isGif) { charCount = ($(this).val().length - parsedUrl[0].length) if (charCount <= 140) { $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); $('#gif-post-dialog .message').text("").removeClass("error").hide(); $('#gif-post-dialog .gif-preview-container').html('<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
  126. App.GfNewPostComponent = Ember.Component.extend({ /* PROPERTIES */ layoutName: "components/gf-new-post", gifPost: null,

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

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

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

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

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

  132. “Fav” Feature jspec/components/gf-list-posts_spec.coffee describe "favoriting a post", -> beforeEach ->

    // Stub fixture data here @entry = @component.$("article.gif-entry:first") click $(@entry).find(".gif-entry-fav-link") it "favorites the post", -> expect($("article.gif-entry i.fav-star")).to.have.class "is-favorited" it "lists the favorite count", -> expect($("article.gif-entry .gif-entry-fav-count").text().trim()).to.equal "1"
  133. “Fav” Feature js/models/favorite.js App.Favorite = DS.Model.extend({ userId: DS.attr("number"), gifPost: DS.belongsTo("gifPost")

    });
  134. “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>
  135. “Fav” Feature js/components/gf-list-posts.js toggleFav: function(gifPost) { if (gifPost.get("isFavorited")) { var

    favId = gifPost.get("currentUserFavoriteId") App.store.find("favorite", favId).then(function(result){ result.destroyRecord(); }); } else { fav = App.store.createRecord("favorite", { gifPost: gifPost }); fav.save().then(function(result){ // handle success }, function(){ //handle failure fav.rollback(); }); }
  136. DEMO

  137. Fun to extend More filters Popular posts Sharing

  138. My conference story

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

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

  141. Image credits http://www.flickr.com/photos/davidyuweb/9369008677/ http://www.flickr.com/photos/elescir/9833284434/ http://www.flickr.com/photos/derekskey/5249580870/ http://www.flickr.com/photos/111692634@N04/11407095913/ http://www.flickr.com/photos/armandolobos/9360963587/ http://upload.wikimedia.org/wikipedia/commons/9/9e/ Rainstorm_over_Salt_Lake_City.jpg http://en.wikipedia.org/wiki/File:Sketch_of_Salt_Lake_1860.jpg

    http://www.flickr.com/photos/mommypants/3738076382 http://www.flickr.com/photos/moontorch/9909941394/ http://www.flickr.com/photos/cbnsp/6605969807/ http://www.flickr.com/photos/33216291@N08/4152110541 http://www.flickr.com/photos/schnappischnap/8969004201 http://www.flickr.com/photos/intelfreepress/8294794328/ http://www.flickr.com/photos/gsfc/6385412459/ http://www.flickr.com/photos/uhdigital/8135187152/ http://www.flickr.com/photos/perfectance/6965568618/ http://www.flickr.com/photos/111692634@N04/11406975556/