← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/private-dupe-bug-warning2-943497 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/private-dupe-bug-warning2-943497 into lp:launchpad.

Requested reviews:
  Curtis Hovey (sinzui)
Related bugs:
  Bug #190635 in Launchpad itself: "Cannot tell that you chose the right bug when marking as a duplicate"
  https://bugs.launchpad.net/launchpad/+bug/190635
  Bug #767084 in Launchpad itself: ""Mark as duplicate" popup gives an unhelpful error when attempted to dupe against a dupe"
  https://bugs.launchpad.net/launchpad/+bug/767084
  Bug #943497 in Launchpad itself: "warn when you are going to mark a public bug as a duplicate of a private one"
  https://bugs.launchpad.net/launchpad/+bug/943497
  Bug #1023425 in Launchpad itself: "Duplicate error "not a valid bug number or nickname" when nicknames are defunct"
  https://bugs.launchpad.net/launchpad/+bug/1023425

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/private-dupe-bug-warning2-943497/+merge/116793

== Implementation ==

This branch fixes a number of issues with the mark bug as duplicate form. See the linked bugs. The work is currently only for the bug selection widget used when marking bugs as duplicates. It would need to be generalised to be useful when selecting bugs to link to branches etc.

Not quite everything is implemented in this branch but it's big enough to be put up for review. The bit that is missing is marked with a TODO. 

When a bug number is entered, a web service API call is made to get the json data for the bug. This data is presented to the user to allow then to confirm the bug is what they want. The TODO is because not all necessary data is returned and is currently hard coded. A new service API needs to be added to provided the required data.

Some css changes were necessary. Here some of the "non obvious" ones...

Needed to make spacing around form overlay form table correct due to css rule ordering :-

