← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/link-branch-to-bug-411409 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/link-branch-to-bug-411409 into lp:launchpad.

Requested reviews:
  Curtis Hovey (sinzui)
Related bugs:
  Bug #411409 in Launchpad itself: "AJAX "Link to a bug report" could be improved"
  https://bugs.launchpad.net/launchpad/+bug/411409
  Bug #877880 in Launchpad itself: ""link to a bug" ajax on branch page has poor failure mode"
  https://bugs.launchpad.net/launchpad/+bug/877880

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/link-branch-to-bug-411409/+merge/118083

== Implementation ==

This branch replaces the current javascript used for the link bug to branch functionality and uses the new bug picker widget instead. It also does a few little cleanup things.

The link bug to branch functionality is a little screwed up (even before this mp) and another branch will be needed to fix it. The main issue is that when the page is first rendered, the TAL produces a list of linked bugs which renders the bug titles and a remove icon in a <ul>. When a new bug is linked, the entire list of linked bugs is replaced with some new HTML via an XHR call, but the replacement HTML has the linked bugs in a <table> also showing the status and importance of each linked bug. The TAL should be updated to match the enhanced rendering provided by the javascript.

This mp removes 2 unnecessary XHR calls when the branch page loads. It all looks pretty good now.

Like the case when adding a dupe, a warning is displayed when the user attempts to link a public branch to a private bug.

== QA ==

Go to a branch page and click the "Link to a bug report" link. Marvel at the new widget.

== Tests ==

Rewrite the test_bugspeclinks yui module to add in new tests for the widget and run the current extract_candidate_bug_id tests.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/icing/css/components/bug_picker.css
  lib/lp/bugs/javascript/bug_picker.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/code/javascript/branch.bugspeclinks.js
  lib/lp/code/javascript/tests/test_bugspeclinks.html
  lib/lp/code/javascript/tests/test_bugspeclinks.js
  lib/lp/code/templates/branch-related-bugs-specs.pt
-- 
https://code.launchpad.net/~wallyworld/launchpad/link-branch-to-bug-411409/+merge/118083
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/css/components/bug_picker.css'
--- lib/canonical/launchpad/icing/css/components/bug_picker.css	2012-08-02 09:04:27 +0000
+++ lib/canonical/launchpad/icing/css/components/bug_picker.css	2012-08-03 12:55:39 +0000
@@ -1,8 +1,8 @@
 /* The bug picker buttons should be left aligned, not centred. */
-.yui3-duplicatebugpickerwidget .yui3-picker-footer-slot {
+.yui3-bugpickerwidget .yui3-picker-footer-slot {
     margin-left: 0;
     }
 
-.yui3-duplicatebugpickerwidget .search-header {
+.yui3-bugpickerwidget .search-header {
     margin-top: 6px;
     }

=== modified file 'lib/lp/bugs/javascript/bug_picker.js'
--- lib/lp/bugs/javascript/bug_picker.js	2012-08-03 01:45:37 +0000
+++ lib/lp/bugs/javascript/bug_picker.js	2012-08-03 12:55:39 +0000
@@ -171,7 +171,7 @@
         '      <a href="{{bug_url}}" class="bugtitle sprite new-window">',
         '      <span class="bugnumber">#{{id}}</span>&nbsp;{{bug_summary}}</a>',
         '      <div class="buginfo-extra">',
-        '          <p class="ellipsis line-block" style="max-width: 70em;">',
+        '          <p class="ellipsis line-block">',
         '          {{description}}</p>',
         '      </div>',
         '  </div>',
@@ -211,8 +211,6 @@
         // The server may return multiple bugs but for now we only
         // support displaying one of them.
         bug_data = bug_data[0];
-        var bug_id = bug_data.id;
-        var bug_title = bug_data.bug_summary;
         bug_data.private_warning
             = this.get('public_context') && bug_data.is_private;
         var private_warning_message
@@ -236,7 +234,7 @@
             .on('click', function(e) {
                 e.halt();
                 that.fire(
-                    namespace.BugPicker.SAVE_DUPLICATE, bug_id, bug_title);
+                    namespace.BugPicker.SAVE_DUPLICATE, bug_data);
             });
     },
 

