← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/sharing-view-batching-957663 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/sharing-view-batching-957663 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #957663 in Launchpad itself: "+sharing view needs support for batching"
  https://bugs.launchpad.net/launchpad/+bug/957663

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/sharing-view-batching-957663/+merge/98310

== Implementation ==

Add batching support to the +sharing page. This is really only for corner cases like ubuntu/+sharing. Most all other projects/distros have fewer sharees than the batch size. For those cases, the addition of the batching control changes nothing except that the number of sharees is displayed.

This is also part 1 - part 2 will use StormRangeFactory to make the back end queries more efficient. For now, the back end loads all records and the resulting list is sliced rather than the ResultSet.

The lp.app.listing_navigator.ListingNavigator was sub-classed (ShareeListingNavigator) to do the work, but the first cut resulted in a fair bit of duplicate code with the BugListingNavigator. So the common code was pushed down to the base class. This resulted in a very lean ShareeListingNavigator (the extra stuff added for BugListNavigator was for user selectable columns etc). It mainly just overrides the content rendering method because of different assumptions made about the expected structure of the model data. Part of the work involved adding a default listing model implementation to the base navigator class, and converting a couple of static class methods to instance methods. Another change was to add a single 'total' property to the listing model, copied from the 'total' property on each batch (it's the same on each batch).

Adding batching using the current infrastructure introduced a complication because the current batcher assumes the various batched items are static once loaded. But the +sharing UI allows sharees to be added and deleted. ie the current displayed batch can have items inserted and removed, and this affects displayed totals and counts etc. A minimal approach was used to provide something "reasonable" which can be improved later. Note that the limitations which follow are really only for ubuntu. When a new sharee is added, sharee is rendered at the top of the table as was the case without batching, even if it might belong in another batch according to the sort order. The total record count in the navigation control is correctly updated. But the individual batch range numbers may be out by one - this will not really be noticed for large numbers of sharees like ubuntu has.

With deletes, if the last sharee in a batch is deleted, the next batch will be attempted to be loaded (or the previous if there is no next). This would be a very rare occurrence for pillars (ubuntu) where batching would be required in the first place.

The TAL used for the +sharing view had the sharee table extracted out to a separate template file to support the batching control.

== Tests ==

Because of the ListingNavigator refactoring, the history tests in test_buglisting were moved to the tests for the base class.

New or updated yui tests:
- test_sharetable
- test_listing_navigator
- test_buglisting
- test_shareelisting_navigator

Additional back end tests for the batching will be added in the next branch when StormRangeFactory is used.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/javascript/listing_navigator.js
  lib/lp/app/javascript/tests/test_listing_navigator.js
  lib/lp/bugs/javascript/buglisting.js
  lib/lp/bugs/javascript/tests/test_buglisting.js
  lib/lp/registry/browser/configure.zcml
  lib/lp/registry/browser/pillar.py
  lib/lp/registry/javascript/sharing/shareelisting_navigator.js
  lib/lp/registry/javascript/sharing/shareetable.js
  lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.html
  lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.js
  lib/lp/registry/javascript/sharing/tests/test_shareetable.html
  lib/lp/registry/javascript/sharing/tests/test_shareetable.js
  lib/lp/registry/templates/pillar-sharing-table.pt
  lib/lp/registry/templates/pillar-sharing.pt
-- 
https://code.launchpad.net/~wallyworld/launchpad/sharing-view-batching-957663/+merge/98310
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/sharing-view-batching-957663 into lp:launchpad.
=== modified file 'configs/development/launchpad-lazr.conf'
--- configs/development/launchpad-lazr.conf	2012-02-21 02:25:39 +0000
+++ configs/development/launchpad-lazr.conf	2012-03-20 12:15:24 +0000
@@ -132,7 +132,7 @@
 enable_test_openid_provider: True
 openid_provider_vhost: testopenid
 code_domain: code.launchpad.dev
-default_batch_size: 5
+default_batch_size: 2
 max_attachment_size: 2097152
 branchlisting_batch_size: 6
 mugshot_batch_size: 8
@@ -169,7 +169,7 @@
 
 [malone]
 bugmail_error_from_address: noreply@xxxxxxxxxxxxxxxxxx
-buglist_batch_size: 7
+buglist_batch_size: 2
 max_comment_size: 300
 bugnotification_interval: 1
 debbugs_db_location: lib/lp/bugs/tests/data/debbugs_db

=== modified file 'lib/lp/app/javascript/listing_navigator.js'
--- lib/lp/app/javascript/listing_navigator.js	2012-01-11 14:01:11 +0000
+++ lib/lp/app/javascript/listing_navigator.js	2012-03-20 12:15:24 +0000
@@ -28,6 +28,61 @@
     return url.substr(0, cut_at - 1);
 }
 
