← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/inconsistent-bugtask-table-346531 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/inconsistent-bugtask-table-346531 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #346531 in Launchpad itself: "marking a bug as duplicate (or not duplicate) via ajax leaves the bug tasks table in its old state"
  https://bugs.launchpad.net/launchpad/+bug/346531

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/inconsistent-bugtask-table-346531/+merge/118518

== Implementation ==

The XHR call to mark a bug as a dupe used a web service patch operation which simply returned an updated copy of the resource entry. This branch changes that to instead make an XHR POST call to the MarkBugAsDuplicateView, the same as the HTML dupe form would. The view has been modified to detect the ajax request and return the HTML for the new bug task table which is then rendered in place of the original table. This same technique is used to process bug task deletes.

As a drive by, the error handling for the bug picker widgets was improved to use an ErrorHandler instance to process XHR failures. This means that the error text displayed in the picker correctly has the ooops id etc extracted and displayed.

== QA ==

Mark and unmark a bug as a duplicate and ensure the bug tasks table turns grey when there is a dupe and is enabled otherwise.  

== Tests ==

The yui tests for the various bug picker instances had to be updated to test the new XHR call sequences as well as checking that the new bug task table is rendered.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/browser/bug.py
  lib/lp/bugs/browser/tests/test_bug_views.py
  lib/lp/bugs/javascript/bug_picker.js
  lib/lp/bugs/javascript/bugtask_index.js
  lib/lp/bugs/javascript/duplicates.js
  lib/lp/bugs/javascript/tests/test_duplicates.html
  lib/lp/bugs/javascript/tests/test_duplicates.js
  lib/lp/code/javascript/branch.bugspeclinks.js
-- 
https://code.launchpad.net/~wallyworld/launchpad/inconsistent-bugtask-table-346531/+merge/118518
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/inconsistent-bugtask-table-346531 into lp:launchpad.
=== modified file 'lib/lp/bugs/browser/bug.py'
--- lib/lp/bugs/browser/bug.py	2012-08-07 02:31:56 +0000
+++ lib/lp/bugs/browser/bug.py	2012-08-07 10:12:43 +0000
@@ -45,7 +45,10 @@
 from simplejson import dumps
 from zope import formlib
 from zope.app.form.browser import TextWidget
-from zope.component import getUtility
+from zope.component import (
+    getMultiAdapter,
+    getUtility,
+    )
 from zope.event import notify
 from zope.interface import (
     implements,
@@ -746,12 +749,20 @@
         """See `LaunchpadFormView.`"""
         return {'duplicateof': self.context.bug.duplicateof}
 
+    @property
+    def next_url(self):
+        """Return the next URL to call when this call completes."""
+        if not self.request.is_ajax:
+            return canonical_url(self.context)
+        return None
+
     def _validate(self, action, data):
         if action.name != 'remove':
             return super(BugMarkAsDuplicateView, self)._validate(action, data)
         return []
 
-    @action('Set Duplicate', name='change')
+    @action('Set Duplicate', name='change',
+        failure=LaunchpadFormView.ajax_failure_handler)
     def change_action(self, action, data):
         """Update the bug."""
         data = dict(data)
@@ -765,6 +776,7 @@
             ObjectModifiedEvent(bug, bug_before_modification, 'duplicateof'))
         # Apply other changes.
         self.updateBugFromData(data)
+        return self._duplicate_action_result()
 
     def shouldShowRemoveButton(self, action):
         return self.context.bug.duplicateof is not None
@@ -778,6 +790,19 @@
         bug.markAsDuplicate(None)
         notify(
             ObjectModifiedEvent(bug, bug_before_modification, 'duplicateof'))
