← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/improve-dupe-bug-ui-227310 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/improve-dupe-bug-ui-227310 into lp:launchpad.

Requested reviews:
  Curtis Hovey (sinzui)
Related bugs:
  Bug #227310 in Launchpad itself: "duplicate bug handling UI is awkward and confusing"
  https://bugs.launchpad.net/launchpad/+bug/227310

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/improve-dupe-bug-ui-227310/+merge/118738

== Implementation ==

This branch improves the display of bug duplicate information. It renders an informational message at the top of the bug tasks table, along with an edit and remove icon. It also adds a remove icon to the duplicate portlet. So the dupe can be removed directly from the remove icon or from the picker.

As a driveby, the bug picker event names were corrected.

The method of rendering the in-progress spinner for the bug picker was changed to use a css approach instead of explicitly adding and removing an extra spinner node. This was necessary to fit with using the additional remove icons. There was already a button.spinner style defined (not sure where it is used but it looks horrible) so I created a new button.update-in-progress-message style to match the existing non button update-in-progress-message style. This style displays a spinner on the right hand side of the button. So now the bug picker widget can simply apply the same update-in-progress-message style to the clicked element (button or anchor) and the spinner displays nicely.

== Demo ==

Screenshot of the picker showing the new spinner icon for the for button and the message display:

http://people.canonical.com/~ianb/bug-dupe-message.png

== Tests ==

Update test_duplicates yui tests to check the rendering of the new informational message.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/icing/style.css
  lib/lp/app/javascript/picker/picker.js
  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_bug_picker.js
  lib/lp/bugs/javascript/tests/test_duplicates.html
  lib/lp/bugs/javascript/tests/test_duplicates.js
  lib/lp/bugs/templates/bug-portlet-actions.pt
  lib/lp/bugs/templates/bugtask-index.pt
  lib/lp/code/javascript/branch.bugspeclinks.js
-- 
https://code.launchpad.net/~wallyworld/launchpad/improve-dupe-bug-ui-227310/+merge/118738
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/style.css'
--- lib/canonical/launchpad/icing/style.css	2012-07-05 00:47:31 +0000
+++ lib/canonical/launchpad/icing/style.css	2012-08-08 13:36:24 +0000
@@ -387,12 +387,19 @@
     padding-left: 18px;
 }
 