+/**
+ * Constructor.
+ *
+ * A simplistic model of the current batch.
+ *
+ * These values are stored in the History object, so that the browser
+ * back/next buttons correctly adjust.
+ *
+ * Accepts a config containing:
+ *  - batch_key: A string representing the position and ordering of the
+ *    current batch, as returned by listing_navigator.get_batch_key
+ */
+module.SimpleListingModel = function () {
+    module.SimpleListingModel.superclass.constructor.apply(this, arguments);
+};
+
+
+module.SimpleListingModel.NAME = 'simple-listing-model';
+
+module.SimpleListingModel.ATTRS = {
+    total: {
+        value: null
+    }
+};
+
+Y.extend(module.SimpleListingModel, Y.Base, {
+    /**
+     * Initializer sets up the History object that stores most of the
+     * model data.
+     */
+    initializer: function(config) {
+        this.set('history', new Y.History({
+            initialState: {
+                    batch_key: config.batch_key
+                }
+        }));
+    },
+
+    /**
+     * Return the current batch key.
+     */
+    get_batch_key: function() {
+        return this.get('history').get('batch_key');
+    },
+
+    /**
+     * Set the current batch.  The batch_key and the query mapping
+     * identifying the batch must be supplied.
+     */
+    set_batch: function(batch_key, query) {
+        var url = '?' + Y.QueryString.stringify(query);
+        this.get('history').addValue('batch_key', batch_key, {url: url});
+    }
+});
+
 
 /**
  * Constructor.
@@ -64,10 +119,10 @@
         var batch_key;
         var template = config.template;
 
-        var search_params = this.constructor.get_search_params(config);
+        var search_params = this.get_search_params(config);
         this.set('search_params', search_params);
         batch_key = this.handle_new_batch(cache);
-        this.set('model', this.constructor.make_model(batch_key, cache));
+        this.set('model', this.make_model(batch_key, cache));
 
         // init the indicator plugin on the target
         // it defaults to invisible, so safe init
@@ -85,6 +140,31 @@
             template = template.replace(/\n/g, '
');
         }
         this.set('template', template);
+
+        // Wire up the default history change processing if supported by the
+        // model.
+        var history = this.get('model').get('history');
+        if (Y.Lang.isValue(history)) {
+            this.get('model').get('history').after(
+                'change', this.default_history_changed, this);
+        }
+    },
+
+    /**
+     * Default event handler for history:change events.
+     */
+    default_history_changed: function(e) {
+        if (e.newVal.hasOwnProperty('batch_key')) {
+            var batch_key = e.newVal.batch_key;
+            var batch = this.get('batches')[batch_key];
+            this.pre_fetch_batches();
+            this.render();
+            this._bindUI();
+        }
+        else {
+            // Handle Chrom(e|ium)'s initial popstate.
+            this.get('model').get('history').replace(e.prevVal);
+        }
     },
 
     get_failure_handler: function(fetch_only) {
@@ -163,6 +243,16 @@
         return current_batch.mustache_model;
     },
 
+    _bindUI: function () {
+        // Sub-classes override this.
+    },
+
+    render: function() {
+        this.render_content();
+        this.render_navigation();
+    },
+
+
     /**
      * Render listings via Mustache.
      *
@@ -171,20 +261,35 @@
      *
      * The template is always LP.mustache_listings.
      */
-    render: function() {
+    render_content: function() {
         var current_batch = this.get_current_batch();
-        var batch_info = Y.lp.mustache.to_html(this.get('batch_info_template'), {
-            start: current_batch.start + 1,
-            end: current_batch.start +
-                current_batch.mustache_model.items.length,
-            total: current_batch.total
-        });
         var content = Y.lp.mustache.to_html(
             this.get('template'), this.get_render_model(current_batch));
         this.get('target').setContent(content);
-
+    },
+
+    /**
+     * Return the number of items in the specified batch.
+     * @param batch
+     */
+    _batch_size: function(batch) {
+        return batch.mustache_model.items.length;
+    },
+
+    /**
+     * Render the navigation elements.
+     */
+    render_navigation: function() {
+        var current_batch = this.get_current_batch();
+        var total = this.get('model').get('total');
+        var batch_info = Y.lp.mustache.to_html(
+            this.get('batch_info_template'), {
+            start: current_batch.start + 1,
+            end: current_batch.start + this._batch_size(current_batch),
+            total: total
+        });
         this.get('navigation_indices').setContent(batch_info);
-        this.render_navigation();
+        this.update_navigation_links();
     },
 
     has_prev: function() {
@@ -198,7 +303,7 @@
     /**
      * Enable/disable navigation links as appropriate.
      */
-    render_navigation: function() {
+    update_navigation_links: function() {
         this.get('backwards_navigation').toggleClass(
             'inactive', !this.has_prev());
         this.get('forwards_navigation').toggleClass(
@@ -211,6 +316,7 @@
         if (fetch_only) {
             return;
         }
+        this.get('model').set('total', model.total);
         this.update_from_cache(query, batch_key);
     },
 
@@ -389,6 +495,23 @@
 
         Y.lp.client.load_model(
             context, view_name, load_model_config, query);
+    },
+
+    make_model: function(batch_key, cache) {
+        return new module.SimpleListingModel({
+            batch_key: batch_key,
+            total: cache.total
+        });
+    },
+
+    get_search_params: function(config) {
+        var search_params = Y.lp.app.listing_navigator.get_query(
+            config.current_url);
+        delete search_params.start;
+        delete search_params.memo;
+        delete search_params.direction;
+        delete search_params.orderby;
+        return search_params;
     }
 });
 
@@ -440,7 +563,7 @@
  * Return a mapping describing the batch previous to the current batch.
  */
 module.prev_batch_config = function(batch) {
-    if (Y.Lang.isNull(batch.prev)){
+    if (!Y.Lang.isValue(batch.prev)){
         return null;
     }
     return {
@@ -456,7 +579,7 @@
  * Return a mapping describing the batch after the current batch.
  */
 module.next_batch_config = function(batch) {
-    if (Y.Lang.isNull(batch.next)) {
+    if (!Y.Lang.isValue(batch.next)) {
         return null;
     }
     return {
@@ -517,7 +640,7 @@
 
 }, "0.1", {
     "requires": [
-        "node", 'lp.client', 'lp.app.errors', 'lp.app.indicator',
+        "node", 'history', 'lp.client', 'lp.app.errors', 'lp.app.indicator',
         'lp.mustache'
     ]
 });

=== modified file 'lib/lp/app/javascript/tests/test_listing_navigator.js'
--- lib/lp/app/javascript/tests/test_listing_navigator.js	2012-01-06 13:17:34 +0000
+++ lib/lp/app/javascript/tests/test_listing_navigator.js	2012-03-20 12:15:24 +0000
@@ -2,7 +2,7 @@
     base: '../../../../canonical/launchpad/icing/yui/',
     filter: 'raw', combine: false, fetchCSS: false
     }).use('test', 'console', 'lp.app.listing_navigator', 'lp.testing.mockio',
-           'lp.testing.assert',
+           'lp.testing.assert', 'history',
            function(Y) {
 
 var suite = new Y.Test.Suite("lp.app.listing_navigator Tests");
@@ -16,15 +16,6 @@
     batch_key: {value: null}
 };
 
-Y.extend(TestModel, Y.Base, {
-    get_batch_key: function() {
-        return this.get('batch_key');
-    },
-    set_batch: function(batch_key) {
-        this.set('batch_key', batch_key);
-    }
-});
-
 var TestListingNavigator = function() {
     this.constructor.superclass.constructor.apply(this, arguments);
 };
@@ -34,15 +25,9 @@
         this.constructor.superclass.update_from_cache.apply(this, arguments);
         this.pre_fetch_batches();
         this.render();
-    }
-}, {
+    },
     get_search_params: function(config) {
         return config.search_params;
-    },
-    make_model: function(batch_key, cache) {
-        return new TestModel({
-            batch_key: batch_key
-        });
     }
 });
 
