← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/bugpicker-search-button-1031544 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/bugpicker-search-button-1031544 into lp:launchpad.

Requested reviews:
  Curtis Hovey (sinzui)
Related bugs:
  Bug #1031544 in Launchpad itself: "duplicate bug picker search button"
  https://bugs.launchpad.net/launchpad/+bug/1031544

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/bugpicker-search-button-1031544/+merge/117839

== Implementation ==

The line count for this branch makes it look bigger than it is. It boils down to pulling out base bug picker code from the dupe picker widget and introducing a base bug picker extending the standard picker class. The benefits are twofold:

1. Standard picker infrastructure used, providing search button, progress bar etc, plus the ability to display a number of matching bugs and select one when/if we support searching on more than just a bug number.

2. Separate out the search and selection of a bug from what to do with it once selected. Thus we can now very easily apply this new bug picker everywhere eg linking a bug to a branch.

The dupe bug picker widget is now quite small - it just provides the dupe specific messages and and hooks into the save/remove events to perform the xhr calls to complete the use cases.

The diff contains a lot of red since code was moved from one module to another, and the copied code in the new module shows up green. But it's essentially the same code.

== Demo/QA ==

http://people.canonical.com/~ianb/dupe-bug-picker.ogv

== Tests ==

Separate yui tests modules are provided for the base bug picker widget and the derived dupe bug picker widget. A common set of tests is factored out and used for both test modules so that the dupe picker widget runs the same tests as the base picker widget plus additional tests.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/icing/css/components/bug_picker.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.html
  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
