← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~julian-edwards/launchpad/cancel-build-bug-173018-ui-part4 into lp:launchpad

 

Julian Edwards has proposed merging lp:~julian-edwards/launchpad/cancel-build-bug-173018-ui-part4 into lp:launchpad with lp:~julian-edwards/launchpad/cancel-build-bug-173018-ui-part3 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #173018 in Launchpad itself: "In progress builds cannot be cancelled"
  https://bugs.launchpad.net/launchpad/+bug/173018

For more details, see:
https://code.launchpad.net/~julian-edwards/launchpad/cancel-build-bug-173018-ui-part4/+merge/80997

= Summary =
Final branch in pipeline of changes to add build cancellation.

== Proposed fix ==
Add a cancellation link/button on build pages.

== Pre-implementation notes ==
Pre-what? :)

== Implementation details ==
Similar to the cancellation for recipe builds, this adds a traversal to
+cancel from a binary build which uses the general edit template.

Most of the change is pretty boilerplate.

== Tests ==
bin/test -cvv test_build_views

== Demo and Q/A ==
Already tested on Dogfood, works like a charm.


-- 
https://code.launchpad.net/~julian-edwards/launchpad/cancel-build-bug-173018-ui-part4/+merge/80997
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~julian-edwards/launchpad/cancel-build-bug-173018-ui-part4 into lp:launchpad.
=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-11-01 15:43:30 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-11-02 10:50:34 +0000
@@ -2484,25 +2484,48 @@
             self.context, self.request, self.user)
         if getFeatureFlag('bugs.dynamic_bug_listings.enabled'):
             cache = IJSONRequestCache(self.request)
-            batch_navigator = self.search()
-            cache.objects['mustache_model'] = batch_navigator.model
-
-            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['order_by'] = ','.join(
-                get_sortorder_from_request(self.request))
-            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))
+<<<<<<< TREE
+            batch_navigator = self.search()
+            cache.objects['mustache_model'] = batch_navigator.model
+
+            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['order_by'] = ','.join(
+                get_sortorder_from_request(self.request))
+            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))
+=======
+            batch_navigator = self.search()
+            cache.objects['mustache_model'] = batch_navigator.model
+
+            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['order_by'] = ','.join(
+                get_sortorder_from_request(self.request))
+            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))
+>>>>>>> MERGE-SOURCE
 
     @property
     def columns_to_show(self):

=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
--- lib/lp/bugs/browser/tests/test_bugtask.py	2011-11-01 15:43:30 +0000
+++ lib/lp/bugs/browser/tests/test_bugtask.py	2011-11-02 10:50:34 +0000
@@ -1351,90 +1351,178 @@
         self.assertEqual(1, len(bugtasks))
         self.assertEqual(item.model, bugtasks[0])
 
