← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/batch-nav-ajax into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/batch-nav-ajax into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/batch-nav-ajax/+merge/78823

Provide infrastructure to support ajax batch navigation widgets ie load the next batch without refreshing the entire page.

== Implementation ==

The implementation was chosen to have the least change to existing code. The href url from each navigation link when clicked is simply submitted as a xhr get request and the result is the new table html for the next batch to be rendered. The existing view which renders the entire page is reused to render the next batch of results but the tales template used is changed to just provide the required data. The javascript which wires everything up appends a query parameter - batch_request - to the url which is submitted, This is used by the view to decide whether to render the entire page or just the new batch of results. The mechanism also very easily supports more than one batched table on the one page - each is given its own batch_request value.

This is done behind a feature flag: ajax.batch_navigator.enabled

The code changes to make it work are trivial:

1. Provide a new template property implementation for the view
eg
    bugtask_table_template = ViewPageTemplateFile(
        '../templates/bugs-table-include.pt')

    @property
    def template(self):
        query_string = self.request.get('QUERY_STRING') or ''
        query_params = urlparse.parse_qs(query_string)
        if 'batch_request' in query_params:
            return self.bugtask_table_template
        else:
            return super(BugTaskSearchListingView, self).template

2. Provide a new tales file with just the markup for the table
eg
<tal:branchlisting define="branches view/branches">
  <tal:branchlisting content="structure branches/@@+branch-listing" />
</tal:branchlisting>

3. Include a snippet of javascript in the main tales file to wire it up
eg
    LPS.use("lp.app.batchnavigator",
        function(Y) {
            Y.on("domready", function () {
                var config = {
                    contentBox: "#bugs-table-listing",
                };
                new Y.lp.app.batchnavigator.BatchNavigatorHooks(config);
            });
        });

The implementation also supports multiple batched tables on the same page. A subsequent branch provides batched bugs and blueprints tables on the +milestone/xyz page

== Demo ==

Try out these pages:
code.lp.net/~user/+branches
code.lp.net/product/+branches
bugs.lp.net/~user and do a bugs search
bugs.lp.net/product and do a bugs search

== Tests ==

Unit tests are written for the bug and branch views affected, plus yui tests for the javascript batching support stuff.
The view tests check that the feature flag is required for the javascript to be wired up, plus the rendering output when the batch_request query parameter is used.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/javascript/ajax_batch_navigator.js
  lib/lp/app/javascript/tests/test_ajax_batch_navigator.html
  lib/lp/app/javascript/tests/test_ajax_batch_navigator.js
  lib/lp/bugs/browser/bugtask.py
  lib/lp/bugs/browser/tests/test_buglisting.py
  lib/lp/bugs/templates/bugs-listing-table.pt
  lib/lp/bugs/templates/bugs-table-include.pt
  lib/lp/code/browser/branchlisting.py
  lib/lp/code/browser/tests/test_branchlisting.py
  lib/lp/code/templates/branch-listing.pt
  lib/lp/code/templates/person-branches-table.pt
  lib/lp/services/features/flags.py

./lib/lp/bugs/templates/bugs-table-include.pt
       1: unbound prefix
./lib/lp/code/templates/person-branches-table.pt
       1: unbound prefix