@@ -98,21 +83,21 @@
         Y.Assert.areEqual('bar', navigator.get('target').getContent());
     },
     /**
-     * render_navigation should disable "previous" and "first" if there is
-     * no previous batch (i.e. we're at the beginning.)
+     * update_navigation_links should disable "previous" and "first" if there
+     * is no previous batch (i.e. we're at the beginning.)
      */
-    test_render_navigation_disables_backwards_navigation_if_no_prev:
+    test_update_navigation_links_disables_backwards_navigation_if_no_prev:
     function() {
         var navigator = this.get_render_navigator();
         var action = navigator.get('backwards_navigation').item(0);
-        navigator.render_navigation();
+        navigator.update_navigation_links();
         Y.Assert.isTrue(action.hasClass('inactive'));
     },
     /**
-     * render_navigation should enable "previous" and "first" if there is
+     * update_navigation_links should enable "previous" and "first" if there is
      * a previous batch (i.e. we're not at the beginning.)
      */
-    test_render_navigation_enables_backwards_navigation_if_prev:
+    test_update_navigation_links_enables_backwards_navigation_if_prev:
     function() {
         var navigator = this.get_render_navigator();
         var action = navigator.get('backwards_navigation').item(0);
@@ -120,32 +105,33 @@
         navigator.get_current_batch().prev = {
             start: 1, memo: 'pi'
         };
-        navigator.render_navigation();
+        navigator.update_navigation_links();
         Y.Assert.isFalse(action.hasClass('inactive'));
     },
     /**
-     * render_navigation should disable "next" and "last" if there is
+     * update_navigation_links should disable "next" and "last" if there is
      * no next batch (i.e. we're at the end.)
      */
-    test_render_navigation_disables_forwards_navigation_if_no_next:
+    test_update_navigation_links_disables_forwards_navigation_if_no_next:
     function() {
         var navigator = this.get_render_navigator();
         var action = navigator.get('forwards_navigation').item(0);
-        navigator.render_navigation();
+        navigator.update_navigation_links();
         Y.Assert.isTrue(action.hasClass('inactive'));
     },
     /**
-     * render_navigation should enable "next" and "last" if there is a next
-     * batch (i.e. we're not at the end.)
+     * update_navigation_links should enable "next" and "last" if there is a
+     * next batch (i.e. we're not at the end.)
      */
-    test_render_navigation_enables_forwards_navigation_if_next: function() {
+    test_update_navigation_links_enables_forwards_navigation_if_next:
+            function() {
         var navigator = this.get_render_navigator();
         var action = navigator.get('forwards_navigation').item(0);
         action.addClass('inactive');
         navigator.get_current_batch().next = {
             start: 1, memo: 'pi'
         };
-        navigator.render_navigation();
+        navigator.update_navigation_links();
         Y.Assert.isFalse(action.hasClass('inactive'));
     },
     /**
@@ -173,7 +159,7 @@
     /**
      * Render should update the navigation_indices with the result info.
      */
-    test_render_navigation_indices: function() {
+    test_update_navigation_links_indices: function() {
         var navigator = this.get_render_navigator();
         var index = navigator.get('navigation_indices').item(0);
         Y.Assert.areEqual(
@@ -965,6 +951,69 @@
     }
 }));
 
+var TestListingNavigatorWithHistory = function() {
+    this.constructor.superclass.constructor.apply(this, arguments);
+};
+
+suite.add(new Y.Test.Case({
+    name: 'browser history',
+
+    setUp: function() {
+        this.target = Y.Node.create('<div></div>').set(
+            'id', 'client-listing');
+        Y.one('body').appendChild(this.target);
+    },
+
+    tearDown: function() {
+        this.target.remove();
+        delete this.target;
+    },
+
+    /**
+     * Update from cache generates a change event for the specified batch.
+     */
+    test_update_from_cache_generates_event: function() {
+        var navigator = get_navigator('', {target: this.target});
+        var e = null;
+        navigator.get('model').get('history').on('change', function(inner_e) {
+            e = inner_e;
+        });
+        navigator.get('batches')['some-batch-key'] = {
+            mustache_model: {
+                items: []
+            },
+            next: null,
+            prev: null
+        };
+        navigator.update_from_cache({foo: 'bar'}, 'some-batch-key');
+        Y.Assert.areEqual('some-batch-key', e.newVal.batch_key);
+        Y.Assert.areEqual('?foo=bar', e._options.url);
+    },
+
+    /**
+     * When a change event is emitted, the relevant batch becomes the current
+     * batch and is rendered.
+     */
+    test_change_event_renders_cache: function() {
+        var navigator = get_navigator('', {target: this.target});
+        var batch = {
+            mustache_model: {
+                items: [],
+                foo: 'bar'
+            },
+            next: null,
+            prev: null
+        };
+        navigator.set('template', '{{foo}}');
+        navigator.get('batches')['some-batch-key'] = batch;
+        navigator.get('model').get('history').addValue(
+            'batch_key', 'some-batch-key');
+        Y.Assert.areEqual(batch, navigator.get_current_batch());
+        Y.Assert.areEqual('bar', navigator.get('target').getContent());
+    }
+}));
+
+
 var handle_complete = function(data) {
     window.status = '::::' + JSON.stringify(data);
     };

=== modified file 'lib/lp/bugs/javascript/buglisting.js'
--- lib/lp/bugs/javascript/buglisting.js	2012-03-01 19:29:01 +0000
+++ lib/lp/bugs/javascript/buglisting.js	2012-03-20 12:15:24 +0000
@@ -129,27 +129,6 @@
             Y.lp.app.inlinehelp.init_help();
         },
 