13 table.form, table.extra-options {
...
16	- margin: 1em 0;
17	+ margin: 1em 0 inherit inherit;

Needed to un-squash form buttons when they are not little lazr icons.

48	+.yui3-lazr-formoverlay-form div.yui3-lazr-formoverlay-actions button {
49	+ margin-right: 0.7em;
50	+ }
51	

Expand out the error div on the form overlay when there is an error so it is not all squashed up when the form is narrow.

58	+.yui3-lazr-formoverlay-form .yui3-lazr-formoverlay-errors:not(:empty) {
59	+ min-width: 250px;
60	+ text-align: center;
61	+ }
62	+


== Demo and QA ==

Here's an early video of it in operation.

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

This video has a number of minor flaws (eg uncentred form, text alignment). These are addressed in this branch.


== Tests ==

Add a number of new yui tests and update existing ones.

== Lint ==

Linting changed files:
  lib/canonical/launchpad/icing/css/forms.css
  lib/canonical/launchpad/icing/css/modifiers.css
  lib/lp/app/javascript/formoverlay/formoverlay.js
  lib/lp/app/javascript/formoverlay/assets/formoverlay-core.css
  lib/lp/bugs/javascript/duplicates.js
  lib/lp/bugs/javascript/tests/test_duplicates.html
  lib/lp/bugs/javascript/tests/test_duplicates.js

Clean except for some css noise.
-- 
https://code.launchpad.net/~wallyworld/launchpad/private-dupe-bug-warning2-943497/+merge/116793
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/css/forms.css'
--- lib/canonical/launchpad/icing/css/forms.css	2012-06-23 13:44:13 +0000
+++ lib/canonical/launchpad/icing/css/forms.css	2012-07-26 11:02:24 +0000
@@ -90,7 +90,6 @@
 .extra-form-buttons {
     text-align: center;
     padding-top: 1em;
-    white-space: nowrap;
     }
 .extra-form-buttons button {
     margin-right: 0.7em;
@@ -140,7 +139,7 @@
 table.form, table.extra-options {
     /* Many forms are laid out using tables, with appropriate spacing: */
     /* http://launchpad.dev/firefox/+edit */
-    margin: 1em 0;
+    margin: 1em 0 inherit inherit;
     width: 100%;
     }
 table.form th {

=== modified file 'lib/canonical/launchpad/icing/css/modifiers.css'
--- lib/canonical/launchpad/icing/css/modifiers.css	2012-07-23 03:40:33 +0000
+++ lib/canonical/launchpad/icing/css/modifiers.css	2012-07-26 11:02:24 +0000
@@ -112,6 +112,14 @@
     height: 10px;
     }
 
+.ellipsis {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    -o-text-overflow: ellipsis;
+    -ms-text-overflow: ellipsis;
+    }
+
 .exception {
     color: #cc0000;
     }

=== modified file 'lib/lp/app/javascript/formoverlay/assets/formoverlay-core.css'
--- lib/lp/app/javascript/formoverlay/assets/formoverlay-core.css	2012-05-12 01:56:24 +0000
+++ lib/lp/app/javascript/formoverlay/assets/formoverlay-core.css	2012-07-26 11:02:24 +0000
@@ -21,12 +21,21 @@
     text-align: right;
     }
 
+.yui3-lazr-formoverlay-form div.yui3-lazr-formoverlay-actions button {
+    margin-right: 0.7em;
+    }
+
 .yui3-lazr-formoverlay-form .yui3-lazr-formoverlay-errors {
     padding-top: 0;
     padding-bottom: 0;
     color: red;
     }
 
+.yui3-lazr-formoverlay-form .yui3-lazr-formoverlay-errors:not(:empty) {
+    min-width: 250px;
+    text-align: center;
+    }
+
 .yui3-lazr-formoverlay-form table {
     /* This gets rid of the 12px margin-bottom that yui specifies
      * in its base.css.

=== modified file 'lib/lp/app/javascript/formoverlay/formoverlay.js'
--- lib/lp/app/javascript/formoverlay/formoverlay.js	2012-06-26 00:15:11 +0000
+++ lib/lp/app/javascript/formoverlay/formoverlay.js	2012-07-26 11:02:24 +0000
@@ -492,18 +492,19 @@
      * @method showError
      */
     showError: function(error_msgs){
+        var error_content;
         if (Y.Lang.isString(error_msgs)) {
-            error_msgs = [error_msgs];
+            error_content = Y.Node.create('<p></p>').set('text', error_msgs);
+        } else {
+            error_content = Y.Node.create(
+                '<p>The following errors were encountered:</p><ul></ul>');
+            var error_list = error_content.one('ul');
+            Y.each(error_msgs, function(error_msg){
+                error_list.appendChild(
+                    Y.Node.create('<li></li>').set('text', error_msg));
+            });
         }
-        var error_html = "The following errors were encountered: <ul>";
-        Y.each(error_msgs, function(error_msg){
-            // XXX noodles 2009-02-13 bug=342212. We need to decide on
-            // or provide our own escapeHTML() helper.
-            error_html += "<li>" + error_msg.replace(/<([^>]+)>/g,'') +
-                          "</li>";
-        });
-        error_html += "</ul>";
-        this.error_node.set('innerHTML', error_html);
+        this.error_node.appendChild(error_content);
     },
 
     /**
@@ -512,7 +513,7 @@
      * @method clearError
      */
     clearError: function(){
-        this.error_node.set('innerHTML', '');
+        this.error_node.empty(true);
     },
 
     /**

=== modified file 'lib/lp/bugs/javascript/duplicates.js'
--- lib/lp/bugs/javascript/duplicates.js	2012-07-25 00:31:04 +0000
+++ lib/lp/bugs/javascript/duplicates.js	2012-07-26 11:02:24 +0000
@@ -13,10 +13,10 @@
 // Overlay related vars.
 var submit_button_html =
     '<button type="submit" name="field.actions.change" ' +
-    'value="Change" class="lazr-pos lazr-btn" >OK</button>';
+    '>OK</button>';
 var cancel_button_html =
     '<button type="button" name="field.actions.cancel" ' +
-    'class="lazr-neg lazr-btn" >Cancel</button>';
+    '>Cancel</button>';
 
 /**
  * Manage the process of marking a bug as a duplicate.
@@ -24,6 +24,8 @@
  */
 namespace.MarkBugDuplicate = Y.Base.create("markBugDupeWidget", Y.Widget, [], {
     initializer: function(cfg) {
+        this.lp_client = new Y.lp.client.Launchpad(cfg);
+        this.io_provider = Y.lp.client.get_configured_io_provider(cfg);
         var update_dupe_link = cfg.update_dupe_link;
         if (update_dupe_link) {
             // Add a class denoting the link as js-action link.
@@ -55,19 +57,20 @@
                 '</p>';
         }
 
-        this.duplicate_form_overlay = new Y.lazr.FormOverlay({
+        this.duplicate_form = new Y.lazr.FormOverlay({
             headerContent: '<h2>Mark bug report as duplicate</h2>',
             form_header: form_header,
             form_submit_button: Y.Node.create(submit_button_html),
             form_cancel_button: Y.Node.create(cancel_button_html),
             centered: true,
+            // The form submit first searches for the bug.
             form_submit_callback:
-                Y.bind(this._update_bug_duplicate, this),
+                    Y.bind(this._find_duplicate_bug, this),
             visible: false,
-            io_provider: this.get('io_provider')
+            io_provider: this.io_provider
         });
-        this.duplicate_form_overlay.render('#duplicate-form-container');
-        this.duplicate_form_overlay.loadFormContentAndRender(
+        this.duplicate_form.render('#duplicate-form-container');
+        this.duplicate_form.loadFormContentAndRender(
             mark_dupe_form_url);
     },
 
@@ -82,12 +85,250 @@
         update_dupe_link.on('click', function(e) {
             // Only go ahead if we have received the form content by the
             // time the user clicks:
-            if (that.duplicate_form_overlay) {
+            if (that.duplicate_form) {
                 e.halt();
-                that.duplicate_form_overlay.show();
+                that.duplicate_form.show();
                 Y.DOM.byId('field.duplicateof').focus();
             }
         });
+        // We the due form overlay is hidden, we need to reset the form.
+        this.duplicate_form.after('visibleChange', function() {
+            if (!this.get('visible')) {
+                that._hide_confirm_node();
+            }
+        });
+    },
+
+    /**
+     * Show a spinner next to the specified node.
+     *
+     * @method _show_bug_spinner
+     * @private
+     */
+    _show_bug_spinner: function(node) {
+        if( node === null) {
+            return;
+        }
+        var spinner_node = Y.Node.create(
+        '<img class="spinner" src="/@@/spinner" alt="Searching..." />');
+        node.insert(spinner_node, 'after');
+    },
+
+    /**
+     * Hide the spinner next to the specified node.
+     *
+     * @method _hide_bug_spinner
+     * @private
+     */
+    _hide_bug_spinner: function(node) {
+        if( node === null) {
+            return;
+        }
+        var spinner = node.get('parentNode').one('.spinner');
+        if (spinner !== null) {
+            spinner.remove();
+        }
+    },
+
+    /**
+     * Look up the selected bug and get the user to confirm that it is the one
+     * they want.
+     *
+     * @param data
+     * @private
+     */
+    _find_duplicate_bug: function(data) {
+        var new_dup_id = Y.Lang.trim(data['field.duplicateof'][0]);
+        // If there's no bug data entered then we are deleting the duplicate
+        // link.
+        if (new_dup_id === '') {
+            this._update_bug_duplicate(new_dup_id);
+            return;
+        }
+        var that = this;
+        var qs_data
+            = Y.lp.client.append_qs("", "ws.accept", "application.json");
+
+        var bug_field = this.duplicate_form.form_node
+            .one('[id="field.duplicateof"]');
+        var config = {
+            on: {
+                start: function() {
+                    that._show_bug_spinner(bug_field);
+                    that.duplicate_form.clearError();
+                },
+                end: function() {
+                    that._hide_bug_spinner(bug_field);
+                },
+                success: function(id, response) {
+                    if (response.responseText === '') {
+                        return;
+                    }
+                    var bug_data = Y.JSON.parse(response.responseText);
+                    that._confirm_selected_bug(bug_data);
+                },
+                failure: function(id, response) {
+                    var error_msg;
+                    if (response.status === 404) {
+                        error_msg = new_dup_id +
+                            ' is not a valid bug number.';
+                    } else {
+                        error_msg = response.responseText;
+                    }
+                    that.duplicate_form.showError(error_msg);
+                }
+            },
+            data: qs_data
+        };
+        var uri
+            = Y.lp.client.get_absolute_uri("/api/devel/bugs/" + new_dup_id);
+        this.io_provider.io(uri, config);
+    },
+
+    // Template for rendering the bug details.
+    _bug_details_template: function() {
+        return [
+        '<div id="client-listing" style="max-width: 75em;">',
+        '  <div class="buglisting-col1">',
+        '      <div class="importance {{importance_class}}">',
+        '          {{importance}}',
+        '      </div>',
+        '      <div class="status {{status_class}}">',
+        '          {{status}}',
+        '      </div>',
+        '      <div class="buginfo-extra">',
+        '              <div class="information_type">',
+        '                  {{information_type}}',
+        '              </div>',
+        '      </div>',
+        '  </div>',
+        '  <div class="buglisting-col2">',
+        '      <span class="bugnumber">#{{id}}</span>',
+        '      <a href="{{bug_url}}" class="bugtitle">{{bug_summary}}</a>',
+        '      <div class="buginfo-extra">',
+        '          <p class="ellipsis line-block" style="max-width: 70em;">',
+        '          {{description}}</p>',
+        '      </div>',
+        '  </div>',
+        '</div>'
+        ].join(' ');
+    },
+
+    // Template for the bug confirmation form.
+    _bug_confirmation_form_template: function() {
+        return [
+            '<div class="confirmation-node yui3-lazr-formoverlay-form" ',
+            'style="margin-top: 5px;">',
+            '{{> bug_details}}',
+            '<div class="yui3-lazr-formoverlay-errors"></div>',
+            '<div class="extra-form-buttons">',
+            '  <button class="yes_button" type="button">Select bug</button>',
+            '  <button class="no_button" type="button">Search again</button>',
+            '  <button class="cancel_button" type="button">Cancel</button>',
+            '</div>',
+            '</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) {
+        // TODO - get real data from the server
+        bug_data.importance = 'High';
+        bug_data.importance_class = 'importanceHIGH';
+        bug_data.status = 'Triaged';
+        bug_data.status_class = 'statusTRIAGED';
+        bug_data.bug_summary = bug_data.title;
+        bug_data.bug_url = bug_data.web_link;
+
+        var bug_id = bug_data.id;
+        var html = Y.lp.mustache.to_html(
+            this._bug_confirmation_form_template(), bug_data,
+            {bug_details: this._bug_details_template()});
+        var confirm_node = Y.Node.create(html);
+        this._show_confirm_node(confirm_node);
+        var that = this;
+        confirm_node.one(".yes_button")
+            .on('click', function(e) {
+                e.halt();
+                that._update_bug_duplicate(bug_id);
+            });
+
+        confirm_node.one(".no_button")
+            .on('click', function(e) {
+                e.halt();
+                that._hide_confirm_node(confirm_node);
+            });
+        confirm_node.one(".cancel_button")
+            .on('click', function(e) {
+                e.halt();
+                that.duplicate_form.hide();
+            });
+    },
+
+    // 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.duplicate_form.get('contentBox').get('offsetWidth')/2;
+        this.duplicate_form.move([new_x, this.duplicate_form._getY()]);
+
+    },
+
+    /** Show the bug selection confirmation node.
+     * @method _show_confirm_node
+     * @private
+     */
+    _show_confirm_node: function(confirmation_node) {
+        this.duplicate_form.form_header_node
+            .insert(confirmation_node, 'after');
+        this.confirmation_node = confirmation_node;
+        this._xaxis_centre();
+        this._fade_in(confirmation_node, this.duplicate_form.form_node);
+    },
+
+    /** Hide the bug selection confirmation node.
+     * @method _hide_confirm_node
+     * @private
+     */
+    _hide_confirm_node: function() {
+        this.duplicate_form.form_node.removeClass('hidden');
+        if (Y.Lang.isValue(this.confirmation_node)) {
+            this._fade_in(
+                this.duplicate_form.form_node, this.confirmation_node);
+        this._xaxis_centre();
+            this.confirmation_node.remove();
+            this.confirmation_node = null;
+        }
+    },
+
+    // Animate the display of content.
+    _fade_in: function(content_node, old_content, use_animation) {
+        content_node.removeClass('hidden');
+        if (old_content === null) {
+            content_node.removeClass('transparent');
+            content_node.setStyle('opacity', 1);
+            content_node.show();
+            return;
+        }
+        old_content.addClass('hidden');
+        if (!Y.Lang.isValue(use_animation)) {
+            use_animation = this.get('use_animation');
+        }
+        if (!use_animation) {
+            old_content.setStyle('opacity', 1);
+            return;
+        }
+        content_node.addClass('transparent');
+        content_node.setStyle('opacity', 0);
+        var fade_in = new Y.Anim({
+            node: content_node,
+            to: {opacity: 1},
+            duration: 0.8
+        });
+        fade_in.run();
     },
 
     /**
@@ -101,6 +342,7 @@
      */
     _update_bug_duplicate_success: function(updated_entry, new_dup_url,
                                            new_dup_id) {
+        this.duplicate_form.hide();
         updated_entry.set('duplicate_of_link', new_dup_url);
         this.set('lp_bug_entry', updated_entry);
 
@@ -129,16 +371,20 @@
             dupe_span.one('a').set('href', update_dup_url);
             this._hide_comment_on_duplicate_warning();
         }
+        var anim_duration = 1;
+        if (!this.get('anim_duration')) {
+            anim_duration = 0;
+        }
         Y.lp.anim.green_flash({
             node: dupe_span,
-            duration: this.get('anim_duration')
+            duration: anim_duration
             }).run();
         // ensure the new link is hooked up correctly:
         var that = this;
         dupe_span.one('a').on(
             'click', function(e){
                 e.preventDefault();
-                that.duplicate_form_overlay.show();
+                that.duplicate_form.show();
                 Y.DOM.byId('field.duplicateof').focus();
             });
     },
@@ -156,23 +402,26 @@
         // 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) {
-            this.duplicate_form_overlay.showError(
-                new_dup_id + ' is not a valid bug number or nickname.');
-        } else {
-            this.duplicate_form_overlay.showError(response.responseText);
+            var error_info = response.responseText.split('\n');
+            error_msg = error_info.slice(1).join(' ');
         }
-        this.duplicate_form_overlay.show();
+        this.confirmation_node.one('.yui3-lazr-formoverlay-errors')
+            .setContent(error_msg);
+        this.confirmation_node.one(".yes_button").addClass('hidden');
+        this._xaxis_centre();
+
     },
 
     /**
      * Update the bug duplicate via the LP API
      *
      * @method _update_bug_duplicate
-     * @param data
+     * @param new_dup_id
      * @private
      */
-    _update_bug_duplicate: function(data) {
+    _update_bug_duplicate: 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
@@ -186,11 +435,7 @@
         var lp_bug_entry = this.get('lp_bug_entry');
         lp_bug_entry.removeAttr('http_etag');
 
-        // Hide the formoverlay:
-        this.duplicate_form_overlay.hide();
-
         var new_dup_url = null;
-        var new_dup_id = Y.Lang.trim(data['field.duplicateof'][0]);
         if (new_dup_id !== '') {
             var self_link = lp_bug_entry.get('self_link');
             var last_slash_index = self_link.lastIndexOf('/');
@@ -201,14 +446,20 @@
 
         var dupe_span = this.get('dupe_span');
         var that = this;
+        var submit_btn = null;
+        if (Y.Lang.isValue(this.confirmation_node)) {
+            submit_btn = this.confirmation_node.one(".yes_button");
+        }
         var config = {
             on: {
                 start: function() {
                     dupe_span.removeClass('sprite bug-dupe');
                     dupe_span.addClass('update-in-progress-message');
+                    that._show_bug_spinner(submit_btn);
                 },
                 end: function() {
                     dupe_span.removeClass('update-in-progress-message');
+                    that._hide_bug_spinner(submit_btn);
                 },
                 success: function(updated_entry) {
                     that._update_bug_duplicate_success(
@@ -233,7 +484,7 @@
      */
     _show_comment_on_duplicate_warning: function() {
         var duplicate_warning = Y.one('#warning-comment-on-duplicate');
-        if (duplicate_warning === null) {
+        if (!Y.Lang.isValue(duplicate_warning)) {
             var container = Y.one('#add-comment-form');
             var first_node = container.get('firstChild');
             duplicate_warning = Y.Node.create(
@@ -286,16 +537,13 @@
             }
         },
         // Override for testing.
-        io_provider: {
-        },
-        // Override for testing.
-        anim_duration: {
-            value: 1
+        use_animation: {
+            value: true
         }
     }
 });
 
 }, "0.1", {"requires": [
     "base", "io", "oop", "node", "event", "json", "lazr.formoverlay",
-    "lazr.effects", "lp.app.widgets.expander",
+    "lazr.effects", "lp.app.widgets.expander", "lp.mustache",
     "lp.app.formwidgets.resizing_textarea", "plugin"]});

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.html'
--- lib/lp/bugs/javascript/tests/test_duplicates.html	2012-07-25 00:31:04 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.html	2012-07-26 11:02:24 +0000
@@ -27,6 +27,7 @@
       <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>

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.js'
--- lib/lp/bugs/javascript/tests/test_duplicates.js	2012-07-25 12:13:38 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.js	2012-07-26 11:02:24 +0000
@@ -51,7 +51,7 @@
             var widget = new Y.lp.bugs.duplicates.MarkBugDuplicate({
                 srcNode: '#duplicate-actions',
                 lp_bug_entry: this.lp_bug_entry,
-                anim_duration: 0,
+                use_animation: false,
                 io_provider: this.mockio
             });
             widget.render();
@@ -97,8 +97,8 @@
             Y.Assert.areEqual('http://foo/+duplicate', url);
         },
 
-        // The duplicate entry form renders and submits the expected data.
-        _assert_duplicate_form_submission: function(bug_id) {
+        // 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')
@@ -108,15 +108,123 @@
                 Y.one('#duplicate-form-container div.pretty-overlay-window')
                     .hasClass('yui3-lazr-formoverlay-hidden'));
             Y.DOM.byId('field.duplicateof').value = bug_id;
-            form.one('.lazr-pos').simulate('click');
-            Y.Assert.areEqual(
-                '/api/devel/bugs/1',
-                this.mockio.last_request.url);
-            var expected_link = '{}';
+            form.one('[name="field.actions.change"]').simulate('click');
+            var expected_url = '/api/devel/bugs/1';
             if (bug_id !== '') {
-                expected_link =
+                expected_url = 'file:///api/devel/bugs/' + bug_id;
+            }
+            Y.Assert.areEqual(expected_url, this.mockio.last_request.url);
+        },
+
+        // The bug entry form is visible and the confirmation form is invisible
+        // or visa versa.
+        _assert_form_state: function(confirm_form_visible) {
+            Y.Assert.areEqual(
+                confirm_form_visible,
+                Y.one('#duplicate-form-container form')
+                    .hasClass('hidden'));
+            var bug_info = Y.one('#duplicate-form-container ' +
+                    '.confirmation-node #client-listing');
+            if (confirm_form_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 expected_updated_entry = {
+                id: bug_id,
+                uri: 'api/devel/bugs/' + bug_id,
+                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);
+        },
+
+        // 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);
+        },
+
+        // After a successful search, hitting the Search Again button takes us
+        // back to the bug details entry form.
+        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 .no_button')
+                .simulate('click');
+            this._assert_form_state(false);
+        },
+
+        // 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._update_bug_duplicate = function(bug_id) {
+                update_bug_duplicate_called = true;
+                Y.Assert.areEqual(3, bug_id);
+            };
+            Y.one('#duplicate-form-container .yes_button')
+                .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 a 404 (invalid/not found bug id).
+        test_initial_bug_search_invalid_bug_failure: function() {
+            this.widget = this._createWidget(false);
+            this._assert_search_form_submission(3);
+            var response = {
+                status: 404,
+                responseText: 'An error occurred'
+            };
+            this.mockio.respond(response);
+            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) {
+            this._assert_search_form_submission(bug_id);
+            this._assert_search_form_success(bug_id);
+            Y.one('#duplicate-form-container .yes_button')
+                .simulate('click');
+            this._assert_form_state(true);
+            Y.Assert.areEqual(
+                '/api/devel/bugs/1', this.mockio.last_request.url);
+            var expected_link =
                     '{"duplicate_of_link":"api/devel/bugs/' + bug_id + '"}';
-            }
             Y.Assert.areEqual(
                 expected_link, this.mockio.last_request.config.data);
         },
@@ -124,6 +232,7 @@
         // Submitting a bug dupe works as expected.
         test_duplicate_form_submission_success: function() {
             this.widget = this._createWidget(false);
+            this._assert_confirmation_form_submission(3);
             var success_called = false;
             this.widget._update_bug_duplicate_success =
                 function(updated_entry, new_dup_url, new_dup_id) {
@@ -134,7 +243,6 @@
                     Y.Assert.areEqual(3, new_dup_id);
                     success_called = true;
                 };
-            this._assert_duplicate_form_submission(3);
             var expected_updated_entry = {
                 uri: 'api/devel/bugs/1',
                 duplicate_of_link: 'api/devel/bugs/3',
@@ -146,6 +254,7 @@
         // A submission failure is handled as expected.
         test_duplicate_form_submission_failure: function() {
             this.widget = this._createWidget(false);
+            this._assert_confirmation_form_submission(3);
             var failure_called = false;
             this.widget._update_bug_duplicate_failure =
                 function(response, old_dup_url, new_dup_id) {
@@ -155,7 +264,6 @@
                     Y.Assert.areEqual(3, new_dup_id);
                     failure_called = true;
                 };
-            this._assert_duplicate_form_submission(3);
             this.mockio.respond({
                 status: 400,
                 responseText: 'There was an error',
@@ -165,7 +273,8 @@
 
         // Submitting a dupe removal request works as expected.
         test_duplicate_form_submission_remove_dupe: function() {
-            this.widget = this._createWidget(true);
+            this.widget = this._createWidget(false);
+            this._assert_search_form_submission('');
             var success_called = false;
             this.widget._update_bug_duplicate_success =
                 function(updated_entry, new_dup_url, new_dup_id) {
@@ -174,7 +283,6 @@
                     Y.Assert.areEqual('', new_dup_id);
                     success_called = true;
                 };
-            this._assert_duplicate_form_submission('');
             var expected_updated_entry =
                 '{"duplicate_of_link":""}';
             this.mockio.success({
@@ -221,48 +329,6 @@
                 Y.one('#mark-duplicate-text .menu-link-mark-dupe'));
             // Test the duplicate warning message is gone.
             Y.Assert.isNull(Y.one('#warning-comment-on-duplicate'));
-        },
-
-        // The remove bug duplicate error function works as expected for
-        // generic errors.
-        test_update_bug_duplicate_generic_failure: function() {
-            this.widget = this._createWidget(false);
-            var data = {
-                self_link: 'api/devel/bugs/1'};
-            var new_bug_entry = new Y.lp.client.Entry(
-                this.lp_client, data, data.self_link);
-            var response = {
-                status: 500,
-                responseText: 'An error occurred'
-            };
-            this.widget._update_bug_duplicate_failure(response, null, 3);
-            Y.Assert.isFalse(
-                Y.one('#duplicate-form-container div.pretty-overlay-window')
-                    .hasClass('yui3-lazr-formoverlay-hidden'));
-            var error_msg = Y.one('.yui3-lazr-formoverlay-errors ul li');
-            Y.Assert.areEqual('An error occurred', error_msg.get('text'));
-        },
-
-        // The remove bug duplicate error function works as expected for
-        // invalid bug errors.
-        test_update_bug_duplicate_invalid_bug_failure: function() {
-            this.widget = this._createWidget(false);
-            var data = {
-                self_link: 'api/devel/bugs/1'};
-            var new_bug_entry = new Y.lp.client.Entry(
-                this.lp_client, data, data.self_link);
-            var response = {
-                status: 400,
-                responseText: 'An error occurred'
-            };
-            this.widget._update_bug_duplicate_failure(response, null, 3);
-            Y.Assert.isFalse(
-                Y.one('#duplicate-form-container div.pretty-overlay-window')
-                    .hasClass('yui3-lazr-formoverlay-hidden'));
-            var error_msg = Y.one('.yui3-lazr-formoverlay-errors ul li');
-            Y.Assert.areEqual(
-                '3 is not a valid bug number or nickname.',
-                error_msg.get('text'));
         }
     }));
 


Follow ups