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

Developing a Twitter Flight Edge

6. Stateful UI Components

In this chapter, we will:

  • Develop a component with internally managed state
  • Insert the new component into the existing application ecosystem

In the last chapter, we developed a "data" component that we use to provide relevant feed data to our application. When we were developing it, our relatively simple FeedManager was our only UI component. Now we will develop a more complicated FeedAggregator component that will listen for the same events but manage the received data differently to accomplish its purpose.

The Feed Aggregator

Now that we have a way to enter RSS feeds and a data component to fetch our data, we will need to display entries within those feeds to our user. Let's set up our application to include this new module with the same markup we've seen in previous chapters:

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

Now that our component exists in our running application, we need to incorporate it so it can interact with other components on the page. Since the component will be displaying the details of specific feed items, it will register as an interested party to the already-implemented dataFeedInfo event. It will not, however, need to request this data, so it will not emit a uiNeedsFeedInfo event. In handling the data response, our component will need to make sure that existing items are not duplicated. We will also implement filtering by source to see only items from a given RSS feed. We will start with the same basic component functionality we saw with the FeedManager and build on that.

0001:    // Now that we are testing RSS feed entries themselves, we'll need fixture data.
0002: // See tests/feed_aggregator_spec.js for the contents of FEED_DATA, FEED_ITEMS,
0003: // OTHER_FEED_DATA, and OTHER_FEED_ITEMS.
0004: it("should have no feed items when it is created", function() {
0005: expect(this.component.select('feedItem').length).toBe(0);
0006: });
0007: it("should listen for the 'dataFeedInfo' event and insert entries into the
0008:component", function() {
0009: this.component.trigger('dataFeedInfo', FEED_DATA);
0010: expect(this.component.select('feedItem').length).toBe(FEED_ITEMS.length);
0011: });

And an equally simple implementation to get our tests green:

0001:    define(function(require) {
0002: var defineComponent = require('flight/lib/component');
0003: var template = '<div class="feed"></div>';
0004: function FeedAggregator() {
0005: this.defaultAttrs({
0006: "feedItem": ".feed"
0007: });
0008: this.updateFeeds = function(event, feedData) {
0009: if (feedData.entries) {
0010: feedData.entries.forEach(this.insertEntry, this);
0011: }
0012: };
0013: this.insertEntry = function(entry) {
0014: $(template).appendTo(this.$node);
0015: };
0016: this.after('initialize', function() {
0017: this.on(document, 'dataFeedInfo', this.updateFeeds);
0018: });
0019: }
0020: return defineComponent(FeedAggregator);
0021: });</
0022:

This component now has the basic workings of our FeedAggregator - it will listen for the appropriate "data provided" event and insert entries provided by that event into the bound DOM element. Note that we've separated the act of inserting an entry into our component from the function that processes the received data. It's a convenient way to make our code easier to read and follows the idea that work functions should be distinct and separate. The function bound as our event listener extracts the individual entries from the feed data provided by the event and inserts each one individually, allowing us to manage the event handling code separately from the UI update code. Of course, it doesn't actually display the data yet, so let's quickly build that functionality before moving on. Doing so will let us interact with our app to demonstrate the behavior we will then move on to eliminate - duplicate entries.

0001:    it("should insert a feed item that display the relevant entry information", 
0002:function() {
0003: this.component.trigger('dataFeedInfo', FEED_DATA);
0004: expect(this.component.select('feedItem').length).toBe(FEED_ITEMS.length);
0005: this.component.select('feedItem').each(function(index) {
0006: var feedItem = $(this);
0007: expect(feedItem.find('.title').text()).toBe(FEED_ITEMS[index].title);
0008: expect(feedItem.find('.link').attr('href')).toBe(FEED_ITEMS[index].link);
0009: expect(feedItem.find('.snippet').text()).toBe(FEED_ITEMS[index]
0010:.contentSnippet);
0011: });
0012: });

All we need is a template and a formatter to get our tests green:

0001:    // in js/app/feed-aggregator.js
0002: this.insertEntry = function(entry) {
0003: $(template).
0004: find('.title').text(entry.title).end().
0005: find('.link').attr('href', entry.link).end().
0006: find('.snippet').text(entry.contentSnippet).end().
0007: appendTo(this.$node);
0008: }
0009: };
0010: // in js/tmpl/feed-item.html
0011: <div class="feed">
0012: <h2><a class="title link"></a></h2>
0013: <h3 class="author"></h3>
0014: <p class="snippet"></p>
0015: </div>

