Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

Engineering

Slide 3

Slide 3 text

Architecture

Slide 4

Slide 4 text

Craftsmanship

Slide 5

Slide 5 text

Cloud

Slide 6

Slide 6 text

Cloud gineering itecture smanship

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

Brandon Hays Cloudgineer

Slide 9

Slide 9 text

Credentials YMCA basketball participant Beat Mario 3 (almost) Donut master Author of 40 unreleased software projects Sandwich Artist Karate Champ Champ Can recite Back to the Future from memory 3 years jazz, 2 years tap Creator of 0 frameworks My mom says I am cool Friends with @fivetanley Very nearly graduated high school

Slide 10

Slide 10 text

We don’t live here

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Salt Lake City

Slide 13

Slide 13 text

Salt Lake City Salt Lake City

Slide 14

Slide 14 text

Salt Lake City We don’t live here.

Slide 15

Slide 15 text

Austin

Slide 16

Slide 16 text

We don’t even live here.

Slide 17

Slide 17 text

We live here.

Slide 18

Slide 18 text

Charming, right?

Slide 19

Slide 19 text

Temporary made permanent

Slide 20

Slide 20 text

Unskilled labor, Found materials

Slide 21

Slide 21 text

No fire codes

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Oh we are so not getting into pronunciation

Slide 28

Slide 28 text

OK since you asked

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

Feature request: Submit a gif from main page

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