-.update-in-progress-message {
+:not(button).update-in-progress-message {
     background: url(/@@/spinner) center left no-repeat;
     padding-left: 18px;
     color: #666;
 }
 
+button.update-in-progress-message:after {
+    background: url("/@@/spinner") center left no-repeat;
+    content: "\00A0";
+    margin-left: 6px;
+    padding-right: 12px;
+}
+
 a.update-retry {
     padding-left: 1em;
 }

=== modified file 'lib/lp/app/javascript/picker/picker.js'
--- lib/lp/app/javascript/picker/picker.js	2012-08-02 20:40:57 +0000
+++ lib/lp/app/javascript/picker/picker.js	2012-08-08 13:36:24 +0000
@@ -143,12 +143,12 @@
         this.subscribe('cancel', this._defaultCancel);
 
         if ( this.get('picker_activator') ) {
-            var element = Y.one(this.get('picker_activator'));
-            element.on('click', function(e) {
+            var elements = Y.all(this.get('picker_activator'));
+            elements.on('click', function(e) {
                 e.halt();
                 this.show();
             }, this);
-            element.addClass(this.get('picker_activator_css_class'));
+            elements.addClass(this.get('picker_activator_css_class'));
         }
 
         if (!Y.Lang.isUndefined(cfg)) {

=== modified file 'lib/lp/bugs/javascript/bug_picker.js'
--- lib/lp/bugs/javascript/bug_picker.js	2012-08-07 09:28:09 +0000
+++ lib/lp/bugs/javascript/bug_picker.js	2012-08-08 13:36:24 +0000
@@ -70,7 +70,7 @@
         if (Y.Lang.isValue(this.remove_link)) {
             this.remove_link.on('click', function(e) {
                 e.halt();
-                that.fire(namespace.BugPicker.REMOVE_DUPLICATE);
+                that.fire(namespace.BugPicker.REMOVE);
             });
         }
         this.after('visibleChange', function() {
@@ -88,30 +88,26 @@
     },
 
     /**
-     * Show a spinner next to the specified node.
+     * Show a spinner for the specified node.
      *
      * @method _show_bug_spinner
      * @param node
      * @protected
      */
     _show_bug_spinner: function(node) {
-        if( !Y.Lang.isValue(node)) {
-            return null;
+        if( Y.Lang.isValue(node)) {
+            node.addClass('update-in-progress-message');
         }
-        var spinner_node = Y.Node.create(
-        '<img class="spinner" src="/@@/spinner" alt="Searching..." />');
-        node.insert(spinner_node, 'after');
-        return spinner_node;
     },
 
     /**
-     * Hide the specified spinner.
-     * @param spinner
+     * Hide the spinner for the specified node.
+     * @param node
      * @protected
      */
-    _hide_bug_spinner: function(spinner) {
-        if( Y.Lang.isValue(spinner)) {
-            spinner.remove(true);
+    _hide_bug_spinner: function(node) {
+        if( Y.Lang.isValue(node)) {
+            node.removeClass('update-in-progress-message');
         }
     },
 
@@ -248,8 +244,7 @@
         this.save_button
             .on('click', function(e) {
                 e.halt();
-                that.fire(
-                    namespace.BugPicker.SAVE_DUPLICATE, bug_data);
+                that.fire(namespace.BugPicker.SAVE, bug_data);
             });
     },
 
@@ -366,8 +361,8 @@
 });
 
 // Events
-namespace.BugPicker.SAVE_DUPLICATE = 'save';
-namespace.BugPicker.REMOVE_DUPLICATE = 'remove';
+namespace.BugPicker.SAVE = 'save';
+namespace.BugPicker.REMOVE = 'remove';
 
 }, "0.1", {"requires": [
     "base", "io", "oop", "node", "event", "json",

=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2012-08-07 09:28:09 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2012-08-08 13:36:24 +0000
@@ -41,7 +41,7 @@
         setup_client_and_bug();
 
         var config = {
-            picker_activator: '.menu-link-mark-dupe, #change_duplicate_bug'
+            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-08 00:45:37 +0000
+++ lib/lp/bugs/javascript/duplicates.js	2012-08-08 13:36:24 +0000
@@ -25,8 +25,8 @@
         superclass.prototype.bindUI.apply(this, arguments);
         var that = this;
         this.subscribe(
-            Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE, function(e) {
-            e.preventDefault();
+            Y.lp.bugs.bug_picker.BugPicker.SAVE, function(e) {
+            e.halt();
             that.set('progress', 100);
             var bug_data = e.details[0];
             var bug_id = bug_data.id;
@@ -34,11 +34,31 @@
             that._submit_bug(bug_id, bug_title, this.save_button);
         });
         this.subscribe(
-            Y.lp.bugs.bug_picker.BugPicker.REMOVE_DUPLICATE, function(e) {
-            e.preventDefault();
+            Y.lp.bugs.bug_picker.BugPicker.REMOVE, function(e) {
+            e.halt();
             that.set('progress', 100);
             that._submit_bug('', null, this.remove_link);
         });
+        this._connect_links();
+    },
+
+    // Wire up the edit and remove links.
+    _connect_links: function() {
+        Y.all(
+            '.change-duplicate-bug, .menu-link-mark-dupe, ' +
+            '.remove-duplicate-bug').detachAll();
+        var that = this;
+        Y.all(
+            '.remove-duplicate-bug').on('click', function(e) {
+                e.halt();
+                that._submit_bug('', null, e.currentTarget);
+            });
+        Y.all(
+            '.change-duplicate-bug, .menu-link-mark-dupe').on('click',
+            function(e) {
+                e.halt();
+                that.show();
+            });
     },
 
     _syncResultsUI: function() {
@@ -121,7 +141,7 @@
     },
 
     // A common error handler for XHR operations.
-    _error_handler: function() {
+    _error_handler: function(widget) {
         var that = this;
         var error_handler = new Y.lp.client.ErrorHandler();
         error_handler.handleError = function(id, response) {
@@ -145,6 +165,11 @@
         error_handler.showError = function(error_msg) {
             that.set('error', error_msg);
         };
+        error_handler.clearProgressUI = function() {
+            that._hide_bug_spinner(widget);
+            var dupe_span = that.get('dupe_span');
+            dupe_span.removeClass('update-in-progress-message');
+        };
         return error_handler;
     },
 
@@ -155,6 +180,38 @@
         Y.lp.bugs.bugtask_index.setup_bugtask_table();
     },
 
+    // Create the duplicate edit anchor.
+    _dupe_edit_link: function(link_id, url, dup_id) {
+        var template = [
+                '<a id="{link_id}" ',
+                'title="Edit or remove linked duplicate bug" ',
+                'href={url} ',
+                'class="sprite edit action-icon change-duplicate-bug"',
+                'style="margin-left: 0">Edit</a>',
+                '<span id="mark-duplicate-text">',
+                'Duplicate of <a href="/bugs/{dup_id}">bug #{dup_id}</a>',
+                '</span>'].join("");
+        return Y.Lang.substitute(template, {
+            link_id: link_id,
+            url: url,
+            dup_id: dup_id
+        });
+    },
+
+    // Create the duplicate removal anchor.
+    _dupe_remove_link: function(link_id, url) {
+        var template = [
+                '<a id="{link_id}" ',
+                'title="Edit or remove linked duplicate bug" ',
+                'href={url} ',
+                'class="sprite remove action-icon remove-duplicate-bug"',
+                'style="float: right;">Remove</a>'].join("");
+        return Y.Lang.substitute(template, {
+            link_id: link_id,
+            url: url
+        });
+    },
+
     /**
      * Bug was successfully marked as a duplicate, update the UI.
      *
@@ -168,62 +225,78 @@
     _submit_bug_success: function(response, new_dup_url,
                                            new_dup_id, new_dupe_title) {
         this._performDefaultSave();
-
         // 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 dupe_portlet_node = this.get('dupe_span').ancestor('li');
+        var update_dup_url = dupe_portlet_node.one('a').get('href');
         var edit_link;
         if (Y.Lang.isValue(new_dup_url)) {
-            dupe_span.removeClass('sprite bug-dupe');
-            dupe_span.setContent([
-                '<a id="change_duplicate_bug" ',
-                'title="Edit or remove linked duplicate bug" ',
-                'class="sprite edit action-icon"',
-                'style="margin-left: 0">Edit</a>',
-                '<span id="mark-duplicate-text">',
-                'Duplicate of <a>bug #</a></span>'].join(""));
-            edit_link = dupe_span.one('#change_duplicate_bug');
-            edit_link.set('href', update_dup_url);
-            dupe_span.all('a').item(1)
-                .set('href', '/bugs/' + new_dup_id)
-                .appendChild(document.createTextNode(new_dup_id));
-            var duplicatesNode = this.get('duplicatesNode');
-            if (Y.Lang.isValue(duplicatesNode)) {
-                duplicatesNode.remove(true);
-            }
-            this._show_comment_on_duplicate_warning(
-                new_dup_id, new_dupe_title);
+            this._render_dupe_information(new_dup_id, new_dupe_title);
         } else {
-            dupe_span.addClass('sprite bug-dupe');
-            dupe_span.setContent([
-                '<span id="mark-duplicate-text">',
-                '<a class="menu-link-mark-dupe js-action">',
-                'Mark as duplicate</a></span>'].join(""));
-            edit_link = dupe_span.one('a');
-            edit_link.set('href', update_dup_url);
-            this._hide_comment_on_duplicate_warning();
-        }
-        dupe_span = this.get('portletNode').one('#mark-duplicate-text');
-        this.set('dupe_span', dupe_span);
+            this._remove_dupe_information();
+        }
+        dupe_portlet_node = this.get('portletNode').one('#mark-duplicate-text');
+        this.set('dupe_portlet_node', dupe_portlet_node);
+        this._connect_links();
+    },
+
+    // Render the new duplicate information.
+    _render_dupe_information: function(new_dup_id, new_dupe_title) {
+        var dupe_portlet_node = this.get('dupe_span').ancestor('li');
+        var update_dup_url = dupe_portlet_node.one('a').get('href');
+        dupe_portlet_node.empty(true);
+        dupe_portlet_node.removeClass('sprite bug-dupe');
+        var edit_link = this._dupe_edit_link(
+            'change-duplicate-bug', update_dup_url, new_dup_id);
+        dupe_portlet_node.appendChild(edit_link);
+        var remove_link = this._dupe_remove_link(
+            'remove-duplicate-bug', update_dup_url);
+        dupe_portlet_node.appendChild(remove_link);
+        var duplicatesNode = this.get('duplicatesNode');
+        if (Y.Lang.isValue(duplicatesNode)) {
+            duplicatesNode.remove(true);
+        }
+        this._show_comment_on_duplicate_warning(new_dup_id, new_dupe_title);
+        this._show_bugtasks_duplicate_message(new_dup_id, new_dupe_title);
         var anim_duration = 1;
         if (!this.get('use_animation')) {
             anim_duration = 0;
         }
         Y.lp.anim.green_flash({
-            node: dupe_span,
+            node: '.bug-duplicate-details',
             duration: anim_duration
             }).run();
-        // ensure the new link is hooked up correctly:
+    },
+
+    // Remove the old duplicate information.
+    _remove_dupe_information: function() {
+        var dupe_portlet_node = this.get('dupe_span').ancestor('li');
+        var update_dup_url = dupe_portlet_node.one('a').get('href');
+        dupe_portlet_node.addClass('sprite bug-dupe');
+        dupe_portlet_node.setContent([
+            '<span id="mark-duplicate-text">',
+            '<a class="menu-link-mark-dupe js-action">',
+            'Mark as duplicate</a></span>'].join(""));
+        var edit_link = dupe_portlet_node.one('a');
+        edit_link.set('href', update_dup_url);
+        this._hide_comment_on_duplicate_warning();
         var that = this;
-        edit_link.on(
-            'click', function(e){
-                e.preventDefault();
-                that.show();
+        var hide_dupe_message = function() {
+            that._hide_bugtasks_duplicate_message();
+        };
+        if (!this.get('use_animation')) {
+            hide_dupe_message();
+            return;
+        }
+        var anim = Y.lp.anim.green_flash({
+            node: '.bug-duplicate-details',
+            duration: 1
             });
+        anim.on('end', hide_dupe_message);
+        anim.run();
     },
 
     /**
@@ -237,7 +310,6 @@
      */
     _submit_bug: function(new_dup_id, new_dupe_title, widget) {
         var dupe_span = this.get('dupe_span');
-        var that = this;
         var new_dup_url = null;
 
         var qs;
@@ -253,23 +325,21 @@
                 '', 'field.actions.remove', 'Remove Duplicate');
         }
 
+        var that = this;
         var spinner = null;
-        var error_handler = this._error_handler();
+        var error_handler = this._error_handler(widget);
         var submit_url = LP.cache.context.web_link + '/+duplicate';
         var y_config = {
             method: "POST",
             headers: {'Accept': 'application/json; application/xhtml'},
             on: {
                 start: function() {
+                    var dupe_span = that.get('dupe_span');
                     dupe_span.removeClass('sprite bug-dupe');
                     dupe_span.addClass('update-in-progress-message');
                     that.set('error', null);
                     spinner = that._show_bug_spinner(widget);
                 },
-                end: function() {
-                    dupe_span.removeClass('update-in-progress-message');
-                    that._hide_bug_spinner(spinner);
-                },
                 success: function(id, response) {
                     that._submit_bug_success(
                         response, new_dup_url, new_dup_id, new_dupe_title);
@@ -282,6 +352,49 @@
         io_provider.io(submit_url, y_config);
     },
 
+    // Create the informational message to go at the top of the bug tasks
+    // table.
+    _duplicate_bug_info_message: function(dup_id, dup_title) {
+        var info_template = [
+            '<p class="bug-duplicate-details ellipsis line-block"',
+            'style="max-width: 60em;">',
+            '<span class="sprite info"></span>',
+            'This bug report is a duplicate of:&nbsp;',
+            '<a href="/bugs/{dup_id}">Bug #{dup_id} {dup_title}</a>',
+            '<a id="change-duplicate-bug-bugtasks"',
+            '    href="+duplicate"',
+            '    title="Edit or remove linked duplicate bug"',
+            '    class="sprite edit action-icon','change-duplicate-bug">',
+            '    Edit</a>',
+            '<a id="remove-duplicate-bug-bugtasks"',
+            '    href="+duplicate"',
+            '    title="Remove linked duplicate bug"',
+            '    class="sprite remove action-icon remove-duplicate-bug">',
+            '    Remove</a>',
+            '</p>'].join(" ");
+        return Y.Lang.substitute(info_template, {
+            dup_id: dup_id,
+            dup_title: dup_title
+        });
+    },
+
+    // Render the duplicate message at the top of the bug tasks table.
+    _show_bugtasks_duplicate_message: function(dup_id, dup_title) {
+        var dupe_info = Y.one("#bug-is-duplicate");
+        if (Y.Lang.isValue(dupe_info)) {
+            dupe_info.setContent(Y.Node.create(
+                this._duplicate_bug_info_message(dup_id, dup_title)));
+        }
+    },
+
+    // Hide the duplicate message at the top of the bug tasks table.
+    _hide_bugtasks_duplicate_message: function() {
+        var dupe_info = Y.one("#bug-is-duplicate");
+        if (Y.Lang.isValue(dupe_info)) {
+            dupe_info.empty();
+        }
+    },
+
     /*
      * Ensure that a warning about adding a comment to a duplicate bug
      * is displayed.

=== modified file 'lib/lp/bugs/javascript/tests/test_bug_picker.js'
--- lib/lp/bugs/javascript/tests/test_bug_picker.js	2012-08-07 05:00:15 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_picker.js	2012-08-08 13:36:24 +0000
@@ -125,7 +125,7 @@
             this._assert_search_form_success(3);
             var save_bug_called = false;
             this.widget.subscribe(
-                    Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE,
+                    Y.lp.bugs.bug_picker.BugPicker.SAVE,
                     function(e) {
                 e.preventDefault();
                 var bug_data = e.details[0];
@@ -173,7 +173,7 @@
             this.widget = this._createWidget();
             var remove_bug_called = false;
             this.widget.subscribe(
-                    Y.lp.bugs.bug_picker.BugPicker.REMOVE_DUPLICATE,
+                    Y.lp.bugs.bug_picker.BugPicker.REMOVE,
                     function(e) {
                 e.preventDefault();
                 remove_bug_called = true;

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.html'
--- lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-08 00:45:37 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-08 13:36:24 +0000
@@ -83,6 +83,7 @@
             <div id="warning-comment-on-duplicate"></div>
             <div id="add-comment-form"></div>
             <div id="portlet-duplicates"></div>
+            <div id="bug-is-duplicate"></div>
         </script>
         <script type="text/x-template" id="existing-duplicate">
             <table id="affected-software"></table>
@@ -90,9 +91,9 @@
             <div><ul id="duplicate-actions">
                 <li>
                     <span id="mark-duplicate-text">
-                    <a class="sprite edit action-icon"
+                    <a class="sprite edit action-icon bug-duplicate-details"
                        title="Edit or remove linked duplicate bug"
-                       id="change_duplicate_bug"
+                       id="change-duplicate-bug"
                        href="http://foo/+duplicate";>Edit</a>
                     Duplicate of <a href="http://foo/bugs/1234";>bug #1234
                     </a>
@@ -102,6 +103,9 @@
             <div id="duplicate-form-container"></div>
             <div id="warning-comment-on-duplicate"></div>
             <div id="add-comment-form"></div>
+            <div id="bug-is-duplicate">
+                <p class="bug-duplicate-details">A dupe</p>
+            </div>
         </script>
     </body>
 </html>

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.js'
--- lib/lp/bugs/javascript/tests/test_duplicates.js	2012-08-08 00:45:37 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.js	2012-08-08 13:36:24 +0000
@@ -241,6 +241,9 @@
                 'Comment here only if you think the duplicate status ' +
                 'is wrong.',
                 dupe_warning.get('text').trim());
+            // The duplicate info message
+            Y.Assert.isNotNull(
+                Y.one('#bug-is-duplicate p.bug-duplicate-details'));
             // Any previously listed duplicates are removed.
             Y.Assert.isNull(Y.one('#portlet-duplicates'));
             // The bug dupe table is updated.
@@ -263,6 +266,9 @@
                 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 duplicate info message is gone.
+            Y.Assert.isNull(
+                Y.one('#bug-is-duplicate p.bug-duplicate-details'));
             // The bug dupe table is updated.
             Y.Assert.areEqual(
                 'Bug tasks', Y.one('#affected-software').get('text'));

=== modified file 'lib/lp/bugs/templates/bug-portlet-actions.pt'
--- lib/lp/bugs/templates/bug-portlet-actions.pt	2012-07-30 23:36:26 +0000
+++ lib/lp/bugs/templates/bug-portlet-actions.pt	2012-08-08 13:36:24 +0000
@@ -23,12 +23,12 @@
       tal:define="duplicateof context/duplicateof"
       tal:condition="duplicateof"
     >
-      <li><a
+      <li class="bug-duplicate-details"><a
         tal:define="link context_menu/markduplicate"
         tal:condition="python: link.enabled"
-        id="change_duplicate_bug"
+        id="change-duplicate-bug"
         title="Edit or remove linked duplicate bug"
-        class="sprite edit"
+        class="sprite edit change-duplicate-bug"
         tal:attributes="href link/url"></a><span id="mark-duplicate-text">Duplicate of
       <a
         tal:condition="duplicateof/required:launchpad.View"
@@ -39,7 +39,14 @@
       >bug #42</a>
         <span
           tal:condition="not:duplicateof/required:launchpad.View"
-          tal:replace="string:a private bug" /></span></li>
+          tal:replace="string:a private bug" /></span>
+      <a id="remove-duplicate-bug"
+          href="+duplicate"
+          title="Remove linked duplicate bug"
+          class="sprite remove action-icon remove-duplicate-bug"
+          style="float: right;">Remove</a>
+
+      </li>
     </tal:block>
     <li
       tal:define="link context_menu/createquestion"

=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
--- lib/lp/bugs/templates/bugtask-index.pt	2012-07-25 21:49:38 +0000
+++ lib/lp/bugs/templates/bugtask-index.pt	2012-08-08 13:36:24 +0000
@@ -97,6 +97,30 @@
         (<a href="https://help.launchpad.net/BugExpiry";>find out why</a>)
       </p>
 
+      <div id="bug-is-duplicate">
+          <p class="bug-duplicate-details ellipsis line-block"
+             style="max-width: 60em;"
+             tal:condition="context/bug/duplicateof|nothing"
+             tal:define="duplicateof context/bug/duplicateof">
+            <span class="sprite info"></span>
+            This bug report is a duplicate of:&nbsp;
+                <a
+                  tal:condition="duplicateof/required:launchpad.View"
+                  tal:attributes="href duplicateof/fmt:url; title
+                     duplicateof/title;
+                     id string:duplicate-of-warning-link-bugtasks;"
+                  tal:content="string:Bug #${duplicateof/id}: ${duplicateof/title}."
+                >bug #42</a>
+                <a id="change-duplicate-bug-bugtasks"
+                    href="+duplicate"
+                    title="Edit or remove linked duplicate bug"
+                    class="sprite edit action-icon change-duplicate-bug">Edit</a>
+                <a id="remove-duplicate-bug-bugtasks"
+                    href="+duplicate"
+                    title="Remove linked duplicate bug"
+                    class="sprite remove action-icon remove-duplicate-bug">Remove</a>
+          </p>
+      </div>
       <p id="bug-is-question"
          tal:condition="context/bug/getQuestionCreatedFromBug"
          tal:define="question context/bug/getQuestionCreatedFromBug">

=== modified file 'lib/lp/code/javascript/branch.bugspeclinks.js'
--- lib/lp/code/javascript/branch.bugspeclinks.js	2012-08-07 09:28:09 +0000
+++ lib/lp/code/javascript/branch.bugspeclinks.js	2012-08-08 13:36:24 +0000
@@ -51,7 +51,7 @@
         superclass.prototype.bindUI.apply(this, arguments);
         var that = this;
         this.subscribe(
-            Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE, function(e) {
+            Y.lp.bugs.bug_picker.BugPicker.SAVE, function(e) {
             e.preventDefault();
             that.set('progress', 100);
             var bug_data = e.details[0];
@@ -118,11 +118,10 @@
         }
         var bug_link =
             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_bug_spinner(widget);
             that._hide_temporary_spinner();
         };
         error_handler.showError = function(error_msg) {
@@ -132,11 +131,11 @@
             on: {
                 start: function() {
                     that.set('error', null);
-                    spinner = that._show_bug_spinner(widget);
+                    that._show_bug_spinner(widget);
                     that._show_temporary_spinner();
                 },
                 success: function() {
-                    that._update_bug_links(bug_id, spinner);
+                    that._update_bug_links(bug_id, widget);
                 },
                 failure: error_handler.getFailureHandler()
             },
@@ -154,7 +153,7 @@
      * @param widget
      * @private
      */
-    _update_bug_links: function(bug_id, spinner) {
+    _update_bug_links: function(bug_id, widget) {
         var error_handler = new Y.lp.client.ErrorHandler();
         error_handler.showError = function(error_msg) {
             that.set('error', error_msg);
@@ -163,9 +162,6 @@
         this.lp_client.io_provider.io('++bug-links', {
             on: {
                 end: function() {
-                    if (spinner !== null) {
-                        spinner.remove(true);
-                    }
                     that._hide_temporary_spinner();
                 },
                 success: function(id, response) {
@@ -273,7 +269,6 @@
      * Destroy the temporary "Linking..." text.
      */
     _hide_temporary_spinner: function() {
-
         var temp_spinner = Y.one('#temp-spinner');
         var spinner_parent = temp_spinner.get('parentNode');
         spinner_parent.removeChild(temp_spinner);


Follow ups