-- 
https://code.launchpad.net/~wallyworld/launchpad/bugpicker-search-button-1031544/+merge/117839
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-07-31 00:01:15 +0000
+++ lib/canonical/launchpad/icing/css/components/bug_picker.css	2012-08-02 09:11:35 +0000
@@ -1,4 +1,8 @@
-/* The bug picker form should be left aligned, not centred. */
-.yui3-lazr-formoverlay-form {
+/* The bug picker buttons should be left aligned, not centred. */
+.yui3-duplicatebugpickerwidget .yui3-picker-footer-slot {
     margin-left: 0;
     }
+
+.yui3-duplicatebugpickerwidget .search-header {
+    margin-top: 6px;
+    }

=== modified file 'lib/lp/app/javascript/picker/picker.js'
--- lib/lp/app/javascript/picker/picker.js	2012-06-22 14:13:19 +0000
+++ lib/lp/app/javascript/picker/picker.js	2012-08-02 09:11:35 +0000
@@ -946,10 +946,7 @@
      */
     _performSearch: function(search_string, filter_name) {
         if (search_string.length < this.get('min_search_chars')) {
-            var msg =  Y.substitute(
-                "Please enter at least {min} characters.",
-                {min: this.get('min_search_chars')});
-            this.set('error', msg);
+            this.set('error', this.get('search_text_too_short_message'));
         } else {
             // Reset the selected batch for new searches.
             var current_search_string = this.get('current_search_string');
@@ -992,16 +989,26 @@
     },
 
     /**
+     * The default save event handler.
+     *
+     * @method _defaultSave
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _defaultSave : function(e) {
+        this._performDefaultSave();
+    },
+
+    /**
      * By default, the save event clears and hides the widget, but you can
      * have it not cleared by setting clear_on_save to 'false'. The search
      * entered by the user is passed in the first details attribute of the
      * event.
      *
-     * @method _defaultSave
-     * @param e {Event.Facade} An Event Facade object.
+     * @method _performDefaultSave
      * @protected
      */
-    _defaultSave : function(e) {
+    _performDefaultSave: function() {
         this.hide();
         if ( this.get('clear_on_save') ) {
             this._clear();
@@ -1230,6 +1237,14 @@
             value: 'No items matched "{query}".'
         },
 
+        search_text_too_short_message: {
+            value: function() {
+                return Y.Lang.substitute(
+                    "Please enter at least {min} characters.",
+                    {min: this.get('min_search_chars')});
+            }
+        },
+
         /**
          * Whether to use animations (fade in/out) for content rendering.
          *

=== modified file 'lib/lp/bugs/javascript/bug_picker.js'
--- lib/lp/bugs/javascript/bug_picker.js	2012-07-31 21:04:26 +0000
+++ lib/lp/bugs/javascript/bug_picker.js	2012-08-02 09:11:35 +0000
@@ -10,27 +10,26 @@
 
 var namespace = Y.namespace('lp.bugs.bug_picker');
 
-// Overlay related vars.
-var find_button_html =
-    '<button type="submit" name="field.actions.find"' +
-    '>Find Duplicate</button>';
-var save_button_html =
-    '<button type="button" name="field.actions.save"' +
-    '>Save Duplicate</button>';
-
 /**
  * A widget to allow a user to choose a bug.
  * This widget does no rendering itself; it is used to enhance existing HTML.
  */
-namespace.BugPicker = Y.Base.create("bugPickerWidget", Y.Widget, [], {
+namespace.BugPicker = Y.Base.create(
+        "bugPickerWidget", Y.lazr.picker.Picker, [], {
     initializer: function(cfg) {
-        this.lp_client = new Y.lp.client.Launchpad(cfg);
         this.io_provider = Y.lp.client.get_configured_io_provider(cfg);
-        var select_bug_link = cfg.select_bug_link;
-        if (select_bug_link) {
-            // Add a class denoting the link as js-action link.
-            select_bug_link.addClass('js-action');
-        }
+        var that = this;
+        this.after('search', function(e) {
+            var bug_id = e.details[0];
+            that._find_bug({id: bug_id});
+        });
+    },
+
+    _save_button_html: function() {
+        return [
+            '<button type="button" name="field.actions.save">',
+            this.get('save_link_text'),
+            '</button>'].join('');
     },
 
     _remove_link_html: function() {
@@ -42,90 +41,47 @@
             '</a></div>'].join('');
     },
 
+    hide: function() {
+        this.get('boundingBox').setStyle('display', 'none');
+        Y.lazr.picker.Picker.prototype.hide.call(this);
+    },
+
+    show: function() {
+        this.get('boundingBox').setStyle('display', 'block');
+        Y.lazr.picker.Picker.prototype.show.call(this);
+    },
+
+    _bug_search_header: function() {
+        return this._remove_link_html();
+    },
+
     renderUI: function() {
-        var select_bug_link = this.get('select_bug_link');
-        if (!Y.Lang.isValue(select_bug_link)) {
-            return;
-        }
-        // Pre-load the mark-dupe form.
-        var mark_dupe_form_url =
-            select_bug_link.get('href') + '/++form++';
-
-        var form_header = '<p>Marking this bug as a duplicate will,' +
-                          ' by default, hide it from search results ' +
-                          'listings.</p>';
-
-        var duplicates_div = this.get('duplicates_div');
-        if (Y.Lang.isValue(duplicates_div)) {
-            form_header = form_header +
-                '<p class="block-sprite large-warning">' +
-                '<strong>Note:</strong> ' +
-                'This bug has duplicates of its own. ' +
-                'If you go ahead, they too will become duplicates of ' +
-                'the bug you specify here.  This cannot be undone.' +
-                '</p>';
-        }
-        form_header = form_header + this._remove_link_html();
-
-        this.find_button = Y.Node.create(find_button_html);
-        this.picker_form = new Y.lazr.FormOverlay({
-            headerContent: '<h2>Mark bug report as duplicate</h2>',
-            form_header: form_header,
-            form_submit_button: this.find_button,
-            form_cancel_button: undefined,
-            centered: true,
-            // The form submit first searches for the bug.
-            form_submit_callback:
-                    Y.bind(this._find_bug, this),
-            visible: false,
-            io_provider: this.io_provider,
-            show_close_button: true
-        });
-        this.picker_form.render('#duplicate-form-container');
-        this.picker_form.loadFormContentAndRender(mark_dupe_form_url);
+        Y.lazr.picker.Picker.prototype.renderUI.apply(this, arguments);
+        var search_header = Y.Node.create(this._bug_search_header());
+        var search_node = this._search_input.get('parentNode');
+        search_node.insert(search_header, 'before');
+        this.remove_link = search_node.get('parentNode').one('a.remove');
     },
 
-    bindUI: function () {
-        // Add an on-click handler to any links found that displays
-        // the form overlay.
-        var select_bug_link = this.get('select_bug_link');
-        if (!Y.Lang.isValue(select_bug_link)) {
-            return;
-        }
-        var that = this;
-        select_bug_link.on('click', function(e) {
-            // Only go ahead if we have received the form content by the
-            // time the user clicks:
-            if (that.picker_form) {
-                e.halt();
-                that.picker_form.show();
-                Y.DOM.byId('field.duplicateof').focus();
-            }
-        });
+    bindUI: function() {
+        Y.lazr.picker.Picker.prototype.bindUI.apply(this, arguments);
         // Wire up the Remove link.
-        this.picker_form.after('contentUpdate', function() {
-            var remove_link = that.picker_form.form_header_node
-                .one('a.remove');
-            if (Y.Lang.isValue(remove_link)) {
-                remove_link.on('click', function(e) {
-                    e.halt();
-                    that._submit_bug('');
-                });
-            }
-        });
-        // When the dupe form overlay is hidden, we need to reset the form.
-        this.picker_form.after('visibleChange', function() {
+        var that = this;
+        if (Y.Lang.isValue(this.remove_link)) {
+            this.remove_link.on('click', function(e) {
+                e.halt();
+                that.fire(namespace.BugPicker.REMOVE_DUPLICATE);
+            });
+        }
+        this.after('visibleChange', function() {
             if (!this.get('visible')) {
-                that._hide_bug_details_node();
+                that._hide_bug_results();
             } else {
-                Y.DOM.byId('field.duplicateof').value = '';
-                var remove_link = that.picker_form.form_header_node
-                    .one('a.remove');
-                var existing_dupe = LP.cache.bug.duplicate_of_link;
-                if (Y.Lang.isString(existing_dupe) && existing_dupe !== '') {
-                    remove_link.removeClass('hidden');
+                if (Y.Lang.isValue(that.remove_link)
+                        && that.get('remove_link_visible')) {
+                    that.remove_link.removeClass('hidden');
                 } else {
-                    remove_link.addClass('hidden');
+                    that.remove_link.addClass('hidden');
                 }
             }
         });
@@ -135,7 +91,7 @@
      * Show a spinner next to the specified node.
      *
      * @method _show_bug_spinner
-     * @private
+     * @protected
      */
     _show_bug_spinner: function(node) {
         if( !Y.Lang.isValue(node)) {
@@ -152,55 +108,20 @@
      * they want.
      *
      * @param data
-     * @private
+     * @protected
      */
     _find_bug: function(data) {
-        var new_dup_id = Y.Lang.trim(data['field.duplicateof'][0]).trim();
-        // If there's no bug data entered then we are deleting the duplicate
-        // link.
-        if (new_dup_id === '') {
-            this.picker_form.showError(
-                'Please enter a valid bug number.');
-            return;
-        }
-
-        // Do some quick checks before we submit.
-        if (new_dup_id === LP.cache.bug.id.toString()) {
-            this._hide_bug_details_node();
-            this.picker_form.showError(
-                'A bug cannot be marked as a duplicate of itself.');
-            return;
-        }
-        var duplicate_of_link = LP.cache.bug.duplicate_of_link;
-        var new_dupe_link
-            = Y.lp.client.get_absolute_uri("/api/devel/bugs/" + new_dup_id);
-        if (new_dupe_link === duplicate_of_link) {
-            this._hide_bug_details_node();
-            this.picker_form.showError(
-                'This bug is already marked as a duplicate of bug ' +
-                new_dup_id + '.');
-            return;
-        }
-
-        var that = this;
+        var bug_id = Y.Lang.trim(data.id);
         var qs_data
             = Y.lp.client.append_qs("", "ws.accept", "application.json");
         qs_data = Y.lp.client.append_qs(qs_data, "ws.op", "getBugData");
-        qs_data = Y.lp.client.append_qs(qs_data, "bug_id", new_dup_id);
+        qs_data = Y.lp.client.append_qs(qs_data, "bug_id", bug_id);
 
-        var bug_field = this.picker_form.form_node
-            .one('[id="field.duplicateof"]');
-        var spinner = null;
+        var that = this;
         var config = {
             on: {
-                start: function() {
-                    spinner = that._show_bug_spinner(bug_field);
-                    that.picker_form.clearError();
-                },
                 end: function() {
-                    if (spinner !== null) {
-                        spinner.remove(true);
-                    }
+                    that.set('search_mode', false);
                 },
                 success: function(id, response) {
                     if (response.responseText === '') {
@@ -209,16 +130,16 @@
                     var bug_data = Y.JSON.parse(response.responseText);
                     if (!Y.Lang.isArray(bug_data) || bug_data.length === 0) {
                         var error_msg =
-                            new_dup_id + ' is not a valid bug number.';
-                        that.picker_form.showError(error_msg);
+                            bug_id + ' is not a valid bug number.';
+                        that._hide_bug_results();
+                        that.set('error', error_msg);
                         return;
                     }
-                    // The server may return multiple bugs but for now we only
-                    // support displaying one of them.
-                    that._confirm_selected_bug(bug_data[0]);
+                    that.set('results', bug_data);
                 },
                 failure: function(id, response) {
-                    that.picker_form.showError(response.responseText);
+                    that._hide_bug_results();
+                    that.set('error', response.responseText);
                 }
             },
             data: qs_data
@@ -281,12 +202,15 @@
             '</div>'].join('');
     },
 
-    /**
-     * Ask the user to confirm the chosen bug is the one they want.
-     * @param bug_data
-     * @private
-     */
-    _confirm_selected_bug: function(bug_data) {
+    _syncResultsUI: function() {
+        var bug_data = this.get('results');
+        if (!bug_data.length) {
+            this._hide_bug_results();
+            return;
+        }
+        // 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;
         bug_data.private_warning
             = this.get('public_context') && bug_data.is_private;
@@ -299,45 +223,31 @@
                 private_warning:
                     this._private_warning_template(private_warning_message)
             });
-        var that = this;
         var bug_details_node = Y.Node.create(html);
         var bug_link = bug_details_node.one('.bugtitle');
         bug_link.on('click', function(e) {
             e.halt();
             window.open(bug_link.get('href'));
         });
-        this._show_bug_details_node(bug_details_node);
+        this._show_bug_results(bug_details_node);
+        var that = this;
         this.save_button
             .on('click', function(e) {
                 e.halt();
-                that._submit_bug(bug_id);
+                that.fire(namespace.BugPicker.SAVE_DUPLICATE, bug_id);
             });
     },
 
-    // Centre the duplicate form along the x axis without changing y position.
-    _xaxis_centre: function() {
-        var viewport = Y.DOM.viewportRegion();
-        var new_x = (viewport.right  + viewport.left)/2 -
-            this.picker_form.get('contentBox').get('offsetWidth')/2;
-        this.picker_form.move([new_x, this.picker_form._getY()]);
-
-    },
-
-    /** Show the bug details node.
+    /** Show the results of a bug search.
      * @method _show_bug_details_node
-     * @private
+     * @protected
      */
-    _show_bug_details_node: function(new_bug_details_node) {
-        var form = this.picker_form.form_node.one('.form');
-        form.insert(new_bug_details_node, 'after');
-
-        if(Y.Lang.isValue(this.bug_details_node)) {
-            this.bug_details_node.remove(true);
-        }
+    _show_bug_results: function(new_bug_details_node) {
+        this._results_box.empty(true);
+        this._results_box.appendChild(new_bug_details_node);
         if (!Y.Lang.isValue(this.save_button)) {
-            this.save_button = this.find_button.insertBefore(
-                Y.Node.create(save_button_html), this.find_button);
-            this.find_button.set('text', 'Search Again');
+            this.save_button = Y.Node.create(this._save_button_html());
+            this.set('footer_slot', this.save_button);
         } else {
             this.save_button.detachAll();
         }
@@ -358,224 +268,25 @@
             that.bug_details_node = new_bug_details_node;
         });
         fade_in.run();
-        this._xaxis_centre();
     },
 
-    /** Hide the bug details node.
-     * @method _hide_bug_details_node
-     * @private
+    /** Hide the results of a bug search.
+     * @method _hide_bug_results
+     * @protected
      */
-    _hide_bug_details_node: function() {
+    _hide_bug_results: function() {
         if(Y.Lang.isValue(this.bug_details_node)) {
-            this.bug_details_node.remove(true);
+            this._results_box.empty(true);
         }
-        this.picker_form.clearError();
+        this.set('error', null);
         this.bug_details_node = null;
-        Y.DOM.byId('field.duplicateof').value = '';
-        this._xaxis_centre();
         if (Y.Lang.isValue(this.save_button)) {
             this.save_button.detachAll();
             this.save_button.remove(true);
             this.save_button = null;
-            this.find_button.set('text', 'Find Duplicate');
-        }
-    },
-
-    /**
-     * Bug was successfully marked as a duplicate, update the UI.
-     *
-     * @method _submit_bug_success
-     * @param updated_entry
-     * @param new_dup_url
-     * @param new_dup_id
-     * @private
-     */
-    _submit_bug_success: function(updated_entry, new_dup_url,
-                                           new_dup_id) {
-        this.picker_form.hide();
-        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);
-
-        var dupe_span = this.get('dupe_span').ancestor('li');
-        var update_dup_url = dupe_span.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 duplicates_div = this.get('duplicates_div');
-            if (Y.Lang.isValue(duplicates_div)) {
-                duplicates_div.remove(true);
-            }
-            this._show_comment_on_duplicate_warning();
-        } 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('srcNode').one('#mark-duplicate-text');
-        this.set('dupe_span', dupe_span);
-        var anim_duration = 1;
-        if (!this.get('use_animation')) {
-            anim_duration = 0;
-        }
-        Y.lp.anim.green_flash({
-            node: dupe_span,
-            duration: anim_duration
-            }).run();
-        // ensure the new link is hooked up correctly:
-        var that = this;
-        edit_link.on(
-            'click', function(e){
-                e.preventDefault();
-                that.picker_form.show();
-                Y.DOM.byId('field.duplicateof').focus();
-            });
-    },
-
-    /**
-     * There was an error marking a bug as a duplicate.
-     *
-     * @method _submit_bug
-     * @param response
-     * @param old_dup_url
-     * @param new_dup_id
-     * @private
-     */
-    _submit_bug_failure: function(response, old_dup_url, new_dup_id) {
-        // 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.picker_form.showError(error_msg);
-        this._xaxis_centre();
-    },
-
-    /**
-     * Update the bug duplicate via the LP API
-     *
-     * @method _submit_bug
-     * @param new_dup_id
-     * @private
-     */
-    _submit_bug: function(new_dup_id) {
-        // 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 new_dup_url = null;
-        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 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 = {
-            on: {
-                start: function() {
-                    dupe_span.removeClass('sprite bug-dupe');
-                    dupe_span.addClass('update-in-progress-message');
-                    that.picker_form.clearError();
-                    spinner = that._show_bug_spinner(that.save_button);
-                },
-                end: function() {
-                    dupe_span.removeClass('update-in-progress-message');
-                    if (spinner !== null) {
-                        spinner.remove(true);
-                    }
-                },
-                success: function(updated_entry) {
-                    that._submit_bug_success(
-                        updated_entry, new_dup_url, new_dup_id);
-                },
-                failure: function(id, response) {
-                    that._submit_bug_failure(
-                        response, old_dup_url, new_dup_id);
-                }
-            }
-        };
-        // And save the updated entry.
-        lp_bug_entry.lp_save(config);
-    },
-
-    /*
-     * Ensure that a warning about adding a comment to a duplicate bug
-     * is displayed.
-     *
-     * @method _show_comment_on_duplicate_warning
-     * @private
-     */
-    _show_comment_on_duplicate_warning: function() {
-        var duplicate_warning = Y.one('#warning-comment-on-duplicate');
-        if (!Y.Lang.isValue(duplicate_warning)) {
-            var container = Y.one('#add-comment-form');
-            var first_node = container.get('firstChild');
-            duplicate_warning = Y.Node.create(
-                ['<div class="warning message"',
-                 'id="warning-comment-on-duplicate">',
-                 'Remember, this bug report is a duplicate. ',
-                 'Comment here only if you think the duplicate status ',
-                 'is wrong.',
-                 '</div>'].join(''));
-            container.insertBefore(duplicate_warning, first_node);
-        }
-    },
-
-    /*
-     * Ensure that no warning about adding a comment to a duplicate bug
-     * is displayed.
-     *
-     * @method _hide_comment_on_duplicate_warning
-     * @private
-     */
-    _hide_comment_on_duplicate_warning: function() {
-        var duplicate_warning = Y.one('#warning-comment-on-duplicate');
-        if (duplicate_warning !== null) {
-            duplicate_warning.ancestor().removeChild(duplicate_warning);
         }
     }
 }, {
-    HTML_PARSER: {
-        // Look for the 'Mark as duplicate' links or the
-        // 'change duplicate bug' link.
-        select_bug_link: '.menu-link-mark-dupe, #change_duplicate_bug',
-        // The rendered duplicate information.
-        dupe_span: '#mark-duplicate-text'
-    },
     ATTRS: {
         // Is the context in which this form being used public.
         public_context: {
@@ -587,34 +298,60 @@
         private_warning_message: {
             value: 'You are selecting a private bug.'
         },
-        // The launchpad client entry for the current bug.
-        lp_bug_entry: {
-            value: null
-        },
-        // The link used to update the duplicate bug number.
-        select_bug_link: {
-        },
-        // The rendered duplicate information.
-        dupe_span: {
-        },
-        // Div containing duplicates of this bug.
-        duplicates_div: {
-            getter: function() {
-                return Y.one('#portlet-duplicates');
-            }
+        // The text used for the remove link.
+        save_link_text: {
+            value: "Save bug"
         },
         // The text used for the remove link.
         remove_link_text: {
-            value: "Remove this bug"
+            value: "Remove bug"
+        },
+        remove_link_visible: {
+            value: false
         },
         // Override for testing.
         use_animation: {
             value: true
+        },
+
+        // The following attributes override the default picker values.
+        align: {
+            value: {
+                points: [Y.WidgetPositionAlign.CC,
+                         Y.WidgetPositionAlign.CC]
+            }
+        },
+        progressbar: {
+            value: true
+        },
+        progress: {
+            value: 0
+        },
+        header_text: {
+           value: 'Select a bug'
+        },
+        headerContent: {
+            getter: function() {
+                return Y.Node.create("<h2></h2>").set('text',
+                    this.get('header_text'));
+            }
+        },
+        search_text_too_short_message: {
+            value: 'Please enter a valid bug number.'
+        },
+        min_search_chars: {
+            value: 1
+        },
+        clear_on_cancel: {
+            value: true
         }
     }
 });
 
+// Events
+namespace.BugPicker.SAVE_DUPLICATE = 'save';
+namespace.BugPicker.REMOVE_DUPLICATE = 'remove';
+
 }, "0.1", {"requires": [
-    "base", "io", "oop", "node", "event", "json", "lazr.formoverlay",
-    "lazr.effects", "lp.app.widgets.expander", "lp.mustache",
-    "lp.app.formwidgets.resizing_textarea", "plugin"]});
+    "base", "io", "oop", "node", "event", "json",
+    "lazr.effects", "lp.mustache", "lazr.picker"]});

=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2012-07-31 03:54:56 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2012-08-02 09:11:35 +0000
@@ -39,17 +39,14 @@
         }
 
         setup_client_and_bug();
-        var dup_widget = new Y.lp.bugs.bug_picker.BugPicker({
-            srcNode: '#duplicate-actions',
-            lp_bug_entry: lp_bug_entry,
-            remove_link_text: 'Bug is not a duplicate',
-            private_warning_message:
-                'Marking this bug as a duplicate of a private bug means '+
-                'that it won\'t be visible to contributors and encourages '+
-                'the reporting of more duplicate bugs.<br>' +
-                'Perhaps there is a public bug that can be used instead.'
-        });
+
+        var config = {
+            picker_activator: '.menu-link-mark-dupe, #change_duplicate_bug',
+            lp_bug_entry: lp_bug_entry
+        };
+        var dup_widget = new Y.lp.bugs.duplicates.DuplicateBugPicker(config);
         dup_widget.render();
+        dup_widget.hide();
 
         privacy_link = Y.one('#privacy-link');
         if (privacy_link) {
@@ -1134,4 +1131,4 @@
                         "lp.client.plugins", "lp.app.errors",
                         "lp.app.banner.privacy",
                         "lp.app.confirmationoverlay",
-                        "lp.bugs.bug_picker"]});
+                        "lp.bugs.duplicates"]});

=== added file 'lib/lp/bugs/javascript/duplicates.js'
--- lib/lp/bugs/javascript/duplicates.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/duplicates.js	2012-08-02 09:11:35 +0000
@@ -0,0 +1,329 @@
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Provide functionality for picking a bug.
+ *
+ * @module bugs
+ * @submodule bug_picker
+ */
+YUI.add('lp.bugs.duplicates', function(Y) {
+
+var namespace = Y.namespace('lp.bugs.duplicates');
+var superclass = Y.lp.bugs.bug_picker.BugPicker;
+
+/**
+ * A widget to allow a user to choose a bug.
+ * This widget does no rendering itself; it is used to enhance existing HTML.
+ */
+namespace.DuplicateBugPicker = Y.Base.create(
+    "duplicateBugPickerWidget", 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_id = e.details[0];
+            that._submit_bug(bug_id, this.save_button);
+        });
+        this.subscribe(
+            Y.lp.bugs.bug_picker.BugPicker.REMOVE_DUPLICATE, function(e) {
+            e.preventDefault();
+            that.set('progress', 100);
+            that._submit_bug('', this.remove_link);
+        });
+    },
+
+    _bug_search_header: function() {
+        var search_header = '<p class="search-header">' +
+            'Marking this bug as a duplicate will, ' +
+            'by default, hide it from search results listings.</p>';
+
+        var duplicatesNode = this.get('duplicatesNode');
+        if (Y.Lang.isValue(duplicatesNode)) {
+            search_header +=
+                '<p class="block-sprite large-warning">' +
+                '<strong>Note:</strong> ' +
+                'This bug has duplicates of its own. ' +
+                'If you go ahead, they too will become duplicates of ' +
+                'the bug you specify here.  This cannot be undone.' +
+                '</p>';
+        }
+        return search_header +
+            superclass.prototype._bug_search_header.call(this);
+    },
+
+    /**
+     * Look up the selected bug and get the user to confirm that it is the one
+     * they want.
+     *
+     * @param data
+     * @private
+     */
+    _find_bug: function(data) {
+        var new_dup_id = Y.Lang.trim(data.id);
+        // Do some quick checks before we submit.
+        var error = false;
+        if (new_dup_id === LP.cache.bug.id.toString()) {
+            this._hide_bug_results();
+            this.set('error',
+                'A bug cannot be marked as a duplicate of itself.');
+            error = true;
+        }
+        var duplicate_of_link = LP.cache.bug.duplicate_of_link;
+        var new_dupe_link
+            = Y.lp.client.get_absolute_uri("/api/devel/bugs/" + new_dup_id);
+        if (new_dupe_link === duplicate_of_link) {
+            this._hide_bug_results();
+            this.set('error',
+                'This bug is already marked as a duplicate of bug ' +
+                new_dup_id + '.');
+            error = true;
+        }
+        if (error) {
+            this.set('search_mode', false);
+            return;
+        }
+        Y.lp.bugs.bug_picker.BugPicker.prototype._find_bug.call(this, data);
+    },
+
+    /**
+     * Bug was successfully marked as a duplicate, update the UI.
+     *
+     * @method _submit_bug_success
+     * @param updated_entry
+     * @param new_dup_url
+     * @param new_dup_id
+     * @private
+     */
+    _submit_bug_success: function(updated_entry, new_dup_url,
+                                           new_dup_id) {
+        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);
+
+        var dupe_span = this.get('dupe_span').ancestor('li');
+        var update_dup_url = dupe_span.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();
+        } 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);
+        var anim_duration = 1;
+        if (!this.get('use_animation')) {
+            anim_duration = 0;
+        }
+        Y.lp.anim.green_flash({
+            node: dupe_span,
+            duration: anim_duration
+            }).run();
+        // ensure the new link is hooked up correctly:
+        var that = this;
+        edit_link.on(
+            'click', function(e){
+                e.preventDefault();
+                that.show();
+            });
+    },
+
+    /**
+     * 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 widget
+     * @private
+     */
+    _submit_bug: function(new_dup_id, 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 new_dup_url = null;
+        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 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 = {
+            on: {
+                start: function() {
+                    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');
+                    if (spinner !== null) {
+                        spinner.remove(true);
+                    }
+                },
+                success: function(updated_entry) {
+                    that._submit_bug_success(
+                        updated_entry, new_dup_url, new_dup_id);
+                },
+                failure: function(id, response) {
+                    that._submit_bug_failure(response, old_dup_url);
+                }
+            }
+        };
+        // And save the updated entry.
+        lp_bug_entry.lp_save(config);
+    },
+
+    /*
+     * Ensure that a warning about adding a comment to a duplicate bug
+     * is displayed.
+     *
+     * @method _show_comment_on_duplicate_warning
+     * @private
+     */
+    _show_comment_on_duplicate_warning: function() {
+        var duplicate_warning = Y.one('#warning-comment-on-duplicate');
+        if (!Y.Lang.isValue(duplicate_warning)) {
+            var container = Y.one('#add-comment-form');
+            var first_node = container.get('firstChild');
+            duplicate_warning = Y.Node.create(
+                ['<div class="warning message"',
+                 'id="warning-comment-on-duplicate">',
+                 'Remember, this bug report is a duplicate. ',
+                 'Comment here only if you think the duplicate status ',
+                 'is wrong.',
+                 '</div>'].join(''));
+            container.insertBefore(duplicate_warning, first_node);
+        }
+    },
+
+    /*
+     * Ensure that no warning about adding a comment to a duplicate bug
+     * is displayed.
+     *
+     * @method _hide_comment_on_duplicate_warning
+     * @private
+     */
+    _hide_comment_on_duplicate_warning: function() {
+        var duplicate_warning = Y.one('#warning-comment-on-duplicate');
+        if (duplicate_warning !== null) {
+            duplicate_warning.ancestor().removeChild(duplicate_warning);
+        }
+    }
+}, {
+    ATTRS: {
+        // The launchpad client entry for the current bug.
+        lp_bug_entry: {
+            value: null
+        },
+        // The rendered duplicate information.
+        dupe_span: {
+            getter: function() {
+                return Y.one('#mark-duplicate-text');
+            }
+        },
+        // Div containing duplicates of this bug.
+        duplicatesNode: {
+            getter: function() {
+                return Y.one('#portlet-duplicates');
+            }
+        },
+        portletNode: {
+            getter: function() {
+                return Y.one('#duplicate-actions');
+            }
+        },
+        header_text: {
+           value: 'Mark bug report as duplicate'
+        },
+        save_link_text: {
+            value: 'Save Duplicate'
+        },
+        remove_link_text: {
+            value: 'Bug is not a duplicate'
+        },
+        remove_link_visible: {
+            getter: function() {
+                var existing_dupe = LP.cache.bug.duplicate_of_link;
+                return Y.Lang.isString(existing_dupe) && existing_dupe !== '';
+            }
+        },
+        private_warning_message: {
+            value:
+            'Marking this bug as a duplicate of a private bug means '+
+            'that it won\'t be visible to contributors and encourages '+
+            'the reporting of more duplicate bugs.<br>' +
+            'Perhaps there is a public bug that can be used instead.'
+        }
+    }
+});
+
+}, "0.1", {"requires": [
+    "base", "io", "oop", "node", "event", "json",
+    "lp.bugs.bug_picker"]});