-- 
https://code.launchpad.net/~wallyworld/launchpad/batch-nav-ajax/+merge/78823
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/batch-nav-ajax into lp:launchpad.
=== added file 'lib/lp/app/javascript/ajax_batch_navigator.js'
--- lib/lp/app/javascript/ajax_batch_navigator.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/ajax_batch_navigator.js	2011-10-12 14:54:03 +0000
@@ -0,0 +1,148 @@
+/* Copyright 2011 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * A module which provides the BatchNavigatorHooks class. This class hooks
+ * into the batch navigation links to provide ajax based navigation.
+ */
+YUI.add('lp.app.batchnavigator', function(Y) {
+
+var namespace = Y.namespace('lp.app.batchnavigator');
+
+function BatchNavigatorHooks(config, io_provider) {
+    if (!Y.Lang.isValue(config.contentBox)) {
+        Y.error("No contentBox specified in config.");
+    }
+    // The contentBox node contains the table and other HTML elements which
+    // will be replaced each time a navigation operation completes.
+    this.contentBox = Y.one(config.contentBox);
+    if (this.contentBox === null ) {
+        Y.error("Invalid contentBox '" + config.contentBox +
+                "' specified in config.");
+    }
+
+    // LP client and error handling.
+    this.lp_client = new Y.lp.client.Launchpad({io_provider: io_provider});
+    this.error_handler = new Y.lp.client.ErrorHandler();
+    this.error_handler.clearProgressUI = Y.bind(this.hideSpinner, this);
+    this.error_handler.showError = Y.bind(function (error_msg) {
+        Y.lp.app.errors.display_error(undefined, error_msg);
+    }, this);
+
+    // We normally make an XHR call to the same batch navigation links as
+    // rendered but we also support getting data from a different specific
+    // view defined on the content object.
+    if (config.view_link !== undefined) {
+        var link_url_base =
+            LP.cache.context.self_link + '/' + config.view_link;
+        this.link_url_base = link_url_base.replace('/api/devel', '');
+    }
+
+    // We add a query parameter called 'batch_request' to the URL so that the
+    // view knows it needs to only render the table data. We support more than
+    // one ajax batch navigator on a page and this parameter is used to tell
+    // the view which one was clicked.
+    this.batch_request_value = 'True';
+    if (config.batch_request_value !== undefined) {
+        this.batch_request_value = config.batch_request_value;
+    }
+
+    // We support invoked a user defined function after results have been
+    // refreshed.
+    this.post_refresh_hook = config.post_refresh_hook;
+    this._connect_links();
+}
+
+namespace.BatchNavigatorHooks = BatchNavigatorHooks;
+
+/**
+ * A function to wire up the batch navigation links.
+ */
+BatchNavigatorHooks.prototype._connect_links = function() {
+    if (Y.Lang.isFunction(this.post_refresh_hook)) {
+        this.post_refresh_hook();
+    }
+    var self = this;
+    self.links_active = true;
+    self.nav_links = [];
+    Y.Array.each(['first', 'previous', 'next', 'last'], function(link_type) {
+        self.contentBox.all(
+            'a.'+link_type+', span.'+link_type).each(function(nav_link) {
+            var href = nav_link.get('href');
+            if (href !== undefined) {
+                var link_url = href;
+                // We either use a custom URL with the batch control
+                // parameters appended or append the batch_request parameter
+                // to the standard batch navigation links.
+                if (self.link_url_base !== undefined) {
+                    var urlparts = href.split('?');
+                    link_url = self.link_url_base + '?' + urlparts[1];
+                }
+                if (link_url.indexOf('batch_request=') < 0) {
+                    link_url += '&batch_request=' + self.batch_request_value;
+                }
+                nav_link.addClass('js-action');
+                nav_link.on('click', function(e) {
+                    e.preventDefault();
+                    if (self.links_active) {
+                        self._link_handler(link_url);
+                    }
+                });
+            }
+            self.nav_links.push(nav_link);
+        });
+    });
+};
+
+/**
+ * The function which fetches the next batch of data and displays it.
+ * @param link_url the URL to invoke to get the data.
+ */
+BatchNavigatorHooks.prototype._link_handler = function(link_url) {
+    var self = this;
+    var y_config = {
+        method: "GET",
+        headers: {'Accept': 'application/json;'},
+        data: '',
+        on: {
+            start: function() {
+                self.showSpinner();
+            },
+            success: function(id, result) {
+                self.hideSpinner();
+                self.contentBox.set('innerHTML', result.responseText);
+                self._connect_links();
+            },
+            failure: self.error_handler.getFailureHandler()
+        }
+    };
+    this.lp_client.io_provider.io(link_url, y_config);
+};
+
+BatchNavigatorHooks.prototype.showSpinner = function() {
+    // We make all the nav links inactive and show spinner(s) before the
+    // 'First' links.
+    this.links_active = false;
+    Y.each(this.nav_links, function(nav_link) {
+        nav_link.addClass('inactive');
+        if (nav_link.hasClass('first')) {
+            var spinner_node = Y.Node.create(
+            '<img class="spinner" src="/@@/spinner" alt="Loading..." />');
+            nav_link.insertBefore(spinner_node, nav_link);
+        }
+    });
+};
+
+BatchNavigatorHooks.prototype.hideSpinner = function() {
+    // Remove the spinner(s) and make links active again.
+    this.links_active = true;
+    this.contentBox.all('.spinner').remove();
+    Y.each(this.nav_links, function(nav_link) {
+        var href = nav_link.get('href');
+        if (href !== undefined) {
+            nav_link.removeClass('inactive');
+        }
+    });
+};
+
+}, "0.1", {"requires": [
+    "dom", "node", "event", "io-base", "lp.client", "lp.app.errors"]});