-        initializer: function(config) {
-            this.get('model').get('history').after(
-                'change', this.history_changed, this);
-        },
-        /**
-         * Event handler for history:change events.
-         */
-        history_changed: function(e) {
-            if (e.newVal.hasOwnProperty('batch_key')) {
-                var batch_key = e.newVal.batch_key;
-                var batch = this.get('batches')[batch_key];
-                this.pre_fetch_batches();
-                this.render();
-                this._bindUI();
-            }
-            else {
-                // Handle Chrom(e|ium)'s initial popstate.
-                this.get('model').get('history').replace(e.prevVal);
-            }
-        },
-
         /**
          * Return the model to use for rendering the batch.  This will include
          * updates to field visibility.
@@ -174,24 +153,15 @@
             });
             return this.constructor.superclass.handle_new_batch.call(this,
                                                                      batch);
-        }
+        },
 
-    },{
         make_model: function(batch_key, cache) {
             return new module.BugListingModel({
-                    batch_key: batch_key,
-                    field_visibility: cache.field_visibility,
-                    field_visibility_defaults: cache.field_visibility_defaults
+                batch_key: batch_key,
+                total: cache.total,
+                field_visibility: cache.field_visibility,
+                field_visibility_defaults: cache.field_visibility_defaults
             });
-        },
-        get_search_params: function(config) {
-            var search_params = Y.lp.app.listing_navigator.get_query(
-                config.current_url);
-            delete search_params.start;
-            delete search_params.memo;
-            delete search_params.direction;
-            delete search_params.orderby;
-            return search_params;
         }
     });
 
@@ -221,7 +191,7 @@
         navigator.clickAction('.next', navigator.next_batch);
         navigator.clickAction('.previous', navigator.prev_batch);
         navigator.clickAction('.last', navigator.last_batch);
-        navigator.render_navigation();
+        navigator.update_navigation_links();
         return navigator;
     };
 

=== modified file 'lib/lp/bugs/javascript/tests/test_buglisting.js'
--- lib/lp/bugs/javascript/tests/test_buglisting.js	2012-01-05 21:13:29 +0000
+++ lib/lp/bugs/javascript/tests/test_buglisting.js	2012-03-20 12:15:24 +0000
@@ -79,117 +79,6 @@
 }));
 
 
-var get_navigator = function(url, config) {
-    var mock_io = new Y.lp.testing.mockio.MockIo();
-    if (Y.Lang.isUndefined(url)){
-        url = '';
-    }
-    if (Y.Lang.isUndefined(config)){
-        config = {};
-    }
-    var target = config.target;
-    if (!Y.Lang.isValue(target)){
-        var target_parent = Y.Node.create('<div></div>');
-        target = Y.Node.create('<div "id=#client-listing"></div>');
-        target_parent.appendChild(target);
-    }
-    lp_cache = {
-        context: {
-            resource_type_link: 'http://foo_type',
-            web_link: 'http://foo/bar'
-        },
-        view_name: '+bugs',
-        next: {
-            memo: 467,
-            start: 500
-        },
-        prev: {
-            memo: 457,
-            start: 400
-        },
-        forwards: true,
-        order_by: 'foo',
-        memo: 457,
-        start: 450,
-        last_start: 23,
-        field_visibility: {},
-        field_visibility_defaults: {}
-    };
-    if (config.no_next){
-        lp_cache.next = null;
-    }
-    if (config.no_prev){
-        lp_cache.prev = null;
-    }
-    var navigator_config = {
-        current_url: url,
-        cache: lp_cache,
-        io_provider: mock_io,
-        pre_fetch: config.pre_fetch,
-        target: target,
-        template: ''
-    };
-    return new module.BugListingNavigator(navigator_config);
-};
-
-suite.add(new Y.Test.Case({
-    name: 'browser history',
-
-    setUp: function() {
-        this.target = Y.Node.create('<div></div>').set(
-            'id', 'client-listing');
-        Y.one('body').appendChild(this.target);
-    },
-
-    tearDown: function() {
-        this.target.remove();
-        delete this.target;
-    },
-
-    /**
-     * Update from cache generates a change event for the specified batch.
-     */
-    test_update_from_cache_generates_event: function() {
-        var navigator = get_navigator('', {target: this.target});
-        var e = null;
-        navigator.get('model').get('history').on('change', function(inner_e) {
-            e = inner_e;
-        });
-        navigator.get('batches')['some-batch-key'] = {
-            mustache_model: {
-                items: []
-            },
-            next: null,
-            prev: null
-        };
-        navigator.update_from_cache({foo: 'bar'}, 'some-batch-key');
-        Y.Assert.areEqual('some-batch-key', e.newVal.batch_key);
-        Y.Assert.areEqual('?foo=bar', e._options.url);
-    },
-
-    /**
-     * When a change event is emitted, the relevant batch becomes the current
-     * batch and is rendered.
-     */
-    test_change_event_renders_cache: function() {
-        var navigator = get_navigator('', {target: this.target});
-        var batch = {
-            mustache_model: {
-                items: [],
-                foo: 'bar'
-            },
-            next: null,
-            prev: null
-        };
-        navigator.set('template', '{{foo}}');
-        navigator.get('batches')['some-batch-key'] = batch;
-        navigator.get('model').get('history').addValue(
-            'batch_key', 'some-batch-key');
-        Y.Assert.areEqual(batch, navigator.get_current_batch());
-        Y.Assert.areEqual('bar', navigator.get('target').getContent());
-    }
-}));
-
 suite.add(new Y.Test.Case({
     name: 'from_page tests',
     setUp: function() {

=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2012-03-02 23:09:34 +0000
+++ lib/lp/registry/browser/configure.zcml	2012-03-20 12:15:24 +0000
@@ -44,6 +44,12 @@
 
 <facet facet="overview">
     <browser:page
+        for="lp.services.webapp.interfaces.ITableBatchNavigator"
+        name="+sharee-table-view"
+        template="../templates/pillar-sharing-table.pt"
+        permission="zope.Public" />
+
+    <browser:page
         for="lp.services.webapp.interfaces.ILaunchpadRoot"
         class="lp.registry.browser.RDFIndexView"
         name="rdf"

=== modified file 'lib/lp/registry/browser/pillar.py'
--- lib/lp/registry/browser/pillar.py	2012-03-16 08:28:03 +0000
+++ lib/lp/registry/browser/pillar.py	2012-03-20 12:15:24 +0000
@@ -17,7 +17,7 @@
 import simplejson
 
 from lazr.restful import ResourceJSONEncoder
-from lazr.restful.interfaces._rest import IJSONRequestCache
+from lazr.restful.interfaces import IJSONRequestCache
 
 from zope.component import getUtility
 from zope.interface import (
@@ -28,6 +28,7 @@
 from zope.schema.vocabulary import getVocabularyRegistry
 from zope.security.interfaces import Unauthorized
 
+from lp.app.browser.launchpad import iter_view_registrations
 from lp.app.browser.tales import MenuAPI
 from lp.app.enums import (
     service_uses_launchpad,
@@ -45,9 +46,11 @@
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.pillar import IPillar
 from lp.registry.interfaces.projectgroup import IProjectGroup
+from lp.services.config import config
 from lp.services.propertycache import cachedproperty
 from lp.services.features import getFeatureFlag
 from lp.services.webapp.authorization import check_permission
+from lp.services.webapp.batching import TableBatchNavigator
 from lp.services.webapp.menu import (
     ApplicationMenu,
     enabled_with_permission,
@@ -229,6 +232,8 @@
         'disclosure.enhanced_sharing.writable',
         )
 
+    _batch_navigator = None
+
     def _getSharingService(self):
         return getUtility(IService, 'sharing')
 
@@ -262,8 +267,21 @@
         return simplejson.dumps(
             self.sharing_picker_config, cls=ResourceJSONEncoder)
 
-    @property
-    def sharee_data(self):
+    def _getBatchNavigator(self, sharees):
+        """Return the batch navigator to be used to batch the sharees."""
+        return TableBatchNavigator(
+            sharees, self.request,
+            size=config.launchpad.default_batch_size)
+
+    def shareeData(self):
+        """Return an `ITableBatchNavigator` for sharees."""
+        if self._batch_navigator is None:
+            unbatchedSharees = self.unbatchedShareeData()
+            self._batch_navigator = self._getBatchNavigator(unbatchedSharees)
+        return self._batch_navigator
+
+    def unbatchedShareeData(self):
+        """Return all the sharees for a pillar."""
         return self._getSharingService().getPillarSharees(self.context)
 
     def initialize(self):
@@ -280,4 +298,27 @@
             and check_permission('launchpad.Edit', self.context))
         cache.objects['information_types'] = self.information_types
         cache.objects['sharing_permissions'] = self.sharing_permissions
-        cache.objects['sharee_data'] = self.sharee_data
+
+        view_names = set(reg.name for reg
+            in iter_view_registrations(self.__class__))
+        if len(view_names) != 1:
+            raise AssertionError("Ambiguous view name.")
+        cache.objects['view_name'] = view_names.pop()
+        batch_navigator = self.shareeData()
+        cache.objects['sharee_data'] = list(batch_navigator.batch)
+
+        def _getBatchInfo(batch):
+            if batch is None:
+                return None
+            return {'memo': batch.range_memo,
+                    'start': batch.startNumber() - 1}
+
+        next_batch = batch_navigator.batch.nextBatch()
+        cache.objects['next'] = _getBatchInfo(next_batch)
+        prev_batch = batch_navigator.batch.prevBatch()
+        cache.objects['prev'] = _getBatchInfo(prev_batch)
+        cache.objects['total'] = batch_navigator.batch.total()
+        cache.objects['forwards'] = batch_navigator.batch.range_forwards
+        last_batch = batch_navigator.batch.lastBatch()
+        cache.objects['last_start'] = last_batch.startNumber() - 1
+        cache.objects.update(_getBatchInfo(batch_navigator.batch))

=== added file 'lib/lp/registry/javascript/sharing/shareelisting_navigator.js'
--- lib/lp/registry/javascript/sharing/shareelisting_navigator.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/sharing/shareelisting_navigator.js	2012-03-20 12:15:24 +0000
@@ -0,0 +1,77 @@
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Batch navigation support for sharees.
+ *
+ * @module registry
+ * @submodule sharing
+ */
+
+YUI.add('lp.registry.sharing.shareelisting_navigator', function (Y) {
+
+var namespace = Y.namespace(
+    'lp.registry.sharing.shareelisting_navigator');
+
+var
+    NAME = "shareeListingNavigator",
+    // Events
+    UPDATE_CONTENT = 'updateContent';
+
+function ShareeListingNavigator(config) {
+    ShareeListingNavigator.superclass.constructor.apply(this, arguments);
+}
+
+Y.extend(ShareeListingNavigator, Y.lp.app.listing_navigator.ListingNavigator, {
+
+    initializer: function(config) {
+        this.publish(UPDATE_CONTENT);
+    },
+
+    render_content: function() {
+        var current_batch = this.get_current_batch();
+        this.fire(UPDATE_CONTENT, current_batch.sharee_data);
+    },
+
+    /**
+     * Return the number of items in the specified batch.
+     * @param batch
+     */
+    _batch_size: function(batch) {
+        return batch.sharee_data.length;
+    },
+
+    /**
+     * The records in the current batch have been changed by another component.
+     * The model attribute 'total' is adjusted according to the value of
+     * total_delta and the navigator rendered to reflect the change.
+     * If the new sharee size is 0, we attempt to navigate to the next or
+     * previous batch so that any remaining records are displayed.
+     * @param new_sharees
+     * @param total_delta
+     */
+    update_batch_totals: function(new_sharees, total_delta) {
+        var model = this.get('model');
+        var batch_key = model.get_batch_key(this.get_current_batch());
+        var current_total = model.get('total');
+        this.get('batches')[batch_key].sharee_data = new_sharees;
+        model.set('total', current_total + total_delta);
+        this.render_navigation();
+        if (new_sharees.length === 0) {
+            if (this.has_prev()) {
+                this.prev_batch();
+            } else if (this.has_next()) {
+                this.next_batch();
+            }
+        }
+    }
+});
+
+ShareeListingNavigator.NAME = NAME;
+ShareeListingNavigator.UPDATE_CONTENT = UPDATE_CONTENT;
+namespace.ShareeListingNavigator = ShareeListingNavigator;
+
+}, '0.1', {
+    'requires': [
+        'node', 'event', 'lp.app.listing_navigator'
+    ]
+});

=== modified file 'lib/lp/registry/javascript/sharing/shareetable.js'
--- lib/lp/registry/javascript/sharing/shareetable.js	2012-03-17 12:53:39 +0000
+++ lib/lp/registry/javascript/sharing/shareetable.js	2012-03-20 12:15:24 +0000
@@ -84,11 +84,40 @@
             'sharee_row_template', this._sharee_row_template());
         this.set(
             'sharee_policy_template', this._sharee_policy_template());
+        this.navigator = this.make_navigator();
+        var self = this;
+        var ns = Y.lp.registry.sharing.shareelisting_navigator;
+        this.navigator.subscribe(
+            ns.ShareeListingNavigator.UPDATE_CONTENT, function(e) {
+                self._replace_content(e.details[0]);
+        });
         this.publish(UPDATE_SHAREE);
         this.publish(UPDATE_PERMISSION);
         this.publish(REMOVE_SHAREE);
     },
 
+    make_navigator: function() {
+        var navigation_indices = Y.all('.batch-navigation-index');
+        Y.lp.app.listing_navigator.linkify_navigation();
+        var ns = Y.lp.registry.sharing.shareelisting_navigator;
+        var cache = LP.cache;
+        cache.total = this.get('sharees').length;
+        var navigator = new ns.ShareeListingNavigator({
+            current_url: window.location,
+            cache: cache,
+            target: Y.one('#sharee-table'),
+            navigation_indices: navigation_indices
+        });
+        navigator.set('backwards_navigation', Y.all('.first,.previous'));
+        navigator.set('forwards_navigation', Y.all('.last,.next'));
+        navigator.clickAction('.first', navigator.first_batch);
+        navigator.clickAction('.next', navigator.next_batch);
+        navigator.clickAction('.previous', navigator.prev_batch);
+        navigator.clickAction('.last', navigator.last_batch);
+        navigator.update_navigation_links();
+        return navigator;
+    },
+
     _sharee_table_template: function() {
         return [
             '<table class="sharing listing" id="sharee-table">',
@@ -209,8 +238,7 @@
     },
 
     // Render the access policy values for the sharees.
-    render_sharing_info: function() {
-        var sharees = this.get('sharees');
+    render_sharing_info: function(sharees) {
         var self = this;
         Y.Array.forEach(sharees, function(sharee) {
             self.render_sharee_sharing_info(sharee);
@@ -226,37 +254,54 @@
         });
     },
 
+    _replace_content: function(sharees) {
+        LP.cache.sharee_data = sharees;
+        this._render_sharees(sharees);
+        this.bindUI();
+    },
+
     renderUI: function() {
+        this._render_sharees(this.get('sharees'));
+    },
+
+    _render_sharees: function(sharees) {
+        var sharee_table = this.get('sharee_table');
+        if (sharees.length === 0) {
+            sharee_table.one('tr#sharee-table-loading td')
+                .setContent('There are currently no sharees.');
+            return;
+        }
         var partials = {
             sharee_access_policies:
                 this.get('sharee_policy_template'),
             sharee_row: this.get('sharee_row_template')
         };
-        var sharees = this.get('sharees');
         this._prepareShareeDisplayData(sharees);
         var html = Y.lp.mustache.to_html(
             this.get('sharee_table_template'),
             {sharees: sharees}, partials);
         var table_node = Y.Node.create(html);
-        this.get('sharee_table').replace(table_node);
-        this.render_sharing_info();
+        sharee_table.replace(table_node);
+        this.render_sharing_info(sharees);
         this._update_editable_status();
+        this.set('sharees', sharees);
     },
 
     bindUI: function() {
+        var sharee_table = this.get('sharee_table');
         // Bind the update and delete sharee links.
         if (!this.get('write_enabled')) {
             return;
         }
         var self = this;
-        this.get('sharee_table').delegate('click', function(e) {
+        sharee_table.delegate('click', function(e) {
             e.preventDefault();
             var delete_link = e.currentTarget;
             var sharee_link = delete_link.getAttribute('data-self_link');
             var person_name = delete_link.getAttribute('data-person_name');
             self.fire(REMOVE_SHAREE, delete_link, sharee_link, person_name);
         }, 'span[id^=remove-] a');
-        this.get('sharee_table').delegate('click', function(e) {
+        sharee_table.delegate('click', function(e) {
             e.preventDefault();
             var update_link = e.currentTarget;
             var sharee_link = update_link.getAttribute('data-self_link');
@@ -297,8 +342,11 @@
             this.update_sharees(new_or_updated_sharees);
         }
         if (deleted_sharees.length > 0) {
-            this.delete_sharees(deleted_sharees);
+            this.delete_sharees(deleted_sharees, new_sharees.length === 0);
         }
+        var current_total = existing_sharees.length;
+        var total_delta = new_sharees.length - current_total;
+        this.navigator.update_batch_totals(new_sharees, total_delta);
         this.set('sharees', new_sharees);
     },
 
@@ -409,6 +457,9 @@
         });
         this._update_editable_status();
         var anim_duration = this.get('anim_duration');
+        if (anim_duration === 0) {
+            return;
+        }
         var anim = Y.lp.anim.green_flash(
             {node: sharee_table.all(
                 update_node_selectors.join(',')), duration:anim_duration});
@@ -416,7 +467,7 @@
     },
 
     // Delete the specified sharees from the table.
-    delete_sharees: function(sharees) {
+    delete_sharees: function(sharees, all_rows_deleted) {
         var deleted_row_selectors = [];
         var sharee_table = this.get('sharee_table');
         Y.Array.each(sharees, function(sharee) {
@@ -432,6 +483,12 @@
         var rows_to_delete = sharee_table.all(deleted_row_selectors.join(','));
         var delete_rows = function() {
             rows_to_delete.remove(true);
+            if (all_rows_deleted === true) {
+                sharee_table.one('tbody')
+                    .appendChild('<tr id="sharee-table-loading"></tr>')
+                    .appendChild('<td></td>')
+                    .setContent('There are currently no sharees.');
+            }
         };
         var anim_duration = this.get('anim_duration');
         if (anim_duration === 0 ) {
@@ -455,5 +512,7 @@
 
 }, "0.1", { "requires": [
     'node', 'event', 'collection', 'json', 'lazr.choiceedit',
-    'lp.mustache', 'lp.registry.sharing.shareepicker'] });
+    'lp.mustache', 'lp.registry.sharing.shareepicker',
+    'lp.registry.sharing.shareelisting_navigator'
+] });
 

=== added file 'lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.html'
--- lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.html	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.html	2012-03-20 12:15:24 +0000
@@ -0,0 +1,62 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+  "http://www.w3.org/TR/html4/strict.dtd";>
+<!--
+Copyright 2012 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>Sharee Listing Navigator Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/skins/sam/console.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+      <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lp.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/mustache.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/indicator/indicator.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/listing_navigator.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../shareelisting_navigator.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_shareelisting_navigator.js"></script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.registry.sharing.shareelisting_navigator.test</li>
+        </ul>
+        <div id="fixture"></div>
+        <script type="text/x-template" id="sharee-table-template">
+            <table id='sharee-table'>
+                <tr id='sharee-table-loading'><td>
+                    Loading...
+                </td></tr>
+            </table>
+        </script>
+    </body>
+</html>

=== added file 'lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.js'
--- lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/sharing/tests/test_shareelisting_navigator.js	2012-03-20 12:15:24 +0000
@@ -0,0 +1,55 @@
+/* Copyright (c) 2012, Canonical Ltd. All rights reserved. */
+
+YUI.add('lp.registry.sharing.shareelisting_navigator.test',
+    function (Y) {
+
+    var tests = Y.namespace(
+        'lp.registry.sharing.shareelisting_navigator.test');
+    tests.suite = new Y.Test.Suite(
+        'lp.registry.sharing.shareelisting_navigator Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'lp.registry.sharing.shareelisting_navigator',
+
+        setUp: function () {
+            this.fixture = Y.one('#fixture');
+            var sharee_table = Y.Node.create(
+                    Y.one('#sharee-table-template').getContent());
+            this.fixture.appendChild(sharee_table);
+
+        },
+
+        tearDown: function () {
+            if (this.fixture !== null) {
+                this.fixture.empty();
+            }
+            delete this.fixture;
+        },
+
+        _create_Widget: function() {
+            var ns = Y.lp.registry.sharing.shareelisting_navigator;
+            return new ns.ShareeListingNavigator({
+                cache: {},
+                target: Y.one('#sharee-table')
+            });
+        },
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.registry.sharing.shareelisting_navigator,
+                "We should be able to locate the " +
+                "lp.registry.sharing.shareelisting_navigator module");
+        },
+
+        test_widget_can_be_instantiated: function() {
+            this.navigator = this._create_Widget();
+            var ns = Y.lp.registry.sharing.shareelisting_navigator;
+            Y.Assert.isInstanceOf(
+                ns.ShareeListingNavigator,
+                this.navigator,
+                "Sharee listing navigator failed to be instantiated");
+        }
+    }));
+
+}, '0.1', {'requires': ['test', 'console',
+        'lp.registry.sharing.shareelisting_navigator'
+    ]});

=== modified file 'lib/lp/registry/javascript/sharing/tests/test_shareetable.html'
--- lib/lp/registry/javascript/sharing/tests/test_shareetable.html	2012-03-08 03:12:23 +0000
+++ lib/lp/registry/javascript/sharing/tests/test_shareetable.html	2012-03-20 12:15:24 +0000
@@ -35,6 +35,8 @@
       <script type="text/javascript"
           src="../../../../../../build/js/lp/app/activator/activator.js"></script>
       <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/indicator/indicator.js"></script>
+      <script type="text/javascript"
           src="../../../../../../build/js/lp/app/choiceedit/choiceedit.js"></script>
       <script type="text/javascript"
           src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
@@ -44,6 +46,8 @@
           src="../../../../../../build/js/lp/app/picker/person_picker.js"></script>
       <script type="text/javascript"
           src="../../../../../../build/js/lp/registry/sharing/shareepicker.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/registry/sharing/shareelisting_navigator.js"></script>
        <script type="text/javascript"
                src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>
       <script type="text/javascript"
@@ -56,6 +60,8 @@
           src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
       <script type="text/javascript"
           src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/listing_navigator.js"></script>
 
       <!-- The module under test. -->
       <script type="text/javascript" src="../shareetable.js"></script>
@@ -68,7 +74,13 @@
         <ul id="suites">
             <li>lp.registry.sharing.shareetable.test</li>
         </ul>
-        <table id='sharee-table'>
-        </table>
+        <div id="fixture"></div>
+        <script type="text/x-template" id="sharee-table-template">
+            <table id='sharee-table'>
+                <tr id='sharee-table-loading'><td>
+                    Loading...
+                </td></tr>
+            </table>
+        </script>
     </body>
 </html>

=== modified file 'lib/lp/registry/javascript/sharing/tests/test_shareetable.js'
--- lib/lp/registry/javascript/sharing/tests/test_shareetable.js	2012-03-16 23:17:13 +0000
+++ lib/lp/registry/javascript/sharing/tests/test_shareetable.js	2012-03-20 12:15:24 +0000
@@ -39,10 +39,18 @@
                     ]
                 }
             };
