English |  Español |  Français |  Italiano |  Português |  Русский |  Shqip

Developing a Twitter Flight Edge

4. Triggering and Responding to Events

In this chapter, we will:

  • Designing and implementing an event-driven component
  • Illustration of the different types of events (native- and application- level)

Enough talk about Flight and the component application model - let's get started with writing one! In this chapter, we will begin writing a basic component for our RSS reader to manage the feeds. When designing components, we should first consider what the responsibilities of that component will be, then consider what functionality the component should expose to accomplish those tasks. This design methodology is effective in designing non-component modules as well, and you should consider it when writing other AMD-style modules in your apps.

Since we will be developing this app with behavior driven design and there is some setup involved, I encourage you to check out the branch chapter-4 and look at the test runner suite. This is the basic framework for BDD with Phantom.js and Jasmine. It will let us write unit tests for incremental functionality to check our refactorings when we introduce concepts from Flight.

The FeedManager Component

First, let's update our application markup and initialization module to correctly attach this module.

0001:    // in js/main.js
0002: define(function(require) {
0003: require('feed-manager').attachTo('#feed-manager')p
0004: });
0005: // in index.html
0006: <body>
0007: <div id="feed-manager"></div>
0008: </body>

This should encompass all the changes necessary for our application to asynchronously load (via Require.js) our module code in js/feed-manager.js and attach the behavior defined therein to the new bit of markup we inserted into our application page. Nearly all of our modules will be this easy to include into our actual application, and including it in our tests is no more difficult. Let's move on to designing the component we've just attached to our freshly written markup.

The very first thing a user will try to do when using our app is add a feed to the initially empty list of RSS feeds watched. It makes sense, then, to start at the beginning and write a component that allows a user to add feeds. Since the list of watched feeds is mutable and users may want to remove feeds, we should embed that functionality in our component as well. Lastly, users may want to view the total list of feeds being watched. The FeedManager component should be able to add feeds, remove feeds, and list feeds. When we're done with our component, it should look like this:

Adding Feeds

In order to add a feed, the user needs to see a way to enter the feed URL into the app. Our first task will be presenting the user with such a form, and in true BDD fashion, we'll start with our tests. In order to unit test our FeedManager component, we'll need to set it up and tear it down after each test. With Jasmine, that can be accomplished with beforeEach and afterEach methods. We'll be using some convenience methods from Flight-Jasmine to facilitate setup and teardown of component instances. Twitter open-sourced a set of extensions for Jasmine to facilitate testing components, most importantly setting them up, tearing them down, and providing access to the component instance that Flight normally discards.

0001:    describeComponent('feed-manager', function() {
0002: var SUBMIT_BUTTONS = [
0003: // buttons default to `type="submit"`
0004: // when `type` is absent
0005: 'button:not([type])',
0006: // include buttons with an explicit
0007: // `type="submit"`
0008: 'button[type="submit"]',
0009: // inputs of type "submit"
0010: 'input[type="submit"]'
0011: ];
0012: beforeEach(setupComponent);
0013: // this is all we need for Flight Jasmine
0014: // to work!
0015: it("should present the user with a way to enter a new feed", function() {
0016: expect(this.component.$node.find('form')).toExist();
0017: expect(this.component.$node.find('input[type="url"]')).toExist();
0018: expect(this.component.$node.find(SUBMIT_BUTTONS.join(','))).toExist();
0019: });
0020: });

We've set up our testing infrastructure such that we can run our tests on the command line with npm test (see the scripts property in package.json to see what this runs) and get a console readout of our test suite, or by visiting localhost/path/to/project/tests/TestRunner.html to view Jasmine's HtmlReporter output. We haven't implemented anything yet, so our tests should be failing with some error indicating that our unwritten component could not be loaded. With one failing test, let's write our first Flight component!

Our FeedManager component will need to add markup to DOM elements it is attached to. To facilitate this, we will use the text plugin for Require.js. This will let us separate the markup we insert into the component node from the behaviors we want to attach. To get our test to pass, we just need to write some HTML and insert it into the component node when it's created.