=== added file 'lib/lp/app/javascript/tests/test_ajax_batch_navigator.html'
--- lib/lp/app/javascript/tests/test_ajax_batch_navigator.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/tests/test_ajax_batch_navigator.html	2011-10-12 14:54:03 +0000
@@ -0,0 +1,32 @@
+<html>
+  <head>
+  <title>Ajax Batch Navigator</title>
+
+  <!-- YUI and test setup -->
+  <script type="text/javascript"
+          src="../../../../canonical/launchpad/icing/yui/yui/yui.js">
+  </script>
+  <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+  <script type="text/javascript"
+          src="../../../app/javascript/testing/testrunner.js"></script>
+
+    <script type="text/javascript" src="../client.js"></script>
+    <script type="text/javascript" src="../errors.js"></script>
+    <script type="text/javascript" src="../lp.js"></script>
+
+    <!-- Other dependencies -->
+    <script type="text/javascript" src="../testing/mockio.js"></script>
+
+    <!-- The module under test -->
+    <script type="text/javascript" src="../ajax_batch_navigator.js"></script>
+
+    <!-- The test suite -->
+    <script type="text/javascript" src="test_ajax_batch_navigator.js"></script>
+  </head>
+  <body class="yui3-skin-sam">
+    <!-- The example markup required by the script to run -->
+    <div class="test-hook">
+    </div>
+
+  </body>
+</html>