=== added file 'lib/lp/bugs/javascript/tests/test_bug_picker.html'
--- lib/lp/bugs/javascript/tests/test_bug_picker.html	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_picker.html	2012-08-02 09:11:35 +0000
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<!--
+Copyright 2012 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>lp.bugs.bug_picker Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/console/assets/skins/sam/console.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../build/js/lp/app/testing/testrunner.js"></script>
+      <script type="text/javascript"
+              src="../../../../../build/js/lp/app/testing/helpers.js"></script>
+
+      <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <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/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>
+      <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>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../bug_picker.js"></script>
+
+      <!-- Placeholder for any css asset for this module. -->
+      <!-- <link rel="stylesheet" href="../assets/lp.bugs.bug_picker-core.css" /> -->
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_bug_picker.js"></script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.bugs.bug_picker.test</li>
+        </ul>
+        <div id="fixture">
+        </div>
+        <script type="text/x-template" id="bug-picker">
+            <a href="#" class="pick-bug js-action">Select a bug</a>
+        </script>
+    </body>
+</html>

=== added file 'lib/lp/bugs/javascript/tests/test_bug_picker.js'
--- lib/lp/bugs/javascript/tests/test_bug_picker.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_picker.js	2012-08-02 09:11:35 +0000
@@ -0,0 +1,240 @@
+/* Copyright (c) 2012 Canonical Ltd. All rights reserved. */
+
+YUI.add('lp.bugs.bug_picker.test', function (Y) {
+
+    var tests = Y.namespace('lp.bugs.bug_picker.test');
+    tests.suite = new Y.Test.Suite('lp.bugs.bug_picker Tests');
+
+    tests.common_bug_picker_tests = {
+        // The search form renders and submits the expected data.
+        _assert_search_form_submission: function(bug_id) {
+            Y.Assert.isFalse(this.widget.get('visible'));
+            Y.one('.pick-bug').simulate('click');
+            Y.Assert.isTrue(this.widget.get('visible'));
+            Y.one('.yui3-picker-search').set('value', bug_id);
+            Y.one('.lazr-search').simulate('click');
+            if (bug_id !== '') {
+                Y.Assert.areEqual(
+                    'file:///api/devel/bugs',
+                    this.mockio.last_request.url);
+                Y.Assert.areEqual(
+                    this.mockio.last_request.config.data,
+                    'ws.accept=application.json&ws.op=getBugData&' +
+                    'bug_id=' + bug_id);
+            } else {
+                Y.Assert.areEqual(
+                    '/api/devel/bugs/1', this.mockio.last_request.url);
+            }
+        },
+
+        // The bug entry form is visible visible or not.
+        _assert_form_state: function(bug_details_visible) {
+            var bug_info = Y.one('.yui3-picker-results ' +
+                    '.bug-details-node #client-listing');
+            if (bug_details_visible) {
+                Y.Assert.isNotNull(bug_info);
+            } else {
+                Y.Assert.isNull(bug_info);
+            }
+        },
+
+        // Invoke a successful search operation and check the form state.
+        _assert_search_form_success: function(bug_id) {
+            var is_private = bug_id === 4;
+            var expected_updated_entry = [{
+                id: bug_id,
+                uri: 'api/devel/bugs/' + bug_id,
+                is_private: is_private,
+                duplicate_of_link: 'api/devel/bugs/' + bug_id,
+                self_link: 'api/devel/bugs/' + bug_id}];
+            this.mockio.last_request.successJSON(expected_updated_entry);
+            this._assert_form_state(true);
+        },
+
+        // Attempt to enter an empty bug number and an error is displayed.
+        test_no_bug_id_entered: function() {
+            this.widget = this._createWidget();
+            Y.one('.pick-bug').simulate('click');
+            this.mockio.last_request = null;
+            Y.one('.yui3-picker-search').set('value', '');
+            Y.one('.lazr-search').simulate('click');
+            Y.Assert.isNull(this.mockio.last_request);
+            this._assert_error_display(
+                'Please enter a valid bug number.');
+            this._assert_form_state(false);
+        },
+
+        // A successful search for a bug displays the search results.
+        test_initial_bug_search_success: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(3);
+            this._assert_search_form_success(3);
+        },
+
+        // No privacy warning when marking a bug as a dupe a public one.
+        test_public_dupe: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(3);
+            this._assert_search_form_success(3);
+            Y.Assert.isNull(Y.one('#privacy-warning'));
+        },
+
+        // Privacy warning when marking a public bug as a dupe of private one.
+        test_public_bug_private_dupe: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(4);
+            this._assert_search_form_success(4);
+            var privacy_message = Y.one('#privacy-warning');
+            Y.Assert.areEqual(
+                'You are selecting a private bug.',
+                privacy_message.get('text').trim());
+        },
+
+        // No privacy warning when marking a private bug as a dupe of another
+        // private bug.
+        test_private_bug_private_dupe: function() {
+            Y.one(document.body).addClass('private');
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(4);
+            this._assert_search_form_success(4);
+            Y.Assert.isNull(Y.one('#privacy-warning'));
+        },
+
+        // After a successful search, hitting the Search button submits
+        // a new search.
+        test_initial_bug_search_try_again: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(3);
+            this._assert_search_form_success(3);
+            Y.one('.lazr-search').simulate('click');
+            this._assert_search_form_success(3);
+        },
+
+        // After a successful search, hitting the Save button fires a Save
+        // event.
+        test_save_bug: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(3);
+            this._assert_search_form_success(3);
+            var save_bug_called = false;
+            this.widget.subscribe(
+                    Y.lp.bugs.bug_picker.BugPicker.SAVE_DUPLICATE,
+                    function(e) {
+                e.preventDefault();
+                Y.Assert.areEqual(3, e.details[0]);
+                save_bug_called = true;
+            });
+            Y.one(
+                '.yui3-picker-footer-slot [name="field.actions.save"]')
+                .simulate('click');
+            this._assert_form_state(true);
+            Y.Assert.isTrue(save_bug_called);
+        },
+
+        // The specified error message is displayed.
+        _assert_error_display: function(message) {
+            var error_msg = Y.one('.yui3-picker-error');
+            Y.Assert.areEqual(message, error_msg.get('text').trim());
+        },
+
+        // The error is displayed as expected when the initial bug search
+        // fails with a generic error.
+        test_initial_bug_search_generic_failure: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(3);
+            var response = {
+                status: 500,
+                responseText: 'An error occurred'
+            };
+            this.mockio.respond(response);
+            this._assert_error_display('An error occurred');
+        },
+
+        // The error is displayed as expected when the initial bug search
+        // fails with an empty bug list.
+        test_initial_bug_search_invalid_bug_failure: function() {
+            this.widget = this._createWidget();
+            this._assert_search_form_submission(3);
+            this.mockio.last_request.successJSON([]);
+            this._assert_error_display('3 is not a valid bug number.');
+        },
+
+        // Hitting the Remove button fires a Remove event.
+        test_remove_bug: function() {
+            this.widget = this._createWidget();
+            var remove_bug_called = false;
+            this.widget.subscribe(
+                    Y.lp.bugs.bug_picker.BugPicker.REMOVE_DUPLICATE,
+                    function(e) {
+                e.preventDefault();
+                remove_bug_called = true;
+            });
+            Y.one('.yui3-bugpickerwidget a.remove').simulate('click');
+            Y.Assert.isTrue(remove_bug_called);
+        }
+    };
+
+    tests.suite.add(new Y.Test.Case(Y.merge(
+        tests.common_bug_picker_tests,
+        {
+        name: 'lp.bugs.bug_picker_tests',
+
+        setUp: function () {
+            window.LP = {
+                links: {},
+                cache: {
+                    bug: {
+                        id: 1,
+                        self_link: 'api/devel/bugs/1'
+                    }
+                }
+            };
+            this.mockio = new Y.lp.testing.mockio.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.bugs.bug_picker module");
+        },
+
+        _createWidget: function() {
+            Y.one('#fixture').appendChild(
+                Y.Node.create(Y.one('#bug-picker').getContent()));
+            var widget = new Y.lp.bugs.bug_picker.BugPicker({
+                io_provider: this.mockio,
+                picker_activator: '.pick-bug',
+                use_animation: false
+            });
+            widget.render();
+            widget.hide();
+            return widget;
+        },
+
+        // The widget is created when there are no bug duplicates.
+        test_widget_creation: function() {
+            this.widget = this._createWidget();
+            Y.Assert.isInstanceOf(
+                Y.lp.bugs.bug_picker.BugPicker,
+                this.widget,
+                "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'));
+        }
+    })));
+
+}, '0.1', {
+    requires: [
+        'test', 'lp.testing.helpers', 'event', 'node-event-simulate',
+        'console', 'lp.client', 'lp.testing.mockio', 'lp.anim',
+        'lazr.picker', 'lp.bugs.bug_picker', 'lp.mustache']
+});

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.html'
--- lib/lp/bugs/javascript/tests/test_duplicates.html	2012-07-30 23:49:42 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.html	2012-08-02 09:11:35 +0000
@@ -6,7 +6,7 @@
 
 <html>
   <head>