Nothing so far is particularly different from what we've seen with the FeedManager, but we needed some groundwork to demonstrate some unexpected behavior. Open the application, enter any valid RSS feed, and submit the form. The FeedManager will add it to the list and fire off a uiNeedsFeedInfo event, which our FeedService will listen for and respond with a dataFeedInfo when the data comes back, which will cause our FeedAggregator to display the list of entries for that feed. Now enter the same feed URL and submit it again; you'll see that our FeedManager and FeedAggregator both oblige the same set of events by creating new, duplicate entries for the feed and displaying the data twice. Let's look at one possible solution to prevent that from happening.

Storing Component State in the DOM

As we look at some of the different solutions available to prevent duplicating RSS content, it will be helpful to have tests in place to tell us whether or not our implementation works. Proper behavior-driven design will have us write a test that treats our component as a black box with some valid set of inputs and assertions on the resulting outputs. 

0001:    it("should not insert duplicate entries into the list of displayed feeds", 
0002:function() {
0003: this.component.trigger('dataFeedInfo', FEED_DATA);
0004: expect(this.component.select('feedItem').length).toBe(FEED_ITEMS.length);
0005: this.component.trigger('dataFeedInfo', FEED_DATA);
0006: expect(this.component.select('feedItem').length).toBe(FEED_ITEMS.length);
0007: });

Avoiding the temptation to peek at the component's internals will save us some trouble when we refactor it to use different strategies. Our test recreates the scenario we just played out from the event communication perspective; we send identical data twice to our component and expect that it does not appear to change the number of visible feed entries. Let's now look at one possible way to prevent data duplication - inspecting the existing rendered entries and ignoring insertion calls for entries that are already displayed. We may have to re-examine our choice when we implement filtering by source since this approach doesn't scale well, but for our current purposes it will work fine.

0001:    this.insertEntry = function(entry) {
0002: // detect an entry's existence by looking for a feed item
0003: // with a link that points to the same place
0004: var exists = (this.select('feedItem').
0005: find('.link[href="' + entry.link + '"]').length > 0);
0006: if (!exists) {
0007: $(template).
0008: find('.title').text(entry.title).end().
0009: find('.link').attr('href', entry.link).end().
0010: find('.snippet').text(entry.contentSnippet).end().
0011: appendTo(this.$node);
0012: }
0013: };

Our validation check inspects all of the currently-displayed feed entries to see if any of them have the same destination (via the link property) as the entry being inserted. If no entry with a matching destination is found, then we format the template and insert it into the rendered set of entries. It's enough to get our tests passing and demonstrates a convenient way to store & inspect state by storing it in the DOM. Some UI components may only be interested in some small part of the data responses, like our FeedManager. In those cases, where the entire set of relevant data is displayed to the user and only a very small subset of the source data is used, using the DOM as a state storage device can work well enough to be useful. However, when the needs for managing state get too complicated, constantly querying the DOM and extracting data from it can get slow, unwieldly, and difficult to manage.

Storing Component State on the Component

We'll now implement a way to filter the displayed feed entries and test the limits of our DOM state management solution. The first step is to create the select element itself and make sure it updates with new source feeds as they are added.

0001:    it("should present the user with a way to filter the displayed entries by 
0002:source", function() {
0003: var options = this.component.select('filterSelector').find('option')
0004:.filter(function() {
0005: return $(this).val().length > 0;
0006: });
0007: expect(options.length).toBe(0);
0008: this.component.trigger('dataFeedInfo', FEED_DATA);
0009: options = this.component.select('filterSelector').find('option').filter(function() {
0010: return $(this).val().length > 0;
0011: });
0012: expect(options.length).toBe(1);
0013: });

This test checks for some filterSelector element to be populated with options for each feed added. It should start empty (barring the default "Filter By Source" option), and when a feed is added, an item should be available. Getting this test to pass will involve changing our component's response to the dataFeedInfo event; in addition to populating the list with feed entries, we will now also need to add the feed data to our filter selector. There are two ways to approach this: either we can modify our updateFeeds method, or we can attach another event listener for the dataFeedInfo event to the document. Because our updateFeeds method is relatively simple and delegates out to different functions internally, we will choose option #1.

0001:    this.updateFeeds = function(event, feedData) {
0002: // insert a new option into our select element, if it doesn't already exist
0003: this.insertOption(feedData);
0004: // ...
0005: };
0006: this.insertOption = function(feed) {
0007: // prevent duplicates
0008: var exists = (this.select('filterSelector').
0009: find('option[value="' + feed.link + '"]').length > 0);
0010: if (!exists) {
0011: $(document.createElement('option')).
0012: text(feed.title).
0013: val(feed.feedUrl).
0014: appendTo(this.select('filterSelector'));
0015: }
0016: };