=== added file 'lib/lp/app/javascript/tests/test_ajax_batch_navigator.js'
--- lib/lp/app/javascript/tests/test_ajax_batch_navigator.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/tests/test_ajax_batch_navigator.js	2011-10-12 14:54:03 +0000
@@ -0,0 +1,181 @@
+/* Copyright 2011 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ */
+
+YUI().use('lp.testing.runner', 'lp.testing.mockio', 'base', 'test', 'console',
+          'node', 'node-event-simulate', 'lp.app.batchnavigator',
+    function(Y) {
+
+    var suite = new Y.Test.Suite("lp.app.batchnavigator Tests");
+    var module = Y.lp.app.batchnavigator;
+
+    var BatchNavigatorTestMixin = {
+
+        setUp: function() {
+            this.findRootTag().setContent('');
+            window.LP = {
+                cache: {
+                    context: {self_link: 'http://foo'}
+                }
+            };
+        },
+
+        tearDown: function() {
+            if (this.navigator !== undefined) {
+                delete this.navigator;
+            }
+            delete window.LP;
+        },
+
+        findRootTag: function() {
+            return Y.one('.test-hook');
+        },
+
+        makeNode: function(node_type, id, css_class) {
+            var node = Y.Node.create(
+                    Y.Lang.substitute(
+                            '<{node_type}></{node_type}>',
+                            {node_type: node_type}));
+            if (id !== undefined) {
+                node.setAttribute('id', id);
+            }
+            if (css_class !== undefined) {
+                node.addClass(css_class);
+            }
+            return node;
+        },
+
+        makeNavLink: function(cell, link_type, active) {
+            if (active) {
+                cell.appendChild(
+                        this.makeNode('a', undefined, link_type))
+                    .set('href', 'http://' + link_type + '?memo=0')
+                    .setContent(link_type);
+            } else {
+                cell.appendChild(this.makeNode('span', undefined, link_type))
+                    .addClass('inactive')
+                    .setContent(link_type);
+            }
+        },
+
+        makeNavigatorHooks: function(args) {
+            if (!Y.Lang.isValue(args)) {
+                args = {};
+            }
+            var root = this.findRootTag();
+            var batch_links = this.makeNode('div', 'batch-links');
+            var table = batch_links.appendChild(this.makeNode(
+                    'table', undefined, 'upper-batch-nav'));
+            var row = table.appendChild(this.makeNode('tr'));
+            var cell = row.appendChild(
+                    this.makeNode('td', undefined, 'batch-navigation-links'));
+            this.makeNavLink(cell, 'first', false);
+            this.makeNavLink(cell, 'previous', false);
+            this.makeNavLink(cell, 'next', true);
+            this.makeNavLink(cell, 'last', true);
+            root.appendChild(batch_links);
+            return root;
+        },
+
+        makeNavigator: function(root, args) {
+            if (!Y.Lang.isValue(args)) {
+                args = {};
+            }
+            if (root === undefined) {
+                root = this.makeNavigatorHooks();
+            }
+            var extra_config = args.config;
+            if (extra_config === undefined) {
+                extra_config = {};
+            }
+            var config = Y.mix(
+                extra_config, {contentBox: root});
+            var navigator = new module.BatchNavigatorHooks(
+                config, args.io_provider);
+            this.navigator = navigator;
+            return navigator;
+        }
+    };
+
+    suite.add(new Y.Test.Case(
+        Y.merge(BatchNavigatorTestMixin, {
+
+        name: 'batchnavigator',
+
+        test_navigator_construction: function() {
+            this.makeNavigator(this.makeNavigatorHooks());
+        },
+
+        _test_enabled_link_click: function(link_type, view_url) {
+            var mockio = new Y.lp.testing.mockio.MockIo();
+            this.makeNavigator(
+                this.makeNavigatorHooks(),
+                {io_provider: mockio,
+                 config: {
+                     batch_request_value: 'foobar',
+                     view_link: view_url}});
+            Y.one('#batch-links a.'+link_type).simulate('click');
+            mockio.success({
+                responseText: '<p>Batch content</p>',
+                responseHeaders: {'Content-Type': 'text/html'}});
+
+            // The URL has the batch_request parameter added.
+            var expected_link;
+            if (view_url !== undefined) {
+                expected_link = 'http://foo/+somewhere';
+            } else {
+                expected_link = 'http://' + link_type + '/';
+            }
+            expected_link += '?memo=0&batch_request=foobar';
+            Y.Assert.areEqual(expected_link, mockio.last_request.url);
+            // The content is rendered.
+            Y.Assert.areEqual(
+                '<p>Batch content</p>',
+                this.findRootTag().getContent());
+        },
+
+        // The following link tests check that the enabled navigation links
+        // work as expected. We test the 'next' and 'last' links. The 'first'
+        // and 'previous' links are not enabled.
+
+        test_next_link: function() {
+            this._test_enabled_link_click('next');
+        },
+
+        test_last_link: function() {
+            this._test_enabled_link_click('last');
+        },
+
+        // We an specify a different URL to use to fetch the batch data from.
+        test_link_with_view_url: function() {
+            this._test_enabled_link_click('last', '+somewhere');
+        },
+
+        test_show_spinner: function() {
+            var navigator = this.makeNavigator(this.makeNavigatorHooks());
+            navigator.showSpinner();
+            Y.Assert.isFalse(navigator.links_active);
+            Y.Assert.isTrue(
+                this.findRootTag().all('.spinner').size() > 0);
+            this.findRootTag().all('a', function(link) {
+                Y.Assert.isTrue(link.hasClass('inactive'));
+            });
+        },
+
+        test_hide_spinner: function() {
+            var navigator = this.makeNavigator(this.makeNavigatorHooks());
+            navigator.showSpinner();
+            navigator.hideSpinner();
+            Y.Assert.isTrue(navigator.links_active);
+            Y.Assert.isFalse(
+                this.findRootTag().all('.spinner').size() > 0);
+            this.findRootTag().all('a', function(link) {
+                Y.Assert.isFalse(link.hasClass('inactive'));
+            });
+        }
+    })));
+
+    Y.lp.testing.Runner.run(suite);
+
+});
+

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-10-05 18:02:45 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-10-12 14:54:03 +0000
@@ -56,6 +56,7 @@
 import re
 import transaction
 import urllib
+import urlparse
 
 from lazr.delegates import delegates
 from lazr.enum import (
@@ -2462,6 +2463,18 @@
                 "Unrecognized context; don't know which report "
                 "columns to show.")
 