-    def test_no_next_prev_for_single_batch(self):
-        """The IJSONRequestCache should contain data about ajacent batches.
-
-        mustache_model should contain bugtasks, the BugTaskListingItem.model
-        for each BugTask.
-        """
-        owner, item = make_bug_task_listing_item(self.factory)
-        self.useContext(person_logged_in(owner))
-        with self.dynamic_listings():
-            view = self.makeView(item.bugtask)
-        cache = IJSONRequestCache(view.request)
-        self.assertIs(None, cache.objects.get('next'))
-        self.assertIs(None, cache.objects.get('prev'))
-
-    def test_next_for_multiple_batch(self):
-        """The IJSONRequestCache should contain data about the next batch.
-
-        mustache_model should contain bugtasks, the BugTaskListingItem.model
-        for each BugTask.
-        """
-        task = self.factory.makeBugTask()
-        self.factory.makeBugTask(target=task.target)
-        with self.dynamic_listings():
-            view = self.makeView(task, size=1)
-        cache = IJSONRequestCache(view.request)
-        self.assertEqual({'memo': '1', 'start': 1}, cache.objects.get('next'))
-
-    def test_prev_for_multiple_batch(self):
-        """The IJSONRequestCache should contain data about the next batch.
-
-        mustache_model should contain bugtasks, the BugTaskListingItem.model
-        for each BugTask.
-        """
-        task = self.factory.makeBugTask()
-        task2 = self.factory.makeBugTask(target=task.target)
-        with self.dynamic_listings():
-            view = self.makeView(task2, size=1, memo=1)
-        cache = IJSONRequestCache(view.request)
-        self.assertEqual({'memo': '1', 'start': 0}, cache.objects.get('prev'))
-
-    def test_default_order_by(self):
-        """order_by defaults to '-importance in JSONRequestCache"""
-        task = self.factory.makeBugTask()
-        with self.dynamic_listings():
-            view = self.makeView(task)
-        cache = IJSONRequestCache(view.request)
-        self.assertEqual('-importance', cache.objects['order_by'])
-
-    def test_order_by_importance(self):
-        """order_by follows query params in JSONRequestCache"""
-        task = self.factory.makeBugTask()
-        with self.dynamic_listings():
-            view = self.makeView(task, orderby='importance')
-        cache = IJSONRequestCache(view.request)
-        self.assertEqual('importance', cache.objects['order_by'])
-
-    def test_cache_has_all_batch_vars_defaults(self):
-        """Cache has all the needed variables.
-
-        order_by, memo, start, forwards.  These default to sane values.
-        """
-        task = self.factory.makeBugTask()
-        with self.dynamic_listings():
-            view = self.makeView(task)
-        cache = IJSONRequestCache(view.request)
-        self.assertEqual('-importance', cache.objects['order_by'])
-        self.assertIs(None, cache.objects['memo'])
-        self.assertEqual(0, cache.objects['start'])
-        self.assertTrue(cache.objects['forwards'])
-
-    def test_cache_has_all_batch_vars_specified(self):
-        """Cache has all the needed variables.
-
-        order_by, memo, start, forwards.  These are calculated appropriately.
-        """
-        task = self.factory.makeBugTask()
-        with self.dynamic_listings():
-            view = self.makeView(task, memo=1, forwards=False, size=1)
-        cache = IJSONRequestCache(view.request)
-        self.assertEqual('1', cache.objects['memo'])
-        self.assertEqual(0, cache.objects['start'])
-        self.assertFalse(cache.objects['forwards'])
-        self.assertEqual(0, cache.objects['last_start'])
-
+<<<<<<< TREE
+    def test_no_next_prev_for_single_batch(self):
+        """The IJSONRequestCache should contain data about ajacent batches.
+
+        mustache_model should contain bugtasks, the BugTaskListingItem.model
+        for each BugTask.
+        """
+        owner, item = make_bug_task_listing_item(self.factory)
+        self.useContext(person_logged_in(owner))
+        with self.dynamic_listings():
+            view = self.makeView(item.bugtask)
+        cache = IJSONRequestCache(view.request)
+        self.assertIs(None, cache.objects.get('next'))
+        self.assertIs(None, cache.objects.get('prev'))
+
+    def test_next_for_multiple_batch(self):
+        """The IJSONRequestCache should contain data about the next batch.
+
+        mustache_model should contain bugtasks, the BugTaskListingItem.model
+        for each BugTask.
+        """
+        task = self.factory.makeBugTask()
+        self.factory.makeBugTask(target=task.target)
+        with self.dynamic_listings():
+            view = self.makeView(task, size=1)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual({'memo': '1', 'start': 1}, cache.objects.get('next'))
+
+    def test_prev_for_multiple_batch(self):
+        """The IJSONRequestCache should contain data about the next batch.
+
+        mustache_model should contain bugtasks, the BugTaskListingItem.model
+        for each BugTask.
+        """
+        task = self.factory.makeBugTask()
+        task2 = self.factory.makeBugTask(target=task.target)
+        with self.dynamic_listings():
+            view = self.makeView(task2, size=1, memo=1)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual({'memo': '1', 'start': 0}, cache.objects.get('prev'))
+
+    def test_default_order_by(self):
+        """order_by defaults to '-importance in JSONRequestCache"""
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('-importance', cache.objects['order_by'])
+
+    def test_order_by_importance(self):
+        """order_by follows query params in JSONRequestCache"""
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task, orderby='importance')
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('importance', cache.objects['order_by'])
+
+    def test_cache_has_all_batch_vars_defaults(self):
+        """Cache has all the needed variables.
+
+        order_by, memo, start, forwards.  These default to sane values.
+        """
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('-importance', cache.objects['order_by'])
+        self.assertIs(None, cache.objects['memo'])
+        self.assertEqual(0, cache.objects['start'])
+        self.assertTrue(cache.objects['forwards'])
+
+    def test_cache_has_all_batch_vars_specified(self):
+        """Cache has all the needed variables.
+
+        order_by, memo, start, forwards.  These are calculated appropriately.
+        """
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task, memo=1, forwards=False, size=1)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('1', cache.objects['memo'])
+        self.assertEqual(0, cache.objects['start'])
+        self.assertFalse(cache.objects['forwards'])
+        self.assertEqual(0, cache.objects['last_start'])
+
+=======
+    def test_no_next_prev_for_single_batch(self):
+        """The IJSONRequestCache should contain data about ajacent batches.
+
+        mustache_model should contain bugtasks, the BugTaskListingItem.model
+        for each BugTask.
+        """
+        owner, item = make_bug_task_listing_item(self.factory)
+        self.useContext(person_logged_in(owner))
+        with self.dynamic_listings():
+            view = self.makeView(item.bugtask)
+        cache = IJSONRequestCache(view.request)
+        self.assertIs(None, cache.objects.get('next'))
+        self.assertIs(None, cache.objects.get('prev'))
+
+    def test_next_for_multiple_batch(self):
+        """The IJSONRequestCache should contain data about the next batch.
+
+        mustache_model should contain bugtasks, the BugTaskListingItem.model
+        for each BugTask.
+        """
+        task = self.factory.makeBugTask()
+        self.factory.makeBugTask(target=task.target)
+        with self.dynamic_listings():
+            view = self.makeView(task, size=1)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual({'memo': '1', 'start': 1}, cache.objects.get('next'))
+
+    def test_prev_for_multiple_batch(self):
+        """The IJSONRequestCache should contain data about the next batch.
+
+        mustache_model should contain bugtasks, the BugTaskListingItem.model
+        for each BugTask.
+        """
+        task = self.factory.makeBugTask()
+        task2 = self.factory.makeBugTask(target=task.target)
+        with self.dynamic_listings():
+            view = self.makeView(task2, size=1, memo=1)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual({'memo': '1', 'start': 0}, cache.objects.get('prev'))
+
+    def test_default_order_by(self):
+        """order_by defaults to '-importance in JSONRequestCache"""
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('-importance', cache.objects['order_by'])
+
+    def test_order_by_importance(self):
+        """order_by follows query params in JSONRequestCache"""
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task, orderby='importance')
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('importance', cache.objects['order_by'])
+
+    def test_cache_has_all_batch_vars_defaults(self):
+        """Cache has all the needed variables.
+
+        order_by, memo, start, forwards.  These default to sane values.
+        """
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('-importance', cache.objects['order_by'])
+        self.assertIs(None, cache.objects['memo'])
+        self.assertEqual(0, cache.objects['start'])
+        self.assertTrue(cache.objects['forwards'])
+        self.assertEqual(1, cache.objects['total'])
+
+    def test_cache_has_all_batch_vars_specified(self):
+        """Cache has all the needed variables.
+
+        order_by, memo, start, forwards.  These are calculated appropriately.
+        """
+        task = self.factory.makeBugTask()
+        with self.dynamic_listings():
+            view = self.makeView(task, memo=1, forwards=False, size=1)
+        cache = IJSONRequestCache(view.request)
+        self.assertEqual('1', cache.objects['memo'])
+        self.assertEqual(0, cache.objects['start'])
+        self.assertFalse(cache.objects['forwards'])
+        self.assertEqual(0, cache.objects['last_start'])
+
+>>>>>>> MERGE-SOURCE
     def getBugtaskBrowser(self):
         """Return a browser for a new bugtask."""
         bugtask = self.factory.makeBugTask()