We now have a viable way to present users with the option to filter the list of feed entries by source and can now implement the response to selecting a value. This is where storing data in the DOM can become problematic; while we can inspect our feed entries individually in the FeedManager to determine all uniquely-identifying relevant data, our FeedAggregator item template doesn't contain a value we can use to match the data from our filter selector to an individual entry. While it could be argued that displaying the feed source would be of value to our users, we would be compromising on user facing design just to support our technical implementation, which rarely results in maintainable code. Let's save ourselves a future headache and switch our component's internal representation of the entries to a hash keyed on the feed's feedUrl property to the entries for that feed source. But first, a test to make sure our changes give us the behavior we want:

0001:    it("should only display feed entries from a specific source when the filter 
0002:has a value selected", function() {
0003: this.component.trigger('dataFeedInfo', FEED_DATA);
0004: this.component.trigger('dataFeedInfo', OTHER_FEED_DATA);
0005: expect(this.component.select('feedItem').length).toBe(FEED_ITEMS
0006:.length +
0007:OTHER_FEED_ITEMS.length);
0008: var selector = this.component.select('filterSelector');
0009: selector.val(selector.find('option:last').val());
0010: selector.trigger('change');
0011: expect(this.component.select('feedItem').length).toBe(OTHER_FEED_ITEMS.length);
0012: this.component.select('feedItem').each(function(i) {
0013: expect($(this).find('.title').text()).toBe(OTHER_FEED_ITEMS[i].title);
0014: });
0015: });

We'll need to make a few changes internally. First, we need to store entries when we get them! We'll override all local entries when new feed data arrives to avoid unnecessary additional complexity for now. Second, we don't necessarily want to render feed entries as soon as the data arrives; the user may already have a filter applied and our app's behavior would be incorrect if we displayed them immediately. We'll want to encapsulate our logic in a render method, which will empty out our feed list and re-insert entries based on the filter. Lastly, we'll need to bind changes to the filter selector to a callback to re-render our component.

0001:this.updateFeeds = function(event, feedData) {
0002: // ...
0003: if (feedData.entries) {
0004: this.entries[feedData.feedUrl] = feedData.entries;
0005: this.render();
0006: }
0007: };
0008: this.render = function() {
0009: // blank out the feed list
0010: this.select('feedList').empty();
0011: var source = this.select('filterSelector').val();
0012: if (source) {
0013: // if a source is chosen, only render the entries in that feed
0014: this.entries[source].forEach(this.insertEntry, this);
0015: } else {
0016: // if no source chosen, render all
0017: $.each(this.entries, function(src, entries) {
0018: entries.forEach(this.insertEntry, this);
0019: }.bind(this));
0020: }
0021: };
0022: this.after('initialize', function() {
0023: // ...
0024: this.on('change', { 'filterSelector': this.render });
0025: // internal store of entries
0026: this.entries = {};
0027: });

We've kept the purpose of updateFeeds focused on processing the event and delegated the updating of our UI to the render method. When component methods don't need to process an event directly, such as when the state can be inferred from the component markup (like the current value of the <select> element) or the event doesn't contain useful data (like the change event), they are useful both as work functions from event listeners and as event listeners themselves. We use this flexibility to update our UI when feed data arrives and when the user changes the feed selector's value. Our tests are green, and our app is now able to let users add feeds and view entries in those feeds. The only remaining behaviors to add to our component are displaying entries sorted by recency and removing entries when a feed is removed; since these features are small and easy to implement with the current state of our component, I'll leave them as exercises for you. If you want to peek and see how it's done, I suggest you check out the chapter-5 branch and look for the changes to render and initialize and the corresponding tests. In the mean time, let's admire our handiwork:


There is a third method for storing state associated with components, one used by Twitter for the Timeline component, which attaches relevant metadata as data-* attributes on the DOM nodes themselves. We could have tagged each of our displayed feed items' parent nodes with data-feed-source (for filtering by source and removing feeds) and data-published-date attributes (for sorting by recency), and that solution would have suited our purposes equally as well. We will revisit our decision to store state locally when we look at mixins, which can help alleviate some of the complexity of storing state in the DOM.

Conclusion

In this chapter, we created a new component to display entries within RSS feeds and integrated it into our existing application structure without modifying our other components. Unlike our FeedManager, which stored its state in the DOM, the FeedAggregator component needed a more robust representation of state, which we were able to implement without touching any other component. The result is a relatively complete RSS headline reader, with the ability to add and remove feeds and preview each feeds' entries. In the next chapter, we will build a way to display the full contents of a feed entry and de-clutter the app so all of our components aren't always visible.

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

You should refresh this page.