+            this.fixture = Y.one('#fixture');
+            var sharee_table = Y.Node.create(
+                    Y.one('#sharee-table-template').getContent());
+            this.fixture.appendChild(sharee_table);
+
         },
 
         tearDown: function () {
-            Y.one('#sharee-table').empty(true);
+            if (this.fixture !== null) {
+                this.fixture.empty();
+            }
+            delete this.fixture;
             delete window.LP;
         },
 
@@ -51,7 +59,8 @@
                 overrides = {};
             }
             var config = Y.merge({
-                anim_duration: 0.001,
+                sharee_table: Y.one('#sharee-table'),
+                anim_duration: 0,
                 sharees: window.LP.cache.sharee_data,
                 sharing_permissions: window.LP.cache.sharing_permissions,
                 information_types: window.LP.cache.information_types,
@@ -88,6 +97,18 @@
             });
         },
 
+        // When there are no sharees, the table contains an informative message.
+        test_no_sharee_message: function() {
+            this.sharee_table = this._create_Widget({
+                sharees: []
+            });
+            this.sharee_table.render();
+            Y.Assert.areEqual(
+                'There are currently no sharees.',
+                Y.one('#sharee-table tr#sharee-table-loading td')
+                    .getContent());
+        },
+
         // The given sharee is correctly rendered.
         _test_sharee_rendered: function(sharee) {
             // The sharee row
@@ -158,11 +179,7 @@
                 'self_link': '~joe',
                 'permissions': {'P1': 's2'}};
             this.sharee_table.update_sharees([new_sharee]);