0001:    // in app/js/feed-manager.js
0002: define(function(require) {
0003: var defineComponent = require('flight/lib/component');
0004: var feedManagerTemplate = require('text!tmpl/feed-manager.html');
0005: function FeedManager() {
0006: this.after('initialize', function() {
0007: // insert the feed manager template into the component node
0008: this.$node.html(feedManagerTemplate);
0009: });
0010: }
0011: return defineComponent(FeedManager);
0012: });
0013: // in app/tmpl/feed-manager.html
0014: <form>
0015: <input type="url" name="feedUrl" placeholder="Enter a feed URL" />
0016: <button>Add</button>
0017: </form>

All Flight components implement an initialize method, usually with the advice method after to attach additional behaviors after mixins initialize. We'll cover the use of these advice methods in later chapters; for this chapter, just consider it to be idiomatic Flight component initialization code. This module fetches the template in app/tmpl/feed-manager.html using the Require.js Text plugin, and in our FeedManager initialization method, we insert this template markup into the node to which our component is attached. The raw node can be accessed at .node and a convenient pre-jQuery-wrapped version at .$node.

Run our test suite again, either in the browser or on the command line, to see our test passes. With no failing tests in our suite, commit the changes and let's move on to the next feature!


Responding to Events

The next phase of our implementation will be to attach functionality to our newly-created form. When the user submits the form, we should intercept the event and interpret it into a "feed addition" behavior. Our approach to this problem highlights an imporant distinction between Flight components and a more traditional model/controller paradigm: components do not maintain a publicly-accessible state that can be inspected, so our tests won't be able to determine our component's response without it communicating the change. To compensate for this, we will store our "state" as a row in the table of RSS feeds and inspect that in our unit test to determine success or failure.

0001:    var FEED_URL = 'http://localhost/not-a-real-feed.rss';
0002: beforeEach(function() {
0003: this.submitFeed = function(feed) {
0004: this.component.$node.find('input[type="url"]').val(feed);
0005: this.component.$node.find(SUBMIT_BUTTONS.join(',')).click();
0006: }.bind(this);
0007: });
0008: it("should add a new feed to the list when the user submits the form",
0009:function() {
0010: var feed = this.component.select('feedItem');
0011: // there should be no feeds to start
0012: expect(feed).not.toExist();
0013: // simulate entering a feed and submitting the form
0014: this.submitFeed(FEED_URL);
0015: // now there should be a feed with the specified url in the component
0016: feed = this.component.select('feedItem');
0017: expect(feed.length).toBe(1);
0018: expect(feed.find('.url').text()).toEqual(FEED_URL);
0019: });

Our test first checks to make sure that no feeds currently exist, then simulates entering a feed URL and submitting the form, and finally verifies that a new feed has appeared with the URL we submitted. We are also using one of the matchers from Jasmine-jQuery, a dependency for Jasmine-Flight. With our previous test passing and our new test failing, let's implement the form submission behavior for our FeedManager.


We will need our component to listen for some event from the user before attempting to add the feed. Submitting the form we're displaying to the user seems like an appropriate event, so we'll want to bind a callback function to the submit event on the form. Since the form is inserted by our initialize method, we'll need to bind the listener after the form is inserted.

0001:    this.after('initialize', function() {
0002: this.$node.html(feedManagerTemplate);
0003: this.$node.find('form').on('submit', this.addFeed);
0004: }

While this code gets the job done, it's not doing much more than using jQuery directly. Even worse, by binding our callback function this way, we will lose the context of our component when executing addFeed! We could certainly write a different callback function to preserve context, but there is a better way. Flight is very opinionated about event binding and the use of selectors within a component. The convenience method select and one form of on accept attribute keys and automatically transform them into the respective selectors. An added benefit of using attribute-to-selector mapping is increased readability, so use appropriate and semantic names for your attributes. Using this, our event binding code becomes:

0001:    this.defaultAttrs({
0002: "addForm": "form"
0003: });
0004: this.after('initialize', function() {
0005: this.on('submit, {
0006: "addForm": this.addFeed
0007: });
0008: });

Using this.defaultAttrs, we've attached the name addForm to some selector representing our feed addition form, then used that name in this.on to bind the submit event. Calling the on method with an object hash as the second parameter will bind the specified callback functions to the selectors found at each attribute key for the given event. Flight automatically handles the preservation of context for each bound function so the value of this isn't lost when the event listener is invoked. All that's left now is to implement the addFeed method: it needs to accept the submit event from our form and respond to it by adding a new feed to our list. We'll also write our feed item template to insert into our table.