-      <title>lp.bugs.bug_picker Tests</title>
+      <title>lp.bugs.duplicates Tests</title>
 
       <!-- YUI and test setup -->
       <script type="text/javascript"
@@ -43,24 +43,28 @@
       <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="../bug_picker.js"></script>
+      <script type="text/javascript" src="../duplicates.js"></script>
 
       <!-- Placeholder for any css asset for this module. -->
       <!-- <link rel="stylesheet" href="../assets/lp.bugs.bug_picker-core.css" /> -->
 
       <!-- The test suite -->
       <script type="text/javascript" src="test_duplicates.js"></script>
+      <script type="text/javascript" src="test_bug_picker.js"></script>
 
     </head>
     <body class="yui3-skin-sam">
         <ul id="suites">
-            <li>lp.bugs.bug_picker.test</li>
+            <li>lp.bugs.duplicates.test</li>
         </ul>
         <div id="fixture">
         </div>
         <script type="text/x-template" id="no-existing-duplicate">
+            <a href="#" class="pick-bug js-action">Select a bug</a>
             <div><ul id="duplicate-actions">
                 <li class="sprite bug-dupe">
                     <span id="mark-duplicate-text">
@@ -75,6 +79,7 @@
             <div id="portlet-duplicates"></div>
         </script>
         <script type="text/x-template" id="existing-duplicate">