+    bugtask_table_template = ViewPageTemplateFile(
+        '../templates/bugs-table-include.pt')
+
+    @property
+    def template(self):
+        query_string = self.request.get('QUERY_STRING') or ''
+        query_params = urlparse.parse_qs(query_string)
+        if 'batch_request' in query_params:
+            return self.bugtask_table_template
+        else:
+            return super(BugTaskSearchListingView, self).template
+
     def validate_search_params(self):
         """Validate the params passed for the search.
 

=== modified file 'lib/lp/bugs/browser/tests/test_buglisting.py'
--- lib/lp/bugs/browser/tests/test_buglisting.py	2011-09-29 14:46:57 +0000
+++ lib/lp/bugs/browser/tests/test_buglisting.py	2011-10-12 14:54:03 +0000
@@ -10,6 +10,7 @@
 
 from canonical.launchpad.testing.pages import (
     extract_text,
+    find_main_content,
     find_tag_by_id,
     find_tags_by_class,
     )
@@ -18,6 +19,7 @@
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.bugs.model.bugtask import BugTask
 from lp.registry.model.person import Person
+from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     BrowserTestCase,
     login_person,
@@ -179,6 +181,34 @@
             canonical_url(product, rootsite='bugs', view_name='+bugs'),
             response.getHeader('Location'))
 
+    def test_search_batch_request(self):
+        # A search request with a 'batch_request' query parameter causes the
+        # view to just render the next batch of results.
+        product = self.factory.makeProduct()
+        form = {'search': 'Search'}
+        view = create_initialized_view(
+            product, '+bugs', form=form, query_string='batch_request=True')
+        content = view()
+        self.assertIsNone(find_main_content(content))
+        self.assertIsNotNone(
+            find_tag_by_id(content, 'bugs-batch-links-upper'))
+
+    def test_ajax_batch_navigation_feature_flag(self):
+        # The Javascript to wire up the ajax batch navigation behavior is
+        # correctly hidden behind a feature flag.
+        product = self.factory.makeProduct()
+        form = {'search': 'Search'}
+        with person_logged_in(product.owner):
+            product.official_malone = True
+        flags = {u"ajax.batch_navigator.enabled": u"true"}
+        with FeatureFixture(flags):
+            view = create_initialized_view(product, '+bugs', form=form)
+            self.assertTrue(
+                'Y.lp.app.batchnavigator.BatchNavigatorHooks' in view())
+        view = create_initialized_view(product, '+bugs', form=form)
+        self.assertFalse(
+            'Y.lp.app.batchnavigator.BatchNavigatorHooks' in view())
+
 
 class BugTargetTestCase(TestCaseWithFactory):
     """Test helpers for setting up `IBugTarget` tests."""

=== modified file 'lib/lp/bugs/templates/bugs-listing-table.pt'
--- lib/lp/bugs/templates/bugs-listing-table.pt	2011-05-27 18:10:50 +0000
+++ lib/lp/bugs/templates/bugs-listing-table.pt	2011-10-12 14:54:03 +0000
@@ -1,11 +1,11 @@
-<div
+<div id='bugs-table-listing'
   xmlns:tal="http://xml.zope.org/namespaces/tal";
   xmlns:metal="http://xml.zope.org/namespaces/metal";
   xmlns:i18n="http://xml.zope.org/namespaces/i18n";
 >
   <tal:no-results condition="not: context/batch">
     <tal:search-performed condition="request/search|nothing">
-      <p>No results for search 
+      <p>No results for search
         <strong tal:content="request/field.searchtext|nothing" /></p>
     </tal:search-performed>
 
@@ -19,4 +19,18 @@
     <div tal:replace="structure context/@@+table-view-without-navlinks" />
     <div class="lesser" tal:content="structure context/@@+navigation-links-lower" />
   </tal:results>
+
+  <tal:comment
+    tal:condition="request/features/ajax.batch_navigator.enabled"
+    replace='structure string:&lt;script type="text/javascript"&gt;
+    LPS.use("lp.app.batchnavigator",
+        function(Y) {
+            Y.on("domready", function () {
+                var config = {
+                    contentBox: "#bugs-table-listing",
+                };
+                new Y.lp.app.batchnavigator.BatchNavigatorHooks(config);
+            });
+        });
+  &lt;/script&gt;'/>
 </div>

=== added file 'lib/lp/bugs/templates/bugs-table-include.pt'
--- lib/lp/bugs/templates/bugs-table-include.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/templates/bugs-table-include.pt	2011-10-12 14:54:03 +0000
@@ -0,0 +1,5 @@
+<tal:bugtask-batch define="batch_navigator view/search">
+    <div id='bugs-batch-links-upper' class="lesser" tal:content="structure batch_navigator/@@+navigation-links-upper" />
+    <tal:buglisting replace="structure batch_navigator/@@+table-view-without-navlinks" />
+    <div id='bugs-batch-links-lower' class="lesser" tal:content="structure batch_navigator/@@+navigation-links-lower" />
+</tal:bugtask-batch>

=== modified file 'lib/lp/code/browser/branchlisting.py'
--- lib/lp/code/browser/branchlisting.py	2011-08-09 15:55:17 +0000
+++ lib/lp/code/browser/branchlisting.py	2011-10-12 14:54:03 +0000
@@ -41,6 +41,8 @@
     Asc,
     Desc,
     )
+import urlparse
+from z3c.ptcompat import ViewPageTemplateFile
 from zope.component import getUtility
 from zope.formlib import form
 from zope.interface import (
@@ -560,6 +562,19 @@
         """
         return self.label
 