+        return self._duplicate_action_result()
+
+    def _duplicate_action_result(self):
+        if self.request.is_ajax:
+            bug = self.context.bug
+            launchbag = getUtility(ILaunchBag)
+            launchbag.add(bug.default_bugtask)
+            view = getMultiAdapter(
+                (bug, self.request),
+                name='+bugtasks-and-nominations-table')
+            view.initialize()
+            return view.render()
+        return None
 
 
 class BugSecrecyEditView(LaunchpadFormView, BugSubscriptionPortletDetails):

=== modified file 'lib/lp/bugs/browser/tests/test_bug_views.py'
--- lib/lp/bugs/browser/tests/test_bug_views.py	2012-07-31 09:41:51 +0000
+++ lib/lp/bugs/browser/tests/test_bug_views.py	2012-08-07 10:12:43 +0000
@@ -567,3 +567,52 @@
                 self.bug.default_bugtask, name="+duplicate",
                 principal=self.bug_owner, form=form)
         self.assertIsNone(self.bug.duplicateof)
+
+    def test_ajax_create_duplicate(self):
+        # An ajax request to create a duplicate returns the new bugtask table.
+        with person_logged_in(self.bug_owner):
+            extra = {
+                'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest',
+                }
+            form = {
+                'field.actions.change': u'Set Duplicate',
+                'field.duplicateof': u'%s' % self.duplicate_bug.id
+                }
+            view = create_initialized_view(
+                self.bug.default_bugtask, name="+duplicate",
+                principal=self.bug_owner, form=form, **extra)
+            result_html = view.render()
+
+        self.assertEqual(self.duplicate_bug, self.bug.duplicateof)
+        self.assertEqual(
+            view.request.response.getHeader('content-type'), 'text/html')
+        soup = BeautifulSoup(result_html)
+        table = soup.find(
+            'table',
+            {'id': 'affected-software', 'class': 'duplicate listing'})
+        self.assertIsNotNone(table)
+
+    def test_ajax_remove_duplicate(self):
+        # An ajax request to remove a duplicate returns the new bugtask table.
+        with person_logged_in(self.bug_owner):
+            self.bug.markAsDuplicate(self.duplicate_bug)
+            extra = {
+                'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest',
+                }
+            form = {
+                'field.actions.remove': u'Remove Duplicate',
+                }
+
+            view = create_initialized_view(
+                self.bug.default_bugtask, name="+duplicate",
+                principal=self.bug_owner, form=form, **extra)
+            result_html = view.render()
+
+        self.assertIsNone(self.bug.duplicateof)
+        self.assertEqual(
+            view.request.response.getHeader('content-type'), 'text/html')
+        soup = BeautifulSoup(result_html)
+        table = soup.find(
+            'table',
+            {'id': 'affected-software', 'class': 'listing'})
+        self.assertIsNotNone(table)

=== modified file 'lib/lp/bugs/javascript/bug_picker.js'
--- lib/lp/bugs/javascript/bug_picker.js	2012-08-07 05:00:15 +0000
+++ lib/lp/bugs/javascript/bug_picker.js	2012-08-07 10:12:43 +0000
@@ -91,6 +91,7 @@
      * Show a spinner next to the specified node.
      *
      * @method _show_bug_spinner