0001:    this.defaultAttrs({
0002: "feedList": ".feed-list tbody",
0003: "feedItem": ".feed-list tbody .feed"
0004: });
0005: this.addFeed = function(event) {
0006: // first, cancel the event; we don't want to
0007: // unintentionally submit the form
0008: event.preventDefault();
0009: // get the form data as a key/value pair
0010: // see app/js/utils/formData.js for the implementation
0011: var formData = extractFormData(event);
0012: // create a new feed row
0013: var feed = $(feedListItemTemplate);
0014: // format it
0015: feed.find('.url').text(formData.feedUrl);
0016: // and insert it into the list
0017: this.select('feedList').append(feed);
0018: };
0019: // in app/tmpl/feed-manager.html
0020: <tr class="feed">
0021: <td class="name"></td>
0022: <td class="url"></td>
0023: <td class="remove">&times;</td>
0024: </tr>

Our addFeed method is taking advantage of defaultAttrs by naming the selectors for our feed list container and each feed item. It intercepts the submit event, extracts the data in the form, and inserts a new row into the feed list. Run our test suite to see the green, commit our changes, and let's apply these concepts to our next feature!

Removing Feeds

Now that we have seen how to define important selectors for our component and attach events using the named selectors, adding the ability to remove a feed should be trivial. We will want to listen for click events on the .remove cell in a feed row, then remove that feed from the list. Let's start with our test; we'll introduce a helper method, submitFeed, to add new feeds via our component. 

0001:    it("should remove a feed from the list when the 'remove' button is clicked", 
0002:function() {
0003: var feed;
0004: submitFeed(FEED_URL);
0005: feed = this.component.select('feedItem');
0006: expect(feed.length).toBe(1);
0007: feed.find('.remove').click();
0008: feed = this.component.select('feedItem');
0009: expect(feed).not.toExist();
0010: });
0011:

Now we need to update our FeedManager component to get our test to pass. Following from our feed addition workflow, we know we will need a selector for the remove button, a callback function for that button, and a binding between the two.

0001:    this.defaultAttrs({
0002: "removeFeed": ".feed .remove"
0003: });
0004: this.removeFeed = function(event) {
0005: $(event.target).closest(this.attr.feedItem).remove();
0006: };
0007: this.after('initialize', function() {
0008: this.on('click', {
0009: "removeFeed": this.removeFeed
0010: });
0011: });

Barely ten lines of code and our FeedManager is now able to remove feeds in addition to adding them! When writing Flight components, you'll find that nearly all features are implemented with a set of named selector attributes and callback functions, and short feature implementations like this are the norm. Our tests are now passing and our feed manager is complete... with the exception of one small detail. Let's commit what we have before poking holes in our application!

Communicating with Other Components

Our FeedManager isn't a particularly well-behaved component. It only responds to events locally and doesn't share updates with the rest of the page. A well-behaved Flight component should listen for internal events and synthesize them into application-level events. In order to change our component to fit better into our application ecosystem, we should look at the major responsibilities of our component: adding and removing feeds. Let's refactor those methods to use events to communicate changes to the rest of the page. We should make sure our tests cover the division of responsibility: one, that the feed manager correctly emits the event, and two, that it responds to the event it emitted. When building evented systems, great care should be taken to separate the event emitting behavior from the work behavior - this makes for a more robust component and also for easier testing. In addition to our existing test suite, we should now listen for global events on the document object and make sure our component is emitting the correct events and with the correct data. Flight Jasmine extensions offer an "event spy" just for this situation.

0001:    it("should emit a 'addFeed' event when a feed is added", function() {
0002: // Create an event listener and attach it to the
0003: // document object to listen for our custom event
0004: var eventSpy = spyOnEvent(document, 'addFeed');
0005: // Add the feed
0006: this.submitFeed(FEED_URL);
0007: // verify our event listener was called and the
0008: // correct data was given
0009: expect(eventSpy).toHaveBeenTriggeredOn(document);
0010: expect(eventSpy.mostRecentCall.args[1]).toEqual({
0011: feedUrl: FEED_URL
0012: });
0013: });
0014: it("should respond to the 'addFeed' event by adding a feed to the list",
0015:function() {
0016: expect(this.component.select('feedItem')).not.toExist();
0017: this.component.trigger('addFeed', {feedUrl: FEED_URL});
0018: expect(this.component.select('feedItem').length).toBe(1);
0019: });