=== modified file 'lib/lp/bugs/javascript/duplicates.js'
--- lib/lp/bugs/javascript/duplicates.js	2012-08-02 13:52:58 +0000
+++ lib/lp/bugs/javascript/duplicates.js	2012-08-03 12:55:39 +0000
@@ -28,8 +28,9 @@
             Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE, function(e) {
             e.preventDefault();
             that.set('progress', 100);
-            var bug_id = e.details[0];
-            var bug_title = e.details[1];
+            var bug_data = e.details[0];
+            var bug_id = bug_data.id;
+            var bug_title = bug_data.bug_summary;
             that._submit_bug(bug_id, bug_title, this.save_button);
         });
         this.subscribe(

=== modified file 'lib/lp/bugs/javascript/tests/test_bug_picker.js'
--- lib/lp/bugs/javascript/tests/test_bug_picker.js	2012-08-02 13:52:58 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_picker.js	2012-08-03 12:55:39 +0000
@@ -122,8 +122,9 @@
                     Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE,
                     function(e) {
                 e.preventDefault();
-                Y.Assert.areEqual(3, e.details[0]);
-                Y.Assert.areEqual('dupe title', e.details[1]);
+                var bug_data = e.details[0];
+                Y.Assert.areEqual(3, bug_data.id);
+                Y.Assert.areEqual('dupe title', bug_data.bug_summary);
                 save_bug_called = true;
             });
             Y.one(

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.html'
--- lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-02 09:04:27 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-03 12:55:39 +0000
@@ -34,7 +34,6 @@
       <script type="text/javascript" src="../../../../../build/js/lp/app/lp.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/app/testing/mockio.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/app/activator/activator.js"></script>
-      <script type="text/javascript" src="../../../../../build/js/lp/app/anim/anim.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/app/effects/effects.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/app/expander.js"></script>
       <script type="text/javascript" src="../../../../../build/js/lp/app/extras/extras.js"></script>

=== modified file 'lib/lp/code/javascript/branch.bugspeclinks.js'
--- lib/lp/code/javascript/branch.bugspeclinks.js	2011-08-09 14:18:02 +0000
+++ lib/lp/code/javascript/branch.bugspeclinks.js	2012-08-03 12:55:39 +0000
@@ -1,26 +1,20 @@
-/* Copyright 2009 Canonical Ltd.  This software is licensed under the
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE).
  *
- * Code for handling links to branches from bugs and specs.
+ * Provide functionality for picking a bug.
  *
- * @module BranchLinks
- * @requires base, lp.anim, lazr.formoverlay
+ * @module bugs
+ * @submodule bug_picker
  */
-
 YUI.add('lp.code.branch.bugspeclinks', function(Y) {
 
 var namespace = Y.namespace('lp.code.branch.bugspeclinks');
-
-var lp_client;          // The LP client
-
-var link_bug_overlay;
-
-var error_handler;
+var superclass = Y.lp.bugs.bug_picker.BugPicker;
 
 /*
  * Extract the best candidate for a bug number from the branch name.
  */
-function extract_candidate_bug_id(branch_name) {
+namespace.extract_candidate_bug_id = function(branch_name) {
     // Extract all the runs of numbers in the branch name and sort by
     // descending length.
     var chunks = branch_name.split(/\D/g).sort(function (a, b) {
@@ -41,256 +35,257 @@
         }
     }
     return null;
-}
-// Expose in the namespace so we can test it.
-namespace._extract_candidate_bug_id = extract_candidate_bug_id;
-
-/*
- * Connect the links to the javascript events.
- */
-namespace.connect_branchlinks = function() {
-
-    error_handler = new Y.lp.client.ErrorHandler();
-    error_handler.clearProgressUI = function() {
-        destroy_temporary_spinner();
-    };
-    error_handler.showError = function(error_message) {
-        alert('An unexpected error has occurred.');
-        Y.log(error_message);
-    };
-
-    link_bug_overlay = new Y.lazr.FormOverlay({
-        headerContent: '<h2>Link to a bug</h2>',
-        form_submit_button: Y.Node.create(
-            '<button type="submit" name="buglink.actions.change" ' +
-            'value="Change" class="lazr-pos lazr-btn">Ok</button>'),
-        form_cancel_button: Y.Node.create(
-            '<button type="button" name="buglink.actions.cancel" ' +
-            'class="lazr-neg lazr-btn">Cancel</button>'),
-        centered: true,
-        form_submit_callback: link_bug_to_branch,
-        visible: false
-    });
-    link_bug_overlay.render();
-    link_bug_overlay.loadFormContentAndRender('+linkbug/++form++');
-    var linkbug_handle = Y.one('#linkbug');
-    linkbug_handle.addClass('js-action');
-    linkbug_handle.on('click', function(e) {
-        e.preventDefault();
-        link_bug_overlay.show();
-        var field = Y.DOM.byId('field.bug');
-        field.focus();
-        var guessed_bug_id = extract_candidate_bug_id(LP.cache.context.name);
-        if (Y.Lang.isValue(guessed_bug_id)) {
-            field.value = guessed_bug_id;
-            // Select the pre-filled bug number (if any) so that it will be
-            // replaced by anything the user types (getting the guessed bug
-            // number out of the way quickly if we guessed incorrectly).
-            field.selectionStart = 0;
-            field.selectionEnd = 999;
-        }
-    });
-    connect_remove_links();
 };
 
-/*
- * Connect the remove links of each bug link to the javascript functions to
- * remove the links.
- */
-function connect_remove_links() {
-    Y.on('click', function(e) {
-        e.preventDefault();
-        var bugnumber = get_bugnumber_from_id(e.currentTarget.get('id'));
-        unlink_bug_from_branch(bugnumber);
-    }, '.delete-buglink');
-}
-
-/*
- * Link a specified bug to the branch.
- */
-function link_bug_to_branch(data) {
-    link_bug_overlay.hide();
-
-    create_temporary_spinner();
-
-    var bugnumber = data['field.bug'];
-    var existing = Y.one('#buglink-' + bugnumber);
-    if (Y.Lang.isValue(existing)) {
-        // Bug is already linked, don't do unneccessary requests.
-        Y.lp.anim.green_flash({node: existing}).run();
-        return;
-    }
-
-    get_bug_from_bugnumber(bugnumber, function(bug) {
-
-        config = {
+
+/**
+ * A widget to allow a user to choose a bug to link to a branch.
+ */
+namespace.LinkedBugPicker = Y.Base.create(
+    "linkedBugPickerWidget", Y.lp.bugs.bug_picker.BugPicker, [], {
+    initializer: function(cfg) {
+        this.lp_client = new Y.lp.client.Launchpad(cfg);
+    },
+
+    bindUI: function() {
+        superclass.prototype.bindUI.apply(this, arguments);
+        var that = this;
+        this.subscribe(
+            Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE, function(e) {
+            e.preventDefault();
+            that.set('progress', 100);
+            var bug_data = e.details[0];
+            var bug_id = bug_data.id;
+            that._link_bug_to_branch(bug_id, this.save_button);
+        });
+        this.after('visibleChange', function() {
+            if (this.get('visible')) {
+                var guessed_bug_id =
+                    namespace.extract_candidate_bug_id(LP.cache.context.name);
+                if (Y.Lang.isValue(guessed_bug_id)) {
+                    this._search_input.set('value', guessed_bug_id);
+                    // Select the pre-filled bug number (if any) so that it will
+                    // be replaced by anything the user types (getting the
+                    // guessed bug number out of the way quickly if we guessed
+                    // incorrectly).
+                    this._search_input.set('selectionStart', 0);
+                    this._search_input.set('selectionEnd', 999);
+                }
+            }
+        });
+        this._connect_remove_links();
+    },
+
+    /**
+     * Wire up the links to remove a bug link.
+     * @private
+     */
+    _connect_remove_links: function() {
+        var that = this;
+        Y.on('click', function(e) {
+            e.halt();
+            var bug_id = that._get_bug_id_from_remove_link(e.currentTarget);
+            var bug_link =
+                Y.lp.client.get_absolute_uri("/api/devel/bugs/" + bug_id);
+            that._unlink_bug_from_branch(bug_id, bug_link);
+        }, '#buglinks.actions .delete-buglink');
+    },
+
+    /*
+     * Get the bug id for the link element.
+     *
+     * Since we control the element id, we don't have to use crazy reqexes or
+     * something.
+     */
+    _get_bug_id_from_remove_link: function(link) {
+        var dom_id = link.get('id');
+        return dom_id.substr('delete-buglink-'.length, dom_id.length);
+    },
+
+    /**
+     * Link a specified bug to the branch.
+     * @param bug_id
+     * @param widget
+     * @private
+     */
+     _link_bug_to_branch: function(bug_id, widget) {
+        var existing = Y.one('#buglink-' + bug_id);
+        if (Y.Lang.isValue(existing)) {
+            // Bug is already linked, don't do unnecessary requests.
+            this._performDefaultSave();
+            Y.lp.anim.green_flash({node: existing}).run();
+            return;
+        }
+        var bug_link =
+            Y.lp.client.get_absolute_uri("/api/devel/bugs/" + bug_id);
+        var spinner;
+        var that = this;
+        var config = {
             on: {
+                start: function() {
+                    that.set('error', null);
+                    spinner = that._show_bug_spinner(widget);
+                    that._show_temporary_spinner();
+                },
                 success: function(entry) {
-                    // XXX: rockstar - linkBug still is returning BugBranches.
-                    // This means that I'll need to change this once I fix
-                    // that.
-                    var config = {
-                        on: {
-                            success: function(bugtasks) {
-                                update_bug_links(bug);
-                            }
-                        }
-                    };
-                    bug.follow_link('bug_tasks', config);
+                    that._update_bug_links(bug_id, spinner);
                 },
-                failure: error_handler.getFailureHandler()
+                failure: function(id, response) {
+                    if (spinner !== null) {
+                        spinner.remove(true);
+                    }
+                    that._hide_temporary_spinner();
+                    that.set('error', response.responseText);
+                }
             },
             parameters: {
-                bug: bug.get('self_link')
+                bug: bug_link
             }
         };
-        set_up_lp_client();
-        lp_client.named_post(
+        this.lp_client.named_post(
             LP.cache.context.self_link, 'linkBug', config);
-    });
-}
-
-/*
- * Update the list of bug links.
- */
-function update_bug_links(bug) {
-
-    BUG_LINK_SNIPPET = '++bug-links';
-    Y.io(BUG_LINK_SNIPPET, {
-        on: {
-            success: function(id, response) {
-                destroy_temporary_spinner();
-                Y.one('#linkbug')
-                    .set('innerHTML', 'Link to another bug report');
-                Y.one('#buglink-list')
-                    .set('innerHTML', response.responseText);
-                var new_buglink = Y.one('#buglink-' + bug.get('id'));
-                var anim = Y.lp.anim.green_flash({node: new_buglink});
-                anim.on('end', connect_remove_links);
-                anim.run();
-            },
-            failure: function(id, response) {
-                // At least remove the "Linking..." text
-                destroy_temporary_spinner();
-
-                alert('Unable to update bug links.');
-                Y.log(response);
+    },
+
+    /**
+     * Update the list of bug links.
+     * @param bug_id
+     * @param spinner
+     * @private
+     */
+    _update_bug_links: function(bug_id, spinner) {
+        var that = this;
+        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) {
+                    that._link_bug_success(bug_id, response.responseText);
+                },
+                failure: function(id, response) {
+                    that.set('error', response.responseText);
+                }
             }
-        }
-    });
-
-}
-
-/*
- * Unlink a bug from the branch.
- */
-function unlink_bug_from_branch(bugnumber) {
-    link_bug_overlay.hide();
-
-    Y.one('#delete-buglink-' + bugnumber).get('children').set(
-        'src', '/@@/spinner');
-    get_bug_from_bugnumber(bugnumber, function(bug) {
-
-        config = {
+        });
+    },
+
+    /**
+     * A bug was linked successfully.
+     * @param bug_id
+     * @param buglink_content
+     * @private
+     */
+    _link_bug_success: function(bug_id, buglink_content) {
+        this._performDefaultSave();
+        Y.one('#linkbug').setContent('Link to another bug report');
+        Y.one('#buglink-list').setContent(buglink_content);
+        this._connect_remove_links();
+        var new_buglink = Y.one('#buglink-' + bug_id);
+        var anim = Y.lp.anim.green_flash({node: new_buglink});
+        anim.run();
+    },
+
+    /**
+     * Unlink a bug from the branch.
+     * @param bug_id
+     * @param bug_link
+     * @private
+     */
+    _unlink_bug_from_branch: function(bug_id, bug_link) {
+        var that = this;
+        var config = {
             on: {
-                success: function(updated_entry) {
-                    var element = Y.one('#buglink-' + bugnumber);
-                    var parent_element = element.get('parentNode');
-                    anim = Y.lp.anim.red_flash({node: element});
-                    anim.on('end', function() {
-                        parent_element.removeChild(element);
-
-                        // Check to see if that was the only bug linked.
-                        var buglinks = Y.all(".bug-branch-summary");
-                        if (!buglinks.size()) {
-                            Y.one('#linkbug').set('innerHTML',
-                                'Link to a bug report');
-                        }
-                    });
-                    anim.run();
+                start: function() {
+                    Y.one('#delete-buglink-' + bug_id).get('children').set(
+                        'src', '/@@/spinner');
                 },
-                failure: function(id, response) {
-                    alert('An unexpected error has occurred.');
-                    Y.one('#delete-buglink-' + bugnumber).get('children').set(
+                end: function() {
+                    Y.one('#delete-buglink-' + bug_id).get('children').set(
                         'src', '/@@/remove');
-                    Y.log(response.responseText);
+                },
+                success: function() {
+                    that._unlink_bug_success(bug_id);
+                },
+                failure: function(id, response) {
+                    that.set('error', response.responseText);
                 }
             },
             parameters: {
-                bug: bug.get('self_link')
+                bug: bug_link
             }
         };
-        set_up_lp_client();
-        lp_client.named_post(
+        this.lp_client.named_post(
             LP.cache.context.self_link, 'unlinkBug', config);
-    });
-}
-
-
-/*
- * Get the bugnumber for the element id.
- *
- * Since we control the element id, we don't have to use crazy reqexes or
- * something.
- */
-function get_bugnumber_from_id(id) {
-    return id.substr('remove-buglink-'.length, id.length);
-}
-
-/*
- * Get the bug representation from the bugnumber.
- *
- * XXX: rockstar - There is a better way to do this, I'm sure.  I just need to
- * figure it out after everything else is done.
- */
-function get_bug_from_bugnumber(bugnumber, callback) {
-    var bug_uri = '/bugs/' + bugnumber;
-    config = {
-        on: {
-            success: callback
-        }
-    };
-    set_up_lp_client();
-    lp_client.get(bug_uri, config);
-}
-
-/*
- * Set up the lp_client.
- *
- * This would probably be better served in a place where everyone could get to
- * it, or at least so everything in code could get to it.
- */
-function set_up_lp_client() {
-    if (lp_client === undefined) {
-        lp_client = new Y.lp.client.Launchpad();
-    }
-}
-
-/*
- * Show the temporary "Linking..." text
- */
-function create_temporary_spinner() {
-    var temp_spinner = Y.Node.create([
-        '<div id="temp-spinner">',
-        '<img src="/@@/spinner"/>Linking...',
-        '</div>'].join(''));
-    var buglinks = Y.one('#buglinks');
-    var last = Y.one('#linkbug').get('parentNode');
-    if (last) {
-        buglinks.insertBefore(temp_spinner, last);
-    }
-}
-
-/*
- * Destroy the temporary "Linking..." text
- */
-function destroy_temporary_spinner() {
-
-    var temp_spinner = Y.one('#temp-spinner');
-    var spinner_parent = temp_spinner.get('parentNode');
-    spinner_parent.removeChild(temp_spinner);
-
-}
-
-}, "0.1", {"requires": ["base", "lp.anim", "lazr.formoverlay",
+    },
+
+    /**
+     * A bug was unlinked successfully.
+     * @param bug_id
+     * @private
+     */
+    _unlink_bug_success: function(bug_id) {
+        var element = Y.one('#buglink-' + bug_id);
+        var parent_element = element.get('parentNode');
+        var anim = Y.lp.anim.red_flash({node: element});
+        var finish_update = function() {
+            parent_element.removeChild(element);
+            // Check to see if that was the only bug linked.
+            var buglinks = Y.all(".bug-branch-summary");
+            if (!buglinks.size()) {
+                Y.one('#linkbug')
+                    .setContent('Link to a bug report');
+            }
+        };
+        if (this.get('use_animation')) {
+            anim.on('end', finish_update);
+        } else {
+            finish_update();
+        }
+        anim.run();
+    },
+
+    /*
+     * Show the temporary "Linking..." text.
+     */
+    _show_temporary_spinner: function() {
+        var temp_spinner = Y.Node.create([
+            '<div id="temp-spinner">',
+            '<img src="/@@/spinner"/>Linking...',
+            '</div>'].join(''));
+        var buglinks = Y.one('#buglinks');
+        var last = Y.one('#linkbug').get('parentNode');
+        if (last) {
+            buglinks.insertBefore(temp_spinner, last);
+        }
+    },
+
+    /*
+     * 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);
+
+    }
+}, {
+    ATTRS: {
+        header_text: {
+            value: 'Select bug to link'
+        },
+        save_link_text: {
+            value: "Link Bug"
+        },
+        private_warning_message: {
+            value:
+            'Linking this public branch to a private bug means '+
+            'that it won\'t be visible to contributors.'
+        }
+    }
+});
+}, "0.1", {"requires": ["base", "lp.anim", "lp.bugs.bug_picker",
                         "lp.client", "lp.client.plugins"]});

=== modified file 'lib/lp/code/javascript/tests/test_bugspeclinks.html'
--- lib/lp/code/javascript/tests/test_bugspeclinks.html	2012-07-02 12:58:02 +0000
+++ lib/lp/code/javascript/tests/test_bugspeclinks.html	2012-08-03 12:55:39 +0000
@@ -25,10 +25,23 @@
       <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
 
       <!-- Dependencies -->
-      <script type="text/javascript"
-          src="../../../../../build/js/lp/app/client.js"></script>
-      <script type="text/javascript"
-          src="../../../../../build/js/lp/app/overlay/overlay.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/mustache.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/anim/anim.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/errors.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/lp.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/testing/mockio.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/activator/activator.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/effects/effects.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/expander.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/extras/extras.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/formoverlay/formoverlay.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/formwidgets/formwidgets.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/inlineedit/editor.js"></script>
+      <script type="text/javascript" src="../../../../../build/js/lp/app/lazr/lazr.js"></script>
+      <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>
 
       <!-- The module under test. -->
       <script type="text/javascript" src="../branch.bugspeclinks.js"></script>
@@ -38,11 +51,28 @@
 
       <!-- The test suite. -->
       <script type="text/javascript" src="test_bugspeclinks.js"></script>
+      <script type="text/javascript" src="../../../bugs/javascript/tests/test_bug_picker.js"></script>
 
     </head>
     <body class="yui3-skin-sam">
         <ul id="suites">
             <li>lp.code.branch.bugspeclinks.test</li>
         </ul>
+        <div id="fixture">
+        </div>
+        <script type="text/x-template" id="bugspec-links">
+            <a href="#" class="pick-bug js-action">Select a bug</a>
+        </script>
+        <div>
+            <div id="buglinks" class="actions">
+                <a href="https://foo/+linkbug"; id="linkbug">
+                    Link to bug report</a>
+                <div id="buglink-list">
+                    <a id="delete-buglink-6"
+                       href="+bug/6/+delete"
+                       class="delete-buglink" title="Remove link"></a>
+                </div>
+            </div>
+        </div>
     </body>
 </html>

=== modified file 'lib/lp/code/javascript/tests/test_bugspeclinks.js'
--- lib/lp/code/javascript/tests/test_bugspeclinks.js	2012-07-02 12:58:02 +0000
+++ lib/lp/code/javascript/tests/test_bugspeclinks.js	2012-08-03 12:55:39 +0000
@@ -2,7 +2,7 @@
 
 YUI.add('lp.code.branch.bugspeclinks.test', function (Y) {
     var module = Y.lp.code.branch.bugspeclinks;
-    var extract_candidate_bug_id = module._extract_candidate_bug_id;
+    var extract_candidate_bug_id = module.extract_candidate_bug_id;
 
     var tests = Y.namespace('lp.code.branch.bugspeclinks.test');
     tests.suite = new Y.Test.Suite('code.branch.bugspeclinks Tests');
@@ -59,8 +59,173 @@
 
     }));
 
+    tests.suite.add(new Y.Test.Case(Y.merge(
+        Y.lp.bugs.bug_picker.test.common_bug_picker_tests,
+        {
+        name: 'Linked Bug Picker Tests',
+
+        setUp: function () {
+            window.LP = {
+                links: {},
+                cache: {
+                    context: {
+                        name: 'abranch',
+                        self_link:
+                            'https://foo/api/devel/~fred/firefox/abranch'
+                    }
+                }
+            };
+            this.mockio = new Y.lp.testing.mockio.MockIo();
+            this.lp_client = new Y.lp.client.Launchpad({
+                io_provider: this.mockio
+            });
+        },
+
+        tearDown: function () {
+            Y.one('#fixture').empty(true);
+            if (Y.Lang.isValue(this.widget)) {
+                this.widget.destroy();
+            }
+            delete this.mockio;
+            delete window.LP;
+        },
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.bugs.bug_picker,
+                "Could not locate the lp.code.branch.bugspeclinks module");
+        },
+
+        _createWidget: function() {
+            Y.one('#fixture').appendChild(
+                Y.Node.create(Y.one('#bugspec-links').getContent()));
+            var widget = new Y.lp.code.branch.bugspeclinks.LinkedBugPicker({
+                picker_activator: '.pick-bug',
+                private_warning_message:
+                    'You are selecting a private bug.',
+                use_animation: false,
+                io_provider: this.mockio
+            });
+            widget.render();
+            widget.hide();
+            return widget;
+        },
+
+        // The widget is created as expected.
+        test_create_widget: function() {
+            this.widget = this._createWidget();
+            Y.Assert.isInstanceOf(
+                Y.lp.code.branch.bugspeclinks.LinkedBugPicker,
+                this.widget,
+                "Linked bug picker failed to be instantiated");
+            Y.Assert.isFalse(this.widget.get('visible'));
+            Y.one('.pick-bug').simulate('click');
+            Y.Assert.isTrue(this.widget.get('visible'));
+            var remove_dupe = Y.one('.yui3-bugpickerwidget a.remove');
+            Y.Assert.isTrue(remove_dupe.hasClass('hidden'));
+        },
+
+        // The expected data is submitted after searching for and selecting a
+        // bug.
+        _assert_link_bug_submission: function(bug_id) {
+            this._assert_search_form_submission(bug_id);
+            this._assert_search_form_success(bug_id);
+            Y.one(
+                '.yui3-picker-footer-slot [name="field.actions.save"]')
+                .simulate('click');
+            this._assert_form_state(true);
+            Y.Assert.areEqual(
+                '/api/devel/~fred/firefox/abranch',
+                this.mockio.last_request.url);
+            var bug_uri = encodeURIComponent(
+                'file:///api/devel/bugs/' + bug_id);
+            var expected_data =
+                    'ws.op=linkBug&bug=' + bug_uri;
+            Y.Assert.areEqual(
+                expected_data, this.mockio.last_request.config.data);
+        },
+
+        // Linking a bug works as expected.
+        test_picker_form_submission_success: function() {
+            this.widget = this._createWidget();
+            this._assert_link_bug_submission(3);
+            var success_called = false;
+            this.widget._link_bug_success =
+                function(bug_id, link_bug_content) {
+                    Y.Assert.areEqual(3, bug_id);
+                    Y.Assert.areEqual('<html></html>', link_bug_content);
+                    success_called = true;
+                };
+            var bug_data = {
+                bug_link: "api/devel/bugs/3"
+            };
+            this.mockio.last_request.successJSON(bug_data);
+            Y.Assert.areEqual('++bug-links', this.mockio.last_request.url);
+            this.mockio.last_request.respond({
+                responseText: '<html></html>',
+                responseHeaders: {'Content-Type': 'text/html'}
+            });
+            Y.Assert.isTrue(success_called);
+        },
+
+        // A link failure is handled as expected.
+        test_picker_form_submission_failure: function() {
+            this.widget = this._createWidget();
+            this._assert_link_bug_submission(3);
+            this.mockio.respond({
+                status: 400,
+                responseText: 'There was an error',
+                responseHeaders: {'Content-Type': 'text/html'}});
+            Y.Assert.areEqual(
+                'There was an error', this.widget.get('error'));
+        },
+
+        // Submitting an unlink request works as expected.
+        test_picker_form_submission_remove_buglink: function() {
+            this.widget = this._createWidget();
+            var success_called = false;
+            this.widget._unlink_bug_success =
+                function(bug_id) {
+                    Y.Assert.areEqual(6, bug_id);
+                    success_called = true;
+                };
+            Y.one('#delete-buglink-6').simulate('click');
+            this.mockio.success({
+                responseText: null,
+                responseHeaders: {'Content-Type': 'text/html'}});
+            Y.Assert.isTrue(success_called);
+        },
+
+        // The link bug success function works as expected.
+        test_link_bug_success: function() {
+            this.widget = this._createWidget();
+            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 link_html = '<div id="buglink-3"></div>';
+            this.widget._link_bug_success(3, link_html);
+            Y.Assert.areEqual(
+                'Link to another bug report',
+                Y.one('#linkbug').get('text'));
+            Y.Assert.areEqual(
+                link_html, Y.one('#buglink-list').getContent());
+        },
+
+        // The unlink bug success function works as expected.
+        test_unlink_bug_success: function() {
+            this.widget = this._createWidget();
+            // Set up the bug data on the page.
+            Y.one('#linkbug').setContent('Link to another');
+            Y.one('#buglink-list').appendChild(
+                Y.Node.create('<div id="buglink-3"></div>'));
+            this.widget._unlink_bug_success(3);
+            Y.Assert.areEqual(
+                'Link to a bug report', Y.one('#linkbug').getContent());
+        }
+    })));
 
 }, '0.1', {
     requires: ['test', 'lp.testing.helpers', 'console',
-        'lp.code.branch.bugspeclinks', 'node-event-simulate']
+        'lp.code.branch.bugspeclinks', 'node-event-simulate',
+        'lp.bugs.bug_picker', 'lp.bugs.bug_picker.test']
 });

=== modified file 'lib/lp/code/templates/branch-related-bugs-specs.pt'
--- lib/lp/code/templates/branch-related-bugs-specs.pt	2012-06-16 13:12:41 +0000
+++ lib/lp/code/templates/branch-related-bugs-specs.pt	2012-08-03 12:55:39 +0000
@@ -76,7 +76,12 @@
         var logged_in = LP.links['me'] !== undefined;
 
         if (logged_in) {
-            Y.lp.code.branch.bugspeclinks.connect_branchlinks();
+            var config = {
+                picker_activator: '#linkbug'
+            };
+            var linked_bug_picker = new Y.lp.code.branch.bugspeclinks.LinkedBugPicker(config);
+            linked_bug_picker.render();
+            linked_bug_picker.hide();
         }
     });
 


Follow ups