Share
Embed
Bring Fun Back to JS: Refactoring Toward Ember
Presented at RailsConf 2014
You just wanted to add some nice interactive functionality to your Rails app. But then one jQuery plugin turns to three, add a dash of statefulness, some error handling, and suddenly you can't sleep at night.
We'll walk through using Ember Components to test-drive a refactor until your front-end code is understandable, usable, and extensible. Armed with TDD and components, you can start to get excited, not exasperated, when asked to add advanced client-side interactions to your website.
tehviking
April 25, 2014
Transcript
141 slides
-
These are all things More specifically, they are all things
this talk is not about -
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 -
Where we live A tale of two cities. Three cities,
actually. And not really a tale. -
The Ball of Mud Pattern Throwaway Code Piecemeal Growth Result:
Shantytown http://laputan.org/mud/ -
How does this happen? “This should be easy” “Has to
happen this week” “Proof of concept” -
How does this happen? Myth of the 2-week feature Under-experienced
labor Prototypes in production -
A shantytown story How the feature that starts too small
to design becomes too big to maintain -
* ce5486d - Initial commit * 96db2d4 - Basic app
with auth * f97c86b - AJAX posting of body without URL The Sprinkling of JS -
$('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 -
Why does the new post not show up on the
page? Terrific! Except... -
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); }); -
* 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 -
Great job slugger, but... Users are submitting garbage, we should
validate that client side. -
$('#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 -
* 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 -
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); -
$(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 -
* 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 -
Excellent work! Add a “Like” button with a count, and
we’ re all set! -
Sweeping under the rug Reconstruction Block-by-block renovation (keeping it working)
Quit your job Now what? http://laputan.org/mud/ -
Black box: Sometimes OK Rewrite: Painful lessons about hidden functionality
Refactor: Haaard Quit: Time to stop running from your problems, pal Now what? -
Make code more idiomatic Refactor to JS objects Path 1:
DIY from scratch -
Use models to manage data & state Get the heck
out of the DOM Path 2: Lean on a Framework -
Our goal: Get out of managing the DOM as fast
as humanly possible -
1. Wrap it 2. Test it 3. Identify Models 4.
Identify States 5. Break it up 5 steps -
1. Move code into Ember Component 2. Move markup into
handlebars template 3. Insert into original DOM A sprinkling of Ember -
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 -
<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 -
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) -
The testing story is starting to mature (blogs, libraries) Opportunity
to make a contribution The good news -
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' -
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) -
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 -
$ 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" -
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 -
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 -
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 -
Identify states js/components/gf-new-post.js App.GfNewPostComponent = Ember.Component.extend({ ... classNameBindings: ["formState"], formState:
"initial", // FORM-STATES: "initial", "editing", "loading", "failure", "success" ... -
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(); } -
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"), -
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); -
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 -
Time for a 2nd component Manage post list Handle delete
Final nail in coffin for direct DOM manipulation -
Makes AJAX requests & maps to model for you This
actually buys us time Detour: Ember Data -
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"), ... -
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 -
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(); }); } } } }); -
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" -
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}} -
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); }); }); } }); -
2 components, shared data Both react to data changes DOM
auto-updates in both -
We already bound state to a CSS class Let CSS
transitions do the work CSS Animation -
CSS Animation js/templates/components/gf-list-posts.js .new-post-component { &.initial { } &.editing {
} &.loading { } &.failure { } &.success { } &.is-invalid { } } -
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; } } -
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); -
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™ -
// 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 -
$(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 -
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 -
Where can you go from here? Just a couple steps
from going full SPA Or keep series of components that share model data -
Why did we do all this? It was a sizable
amount of work What even is my life -
How’s it coming? Once you finish that Like button, we
can IPO and be millionaires! -
Oh yeah Want users to have great stuff Want to
add value Want to enjoy working in our code -
“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" -
“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> -
“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(); }); } -
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/