-            var self = this;
-            this.wait(function() {
-                    self._test_sharee_rendered(new_sharee);
-                }, 60
-            );
+            this._test_sharee_rendered(new_sharee);
         },
 
         // The update_sharees call updates existing sharees in the table.
@@ -175,11 +192,7 @@
                 'self_link': '~fred',
                 'permissions': {'P1': 's2', 'P2': 's1'}};
             this.sharee_table.update_sharees([updated_sharee]);
-            var self = this;
-            this.wait(function() {
-                    self._test_sharee_rendered(updated_sharee);
-                }, 60
-            );
+            this._test_sharee_rendered(updated_sharee);
         },
 
         // When the delete link is clicked, the correct event is published.
@@ -213,10 +226,7 @@
             Y.Assert.isNotNull(Y.one(row_selector));
             this.sharee_table.delete_sharees(
                 [window.LP.cache.sharee_data[0]]);
-            this.wait(function() {
-                    Y.Assert.isNull(Y.one(row_selector));
-                }, 60
-            );
+            Y.Assert.isNull(Y.one(row_selector));
         },
 
         // When the permission popup is clicked, the correct event is published.
@@ -265,14 +275,60 @@
             this.sharee_table.syncUI();
             // Check the results.
             var self = this;
-            this.wait(function() {
-                Y.Array.each(sharee_data, function(sharee) {
-                    self._test_sharee_rendered(sharee);
-                });
-                var deleted_row = '#sharee-table tr[id=permission-fred]';
-                Y.Assert.isNull(Y.one(deleted_row));
-                }, 60
-            );
+            Y.Array.each(sharee_data, function(sharee) {
+                self._test_sharee_rendered(sharee);
+            });
+            var deleted_row = '#sharee-table tr[id=permission-fred]';
+            Y.Assert.isNull(Y.one(deleted_row));
+        },
+
+        // The navigator model total attribute is updated when the currently
+        // displayed sharee data changes.
+        test_navigation_totals_updated: function() {
+            this.sharee_table = this._create_Widget();
+            this.sharee_table.render();
+            // We manipulate the cached model data - delete, add and update
+            var sharee_data = window.LP.cache.sharee_data;
+            // Insert a new record.
+            var new_sharee = {
+                'name': 'joe', 'display_name': 'Joe Smith',
+                'role': '(Maintainer)', web_link: '~joe',
+                'self_link': '~joe',
+                'permissions': {'P1': 's2'}};
+            sharee_data.splice(0, 0, new_sharee);
+            this.sharee_table.syncUI();
+            // Check the results.
+            Y.Assert.areEqual(
+                3, this.sharee_table.navigator.get('model').get('total'));
+        },
+
+        // When all rows are deleted, the table contains an informative message.
+        test_delete_all: function() {
+            this.sharee_table = this._create_Widget();
+            this.sharee_table.render();
+            // We manipulate the cached model data.
+            var sharee_data = window.LP.cache.sharee_data;
+            // Delete all the records.
+            sharee_data.splice(0, 2);
+            this.sharee_table.syncUI();
+            // Check the results.
+            Y.Assert.areEqual(
+                'There are currently no sharees.',
+                Y.one('#sharee-table tr#sharee-table-loading td')
+                    .getContent());
+        },
+
+        // A batch update is correctly rendered.
+        test_navigator_content_update: function() {
+            this.sharee_table = this._create_Widget();
+            this.sharee_table.render();
+            var new_sharee = {
+                'name': 'joe', 'display_name': 'Joe Smith',
+                'role': '(Maintainer)', web_link: '~joe',
+                'self_link': '~joe',
+                'permissions': {'P1': 's2'}};
+            this.sharee_table.navigator.fire('updateContent', [new_sharee]);
+            this._test_sharee_rendered(new_sharee);
         }
     }));
 