=== modified file 'lib/lp/bugs/javascript/buglisting.js'
--- lib/lp/bugs/javascript/buglisting.js	2011-11-01 15:43:30 +0000
+++ lib/lp/bugs/javascript/buglisting.js	2011-11-02 10:50:34 +0000
@@ -13,6 +13,7 @@
 
 
 /**
+<<<<<<< TREE
  * Constructor.
  * current_url is used to determine search params.
  * cache is the JSONRequestCache for the batch.
@@ -70,6 +71,95 @@
 
 
 /**
+=======
+ * Constructor.
+ * current_url is used to determine search params.
+ * cache is the JSONRequestCache for the batch.
+ * template is the template to use for rendering batches.
+ * target is a YUI node to update when rendering batches.
+ * navigation_indices is a YUI NodeList of nodes to update with the current
+ * batch info.
+ * io_provider is something providing the Y.io interface, typically used for
+ * testing.  Defaults to Y.io.
+ */
+namespace.ListingNavigator = function(current_url, cache, template, target,
+                                      navigation_indices, io_provider) {
+    var lp_client = new Y.lp.client.Launchpad();
+    this.search_params = namespace.get_query(current_url);
+    delete this.search_params.start;
+    delete this.search_params.memo;
+    delete this.search_params.direction;
+    delete this.search_params.orderby;
+    this.io_provider = io_provider;
+    this.current_batch = lp_client.wrap_resource(null, cache);
+    this.template = template;
+    this.target = target;
+    this.batches = {};
+    this.backwards_navigation = new Y.NodeList([]);
+    this.forwards_navigation = new Y.NodeList([]);
+    if (!Y.Lang.isValue(navigation_indices)){
+        navigation_indices = new Y.NodeList([]);
+    }
+    this.navigation_indices = navigation_indices;
+    this.batch_info_template = '<strong>{{start}}</strong> &rarr; ' +
+        '<strong>{{end}}</strong> of {{total}} results';
+};
+
+
+/**
+ * Call the callback when a node matching the selector is clicked.
+ *
+ * The node is also marked up appropriately.
+ */
+namespace.ListingNavigator.prototype.clickAction = function(selector,
+                                                            callback){
+    that = this;
+    var nodes = Y.all(selector);
+    nodes.on('click', function(e){
+        e.preventDefault();
+        callback.call(that);
+    });
+    nodes.addClass('js-action');
+};
+
+/**
+ * Rewrite all nodes with navigation classes so that they are hyperlinks.
+ * Content is retained.
+ */
+namespace.linkify_navigation = function(){
+    Y.each(['previous', 'next', 'first', 'last'], function(class_name){
+        Y.all('.' + class_name).each(function(node){
+            new_node = Y.Node.create('<a href="#"></a>');
+            new_node.addClass(class_name);
+            new_node.setContent(node.getContent());
+            node.replace(new_node);
+        });
+    });
+};
+
+/**
+ * Factory to return a ListingNavigator for the given page.
+ */
+namespace.ListingNavigator.from_page = function(){
+    var target = Y.one('#client-listing');
+    var navigation_indices = Y.all('.batch-navigation-index');
+    var navigator = new namespace.ListingNavigator(
+        window.location, LP.cache, LP.mustache_listings, target,
+        navigation_indices);
+    namespace.linkify_navigation();
+    navigator.backwards_navigation = Y.all('.first,.previous');
+    navigator.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.render_navigation();
+    return navigator;
+};
+
+
+/**
+>>>>>>> MERGE-SOURCE
  * Render bug listings via Mustache.
  *
  * If model is supplied, it is used as the data for rendering the listings.
@@ -77,10 +167,16 @@
  *
  * The template is always LP.mustache_listings.
  */