$(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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

$('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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

Oh goody, string concat into the DOM .done(function(data) { $('.button.gif-submit').show('fade'); $('.share-gif-form').hide('fade'); $("#new-gif-body").val(""); $('#gif-post-dialog').append("

New gif posted: " + post.url + "

"); var newRow = '' + username + '' + post.url + '' + body + 'ShowEditDestroy'; $('table.gif-list tbody').append(newRow); });

Slide 37

Slide 37 text

* 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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

$('#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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

$('#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

Slide 42

Slide 42 text

* 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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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('
') } 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);

Slide 45

Slide 45 text

$(document).ready(function(){ $('#toggle-post-dialog').on("click", function(e) { e.preventDefault(); $("#new-gif-body").val(""); $('#share-section .button-area').hide('fade'); $('#gif-post-dialog').show('blind'); $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled"); }); $('#gif-post-dialog').on("click", 'a.cancel-post', function(e) { e.preventDefault(); $('#share-section .button-area').show('fade'); $("#new-gif-body").val(""); $('#gif-post-dialog').hide('blind'); }); $('#new-gif-body').on("input propertychange", function(e) { var parsedUrl = $(this).val().match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/); var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null; var charCount if (!!isGif) { charCount = ($(this).val().length - parsedUrl[0].length) if (charCount <= 140) { $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); $('#gif-post-dialog .message').text("").removeClass("error").hide(); $('#gif-post-dialog .gif-preview-container').html('
') } else { $('#gif-post-dialog .message').show().addClass("validation-error").text("Your message is too long."); $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled"); } } else { charCount = $(this).val().length $('#gif-post-dialog .message').show().addClass("validation-error").text("There is no valid gif link in this post.") $('#gif-post-dialog .gif-preview').remove() $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled"); } $("#gif-post-dialog .character-count-number").text(charCount); }); $('section.gif-list').on("click", "article a[data-gif-delete]", function(e) { e.preventDefault(); console.log(e); var url = $(this).attr('href'); var id = $(this).data('gifPostId'); if(confirm('Are you sure you want to delete this post?')){ $.ajax({ type: "DELETE", dataType: "json", url: url }).done(function(data) { $('section.gif-list article[data-gif-post-id=' + id.toString() + ']').remove(); }); } }); $('#gif-post-dialog').on("click", 'a.gif-submit', function(e) { e.preventDefault(); var currentUserId = $('meta[name="current-user-id"]').attr("content"); $(this).attr("disabled", "disabled"); var url = $(this).attr('href'); var body = $("#new-gif-body").val(); var parsedUrl = body.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/); $.ajax({ type: "POST", dataType: "json", url: url, data: { gif_post: { body: body, url: parsedUrl[0], } } }).done(function(data) { var post = data.gif_post; var url = post.url || ""; var username = (!!post.user && !!post.user.username) ? post.user.username : ""; var body = post.body || null; $('#gif-post-dialog .message').removeClass("error").text(""); $('.share-gif-form').hide('fade'); $("#gif-post-dialog .character-count-number").text("0"); $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url); var newArticle = '
' + body + '
Delete
Shared by ' + username + '
Permalink
'; $('section.gif-list').prepend(newArticle); setTimeout(function() { $('#share-section .button-area').show('fade'); $('#gif-post-dialog .message').hide().text(""); $('#gif-post-dialog .gif-preview').remove() $('#gif-post-dialog').hide('blind'); $('.share-gif-form').show(); }, 5000); }).fail(function(data) { if (!data.responseText) { $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting 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

Slide 46

Slide 46 text

Code shape: Sack of hot garbage

Slide 47

Slide 47 text

* 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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

That’s it, I’m outta here

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

Because you can’t sleep at night

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

Model layer Bindings to manage state Components Why Ember?

Slide 59

Slide 59 text

1. Rap it

Slide 60

Slide 60 text

1. Rap it 1. Wrap it

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

Code this bad goes to code jail

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

Recently Shared Gifs

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

Slide 65

Slide 65 text

The Sprinkling js/components/gf-new-post.js $(document).ready(function() { $("#new-post-container").each(function(){ var component = App.GfNewPostComponent.create(); component.replaceIn(this); }); });

Slide 66

Slide 66 text

2. Test it

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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)

Slide 69

Slide 69 text

Hand wave alert

Slide 70

Slide 70 text

A note on JS & Ember testing

Slide 71

Slide 71 text

It’s a “wild west”.

Slide 72

Slide 72 text

Not this wild west

Slide 73

Slide 73 text

This wild west

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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'

Slide 77

Slide 77 text

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"

Slide 78

Slide 78 text

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)

Slide 79

Slide 79 text

describe 'new post component', -> beforeEach -> @gifList = $('').appendTo("body"); @component = App.GfNewPostComponent.create().append() @component.container = App.__container__ afterEach -> @gifList.remove() @component.destroy() it "exists", -> expect(@component.get('element')).to.exist expect(@component.$('#gif-post-dialog')).to.exist it "doesn't show the dialog", -> expect(@component.$('#gif-post-dialog')).not.to.be.visible describe "clicking the show dialog button", -> beforeEach -> @component.$("#toggle-post-dialog").click() it "shows the dialog", -> expect(@component.$('#gif-post-dialog')).to.be.visible describe 'entering bad text into the gif box', -> beforeEach -> @component.$("textarea").val("thing: notagif.jpg") @component.$("textarea").trigger "input" it "leaves the save button disabled", -> expect(@component.$("a.gif-submit")).to.have.attr("disabled") it "counts the characters", -> expect(@component.$(".character-count-number").text()).to.equal "18" it "displays a client-side validation error", -> expect(@component.$(".message")).to.be.visible expect(@component.$(".message")).to.have.class("validation-error") expect(@component.$(".message").text()).to.match /no valid gif/ it "does not display a preview section", -> expect(@component.$(".gif-preview")).not.to.exist describe 'putting too much text in the box', -> beforeEach -> @component.$("textarea").val("thing: http://blah.com/cool-gif.gif") @component.$("textarea").trigger "input" @component.$("textarea").val("thing: http://blah.com/cool-gif.gif This is a thing which is pretty cool except the text is too long. Maybe this is going to display a nice error message for people to enjoy") @component.$("textarea").trigger "input" it "disables the save button", -> expect(@component.$("a.gif-submit")).to.have.attr("disabled") it "counts the characters", -> expect(@component.$(".character-count-number").text()).to.equal "145" it "displays a client-side validation error", -> expect(@component.$(".message")).to.be.visible expect(@component.$(".message")).to.have.class("validation-error") expect(@component.$(".message").text()).to.match /too long/ it "continues to display a preview section", -> expect(@component.$(".gif-preview")).to.exist describe 'entering good text into the gif box', -> beforeEach -> @component.$("textarea").val("thing: http://blah.com/cool-gif.gif") @component.$("textarea").trigger "input" it "enables the save button", -> expect(@component.$("a.gif-submit")).not.to.have.attr("disabled") it "counts characters, minus the gif input", -> expect(@component.$(".character-count-number").text()).to.equal "7" it "does not display an error message", -> expect(@component.$(".message")).not.to.be.visible expect(@component.$(".message")).not.to.have.class("validation-error") expect(@component.$(".message").text()).to.be.empty it "displays a preview image", -> expect(@component.$(".gif-preview")).to.exist expect(@component.$(".gif-preview img").attr("src")).to.equal "http://blah.com/cool- gif.gif" describe "clicking submit with good response", -> beforeEach -> @clock = sinon.useFakeTimers() @component.$("a.gif-submit").attr("href", "/gif_posts").click(); ic.ajax.defineFixture '/gif_posts', response: gif_post: id: "1" url: "http://blah.com/cool-gif.gif" user: username: "fakeuser" body: "thing: http://blah.com/cool-gif.gif" jqXHR: {} textStatus: 'success' @component.$("a.gif-submit").click(); afterEach -> @clock.restore() it "shows success message and adds gif", -> expect(@component.$("#gif-post-dialog .message")).to.be.visible expect(@component.$("#gif-post-dialog .message")).to.have.class "success" expect(@component.$("#gif-post-dialog .message").text()).to.equal "New gif posted: http://blah.com/cool-gif.gif" newGif = $("section.gif-list article.gif-entry") expect($(newGif[0]).find(".gif-entry-user").text()).to.equal "Shared by fakeuser" describe "after 5 seconds", -> beforeEach -> @clock.tick(5010) it "resets to initial state", -> expect(@component.$("#gif-post-dialog .message")).not.to.be.visible expect(@component.$("#gif-post-dialog .message")).not.to.have.class "success" expect(@component.$("#gif-post-dialog .message").text()).to.equal "" describe "deleting the gif with success response", -> beforeEach -> ic.ajax.defineFixture '/gif_posts/1', response: "yay" jqXHR: {} textStatus: 'success' sinon.stub(window, "confirm").returns(true) @newGif = $("section.gif-list article.gif-entry") @deleteButton = $(@newGif).find(".gif-entry-delete [data-gif-delete]") $(@deleteButton).trigger("click") afterEach -> window.confirm.restore() it "removes the gif", -> expect($("section.gif-list article.gif-entry")).not.to.exist describe "clicking submit with validation error", -> beforeEach -> @clock = sinon.useFakeTimers() @component.$("a.gif-submit").attr("href", "/gif_posts").click(); ic.ajax.defineFixture '/gif_posts', responseText: "Validation fail" responseJSON: errors: url: ["LOL NOPE VALIDATION FAILZ"] jqXHR: {} textStatus: 'unprocessable entity' @component.$("a.gif-submit").click(); it "shows an error message", -> expect(@component.$("#gif-post-dialog .message")).not.to.have.class "success" expect(@component.$("#gif-post-dialog .message")).to.have.class "error" expect(@component.$("#gif-post-dialog .message").text()).to.equal "LOL NOPE VALIDATION FAILZ" describe "after 5 seconds", -> beforeEach -> @clock.tick(5010) afterEach -> @clock.restore() it "resets to initial state", -> expect(@component.$("#gif-post-dialog .message")).not.to.be.visible expect(@component.$("#gif-post-dialog .message")).not.to.have.class "error" expect(@component.$("#gif-post-dialog .message").text()).to.equal "" describe "clicking submit with bad response", -> beforeEach -> @component.$("a.gif-submit").attr("href", "/gif_posts").click(); ic.ajax.defineFixture '/gif_posts', response: "BARF" @component.$("a.gif-submit").click(); it "shows an error message", -> expect(@component.$("#gif-post-dialog .message")).not.to.have.class "success" expect(@component.$("#gif-post-dialog .message")).to.have.class "error" expect(@component.$("#gif-post-dialog .message").text()).to.match /error posting/ describe "clicking cancel", -> beforeEach -> @component.$("#gif-post-dialog a.cancel-post").click() it "closes the dialog", -> expect(@component.$('#gif-post-dialog')).not.to.be.visible it "clears the text input", -> expect(@component.$('textarea').val()).to.equal "" Fully tested jspec/components/gf-new-post_spec.coffee

Slide 80

Slide 80 text

Go ahead, take a victory lap

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

Unit tests! jspec/models/gif-post_spec.coffee describe "GifPost", -> beforeEach -> @gifPost = App.GifPost.create() describe "with a valid url", -> beforeEach -> @gifPost.set("body", "thing: http://blah.com/cool- gif.gif") it "parses the url from the body", -> expect(@gifPost.get("parsedUrl")).to.equal "http:// blah.com/cool-gif.gif"

Slide 83

Slide 83 text

$ karma run Chrome 35.0.1887 (Mac OS X 10.8.5) GifPost with a valid url parses the url from the body FAILED AssertionError: expected undefined to equal 'http://blah.com/ cool-gif.gif' Unit tests! jspec/models/gif-post_spec.coffee describe "GifPost", -> beforeEach -> @gifPost = App.GifPost.create() describe "with a valid url", -> beforeEach -> @gifPost.set("body", "thing: http://blah.com/cool- gif.gif") it "parses the url from the body", -> expect(@gifPost.get("parsedUrl")).to.equal "http:// blah.com/cool-gif.gif"

Slide 84

Slide 84 text

Extract to model App.GifPost = Ember.Object.extend({ regex: gifRegex(), body: "", parsedUrl: function() { var matches = this.get("body").match(this.get("regex")) return (matches && matches.length > 0) ? matches[0] : ""; }.property("body"), }); js/models/gif-post.js

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

Add Handlebars
{{textarea name="new-gif-post" class= "new-gif-input" value=gifPost.body}}
Post

{{gifPost.charCount}}

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

Let the framework carry the load

Slide 89

Slide 89 text

4. Identify States

Slide 90

Slide 90 text

State 1: initial

Slide 91

Slide 91 text

State 2: editing

Slide 92

Slide 92 text

State 3: loading

Slide 93

Slide 93 text

State 4: error

Slide 94

Slide 94 text

State 5: success

Slide 95

Slide 95 text

Identify states js/components/gf-new-post.js App.GfNewPostComponent = Ember.Component.extend({ ... classNameBindings: ["formState"], formState: "initial", // FORM-STATES: "initial", "editing", "loading", "failure", "success" ...

Slide 96

Slide 96 text

NO TOUCHING THE DOM!

Slide 97

Slide 97 text

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(); }

Slide 98

Slide 98 text

4. Break it up

Slide 99

Slide 99 text

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"),

Slide 100

Slide 100 text

What’s left? js/components/gf-new-post.js var newArticle = '
' + gifPost.get("body") + '
Delete
Shared by ' + gifPost.get("username") + '
Permalink
'; $('section.gif-list').prepend(newArticle);

Slide 101

Slide 101 text

The call is coming var newArticle = '
' + gifPost.get("body") + '
Delete
Shared by ' + gifPost.get("username") + '
Permalink
'; $('section.gif-list').prepend(newArticle); From inside the codebase

Slide 102

Slide 102 text

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

Slide 103

Slide 103 text

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

Slide 104

Slide 104 text

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"), ...

Slide 105

Slide 105 text

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

Slide 106

Slide 106 text

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(); }); } } } });

Slide 107

Slide 107 text

2nd Template js/templates/components/gf-list-posts.js {{#each gifPosts}}
{{body}}
Delete
Shared by {{username}}
{{/each}}

Slide 108

Slide 108 text

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); }); }); } });