+    table_only_template = ViewPageTemplateFile(
+        '../templates/person-branches-table.pt')
+
+    @property
+    def template(self):
+        query_string = self.request.get('QUERY_STRING') or ''
+        query_params = urlparse.parse_qs(query_string)
+        render_table_only = 'batch_request' in query_params
+        if render_table_only:
+            return self.table_only_template
+        else:
+            return super(BranchListingView, self).template
+
     @property
     def initial_values(self):
         return {

=== modified file 'lib/lp/code/browser/tests/test_branchlisting.py'
--- lib/lp/code/browser/tests/test_branchlisting.py	2011-08-09 15:55:17 +0000
+++ lib/lp/code/browser/tests/test_branchlisting.py	2011-10-12 14:54:03 +0000
@@ -19,7 +19,7 @@
 from canonical.launchpad.testing.pages import (
     extract_text,
     find_tag_by_id,
-    )
+    find_main_content)
 from canonical.launchpad.webapp import canonical_url
 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
 from canonical.testing.layers import DatabaseFunctionalLayer
@@ -42,6 +42,7 @@
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.model.person import Owner
 from lp.registry.model.product import Product
+from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     BrowserTestCase,
     login_person,
@@ -119,7 +120,35 @@
             registrant_order)
 
 
-class TestPersonOwnedBranchesView(TestCaseWithFactory):
+class AjaxBatchNavigationMixin:
+    def _test_search_batch_request(self, context, user=None):
+        # A search request with a 'batch_request' query parameter causes the
+        # view to just render the next batch of results.
+        view = create_initialized_view(
+            context, name="+branches", rootsite='code',
+            principal=user, query_string='batch_request=True')
+        content = view()
+        self.assertIsNone(find_main_content(content))
+        self.assertIsNotNone(
+            find_tag_by_id(content, 'branches-table-listing'))
+
+    def _test_ajax_batch_navigation_feature_flag(self, context, user=None):
+        # The Javascript to wire up the ajax batch navigation behavior is
+        # correctly hidden behind a feature flag.
+        flags = {u"ajax.batch_navigator.enabled": u"true"}
+        with FeatureFixture(flags):
+            view = create_initialized_view(
+                context, name="+branches", rootsite='code', principal=user)
+            self.assertTrue(
+                'Y.lp.app.batchnavigator.BatchNavigatorHooks' in view())
+        view = create_initialized_view(
+            context, name="+branches", rootsite='code', principal=user)
+        self.assertFalse(
+            'Y.lp.app.batchnavigator.BatchNavigatorHooks' in view())
+
+
+class TestPersonOwnedBranchesView(TestCaseWithFactory,
+                                  AjaxBatchNavigationMixin):
 
     layer = DatabaseFunctionalLayer
 
@@ -136,7 +165,7 @@
             self.factory.makeProductBranch(
                 product=self.bambam, owner=self.barney,
                 date_created=time_gen.next())
-            for i in range(5)]
+            for i in range(10)]
         self.bug = self.factory.makeBug()
         self.bug.linkBranch(self.branches[0], self.barney)
         self.spec = self.factory.makeSpecification()
@@ -177,7 +206,8 @@
     def test_tip_revisions(self):
         # _branches_for_current_batch should return a list of all branches in
         # the current batch.