+     * @param node
      * @protected
      */
     _show_bug_spinner: function(node) {
@@ -104,6 +105,17 @@
     },
 
     /**
+     * Hide the specified spinner.
+     * @param spinner
+     * @protected
+     */
+    _hide_bug_spinner: function(spinner) {
+        if( Y.Lang.isValue(spinner)) {
+            spinner.remove(true);
+        }
+    },
+
+    /**
      * Look up the selected bug and get the user to confirm that it is the one
      * they want.
      *

=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2012-08-02 09:04:27 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2012-08-07 10:12:43 +0000
@@ -41,8 +41,7 @@
         setup_client_and_bug();
 
         var config = {
-            picker_activator: '.menu-link-mark-dupe, #change_duplicate_bug',
-            lp_bug_entry: lp_bug_entry
+            picker_activator: '.menu-link-mark-dupe, #change_duplicate_bug'
         };
         var dup_widget = new Y.lp.bugs.duplicates.DuplicateBugPicker(config);
         dup_widget.render();

=== modified file 'lib/lp/bugs/javascript/duplicates.js'
--- lib/lp/bugs/javascript/duplicates.js	2012-08-06 11:19:31 +0000
+++ lib/lp/bugs/javascript/duplicates.js	2012-08-07 10:12:43 +0000
@@ -120,23 +120,60 @@
         Y.lp.bugs.bug_picker.BugPicker.prototype._find_bug.call(this, data);
     },
 
+    // A common error handler for XHR operations.
+    _error_handler: function() {
+        var that = this;
+        var error_handler = new Y.lp.client.ErrorHandler();
+        error_handler.handleError = function(id, response) {
+            var error_msg = response.responseText;
+            if (response.status === 400) {
+                var response_info = Y.JSON.parse(response.responseText);
+                var dup_error = response_info.errors['field.duplicateof'];
+                if (Y.Lang.isString(dup_error)) {
+                    var error_info = dup_error.split('\n');
+                    if (error_info.length === 1) {
+                        error_msg = error_info;
+                    } else {
+                        error_msg = error_info.slice(1).join(' ');
+                    }
+                    that.set('error', error_msg);
+                    return true;
+                }
+            }
+            return false;
+        };
+        error_handler.showError = function(error_msg) {
+            that.set('error', error_msg);
+        };
+        return error_handler;
+    },
+
+    // Render the new bug task table.
+    _render_bugtask_table: function(new_table) {
+        var bugtask_table = Y.one('#affected-software');
+        bugtask_table.replace(new_table);
+        Y.lp.bugs.bugtask_index.setup_bugtask_table();
+    },
+
     /**
      * Bug was successfully marked as a duplicate, update the UI.
      *
      * @method _submit_bug_success
-     * @param updated_entry
+     * @param response
      * @param new_dup_url
      * @param new_dup_id
      * @param new_dupe_title
      * @private
      */
