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. 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
  2. 10.

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

    actually. And not really a tale.
  3. 14.
  4. 23.

    How does this happen? “This should be easy” “Has to

    happen this week” “Proof of concept”
  5. 25.

    A shantytown story How the feature that starts too small

    to design becomes too big to maintain
  6. 26.
  7. 27.
  8. 29.

    * ce5486d - Initial commit * 96db2d4 - Basic app

    with auth * f97c86b - AJAX posting of body without URL The Sprinkling of JS
  9. 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
  10. 36.

    Why does the new post not show up on the

    page? Terrific! Except...
  11. 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); });
  12. 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
  13. 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
  14. 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
  15. 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);
  16. 50.
  17. 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
  18. 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
  19. 57.
  20. 59.

    Black box: Sometimes OK Rewrite: Painful lessons about hidden functionality

    Refactor: Haaard Quit: Time to stop running from your problems, pal Now what?
  21. 63.

    Use models to manage data & state Get the heck

    out of the DOM Path 2: Lean on a Framework
  22. 65.

    1. Wrap it 2. Test it 3. Identify Models 4.

    Identify States 5. Break it up 5 steps
  23. 67.
  24. 69.

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

    handlebars template 3. Insert into original DOM A sprinkling of Ember
  25. 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
  26. 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
  27. 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)
  28. 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'
  29. 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)
  30. 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
  31. 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"
  32. 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
  33. 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
  34. 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
  35. 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(); }
  36. 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"),
  37. 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);
  38. 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
  39. 106.

    Time for a 2nd component Manage post list Handle delete

    Final nail in coffin for direct DOM manipulation
  40. 107.

    Makes AJAX requests & maps to model for you This

    actually buys us time Detour: Ember Data
  41. 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"), ...
  42. 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
  43. 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(); }); } } } });
  44. 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"
  45. 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}}
  46. 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); }); }); } });
  47. 115.
  48. 116.

    We already bound state to a CSS class Let CSS

    transitions do the work CSS Animation
  49. 117.
  50. 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; } }
  51. 119.
  52. 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);
  53. 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™
  54. 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
  55. 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
  56. 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
  57. 127.

    Where can you go from here? Just a couple steps

    from going full SPA Or keep series of components that share model data
  58. 128.

    Why did we do all this? It was a sizable

    amount of work What even is my life
  59. 130.

    Oh yeah Want users to have great stuff Want to

    add value Want to enjoy working in our code
  60. 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"
  61. 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>
  62. 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(); }); }
  63. 136.
  64. 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/