Slide 109

Slide 109 text

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"

Slide 110

Slide 110 text

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

Slide 111

Slide 111 text

No content

Slide 112

Slide 112 text

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

Slide 113

Slide 113 text

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

Slide 114

Slide 114 text

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; } }

Slide 115

Slide 115 text

DEMO

Slide 116

Slide 116 text

No direct DOM manipulation

Slide 117

Slide 117 text

} } }).done(function(data) { var post = data.gif_post; var url = post.url || ""; var username = (!!post.user && !!post.user.username) ? post.user.username : ""; var body = post.body || null; $('#gif-post-dialog .message').removeClass("error").text(""); $('.share-gif-form').hide('fade'); $("#gif-post-dialog .character-count-number").text("0"); $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url) var newArticle = '
' + body + '
Delete
Shared by ' + username + '
Permalink
'; $('section.gif-list').prepend(newArticle); setTimeout(function() { $('#share-section .button-area').show('fade'); $('#gif-post-dialog .message').hide().text(""); $('#gif-post-dialog .gif-preview').remove() $('#gif-post-dialog').hide('blind'); $('.share-gif-form').show(); }, 5000); }).fail(function(data) { if (!data.responseText) { $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting yo Please wait and try again."); } else { $('#gif-post- og .message').show().addClass("error").text(data.responseJSON.errors.url[0]); } setTimeout(function() { $('#gif-post-dialog .message').removeClass("error").hide(); $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); }, 5000);

Slide 118

Slide 118 text

Introducing: 512-bit jQCS encryption

Slide 119

Slide 119 text

} } }).done(function(data) { var post = data.gif_post; var url = post.url || ""; var username = (!!post.user && !!post.user.username) ? post.user.username : ""; var body = post.body || null; $('#gif-post-dialog .message').removeClass("error").text(""); $('.share-gif-form').hide('fade'); $("#gif-post-dialog .character-count-number").text("0"); $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url) var newArticle = '
' + body + '
Delete
Shared by ' + username + '
Permalink
'; $('section.gif-list').prepend(newArticle); setTimeout(function() { $('#share-section .button-area').show('fade'); $('#gif-post-dialog .message').hide().text(""); $('#gif-post-dialog .gif-preview').remove() $('#gif-post-dialog').hide('blind'); $('.share-gif-form').show(); }, 5000); }).fail(function(data) { if (!data.responseText) { $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting yo Please wait and try again."); } else { $('#gif-post- og .message').show().addClass("error").text(data.responseJSON.errors.url[0]); } setTimeout(function() { $('#gif-post-dialog .message').removeClass("error").hide(); $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); }, 5000); Now that’s Cloudgineering™

Slide 120

Slide 120 text

App.GfNewPostComponent = Ember.Component.extend({ /* PROPERTIES */ layoutName: "components/gf-new-post", gifPost: null, classNames: ["new-post-component"], classNameBindings: ["formState", "isInvalid"], formState: "initial", // FORM-STATES: "initial", "editing", "loading", "failure", "success" isValid: Ember.computed.alias("gifPost.isValid"), isInvalid: Ember.computed.not("isValid"), /* OBSERVERS */ // This computes the displayed message for all success/failure message: function(){ var formState = this.get("formState") // On failure, delegate message to the object if (formState === "failure") { return this.get("gifPost.message"); // On success, just scream real loud! } else if (formState === "success") { return "New gif posted: " + this.get("gifPost.parsedUrl") // If it's invalid but has a gif, it's too long: } else if (this.get("isInvalid") && !!this.get("gifPost.isGif")) { return "Your message is too long."; // Otherwise if it's not valid it's not a gif } else if (this.get("isInvalid")) { return "Please add a valid gif link to this post."; } else { return null; } }.property("formState", "isInvalid", "gifPost.isGif", "gifPost.message"), /* ACTIONS */ // These actions are primarily about pushing state around. actions: { showDialog: function() { after

Slide 121

Slide 121 text

$(document).ready(function(){ $('#toggle-post-dialog').on("click", function(e) { e.preventDefault(); $("#new-gif-body").val(""); $('#share-section .button-area').hide('fade'); $('#gif-post-dialog').show('blind'); $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled"); }); $('#gif-post-dialog').on("click", 'a.cancel-post', function(e) { e.preventDefault(); $('#share-section .button-area').show('fade'); $("#new-gif-body").val(""); $('#gif-post-dialog').hide('blind'); }); $('#new-gif-body').on("input propertychange", function(e) { var parsedUrl = $(this).val().match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/); var isGif = !!parsedUrl ? /.gif$/.test(parsedUrl) : null; var charCount if (!!isGif) { charCount = ($(this).val().length - parsedUrl[0].length) if (charCount <= 140) { $("#gif-post-dialog a.gif-submit").removeAttr("disabled"); $('#gif-post-dialog .message').text("").removeClass("error").hide(); $('#gif-post-dialog .gif-preview-container').html('
') } else { $('#gif-post-dialog .message').show().addClass("validation-error").text("Your message is too long."); $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled"); } } else { charCount = $(this).val().length $('#gif-post-dialog .message').show().addClass("validation-error").text("There is no valid gif link in this post.") $('#gif-post-dialog .gif-preview').remove() $("#gif-post-dialog a.gif-submit").attr("disabled", "disabled"); } $("#gif-post-dialog .character-count-number").text(charCount); }); $('section.gif-list').on("click", "article a[data-gif-delete]", function(e) { e.preventDefault(); console.log(e); var url = $(this).attr('href'); var id = $(this).data('gifPostId'); if(confirm('Are you sure you want to delete this post?')){ $.ajax({ type: "DELETE", dataType: "json", url: url }).done(function(data) { $('section.gif-list article[data-gif-post-id=' + id.toString() + ']').remove(); }); } }); $('#gif-post-dialog').on("click", 'a.gif-submit', function(e) { e.preventDefault(); var currentUserId = $('meta[name="current-user-id"]').attr("content"); $(this).attr("disabled", "disabled"); var url = $(this).attr('href'); var body = $("#new-gif-body").val(); var parsedUrl = body.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/); $.ajax({ type: "POST", dataType: "json", url: url, data: { gif_post: { body: body, url: parsedUrl[0], } } }).done(function(data) { var post = data.gif_post; var url = post.url || ""; var username = (!!post.user && !!post.user.username) ? post.user.username : ""; var body = post.body || null; $('#gif-post-dialog .message').removeClass("error").text(""); $('.share-gif-form').hide('fade'); $("#gif-post-dialog .character-count-number").text("0"); $('#gif-post-dialog .message').show().addClass("success").text("New gif posted: " + post.url); var newArticle = '
' + body + '
Delete
Shared by ' + username + '
Permalink
'; $('section.gif-list').prepend(newArticle); setTimeout(function() { $('#share-section .button-area').show('fade'); $('#gif-post-dialog .message').hide().text(""); $('#gif-post-dialog .gif-preview').remove() $('#gif-post-dialog').hide('blind'); $('.share-gif-form').show(); }, 5000); }).fail(function(data) { if (!data.responseText) { $('#gif-post-dialog .message').show().addClass("error").text("There was an error posting 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

Slide 122

Slide 122 text

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

Slide 123

Slide 123 text

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

Slide 124

Slide 124 text

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

Slide 125

Slide 125 text

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

Slide 126

Slide 126 text

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

Slide 127

Slide 127 text

Devs just wanna have fun

Slide 128

Slide 128 text

“Fav” Feature js/handwave.js describe "favoriting a post", -> beforeEach -> // Fixture or stubbing here @component.send("favorite", @gifPost) it "favorites the post", -> expect($("article.gif-entry i.fav-star")).to.have.class "is-favorited" it "lists the favorite count", -> expect($("article.gif-entry .gif-entry-fav- count").text().trim()).to.equal "1"

Slide 129

Slide 129 text

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

Slide 130

Slide 130 text

“Fav” Feature js/components/gf-list-posts.js
{{favorites.length}}

Slide 131

Slide 131 text

“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(); }); }

Slide 132

Slide 132 text

DEMO

Slide 133

Slide 133 text

Fun to extend More filters Popular posts Sharing

Slide 134

Slide 134 text

My MountainWest story

Slide 135

Slide 135 text

I want to hug every dev. Come say hi

Slide 136

Slide 136 text

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

Slide 137

Slide 137 text

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/