+<<<<<<< TREE
 namespace.ListingNavigator.prototype.rendertable = function(){
     if (! Y.Lang.isValue(this.target)){
+=======
+namespace.ListingNavigator.prototype.render = function(){
+    if (! Y.Lang.isValue(this.target)){
+>>>>>>> MERGE-SOURCE
         return;
     }
+<<<<<<< TREE
     var model = this.current_batch.mustache_model;
     var txt = Mustache.to_html(this.template, model);
     this.target.set('innerHTML', txt);
@@ -93,6 +189,38 @@
 namespace.ListingNavigator.get_batch_key = function(config){
     return JSON.stringify([config.order_by, config.memo, config.forwards,
                            config.start]);
+=======
+    var model = this.current_batch.mustache_model;
+    var batch_info = Mustache.to_html(this.batch_info_template, {
+        start: this.current_batch.start + 1,
+        end: this.current_batch.start +
+            this.current_batch.mustache_model.bugtasks.length,
+        total: this.current_batch.total
+    });
+    this.target.setContent(Mustache.to_html(this.template, model));
+    this.navigation_indices.setContent(batch_info);
+    this.render_navigation();
+};
+
+
+/**
+ * Enable/disable navigation links as appropriate.
+ */
+namespace.ListingNavigator.prototype.render_navigation = function(){
+    this.backwards_navigation.toggleClass(
+        'inactive', this.current_batch.prev === null);
+    this.forwards_navigation.toggleClass(
+        'inactive', this.current_batch.next === null);
+};
+
+
+/**
+ * Get the key for the specified batch, for use in the batches mapping.
+ */
+namespace.ListingNavigator.get_batch_key = function(config){
+    return JSON.stringify([config.order_by, config.memo, config.forwards,
+                           config.start]);
+>>>>>>> MERGE-SOURCE
 };
 
 /**
@@ -101,6 +229,7 @@
  *
  * order_by is the ordering used by the model.
  */
+<<<<<<< TREE
 namespace.ListingNavigator.prototype.update_from_model = function(model){
     var key = namespace.ListingNavigator.get_batch_key(model);
     this.batches[key] = model;
@@ -209,6 +338,116 @@
 namespace.ListingNavigator.prototype.load_model = function(config){
     var query = this.get_batch_query(config);
     var load_model_config = {
+=======
+namespace.ListingNavigator.prototype.update_from_model = function(model){
+    var key = namespace.ListingNavigator.get_batch_key(model);
+    this.batches[key] = model;
+    this.current_batch = model;
+    this.render();
+};
+
+
+/**
+ * Return the query vars to use for the specified batch.
+ * This includes the search params and the batch selector.
+ */
+namespace.ListingNavigator.prototype.get_batch_query = function(config){
+    var query = Y.merge(this.search_params, {orderby: config.order_by});
+    if (Y.Lang.isValue(config.memo)){
+        query.memo = config.memo;
+    }
+    if (Y.Lang.isValue(config.start)){
+        query.start = config.start;
+    }
+    if (config.forwards !== undefined && !config.forwards){
+        query.direction = 'backwards';
+    }
+    return query;
+};
+
+
+/**
+ * Update the display to the specified batch.
+ *
+ * If the batch is cached, it will be used immediately.  Otherwise, it will be
+ * retrieved and cached upon retrieval.
+ */
+namespace.ListingNavigator.prototype.update = function(config){
+    var key = namespace.ListingNavigator.get_batch_key(config);
+    var cached_batch = this.batches[key];
+    if (Y.Lang.isValue(cached_batch)){
+        this.current_batch = cached_batch;
+        this.render();
+    }
+    else {
+        this.load_model(config);
+    }
+};
+
+
+/**
+ * Update the navigator to display the last batch.
+ */
+namespace.ListingNavigator.prototype.last_batch = function(){
+    this.update({
+        forwards: false,
+        memo: "",
+        start: this.current_batch.last_start,
+        order_by: this.current_batch.order_by
+    });
+};
+
+
+/**
+ * Update the navigator to display the first batch.
+ *
+ * The order_by defaults to the current ordering, but may be overridden.
+ */
+namespace.ListingNavigator.prototype.first_batch = function(order_by){
+    if (order_by === undefined){
+        order_by = this.current_batch.order_by;
+    }
+    this.update({
+        forwards: true,
+        memo: null,
+        start: 0,
+        order_by: order_by
+    });
+};
+
+
+/**
+ * Update the navigator to display the next batch.
+ */
+namespace.ListingNavigator.prototype.next_batch = function(){
+    this.update({
+        forwards: true,
+        memo: this.current_batch.next.memo,
+        start:this.current_batch.next.start,
+        order_by: this.current_batch.order_by
+    });
+};
+
+/**
+ * Update the navigator to display the previous batch.
+ */
+namespace.ListingNavigator.prototype.prev_batch = function(){
+    this.update({
+        forwards: false,
+        memo: this.current_batch.prev.memo,
+        start:this.current_batch.prev.start,
+        order_by: this.current_batch.order_by
+    });
+};
+
+
+/**
+ * Load the specified batch via ajax.  Display & cache on load.
+ */
+namespace.ListingNavigator.prototype.load_model = function(config){
+    var query = this.get_batch_query(config);
+    var load_model_config = {
+>>>>>>> MERGE-SOURCE
         on: {
             success: Y.bind(this.update_from_model, this)
         }

=== modified file 'lib/lp/bugs/javascript/tests/test_buglisting.js'
--- lib/lp/bugs/javascript/tests/test_buglisting.js	2011-11-01 15:43:30 +0000
+++ lib/lp/bugs/javascript/tests/test_buglisting.js	2011-11-02 10:50:34 +0000
@@ -8,6 +8,7 @@
 var suite = new Y.Test.Suite("lp.bugs.buglisting Tests");
 var module = Y.lp.bugs.buglisting;
 
+<<<<<<< TREE
 
 suite.add(new Y.Test.Case({
     name: 'ListingNavigator',
@@ -24,12 +25,65 @@
     name: 'rendertable',
 
     test_rendertable_no_client_listing: function() {
+=======
+
+suite.add(new Y.Test.Case({
+    name: 'ListingNavigator',
+    test_sets_search_params: function(){
+        // search_parms includes all query values that don't control batching
+        var navigator = new module.ListingNavigator(
+            'http://yahoo.com?foo=bar&start=1&memo=2&direction=3&orderby=4');
+        Y.lp.testing.assert.assert_equal_structure(
+            {foo: 'bar'}, navigator.search_params);
+    }
+}));
+
+suite.add(new Y.Test.Case({
+    name: 'render',
+
+    tearDown: function(){
+        Y.one('#fixture').setContent('');
+    },
+
+    test_render_no_client_listing: function() {
+>>>>>>> MERGE-SOURCE
         // Rendering should not error with no #client-listing.
+<<<<<<< TREE
         var navigator = new module.ListingNavigator();
         navigator.rendertable();
     },
     test_rendertable: function() {
+=======
+        var navigator = new module.ListingNavigator();
+        navigator.render();
+    },
+    get_render_navigator: function() {
+        var target = Y.Node.create('<div id="client-listing"></div>');
+        var lp_cache = {
+            mustache_model: {
+                foo: 'bar',
+                bugtasks: ['a', 'b', 'c']
+            },
+            next: null,
+            prev: null,
+            start: 5,
+            total: 256
+        };
+        var template = "{{foo}}";
+        var navigator =  new module.ListingNavigator(
+            null, lp_cache, template, target);
+        var index = Y.Node.create(
+            '<div><strong>3</strong> &rarr; <strong>4</strong>' +
+            ' of 512 results</div>');
+        navigator.navigation_indices.push(index);
+        navigator.backwards_navigation.push(Y.Node.create('<div></div>'));
+        navigator.forwards_navigation.push(Y.Node.create('<div></div>'));
+        return navigator;
+    },
+    test_render: function() {
+>>>>>>> MERGE-SOURCE
         // Rendering should work with #client-listing supplied.
+<<<<<<< TREE
         var target = Y.Node.create('<div id="client-listing"></div>');
         var lp_cache = {
             mustache_model: {
@@ -41,6 +95,98 @@
             null, lp_cache, template, target);
         navigator.rendertable();
         Y.Assert.areEqual('bar', navigator.target.get('innerHTML'));
+=======
+        var navigator = this.get_render_navigator();
+        navigator.render();
+        Y.Assert.areEqual('bar', navigator.target.getContent());
+    },
+    /**
+     * render_navigation 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:
+    function() {
+        var navigator = this.get_render_navigator();
+        var action = navigator.backwards_navigation.item(0);
+        navigator.render_navigation();
+        Y.Assert.isTrue(action.hasClass('inactive'));
+    },
+    /**
+     * render_navigation 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:
+    function() {
+        var navigator = this.get_render_navigator();
+        var action = navigator.backwards_navigation.item(0);
+        action.addClass('inactive');
+        navigator.current_batch.prev = {
+            start: 1, memo: 'pi'
+        };
+        navigator.render_navigation();
+        Y.Assert.isFalse(action.hasClass('inactive'));
+    },
+    /**
+     * render_navigation 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:
+    function() {
+        var navigator = this.get_render_navigator();
+        var action = navigator.forwards_navigation.item(0);
+        navigator.render_navigation();
+        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.)
+     */
+    test_render_navigation_enables_forwards_navigation_if_next: function() {
+        var navigator = this.get_render_navigator();
+        var action = navigator.forwards_navigation.item(0);
+        action.addClass('inactive');
+        navigator.current_batch.next = {
+            start: 1, memo: 'pi'
+        };
+        navigator.render_navigation();
+        Y.Assert.isFalse(action.hasClass('inactive'));
+    },
+    /**
+     * linkify_navigation should convert previous, next, first last into
+     * hyperlinks, while retaining the original content.
+     */
+    test_linkify_navigation: function(){
+        Y.one('#fixture').setContent(
+            '<span class="previous">PreVious</span>' +
+            '<span class="next">NeXt</span>' +
+            '<span class="first">FiRST</span>' +
+            '<span class="last">lAst</span>');
+        module.linkify_navigation();
+        function checkNav(selector, content){
+            var node = Y.one(selector);
+            Y.Assert.areEqual('a', node.get('tagName').toLowerCase());
+            Y.Assert.areEqual(content, node.getContent());
+            Y.Assert.areEqual('#', node.get('href').substr(-1, 1));
+        }
+        checkNav('.previous', 'PreVious');
+        checkNav('.next', 'NeXt');
+        checkNav('.first', 'FiRST');
+        checkNav('.last', 'lAst');
+    },
+    /**
+     * Render should update the navigation_indices with the result info.
+     */
+    test_render_navigation_indices: function(){
+        var navigator = this.get_render_navigator();
+        index = navigator.navigation_indices.item(0);
+        Y.Assert.areEqual(
+            '<strong>3</strong> \u2192 <strong>4</strong> of 512 results',
+            index.getContent());
+        navigator.render();
+        Y.Assert.areEqual(
+            '<strong>6</strong> \u2192 <strong>8</strong> of 256 results',
+            index.getContent());
+>>>>>>> MERGE-SOURCE
     }
 }));
 
@@ -52,6 +198,7 @@
      */
     get_intensity_listing: function(){
         mock_io = new Y.lp.testing.mockio.MockIo();
+<<<<<<< TREE
         lp_cache = {
             context: {
                 resource_type_link: 'http://foo_type',
@@ -75,13 +222,50 @@
             },
             mustache_model:
             {item: [
+=======
+        lp_cache = {
+            context: {
+                resource_type_link: 'http://foo_type',
+                web_link: 'http://foo/bar'
+            },
+            mustache_model: {
+                foo: 'bar',
+                bugtasks: []
+            }
+        };
+        var target = Y.Node.create('<div id="client-listing"></div>');
+        var navigator = new module.ListingNavigator(
+            "http://yahoo.com?start=5&memo=6&direction=backwards";,
+            lp_cache, "<ol>" + "{{#item}}<li>{{name}}</li>{{/item}}</ol>",
+            target, null, mock_io);
+        navigator.first_batch('intensity');
+        Y.Assert.areEqual('', navigator.target.getContent());
+        mock_io.last_request.successJSON({
+            context: {
+                resource_type_link: 'http://foo_type',
+                web_link: 'http://foo/bar'
+            },
+            mustache_model:
+            {
+                item: [
+>>>>>>> MERGE-SOURCE
                 {name: 'first'},
+<<<<<<< TREE
                 {name: 'second'}
             ]},
             order_by: 'intensity',
             start: 0,
             forwards: true,
             memo: null
+=======
+                {name: 'second'}],
+                bugtasks: []
+            },
+            order_by: 'intensity',
+            start: 0,
+            forwards: true,
+            memo: null
+>>>>>>> MERGE-SOURCE
         });
         return navigator;
     },
@@ -91,8 +275,13 @@
         var navigator = this.get_intensity_listing();
         var mock_io = navigator.io_provider;
         Y.Assert.areEqual('<ol><li>first</li><li>second</li></ol>',
+<<<<<<< TREE
             navigator.target.get('innerHTML'));
         Y.Assert.areEqual('/bar/+bugs/++model++?orderby=intensity&start=0',
+=======
+            navigator.target.getContent());
+        Y.Assert.areEqual('/bar/+bugs/++model++?orderby=intensity&start=0',
+>>>>>>> MERGE-SOURCE
             mock_io.last_request.url);
     },
     test_first_batch_uses_cache: function() {
@@ -166,6 +355,7 @@
     }
 }));
 
+<<<<<<< TREE
 
 suite.add(new Y.Test.Case({
     name: 'get_batch_url',
@@ -301,6 +491,144 @@
     }
 }));
 
+=======
+
+suite.add(new Y.Test.Case({
+    name: 'get_batch_url',
+
+    /**
+     * get_batch_query accepts the order_by param.
+     */
+    test_get_batch_query_orderby: function(){
+        var navigator = new module.ListingNavigator('?param=1');
+        var query = navigator.get_batch_query({order_by: 'importance'});
+        Y.Assert.areSame('importance', query.orderby);
+        Y.Assert.areSame(1, query.param);
+    },
+    /**
+     * get_batch_query accepts the memo param.
+     */
+    test_get_batch_query_memo: function(){
+        var navigator = new module.ListingNavigator('?param=foo');
+        var query = navigator.get_batch_query({memo: 'pi'});
+        Y.Assert.areSame('pi', query.memo);
+        Y.Assert.areSame('foo', query.param);
+    },
+    /**
+     * When memo is null, query.memo is undefined.
+     */
+    test_get_batch_null_memo: function(){
+        var navigator = new module.ListingNavigator('?memo=foo');
+        var query = navigator.get_batch_query({memo: null});
+        Y.Assert.areSame(undefined, query.memo);
+    },
+    /**
+     * If 'forwards' is true, direction does not appear.
+     */
+    test_get_batch_query_forwards: function(){
+        var navigator = new module.ListingNavigator(
+            '?param=pi&direction=backwards');
+        var query = navigator.get_batch_query({forwards: true});
+        Y.Assert.areSame('pi', query.param);
+        Y.Assert.areSame(undefined, query.direction);
+    },
+    /**
+     * If 'forwards' is false, direction is set to backwards.
+     */
+    test_get_batch_query_backwards: function(){
+        var navigator = new module.ListingNavigator('?param=pi');
+        var query = navigator.get_batch_query({forwards: false});
+        Y.Assert.areSame('pi', query.param);
+        Y.Assert.areSame('backwards', query.direction);
+    },
+    /**
+     * If start is provided, it overrides existing values.
+     */
+    test_get_batch_query_start: function(){
+        var navigator = new module.ListingNavigator('?start=pi');
+        var query = navigator.get_batch_query({});
+        Y.Assert.areSame(undefined, query.start);
+        query = navigator.get_batch_query({start: 1});
+        Y.Assert.areSame(1, query.start);
+        query = navigator.get_batch_query({start: null});
+        Y.lp.testing.assert.assert_equal_structure({}, query);
+    }
+}));
+
+var get_navigator = function(url){
+    var mock_io = new Y.lp.testing.mockio.MockIo();
+    lp_cache = {
+        context: {
+            resource_type_link: 'http://foo_type',
+            web_link: 'http://foo/bar'
+        },
+        next: {
+            memo: 467,
+            start: 500
+        },
+        prev: {
+            memo: 457,
+            start: 400
+        },
+        order_by: 'foo',
+        last_start: 23
+    };
+    return new module.ListingNavigator(url, lp_cache, null, null, null,
+                                       mock_io);
+};
+
+suite.add(new Y.Test.Case({
+    name: 'navigation',
+    /**
+     * last_batch uses memo="", start=navigator.current_batch.last_start,
+     * direction=backwards, orderby=navigator.current_batch.order_by.
+     */
+    test_last_batch: function(){
+        var navigator = get_navigator(
+            '?memo=pi&direction=backwards&start=57');
+        navigator.last_batch();
+        Y.Assert.areSame(
+            '/bar/+bugs/++model++?orderby=foo&memo=&start=23&' +
+            'direction=backwards',
+            navigator.io_provider.last_request.url);
+    },
+    /**
+     * first_batch omits memo and direction, start=0,
+     * orderby=navigator.current_batch.order_by.
+     */
+    test_first_batch: function(){
+        var navigator = get_navigator('?memo=pi&start=26');
+        navigator.first_batch();
+        Y.Assert.areSame(
+            '/bar/+bugs/++model++?orderby=foo&start=0',
+            navigator.io_provider.last_request.url);
+    },
+    /**
+     * next_batch uses values from current_batch.next +
+     * current_batch.ordering.
+     */
+    test_next_batch: function(){
+        var navigator = get_navigator('?memo=pi&start=26');
+        navigator.next_batch();
+        Y.Assert.areSame(
+            '/bar/+bugs/++model++?orderby=foo&memo=467&start=500',
+            navigator.io_provider.last_request.url);
+    },
+    /**
+     * prev_batch uses values from current_batch.prev + direction=backwards
+     * and ordering=current_batch.ordering.
+     */
+    test_prev_batch: function(){
+        var navigator = get_navigator('?memo=pi&start=26');
+        navigator.prev_batch();
+        Y.Assert.areSame(
+            '/bar/+bugs/++model++?orderby=foo&memo=457&start=400&' +
+            'direction=backwards',
+            navigator.io_provider.last_request.url);
+    }
+}));
+
+>>>>>>> MERGE-SOURCE
 var handle_complete = function(data) {
     window.status = '::::' + JSON.stringify(data);
     };

=== modified file 'lib/lp/soyuz/browser/build.py'
--- lib/lp/soyuz/browser/build.py	2011-09-20 14:29:43 +0000
+++ lib/lp/soyuz/browser/build.py	2011-11-02 10:50:34 +0000
@@ -7,6 +7,7 @@
 
 __all__ = [
     'BuildBreadcrumb',
+    'BuildCancelView',
     'BuildContextMenu',
     'BuildNavigation',
     'BuildNavigationMixin',
@@ -21,7 +22,10 @@
 from lazr.delegates import delegates
 from lazr.restful.utils import safe_hasattr
 from zope.component import getUtility
-from zope.interface import implements
+from zope.interface import (
+    implements,
+    Interface,
+    )
 
 from canonical.launchpad import _
 from canonical.launchpad.browser.librarian import (
@@ -156,7 +160,7 @@
     """Overview menu for build records """
     usedfor = IBinaryPackageBuild
 
-    links = ['ppa', 'records', 'retry', 'rescore']
+    links = ['ppa', 'records', 'retry', 'rescore', 'cancel']
 
     @property
     def is_ppa_build(self):
@@ -189,6 +193,14 @@
             '+rescore', text, icon='edit',
             enabled=self.context.can_be_rescored)
 
+    @enabled_with_permission('launchpad.Edit')
+    def cancel(self):
+        """Only enabled for pending/active virtual builds."""
+        text = 'Cancel build'
+        return Link(
+            '+cancel', text, icon='edit',
+            enabled=self.context.can_be_cancelled)
+
 
 class BuildBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IBinaryPackageBuild`."""
@@ -214,6 +226,8 @@
     def label(self):
         return self.context.title
 
+    page_title = label
+
     @property
     def user_can_retry_build(self):
         """Return True if the user is permitted to Retry Build.
@@ -397,6 +411,32 @@
             "Build rescored to %s." % score)
 
 
+class BuildCancelView(LaunchpadFormView):
+    """View class for build cancellation."""
+
+    class schema(Interface):
+        """Schema for cancelling a build."""
+
+    page_title = label = "Cancel build"
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action("Cancel build", name="cancel")
+    def request_action(self, action, data):
+        """Cancel the build."""
+        self.context.cancel()
+        if self.context.status == BuildStatus.CANCELLING:
+            self.request.response.addNotification(
+                "Build cancellation in progress")
+        elif self.context.status == BuildStatus.CANCELLED:
+            self.request.response.addNotification("Build cancelled")
+        else:
+            self.request.response.addNotification("Unable to cancel build")
+
+
 class CompleteBuild:
     """Super object to store related IBinaryPackageBuild & IBuildQueue."""
     delegates(IBinaryPackageBuild)

=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml	2011-10-23 02:04:25 +0000
+++ lib/lp/soyuz/browser/configure.zcml	2011-11-02 10:50:34 +0000
@@ -401,6 +401,15 @@
             facet="overview"
             template="../templates/build-rescore.pt"/>
     </browser:pages>
+    <browser:pages
+        for="lp.soyuz.interfaces.binarypackagebuild.IBinaryPackageBuild"
+        class="lp.soyuz.browser.build.BuildCancelView"
+        permission="launchpad.Edit">
+        <browser:page
+            name="+cancel"
+            facet="overview"
+            template="../../app/templates/generic-edit.pt"/>
+    </browser:pages>
     <browser:menus
         classes="
             BuildContextMenu"

=== modified file 'lib/lp/soyuz/browser/tests/test_build_views.py'
--- lib/lp/soyuz/browser/tests/test_build_views.py	2011-09-20 14:18:48 +0000
+++ lib/lp/soyuz/browser/tests/test_build_views.py	2011-11-02 10:50:34 +0000
@@ -8,6 +8,7 @@
     timedelta,
     )
 import pytz
+import soupmatchers
 from testtools.matchers import (
     MatchesException,
     Not,
@@ -60,12 +61,15 @@
         # archive. For instance the 'PPA' action-menu link is not enabled
         # for builds targeted to the PRIMARY archive.
         archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
+        removeSecurityProxy(archive).require_virtualized = False
         build = self.factory.makeBinaryPackageBuild(archive=archive)
         build_menu = BuildContextMenu(build)
         self.assertEquals(build_menu.links,
-            ['ppa', 'records', 'retry', 'rescore'])
+            ['ppa', 'records', 'retry', 'rescore', 'cancel'])
         self.assertFalse(build_menu.is_ppa_build)
         self.assertFalse(build_menu.ppa().enabled)
+        # Cancel is not enabled on non-virtual builds.
+        self.assertFalse(build_menu.cancel().enabled)
 
     def test_build_menu_ppa(self):
         # The 'PPA' action-menu item will be enabled if we target the build
@@ -74,9 +78,11 @@
         build = self.factory.makeBinaryPackageBuild(archive=ppa)
         build_menu = BuildContextMenu(build)
         self.assertEquals(build_menu.links,
-            ['ppa', 'records', 'retry', 'rescore'])
+            ['ppa', 'records', 'retry', 'rescore', 'cancel'])
         self.assertTrue(build_menu.is_ppa_build)
         self.assertTrue(build_menu.ppa().enabled)
+        # Cancel is enabled on virtual builds.
+        self.assertTrue(build_menu.cancel().enabled)
 
     def test_cannot_retry_stable_distroseries(self):
         # 'BuildView.user_can_retry_build' property checks not only the
@@ -236,6 +242,61 @@
         self.assertEquals(notification.message, "Build rescored to 0.")
         self.assertEquals(pending_build.buildqueue_record.lastscore, 0)
 
+    def test_build_page_has_cancel_link(self):
+        build = self.factory.makeBinaryPackageBuild()
+        build.queueBuild()
+        person = build.archive.owner
+        with person_logged_in(person):
+            build_view = create_initialized_view(
+                build, "+index", principal=person)
+            page = build_view()
+        url = canonical_url(build) + "/+cancel"
+        matches_cancel_link = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "CANCEL_LINK", "a", attrs=dict(href=url)))
+        self.assertThat(page, matches_cancel_link)
+
+    def test_cancelling_pending_build(self):
+        ppa = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
+        pending_build = self.factory.makeBinaryPackageBuild(archive=ppa)
+        pending_build.queueBuild()
+        with person_logged_in(ppa.owner):
+            view = create_initialized_view(
+                pending_build, name="+cancel", form={
+                    'field.actions.cancel': 'Cancel'})
+        notification = view.request.response.notifications[0]
+        self.assertEqual(notification.message, "Build cancelled")
+        self.assertEqual(BuildStatus.CANCELLED, pending_build.status)
+
+    def test_cancelling_building_build(self):
+        ppa = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
+        pending_build = self.factory.makeBinaryPackageBuild(archive=ppa)
+        pending_build.queueBuild()
+        removeSecurityProxy(pending_build).status = BuildStatus.BUILDING
+        with person_logged_in(ppa.owner):
+            view = create_initialized_view(
+                pending_build, name="+cancel", form={
+                    'field.actions.cancel': 'Cancel'})
+        notification = view.request.response.notifications[0]
+        self.assertEqual(
+            notification.message, "Build cancellation in progress")
+        self.assertEqual(BuildStatus.CANCELLING, pending_build.status)
+
+    def test_cancelling_uncancellable_build(self):
+        archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
+        removeSecurityProxy(archive).require_virtualized = False
+        pending_build = self.factory.makeBinaryPackageBuild(archive=archive)
+        pending_build.queueBuild()
+        removeSecurityProxy(pending_build).status = BuildStatus.BUILDING
+        with person_logged_in(archive.owner):
+            view = create_initialized_view(
+                pending_build, name="+cancel", form={
+                    'field.actions.cancel': 'Cancel'})
+        notification = view.request.response.notifications[0]
+        self.assertEqual(
+            notification.message, "Unable to cancel build")
+        self.assertEqual(BuildStatus.BUILDING, pending_build.status)
+
     def test_build_records_view(self):
         # The BuildRecordsView can also be used to filter by architecture tag.
         distroseries = self.factory.makeDistroSeries()

=== modified file 'lib/lp/soyuz/templates/build-index.pt'
--- lib/lp/soyuz/templates/build-index.pt	2011-01-19 22:35:43 +0000
+++ lib/lp/soyuz/templates/build-index.pt	2011-11-02 10:50:34 +0000
@@ -144,6 +144,9 @@
       <tal:retry define="link context/menu:context/retry"
                  condition="link/enabled"
                  replace="structure link/fmt:link" />
+      <tal:cancel define="link context/menu:context/cancel"
+                 condition="link/enabled"
+                 replace="structure link/fmt:link" />
     </p>
 
     <ul>