Our tests will make sure that our component is correctly emitting the synthetic addFeed event when the user submits the form, and that it responds to that same addFeed event to insert the feed into the list. It might seem unusual to break this method apart, but this approach will prove useful when we need other components to provide data when a feed is added (such as the feed title). We'll now break our FeedManager.addFeed method into two parts: one to translate the native submit event into an addFeed event, and one to respond to that event.

0001:    this.submitFeed = function(event) {
0002: event.preventDefault();
0003: var formData = extractFormData(event);
0004: // trigger the synthetic event with our form data
0005: this.trigger('addFeed', formData);
0006: };
0007: this.addFeed = function(event, feedData) {
0008: $(feedListItemTemplate).
0009: find('.url').text(feedData.feedUrl).end().
0010: appendTo(this.select('feedList'));
0011: };
0012: this.after('initialize', function() {
0013: this.on('submit', {
0014: "addForm": this.submitFeed
0015: });
0016: this.on(document, 'addFeed', this.addFeed);
0017: });

By breaking our feed addition workflow into two distinct pieces and linking them with a synthetic event, we've gained two major advantages. First and foremost, our component now communicates changes to the entire page so other components may interact with it - an extremely important characteristic for an application consisting exclusively of small moving parts. Secondly, our component has now become easier to test. Our last unit test replaces the test where we inspect the component DOM to assert a feed is correctly added, and that test now asserts that our FeedManager correctly displays newly added feeds. We can apply this same methodology to removing feeds and complete our component.

0001:    it("should emit a 'removeFeed' event when a feed is removed", function() {
0002: var eventSpy = spyOnEvent(document, 'removeFeed');
0003: this.submitFeed(FEED_URL);
0004: // verify our event listener has not been called yet
0005: expect(eventSpy).not.toHaveBeenTriggeredOn(document);
0006: // remove the feed
0007: this.component.select('feedItem').find('.remove').click();
0008: // verify the event listener was called with the correct feed data
0009: expect(eventSpy).toHaveBeenTriggeredOn(document);
0010: expect(eventSpy.mostRecentCall.args[1]).toEqual({
0011: feedUrl: FEED_URL
0012: });
0013: });
0014: it("should respond to the 'removeFeed' event by removing a feed from the list",
0015:function() {
0016: this.submitFeed(FEED_URL);
0017: expect(this.component.select('feedItem').length).toBe(1);
0018: this.component.trigger('removeFeed', {feedUrl: FEED_URL});
0019: expect(this.component.select('feedItem')).not.toExist();
0020: });

This test looks nearly identical to our test for adding feeds, and so will our implementation:

0001:     this.removeFeed = function(event, feed) {
0002: var feedRow = this.select('feedItem').filter(function() {
0003: return $(this).find('.url').text() === feed.feedUrl;
0004: });
0005: feedRow.remove();
0006: };
0007: this.sendRemoveFeed = function(event) {
0008: var feedRow = $(event.target).closest(this.attr.feedItem);
0009: var feed = {
0010: feedUrl: feedRow.find('.url').text()
0011: };
0012: this.trigger('removeFeed', feed);
0013: };
0014: this.after('initialize', function() {
0015: this.on('click', {
0016: "removeFeed": this.sendRemoveFeed
0017: });
0018: this.on(document, 'removeFeed', this.removeFeed);
0019: });

All of our tests, both the new ones and our existing suite, are passing! We now have a well-behaved Flight component useful for managing our RSS feeds. Commit the changes and take a breath; thinking with components is very different from most web frameworks and it does take some getting used to!

Conclusion

In this chapter, we built a Flight component from the ground up. We started by examining our requirements - that a user be able to add and remove feeds - and gradually added features to our component. Once it worked as we expected, we refactored it to use more of the facilities Flight has to offer, focusing on making the component trigger and respond to events. Building components with events in mind is critical in developing applications with Flight; the more decoupled a component is between synthesizing events and responding to them, the easier it is to create new components that interface cleanly with existing components. In the next chapter, we'll create another component that interacts with our existing component and provides the missing title for our added RSS feeds.

There has been error in communication with Booktype server. Not sure right now where is the problem.

You should refresh this page.