-        branch_ids = [branch.id for branch in self.branches]
+        # The batch size is 6
+        branch_ids = [branch.id for branch in self.branches[:6]]
         tip_revisions = {}
         for branch_id in branch_ids:
             tip_revisions[branch_id] = None
@@ -188,6 +218,17 @@
             view.branches().tip_revisions,
             tip_revisions)
 
+    def test_search_batch_request(self):
+        # A search request with a 'batch_request' query parameter causes the
+        # view to just render the next batch of results.
+        self._test_search_batch_request(self.barney, self.barney)
+
+    def test_ajax_batch_navigation_feature_flag(self):
+        # The Javascript to wire up the ajax batch navigation behavior is
+        # correctly hidden behind a feature flag.
+        self._test_ajax_batch_navigation_feature_flag(
+            self.barney, self.barney)
+
 
 class TestSourcePackageBranchesView(TestCaseWithFactory):
 
@@ -440,7 +481,8 @@
         self.assertIn('a moment ago', view())
 
 
-class TestProjectGroupBranches(TestCaseWithFactory):
+class TestProjectGroupBranches(TestCaseWithFactory,
+                               AjaxBatchNavigationMixin):
     """Test for the project group branches page."""
 
     layer = DatabaseFunctionalLayer
@@ -527,6 +569,20 @@
         table = find_tag_by_id(view(), "branchtable")
         self.assertIsNot(None, table)
 
+    def test_search_batch_request(self):
+        # A search request with a 'batch_request' query parameter causes the
+        # view to just render the next batch of results.
+        product = self.factory.makeProduct(project=self.project)
+        self._test_search_batch_request(product)
+
+    def test_ajax_batch_navigation_feature_flag(self):
+        # The Javascript to wire up the ajax batch navigation behavior is
+        # correctly hidden behind a feature flag.
+        product = self.factory.makeProduct(project=self.project)
+        for i in range(10):
+            self.factory.makeProductBranch(product=product)
+        self._test_ajax_batch_navigation_feature_flag(product)
+
 
 class FauxPageTitleContext:
 

=== modified file 'lib/lp/code/templates/branch-listing.pt'
--- lib/lp/code/templates/branch-listing.pt	2011-06-30 15:36:20 +0000
+++ lib/lp/code/templates/branch-listing.pt	2011-10-12 14:54:03 +0000
@@ -1,4 +1,4 @@
-<div
+<div id="branches-table-listing"
   xmlns:tal="http://xml.zope.org/namespaces/tal";
   xmlns:metal="http://xml.zope.org/namespaces/metal";
 >
@@ -39,7 +39,6 @@
         div.style.display = "none";
     }
 }
-registerLaunchpadFunction(hookUpFilterSubmission);
 
 </script>
 
@@ -189,4 +188,18 @@
 
   <tal:navigation replace="structure context/@@+navigation-links-lower" />
 
+  <tal:comment
+    tal:condition="request/features/ajax.batch_navigator.enabled"
+    replace='structure string:&lt;script type="text/javascript"&gt;
+    LPS.use("lp.app.batchnavigator",
+        function(Y) {
+            Y.on("domready", function () {
+                var config = {
+                    contentBox: "#branches-table-listing",
+                    post_refresh_hook: hookUpFilterSubmission
+                };
+                new Y.lp.app.batchnavigator.BatchNavigatorHooks(config);
+            });
+        });
+  &lt;/script&gt;'/>
 </div>

=== added file 'lib/lp/code/templates/person-branches-table.pt'
--- lib/lp/code/templates/person-branches-table.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/person-branches-table.pt	2011-10-12 14:54:03 +0000
@@ -0,0 +1,3 @@
+<tal:branchlisting define="branches view/branches">
+  <tal:branchlisting content="structure branches/@@+branch-listing" />
+</tal:branchlisting>

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py	2011-09-28 14:34:30 +0000
+++ lib/lp/services/features/flags.py	2011-10-12 14:54:03 +0000
@@ -156,6 +156,11 @@
      ('Enables the longpoll mechanism for merge proposals so that diffs, '
       'for example, are updated in-page when they are ready.'),
      ''),
+    ('ajax.batch_navigator.enabled',
+     'boolean',
+     'If true, batch navigators which have been wired to do so use ajax '
+     'calls to load the next batch of data',
+     ''),
     ])
 
 # The set of all flag names that are documented.