=== added file 'lib/lp/registry/templates/pillar-sharing-table.pt'
--- lib/lp/registry/templates/pillar-sharing-table.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/pillar-sharing-table.pt	2012-03-20 12:15:24 +0000
@@ -0,0 +1,26 @@
+<div id='sharee-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";
+>
+
+<div class="lesser"
+    tal:content="structure context/@@+navigation-links-upper" />
+
+<table id="sharee-table" class="disclosure listing">
+    <thead>
+        <tr>
+            <th>User or Team</th>
+            <th>Sharing</th>
+            <th>Shared items</th>
+        </tr>
+    </thead>
+    <tr id='sharee-table-loading'><td style="padding-left: 0.25em">
+        <img class="spinner" src="/@@/spinner" alt="Loading..." />
+        Loading...
+    </td></tr>
+</table>
+
+<div class="lesser"
+     tal:content="structure context/@@+navigation-links-lower" />
+</div>

=== modified file 'lib/lp/registry/templates/pillar-sharing.pt'
--- lib/lp/registry/templates/pillar-sharing.pt	2012-03-17 12:53:39 +0000
+++ lib/lp/registry/templates/pillar-sharing.pt	2012-03-20 12:15:24 +0000
@@ -33,19 +33,11 @@
         with someone</a></li>
       <li><a id="audit-link" class="sprite info" href='#'>Audit sharing</a></li>
     </ul>
-    <table id="sharee-table" class="disclosure listing">
-        <thead>
-            <tr>
-                <th>User or Team</th>
-                <th>Sharing</th>
-                <th>Shared items</th>
-            </tr>
-        </thead>
-        <tr><td style="padding-left: 0.25em">
-            <img class="spinner" src="/@@/spinner" alt="Loading..." />
-            Loading...
-        </td></tr>
-    </table>
+
+    <div tal:define="batch_navigator view/shareeData">
+      <tal:shareelisting content="structure batch_navigator/@@+sharee-table-view" />
+    </div>
+
   </div>
 
 </body>