+            <a href="#" class="pick-bug js-action">Select a bug</a>
             <div><ul id="duplicate-actions">
                 <li>
                     <span id="mark-duplicate-text">

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.js'
--- lib/lp/bugs/javascript/tests/test_duplicates.js	2012-07-31 03:54:56 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.js	2012-08-02 09:11:35 +0000
@@ -1,12 +1,14 @@
 /* Copyright (c) 2012 Canonical Ltd. All rights reserved. */
 
-YUI.add('lp.bugs.bug_picker.test', function (Y) {
-
-    var tests = Y.namespace('lp.bugs.bug_picker.test');
-    tests.suite = new Y.Test.Suite('lp.bugs.bug_picker Tests');
-
-    tests.suite.add(new Y.Test.Case({
-        name: 'lp.bugs.bug_picker_tests',
+YUI.add('lp.bugs.duplicates.test', function (Y) {
+
+    var tests = Y.namespace('lp.bugs.duplicates.test');
+    tests.suite = new Y.Test.Suite('lp.bugs.duplicates Tests');
+
+    tests.suite.add(new Y.Test.Case(Y.merge(
+        Y.lp.bugs.bug_picker.test.common_bug_picker_tests,
+        {
+        name: 'lp.bugs.duplicates_tests',
 
         setUp: function () {
             window.LP = {
@@ -30,14 +32,16 @@
 
         tearDown: function () {
             Y.one('#fixture').empty(true);
-            delete this.lp_bug_entry;
+            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.bugs.bug_picker module");
+                "Could not locate the lp.bugs.duplicates module");
         },
 
         _createWidget: function(existing_duplicate) {
@@ -49,45 +53,32 @@
             }
             Y.one('#fixture').appendChild(
                 Y.Node.create(Y.one('#' + fixture_id).getContent()));
-            var widget = new Y.lp.bugs.bug_picker.BugPicker({
-                srcNode: '#duplicate-actions',
+            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: 'Privacy warning'
+                private_warning_message:
+                    'You are selecting a private bug.'
             });
             widget.render();
-            Y.Assert.areEqual(
-                'http://foo/+duplicate/++form++',
-                this.mockio.last_request.url);
-            this.mockio.success({
-                responseText: this._fake_picker_form(),
-                responseHeaders: {'Content-Type': 'text/html'}});
+            widget.hide();
             return widget;
         },
 
-        _fake_picker_form: function() {
-            return [
-                '<table class="form"><div>',
-                '<input type="text" value="" name="field.duplicateof"',
-                'id="field.duplicateof" class="textType">',
-                '</div></table>'
-            ].join('');
-        },
-
         // The widget is created when there are no bug duplicates.
         test_widget_creation_no_duplicate_exists: function() {
             this.widget = this._createWidget(false);
             Y.Assert.isInstanceOf(
-                Y.lp.bugs.bug_picker.BugPicker,
+                Y.lp.bugs.duplicates.DuplicateBugPicker,
                 this.widget,
-                "Mark bug duplicate failed to be instantiated");
-            var url = this.widget.get('select_bug_link').get('href');
-            Y.Assert.areEqual('http://foo/+duplicate', url);
+                "Mark bug duplicate picker failed to be instantiated");
             Y.Assert.isNotNull(
                 Y.one('#mark-duplicate-text a.menu-link-mark-dupe'));
-            this.widget.get('select_bug_link').simulate('click');
-            var remove_dupe = Y.one('#duplicate-form-container a.remove');
+            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'));
         },
 
@@ -96,89 +87,24 @@
             window.LP.cache.bug.duplicate_of_link = 'bug/5';
             this.widget = this._createWidget(true);
             Y.Assert.isInstanceOf(
-                Y.lp.bugs.bug_picker.BugPicker,
+                Y.lp.bugs.duplicates.DuplicateBugPicker,
                 this.widget,
-                "Mark bug duplicate failed to be instantiated");
-            var url = this.widget.get('select_bug_link').get('href');
-            Y.Assert.areEqual('http://foo/+duplicate', url);
-            this.widget.get('select_bug_link').simulate('click');
-            var remove_dupe = Y.one('#duplicate-form-container a.remove');
+                "Mark bug duplicate 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.isFalse(remove_dupe.hasClass('hidden'));
         },
 
-        // The search form renders and submits the expected data.
-        _assert_search_form_submission: function(bug_id) {
-            var form = Y.one('#duplicate-form-container');
-            Y.Assert.isTrue(
-                form.one('div.pretty-overlay-window')
-                    .hasClass('yui3-lazr-formoverlay-hidden'));
-            this.widget.get('select_bug_link').simulate('click');
-            Y.Assert.isFalse(
-                Y.one('#duplicate-form-container div.pretty-overlay-window')
-                    .hasClass('yui3-lazr-formoverlay-hidden'));
-            Y.DOM.byId('field.duplicateof').value = bug_id;
-            form.one('[name="field.actions.find"]').simulate('click');
-            if (bug_id !== '') {
-                Y.Assert.areEqual(
-                    'file:///api/devel/bugs',
-                    this.mockio.last_request.url);
-                Y.Assert.areEqual(
-                    this.mockio.last_request.config.data,
-                    'ws.accept=application.json&ws.op=getBugData&' +
-                    'bug_id=' + bug_id);
-            } else {
-                Y.Assert.areEqual(
-                    '/api/devel/bugs/1', this.mockio.last_request.url);
-            }
-        },
-
-        // The bug entry form is visible visible or not.
-        _assert_form_state: function(bug_details_visible) {
-            var bug_info = Y.one('#duplicate-form-container ' +
-                    '.bug-details-node #client-listing');
-            if (bug_details_visible) {
-                Y.Assert.isNotNull(bug_info);
-            } else {
-                Y.Assert.isNull(bug_info);
-            }
-        },
-
-        // Invoke a successful search operation and check the form state.
-        _assert_search_form_success: function(bug_id) {
-            var is_private = bug_id === 4;
-            var expected_updated_entry = [{
-                id: bug_id,
-                uri: 'api/devel/bugs/' + bug_id,
-                is_private: is_private,
-                duplicate_of_link: 'api/devel/bugs/' + bug_id,
-                self_link: 'api/devel/bugs/' + bug_id}];
-            this.mockio.last_request.successJSON(expected_updated_entry);
-            this._assert_form_state(true);
-        },
-
-        // Attempt to enter an empty bug number and an error is displayed.
-        test_no_bug_id_entered: function() {
-            this.widget = this._createWidget(false);
-            this.widget.get('select_bug_link').simulate('click');
-            this.mockio.last_request = null;
-            Y.DOM.byId('field.duplicateof').value = '';
-            var form = Y.one('#duplicate-form-container');
-            form.one('[name="field.actions.find"]').simulate('click');
-            Y.Assert.isNull(this.mockio.last_request);
-            this._assert_error_display(
-                'Please enter a valid bug number.');
-            this._assert_form_state(false);
-        },
-
         // Attempt to make a bug as a duplicate of itself is detected and an
         // error is displayed immediately.
         test_mark_bug_as_dupe_of_self: function() {
             this.widget = this._createWidget(false);
-            this.widget.get('select_bug_link').simulate('click');
+            Y.one('.pick-bug').simulate('click');
             this.mockio.last_request = null;
-            Y.DOM.byId('field.duplicateof').value = 1;
-            var form = Y.one('#duplicate-form-container');
-            form.one('[name="field.actions.find"]').simulate('click');
+            Y.one('.yui3-picker-search').set('value', '1');
+            Y.one('.lazr-search').simulate('click');
             Y.Assert.isNull(this.mockio.last_request);
             this._assert_error_display(
                 'A bug cannot be marked as a duplicate of itself.');
@@ -189,125 +115,25 @@
         // detected and an error is displayed immediately.
         test_mark_bug_as_dupe_of_existing_dupe: function() {
             this.widget = this._createWidget(false);
-            this.widget.get('select_bug_link').simulate('click');
+            Y.one('.pick-bug').simulate('click');
             this.mockio.last_request = null;
             window.LP.cache.bug.duplicate_of_link
                 = 'file:///api/devel/bugs/4';
-            Y.DOM.byId('field.duplicateof').value = 4;
-            var form = Y.one('#duplicate-form-container');
-            form.one('[name="field.actions.find"]').simulate('click');
+            Y.one('.yui3-picker-search').set('value', '4');
+            Y.one('.lazr-search').simulate('click');
             Y.Assert.isNull(this.mockio.last_request);
             this._assert_error_display(
                 'This bug is already marked as a duplicate of bug 4.');
             this._assert_form_state(false);
         },
 
-        // A successful search for a bug displays the confirmation form.
-        test_initial_bug_search_success: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(3);
-            this._assert_search_form_success(3);
-        },
-
-        // No privacy warning when marking a bug as a dupe a public one.
-        test_public_dupe: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(3);
-            this._assert_search_form_success(3);
-            Y.Assert.isNull(Y.one('#privacy-warning'));
-        },
-
-        // Privacy warning when marking a public bug as a dupe of private one.
-        test_public_bug_private_dupe: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(4);
-            this._assert_search_form_success(4);
-            var privacy_message = Y.one('#privacy-warning');
-            Y.Assert.areEqual(
-                'Privacy warning', privacy_message.get('text').trim());
-        },
-
-        // No privacy warning when marking a private bug as a dupe of another
-        // private bug.
-        test_private_bug_private_dupe: function() {
-            Y.one(document.body).addClass('private');
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(4);
-            this._assert_search_form_success(4);
-            Y.Assert.isNull(Y.one('#privacy-warning'));
-        },
-
-        // After a successful search, hitting the Search Again button submits
-        // a new search.
-        test_initial_bug_search_try_again: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(3);
-            this._assert_search_form_success(3);
-            Y.one(
-                '#duplicate-form-container' +
-                ' [name="field.actions.find"]')
-                .simulate('click');
-            this._assert_search_form_success(3);
-        },
-
-        // After a successful search, hitting the Select bug button initiates
-        // the mark as duplicate operation.
-        test_bug_search_select_bug: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(3);
-            this._assert_search_form_success(3);
-            var update_bug_duplicate_called = false;
-            this.widget._submit_bug = function(bug_id) {
-                Y.Assert.areEqual(3, bug_id);
-                update_bug_duplicate_called = true;
-            };
-            Y.one(
-                '#duplicate-form-container' +
-                ' [name="field.actions.save"]')
-                .simulate('click');
-            this._assert_form_state(true);
-            Y.Assert.isTrue(update_bug_duplicate_called);
-        },
-
-        // The specified error message is displayed.
-        _assert_error_display: function(message) {
-            var selector
-                = '#duplicate-form-container div.pretty-overlay-window';
-            Y.Assert.isFalse(
-                Y.one(selector).hasClass('yui3-lazr-formoverlay-hidden'));
-            var error_msg = Y.one('.yui3-lazr-formoverlay-errors p');
-            Y.Assert.areEqual(message, error_msg.get('text'));
-        },
-
-        // The error is displayed as expected when the initial bug search
-        // fails with a generic error.
-        test_initial_bug_search_generic_failure: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(3);
-            var response = {
-                status: 500,
-                responseText: 'An error occurred'
-            };
-            this.mockio.respond(response);
-            this._assert_error_display('An error occurred');
-        },
-
-        // The error is displayed as expected when the initial bug search
-        // fails with an empty bug list.
-        test_initial_bug_search_invalid_bug_failure: function() {
-            this.widget = this._createWidget(false);
-            this._assert_search_form_submission(3);
-            this.mockio.last_request.successJSON([]);
-            this._assert_error_display('3 is not a valid bug number.');
-        },
-
-        // The duplicate entry form renders and submits the expected data.
-        _assert_confirmation_form_submission: function(bug_id) {
+        // The expected data is submitted after searching for and selecting a
+        // bug.
+        _assert_dupe_submission: function(bug_id) {
             this._assert_search_form_submission(bug_id);
             this._assert_search_form_success(bug_id);
             Y.one(
-                '#duplicate-form-container' +
-                ' [name="field.actions.save"]')
+                '.yui3-picker-footer-slot [name="field.actions.save"]')
                 .simulate('click');
             this._assert_form_state(true);
             Y.Assert.areEqual(
@@ -321,7 +147,7 @@
         // Submitting a bug dupe works as expected.
         test_picker_form_submission_success: function() {
             this.widget = this._createWidget(false);
-            this._assert_confirmation_form_submission(3);
+            this._assert_dupe_submission(3);
             var success_called = false;
             this.widget._submit_bug_success =
                 function(updated_entry, new_dup_url, new_dup_id) {
@@ -343,14 +169,13 @@
         // A submission failure is handled as expected.
         test_picker_form_submission_failure: function() {
             this.widget = this._createWidget(false);
-            this._assert_confirmation_form_submission(3);
+            this._assert_dupe_submission(3);
             var failure_called = false;
             this.widget._submit_bug_failure =
-                function(response, old_dup_url, new_dup_id) {
+                function(response, old_dup_url) {
                     Y.Assert.areEqual(
                         'There was an error', response.responseText);
                     Y.Assert.areEqual('', old_dup_url);
-                    Y.Assert.areEqual(3, new_dup_id);
                     failure_called = true;
                 };
             this.mockio.respond({
@@ -371,8 +196,7 @@
                     Y.Assert.areEqual('', new_dup_id);
                     success_called = true;
                 };
-            var remove_dupe = Y.one('#duplicate-form-container a.remove');
-            remove_dupe.simulate('click');
+            Y.one('.yui3-bugpickerwidget a.remove').simulate('click');
             var expected_updated_entry =
                 '{"duplicate_of_link":""}';
             this.mockio.success({
@@ -419,11 +243,12 @@
             // Test the duplicate warning message is gone.
             Y.Assert.isNull(Y.one('#warning-comment-on-duplicate'));
         }
-    }));
+    })));
 
 }, '0.1', {
     requires: [
         'test', 'lp.testing.helpers', 'event', 'node-event-simulate',
         'console', 'lp.client', 'lp.testing.mockio', 'lp.anim',
-        'lp.bugs.bug_picker']
+        'lp.bugs.bug_picker', 'lp.bugs.duplicates',
+        'lp.bugs.bug_picker.test']
 });


Follow ups