-    _submit_bug_success: function(updated_entry, new_dup_url,
-                                           new_dup_id, new_dup_title) {
+    _submit_bug_success: function(response, new_dup_url,
+                                           new_dup_id, new_dupe_title) {
         this._performDefaultSave();
-        updated_entry.set('duplicate_of_link', new_dup_url);
-        LP.cache.bug.duplicate_of_link = updated_entry.duplicate_of_link;
-        this.set('lp_bug_entry', updated_entry);
-
+
+        // Render the new bug tasks table.
+        LP.cache.bug.duplicate_of_link = new_dup_url;
+        this._render_bugtask_table(response.responseText);
+
+        // Render the new dupe portlet.
         var dupe_span = this.get('dupe_span').ancestor('li');
         var update_dup_url = dupe_span.one('a').get('href');
         var edit_link;
@@ -158,7 +195,8 @@
             if (Y.Lang.isValue(duplicatesNode)) {
                 duplicatesNode.remove(true);
             }
-            this._show_comment_on_duplicate_warning(new_dup_id, new_dup_title);
+            this._show_comment_on_duplicate_warning(
+                new_dup_id, new_dupe_title);
         } else {
             dupe_span.addClass('sprite bug-dupe');
             dupe_span.setContent([
@@ -189,61 +227,38 @@
     },
 
     /**
-     * There was an error marking a bug as a duplicate.
-     *
-     * @method _submit_bug_failure
-     * @param response
-     * @param old_dup_url
-     * @private
-     */
-    _submit_bug_failure: function(response, old_dup_url) {
-        // Reset the lp_bug_entry.duplicate_of_link as it wasn't
-        // updated.
-        this.get('lp_bug_entry').set('duplicate_of_link', old_dup_url);
-        var error_msg = response.responseText;
-        if (response.status === 400) {
-            var error_info = response.responseText.split('\n');
-            error_msg = error_info.slice(1).join(' ');
-        }
-        this.set('error', error_msg);
-    },
-
-    /**
      * Update the bug duplicate via the LP API
      *
      * @method _submit_bug
      * @param new_dup_id
-     * @param new_dup_title
+     * @param new_dupe_title
      * @param widget
      * @private
      */
     _submit_bug: function(new_dup_id, new_dupe_title, widget) {
-        // XXX noodles 2009-03-17 bug=336866 It seems the etag
-        // returned by lp_save() is incorrect. Remove it for now
-        // so that the second save does not result in a '412
-        // precondition failed' error.
-        //
-        // XXX deryck 2009-04-29 bug=369293 Also, this has to
-        // happen before *any* call to lp_save now that bug
-        // subscribing can be done inline.  Named operations
-        // don't return new objects, making the cached bug's
-        // etag invalid as well.
-        var lp_bug_entry = this.get('lp_bug_entry');
-        lp_bug_entry.removeAttr('http_etag');
-
+        var dupe_span = this.get('dupe_span');
+        var that = this;
         var new_dup_url = null;
+
+        var qs;
         if (new_dup_id !== '') {
-            var self_link = lp_bug_entry.get('self_link');
-            var last_slash_index = self_link.lastIndexOf('/');
-            new_dup_url = self_link.slice(0, last_slash_index+1) + new_dup_id;
+            var bug_link = LP.cache.bug.self_link;
+            var last_slash_index = bug_link.lastIndexOf('/');
+            new_dup_url = bug_link.slice(0, last_slash_index+1) + new_dup_id;
+            qs = Y.lp.client.append_qs(
+                '', 'field.actions.change', 'Set Duplicate');
+            qs = Y.lp.client.append_qs(qs, "field.duplicateof", new_dup_id);
+        } else {
+            qs = Y.lp.client.append_qs(
+                '', 'field.actions.remove', 'Remove Duplicate');
         }
-        var old_dup_url = lp_bug_entry.get('duplicate_of_link');
-        lp_bug_entry.set('duplicate_of_link', new_dup_url);
 
-        var dupe_span = this.get('dupe_span');
-        var that = this;
         var spinner = null;
-        var config = {
+        var error_handler = this._error_handler();
+        var submit_url = LP.cache.context.web_link + '/+duplicate';
+        var y_config = {
+            method: "POST",
+            headers: {'Accept': 'application/json; application/xhtml'},
             on: {
                 start: function() {
                     dupe_span.removeClass('sprite bug-dupe');
@@ -253,21 +268,18 @@
                 },
                 end: function() {
                     dupe_span.removeClass('update-in-progress-message');
-                    if (spinner !== null) {
-                        spinner.remove(true);
-                    }
+                    that._hide_bug_spinner(spinner);
                 },
-                success: function(updated_entry) {
+                success: function(id, response) {
                     that._submit_bug_success(
-                        updated_entry, new_dup_url, new_dup_id, new_dupe_title);
+                        response, new_dup_url, new_dup_id, new_dupe_title);
                 },
-                failure: function(id, response) {
-                    that._submit_bug_failure(response, old_dup_url);
-                }
-            }
+                failure: error_handler.getFailureHandler()
+            },
+            data: qs
         };
-        // And save the updated entry.
-        lp_bug_entry.lp_save(config);
+        var io_provider = this.lp_client.io_provider;
+        io_provider.io(submit_url, y_config);
     },
 
     /*
@@ -315,10 +327,6 @@
     }
 }, {
     ATTRS: {
-        // The launchpad client entry for the current bug.
-        lp_bug_entry: {
-            value: null
-        },
         // The rendered duplicate information.
         dupe_span: {
             getter: function() {
@@ -362,5 +370,5 @@
 });
 
 }, "0.1", {"requires": [
-    "base", "io", "oop", "node", "event", "json",
-    "lp.bugs.bug_picker"]});
+    "base", "io", "oop", "node", "event", "json", "lp.app.errors",
+    "lp.bugs.bug_picker", "lp.bugs.bugtask_index"]});

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.html'
--- lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-03 12:47:11 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-07 10:12:43 +0000
@@ -44,6 +44,11 @@
       <script type="text/javascript" src="../../../../../build/js/lp/app/overlay/overlay.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/app/picker/picker.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/bugs/bug_picker.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/bugs/bugtask_index.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/banners/banner.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/banners/privacy.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/choice.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/choiceedit/choiceedit.js"></script>
 
       <!-- The module under test. -->
       <script type="text/javascript" src="../duplicates.js"></script>
@@ -63,6 +68,7 @@
         <div id="fixture">
         </div>
         <script type="text/x-template" id="no-existing-duplicate">
+            <table id="affected-software"></table>
             <a href="#" class="pick-bug js-action">Select a bug</a>
             <div><ul id="duplicate-actions">
                 <li class="sprite bug-dupe">
@@ -78,6 +84,7 @@
             <div id="portlet-duplicates"></div>
         </script>
         <script type="text/x-template" id="existing-duplicate">
+            <table id="affected-software"></table>
             <a href="#" class="pick-bug js-action">Select a bug</a>
             <div><ul id="duplicate-actions">
                 <li>

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.js'
--- lib/lp/bugs/javascript/tests/test_duplicates.js	2012-08-06 02:54:11 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.js	2012-08-07 10:12:43 +0000
@@ -18,6 +18,9 @@
                         id: 1,
                         self_link: 'api/devel/bugs/1',
                         duplicate_of_link: ''
+                    },
+                    context: {
+                        web_link: '/foobar/bug/1'
                     }
                 }
             };
@@ -25,9 +28,6 @@
             this.lp_client = new Y.lp.client.Launchpad({
                 io_provider: this.mockio
             });
-            var bug_repr = window.LP.cache.bug;
-            this.lp_bug_entry = new Y.lp.client.Entry(
-                this.lp_client, bug_repr, bug_repr.self_link);
         },
 
         tearDown: function () {
@@ -55,7 +55,6 @@
                 Y.Node.create(Y.one('#' + fixture_id).getContent()));
             var widget = new Y.lp.bugs.duplicates.DuplicateBugPicker({
                 picker_activator: '.pick-bug',
-                lp_bug_entry: this.lp_bug_entry,
                 use_animation: false,
                 io_provider: this.mockio,
                 private_warning_message:
@@ -151,9 +150,11 @@
                 .simulate('click');
             this._assert_form_state(true);
             Y.Assert.areEqual(
-                '/api/devel/bugs/1', this.mockio.last_request.url);
+                '/foobar/bug/1/+duplicate',
+                this.mockio.last_request.url);
             var expected_link =
-                    '{"duplicate_of_link":"api/devel/bugs/' + bug_id + '"}';
+                    'field.actions.change=Set%20Duplicate' +
+                    '&field.duplicateof=3';
             Y.Assert.areEqual(
                 expected_link, this.mockio.last_request.config.data);
         },
@@ -164,21 +165,19 @@
             this._assert_dupe_submission(3);
             var success_called = false;
             this.widget._submit_bug_success =
-                function(updated_entry, new_dup_url, new_dup_id,
+                function(response, new_dup_url, new_dup_id,
                          new_dup_title) {
                     Y.Assert.areEqual(
-                        expected_updated_entry.duplicate_of_link,
-                        updated_entry.duplicate_of_link);
+                        '<table>New Table</table>', response.responseText);
                     Y.Assert.areEqual('api/devel/bugs/3', new_dup_url);
                     Y.Assert.areEqual(3, new_dup_id);
                     Y.Assert.areEqual('dupe title', new_dup_title);
                     success_called = true;
                 };
-            var expected_updated_entry = {
-                uri: 'api/devel/bugs/1',
-                duplicate_of_link: 'api/devel/bugs/3',
-                self_link: 'api/devel/bugs/1'};
-            this.mockio.last_request.successJSON(expected_updated_entry);
+            this.mockio.success({
+                responseText: '<table>New Table</table>',
+                responseHeaders: {'Content-Type': 'text/html'}
+            });
             Y.Assert.isTrue(success_called);
         },
 
@@ -187,18 +186,16 @@
             this.widget = this._createWidget(false);
             this._assert_dupe_submission(3);
             var failure_called = false;
-            this.widget._submit_bug_failure =
-                function(response, old_dup_url) {
-                    Y.Assert.areEqual(
-                        'There was an error', response.responseText);
-                    Y.Assert.areEqual('', old_dup_url);
-                    failure_called = true;
-                };
             this.mockio.respond({
                 status: 400,
-                responseText: 'There was an error',
-                responseHeaders: {'Content-Type': 'text/html'}});
-            Y.Assert.isTrue(failure_called);
+                responseText:
+                    '{"error_summary": "There is 1 error.",' +
+                    '"errors":' +
+                    '{"field.duplicateof": "There was an error"}, ' +
+                    '"form_wide_errors": []}',
+                responseHeaders: {'Content-Type': 'application/json'}});
+            var error = this.widget.get('error');
+            Y.Assert.areEqual('There was an error', error);
         },
 
         // Submitting a dupe removal request works as expected.
@@ -206,59 +203,63 @@
             this.widget = this._createWidget(false);
             var success_called = false;
             this.widget._submit_bug_success =
-                function(updated_entry, new_dup_url, new_dup_id,
+                function(response, new_dup_url, new_dup_id,
                          new_dupe_title) {
-                    Y.Assert.areEqual(expected_updated_entry, updated_entry);
+                    Y.Assert.areEqual(
+                        response.responseText, '<table>New Table</table>');
                     Y.Assert.areEqual(null, new_dup_url);
                     Y.Assert.areEqual('', new_dup_id);
                     success_called = true;
                 };
             Y.one('.yui3-bugpickerwidget a.remove').simulate('click');
-            var expected_updated_entry =
-                '{"duplicate_of_link":""}';
             this.mockio.success({
-                responseText: expected_updated_entry,
-                responseHeaders: {'Content-Type': 'text/html'}});
+                responseText: '<table>New Table</table>',
+                responseHeaders: {'Content-Type': 'text/html'}
+            });
             Y.Assert.isTrue(success_called);
         },
 
         // The mark bug duplicate success function works as expected.
         test_submit_bug_success: function() {
             this.widget = this._createWidget(false);
-            var data = {
-                self_link: 'api/devel/bugs/1'};
-            var new_bug_entry = new Y.lp.client.Entry(
-                this.lp_client, data, data.self_link);
+            var response = {
+                responseText: '<table id="affected-software">' +
+                    '<tbody><tr><td>Bug tasks</td></tr></tbody></table>',
+                responseHeaders: {'Content-Type': 'text/html'}};
             this.widget._submit_bug_success(
-                new_bug_entry, 'api/devel/bugs/3', 3, 'dupe title');
+                response, 'api/devel/bugs/3', 3, 'dupe title');
             // Test the updated bug entry.
             Y.Assert.areEqual(
-                'api/devel/bugs/3',
-                this.widget.get('lp_bug_entry').get('duplicate_of_link'));
+                'api/devel/bugs/3', LP.cache.bug.duplicate_of_link);
             // Test the Change Duplicate link.
             Y.Assert.isNotNull(Y.one('#mark-duplicate-text a'));
             // Test the duplicate warning message.
             Y.Assert.isNotNull(Y.one('#warning-comment-on-duplicate'));
             // Any previously listed duplicates are removed.
             Y.Assert.isNull(Y.one('#portlet-duplicates'));
+            // The bug dupe table is updated.
+            Y.Assert.areEqual(
+                'Bug tasks', Y.one('#affected-software').get('text'));
         },
 
         // The remove bug duplicate success function works as expected.
         test_remove_bug_duplicate_success: function() {
             this.widget = this._createWidget(true);
-            var data = {
-                self_link: 'api/devel/bugs/1'};
-            var new_bug_entry = new Y.lp.client.Entry(
-                this.lp_client, data, data.self_link);
-            this.widget._submit_bug_success(new_bug_entry, null, '');
+            var response = {
+                responseText: '<table id="affected-software">' +
+                    '<tbody><tr><td>Bug tasks</td></tr></tbody></table>',
+                responseHeaders: {'Content-Type': 'text/html'}};
+            this.widget._submit_bug_success(response, null, '');
             // Test the updated bug entry.
-            Y.Assert.isNull(
-                this.widget.get('lp_bug_entry').get('duplicate_of_link'));
+            Y.Assert.isNull(LP.cache.bug.duplicate_of_link);
             // Test the Mark as Duplicate link.
             Y.Assert.isNotNull(
                 Y.one('#mark-duplicate-text .menu-link-mark-dupe'));
             // Test the duplicate warning message is gone.
             Y.Assert.isNull(Y.one('#warning-comment-on-duplicate'));
+            // The bug dupe table is updated.
+            Y.Assert.areEqual(
+                'Bug tasks', Y.one('#affected-software').get('text'));
         }
     })));
 
@@ -267,5 +268,5 @@
         'test', 'lp.testing.helpers', 'event', 'node-event-simulate',
         'console', 'lp.client', 'lp.testing.mockio', 'lp.anim',
         'lp.bugs.bug_picker', 'lp.bugs.duplicates',
-        'lp.bugs.bug_picker.test']
+        'lp.bugs.bug_picker.test', 'lp.bugs.bugtask_index']
 });

=== modified file 'lib/lp/code/javascript/branch.bugspeclinks.js'
--- lib/lp/code/javascript/branch.bugspeclinks.js	2012-08-05 10:51:39 +0000
+++ lib/lp/code/javascript/branch.bugspeclinks.js	2012-08-07 10:12:43 +0000
@@ -120,6 +120,14 @@
             Y.lp.client.get_absolute_uri("/api/devel/bugs/" + bug_id);
         var spinner;
         var that = this;
+        var error_handler = new Y.lp.client.ErrorHandler();
+        error_handler.clearProgressUI = function() {
+            that._hide_bug_spinner(spinner);
+            that._hide_temporary_spinner();
+        };
+        error_handler.showError = function(error_msg) {
+            that.set('error', error_msg);
+        };
         var config = {
             on: {
                 start: function() {
@@ -127,16 +135,10 @@
                     spinner = that._show_bug_spinner(widget);
                     that._show_temporary_spinner();
                 },
-                success: function(entry) {
+                success: function() {
                     that._update_bug_links(bug_id, spinner);
                 },
-                failure: function(id, response) {
-                    if (spinner !== null) {
-                        spinner.remove(true);
-                    }
-                    that._hide_temporary_spinner();
-                    that.set('error', response.responseText);
-                }
+                failure: error_handler.getFailureHandler()
             },
             parameters: {
                 bug: bug_link
@@ -149,10 +151,14 @@
     /**
      * Update the list of bug links.
      * @param bug_id
-     * @param spinner
+     * @param widget
      * @private
      */
     _update_bug_links: function(bug_id, spinner) {
+        var error_handler = new Y.lp.client.ErrorHandler();
+        error_handler.showError = function(error_msg) {
+            that.set('error', error_msg);
+        };
         var that = this;
         this.lp_client.io_provider.io('++bug-links', {
             on: {
@@ -165,9 +171,7 @@
                 success: function(id, response) {
                     that._link_bug_success(bug_id, response.responseText);
                 },
-                failure: function(id, response) {
-                    that.set('error', response.responseText);
-                }
+                failure: error_handler.getFailureHandler()
             }
         });
     },


Follow ups