← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  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

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

== Implementation ==

Factor out the javascript to handle setting bug duplicates from bugtask_index into it's own yui module. It's now a widget built using standard yui patterns and has tests for the first time. There were also a couple of other clean up things done as drive bys.

This is the first step in warning fixing bug 943497. I didn't want to add new functionality to the code soup that existed before this branch.

About half the added lines are for the new yui tests.

== Tests ==

Add new yui tests module test_mark_bug_duplicate

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/javascript/bugtask_index.js
  lib/lp/bugs/javascript/filebug_dupefinder.js
  lib/lp/bugs/javascript/mark_bug_duplicate.js
  lib/lp/bugs/javascript/tests/test_bugtask_delete.html
  lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.html
  lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.js
-- 
https://code.launchpad.net/~wallyworld/launchpad/private-dupe-bug-warning-943497/+merge/116248
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/private-dupe-bug-warning-943497 into lp:launchpad.
=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2012-07-21 03:09:16 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2012-07-24 03:47:21 +0000
@@ -14,13 +14,6 @@
 // Override for testing
 namespace.ANIM_DURATION = 1;
 
-// lazr.FormOverlay objects.
-var duplicate_form_overlay;
-var privacy_form_overlay;
-
-// The url of the page used to update bug duplicates.
-var update_dupe_url;
-
 // The launchpad js client used.
 var lp_client;
 
@@ -30,15 +23,7 @@
 // The bug itself, taken from cache.
 var bug_repr;
 
-// Overlay related vars.
-var submit_button_html =
-    '<button type="submit" name="field.actions.change" ' +
-    'value="Change" class="lazr-pos lazr-btn" >OK</button>';
-var cancel_button_html =
-    '<button type="button" name="field.actions.cancel" ' +
-    'class="lazr-neg lazr-btn" >Cancel</button>';
 var privacy_link;
-var privacy_spinner;
 var link_branch_link;
 
 namespace.setup_bugtask_index = function() {
@@ -54,62 +39,12 @@
         }
 
         setup_client_and_bug();
-
-        // Look for the 'Mark as duplicate' links or the
-        // 'change duplicate bug' link.
-        var update_dupe_link = Y.one(
-            '.menu-link-mark-dupe, #change_duplicate_bug');
-
-        if (update_dupe_link) {
-            // First things first, pre-load the mark-dupe form.
-            update_dupe_url = update_dupe_link.get('href');
-            var mark_dupe_form_url = update_dupe_url + '/++form++';
-
-            var form_header = '<p>Marking this bug as a duplicate will,' +
-                              ' by default, hide it from search results ' +
-                              'listings.</p>';
-
-            var has_dupes = Y.one('#portlet-duplicates');
-            if (has_dupes !== null) {
-                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>';
-            }
-
-            duplicate_form_overlay = 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,
-                form_submit_callback: update_bug_duplicate,
-                visible: false
-            });
-            duplicate_form_overlay.render('#duplicate-form-container');
-            duplicate_form_overlay.loadFormContentAndRender(
-                mark_dupe_form_url);
-
-            // Add an on-click handler to any links found that displays
-            // the form overlay.
-            update_dupe_link.on('click', function(e) {
-                // Only go ahead if we have received the form content by the
-                // time the user clicks:
-                if (duplicate_form_overlay){
-                    e.preventDefault();
-                    duplicate_form_overlay.show();
-                    Y.DOM.byId('field.duplicateof').focus();
-                }
-            });
-            // Add a class denoting them as js-action links.
-            update_dupe_link.addClass('js-action');
-        }
+        var dup_widget = new Y.lp.bugs.mark_bug_duplicate.MarkBugDuplicate({
+            srcNode: '#duplicate-actions',
+            lp_bug_entry: lp_bug_entry
+        });
 
         privacy_link = Y.one('#privacy-link');
-
         if (privacy_link) {
             Y.lp.bugs.information_type_choice.setup_information_type_choice(
                 privacy_link, lp_client);
@@ -137,147 +72,6 @@
     }
 }
 
-/*
- * Update the bug duplicate via the LP API
- */
-function update_bug_duplicate(data) {
-    // 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.
-    lp_bug_entry.removeAttr('http_etag');
-
-    // Hide the formoverlay:
-    duplicate_form_overlay.hide();
-
-    // Hide the dupe edit icon if it exists.
-    var dupe_edit_icon = Y.one('#change_duplicate_bug');
-    if (dupe_edit_icon !== null) {
-        dupe_edit_icon.setStyle('display', 'none');
-    }
-
-    // Add the spinner...
-    var dupe_span = Y.one('#mark-duplicate-text');
-    dupe_span.removeClass('sprite bug-dupe');
-    dupe_span.addClass('update-in-progress-message');
-
-    // Set the new duplicate link on the bug entry.
-    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('/');
-        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);
-
-    // Create a config for the lp_save method
-    config = {
-        on: {
-            success: function(updated_entry) {
-                dupe_span.removeClass('update-in-progress-message');
-                lp_bug_entry = updated_entry;
-
-                if (new_dup_url !== null) {
-                    dupe_span.set('innerHTML', [
-                        '<a id="change_duplicate_bug" ',
-                        'title="Edit or remove linked duplicate bug" ',
-                        'class="sprite edit action-icon">Edit</a>',
-                        'Duplicate of <a>bug #</a>'].join(""));
-                    dupe_span.all('a').item(0)
-                        .set('href', update_dupe_url);
-                    dupe_span.all('a').item(1)
-                        .set('href', '/bugs/' + new_dup_id)
-                        .appendChild(document.createTextNode(new_dup_id));
-                    var has_dupes = Y.one('#portlet-duplicates');
-                    if (has_dupes !== null) {
-                        has_dupes.get('parentNode').removeChild(has_dupes);
-                    }
-                    show_comment_on_duplicate_warning();
-                } else {
-                    dupe_span.addClass('sprite bug-dupe');
-                    dupe_span.set('innerHTML', [
-                        '<a class="menu-link-mark-dupe js-action">',
-                        'Mark as duplicate</a>'].join(""));
-                    dupe_span.one('a').set('href', update_dupe_url);
-                    hide_comment_on_duplicate_warning();
-                }
-                Y.lp.anim.green_flash({
-                    node: dupe_span,
-                    duration: namespace.ANIM_DURATION
-                    }).run();
-                // ensure the new link is hooked up correctly:
-                dupe_span.one('a').on(
-                    'click', function(e){
-                        e.preventDefault();
-                        duplicate_form_overlay.show();
-                        Y.DOM.byId('field.duplicateof').focus();
-                    });
-            },
-            failure: function(id, request) {
-                dupe_span.removeClass('update-in-progress-message');
-                if (request.status === 400) {
-                    duplicate_form_overlay.showError(
-                        new_dup_id + ' is not a valid bug number or' +
-                        ' nickname.');
-                } else {
-                    duplicate_form_overlay.showError(request.responseText);
-                }
-                duplicate_form_overlay.show();
-
-                // Reset the lp_bug_entry.duplicate_of_link as it wasn't
-                // updated.
-                lp_bug_entry.set('duplicate_of_link', 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
- */
-var show_comment_on_duplicate_warning = function() {
-    var duplicate_warning = Y.one('#warning-comment-on-duplicate');
-    if (duplicate_warning === null) {
-        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
- */
-var 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);
-    }
-};
-
 /**
  * Do a preemptive search for branches that contain the current bug's ID.
  */
@@ -1332,4 +1126,5 @@
                         "lp.app.widgets.expander", "lp.client", "escape",
                         "lp.client.plugins", "lp.app.errors",
                         "lp.app.banner.privacy",
-                        "lp.app.confirmationoverlay"]});
+                        "lp.app.confirmationoverlay",
+                        "lp.bugs.mark_bug_duplicate"]});

=== modified file 'lib/lp/bugs/javascript/filebug_dupefinder.js'
--- lib/lp/bugs/javascript/filebug_dupefinder.js	2012-07-07 14:00:30 +0000
+++ lib/lp/bugs/javascript/filebug_dupefinder.js	2012-07-24 03:47:21 +0000
@@ -298,7 +298,7 @@
 
     // Alter the overlay's properties to make sure it submits correctly
     // and to the right place.
-    form_node = subscribe_form_overlay.form_node;
+    var form_node = subscribe_form_overlay.form_node;
     form_node.set('action', form.get('action'));
     form_node.set('method', 'post');
 

=== added file 'lib/lp/bugs/javascript/mark_bug_duplicate.js'
--- lib/lp/bugs/javascript/mark_bug_duplicate.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/mark_bug_duplicate.js	2012-07-24 03:47:21 +0000
@@ -0,0 +1,267 @@
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Provide functionality for marking a bug as a duplicate.
+ *
+ * @module bugs
+ * @submodule mark_bug_duplicate
+ */
+YUI.add('lp.bugs.mark_bug_duplicate', function(Y) {
+
+var namespace = Y.namespace('lp.bugs.mark_bug_duplicate');
+
+// Overlay related vars.
+var submit_button_html =
+    '<button type="submit" name="field.actions.change" ' +
+    'value="Change" class="lazr-pos lazr-btn" >OK</button>';
+var cancel_button_html =
+    '<button type="button" name="field.actions.cancel" ' +
+    'class="lazr-neg lazr-btn" >Cancel</button>';
+
+/**
+ * Manage the process of marking a bug as a duplicate.
+ * This widget does no rendering itself; it is used to enhance existing HTML.
+ */
+namespace.MarkBugDuplicate = Y.Base.create("markBugDupeWidget", Y.Widget, [], {
+    initializer: function(cfg) {
+        var update_dupe_link = cfg.update_dupe_link;
+        if (update_dupe_link) {
+            // First things first, pre-load the mark-dupe form.
+            var mark_dupe_form_url =
+                update_dupe_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>';
+            }
+
+            this.duplicate_form_overlay = 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,
+                form_submit_callback:
+                    Y.bind(this.update_bug_duplicate, this),
+                visible: false,
+                io_provider: cfg.io_provider
+            });
+            this.duplicate_form_overlay.render('#duplicate-form-container');
+            this.duplicate_form_overlay.loadFormContentAndRender(
+                mark_dupe_form_url);
+
+            // Add an on-click handler to any links found that displays
+            // the form overlay.
+            var that = this;
+            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){
+                    e.preventDefault();
+                    that.duplicate_form_overlay.show();
+                    Y.DOM.byId('field.duplicateof').focus();
+                }
+            });
+            // Add a class denoting them as js-action links.
+            update_dupe_link.addClass('js-action');
+        }
+    },
+
+    // Bug was successfully marked as a duplicate, update the UI.
+    update_bug_duplicate_success: function(updated_entry, new_dup_url,
+                                           new_dup_id) {
+        updated_entry.set('duplicate_of_link', new_dup_url);
+        this.set('lp_bug_entry', updated_entry);
+
+        var dupe_span = this.get('dupe_span');
+        var update_dup_url = dupe_span.one('a').get('href');
+        if (Y.Lang.isValue(new_dup_url)) {
+            dupe_span.setContent([
+                '<a id="change_duplicate_bug" ',
+                'title="Edit or remove linked duplicate bug" ',
+                'class="sprite edit action-icon">Edit</a>',
+                'Duplicate of <a>bug #</a>'].join(""));
+            dupe_span.all('a').item(0) .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');
+            Y.log(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([
+                '<a class="menu-link-mark-dupe js-action">',
+                'Mark as duplicate</a>'].join(""));
+            dupe_span.one('a').set('href', update_dup_url);
+            this.hide_comment_on_duplicate_warning();
+        }
+        Y.lp.anim.green_flash({
+            node: dupe_span,
+            duration: this.get('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();
+                Y.DOM.byId('field.duplicateof').focus();
+            });
+    },
+
+    // There was an error marking a bug as a duplicate.
+    update_bug_duplicate_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);
+        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);
+        }
+        this.duplicate_form_overlay.show();
+    },
+
+    /**
+     * Update the bug duplicate via the LP API
+     */
+    update_bug_duplicate: function(data) {
+    // 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');
+
+    // 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('/');
+        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 config = {
+        on: {
+            start: function() {
+                dupe_span.removeClass('sprite bug-dupe');
+                dupe_span.addClass('update-in-progress-message');
+            },
+            end: function() {
+                dupe_span.removeClass('update-in-progress-message');
+            },
+            success: function(updated_entry) {
+                that.update_bug_duplicate_success(
+                    updated_entry, new_dup_url, new_dup_id);
+            },
+            failure: function(id, response) {
+                that.update_bug_duplicate_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
+     */
+    show_comment_on_duplicate_warning: function() {
+        var duplicate_warning = Y.one('#warning-comment-on-duplicate');
+        if (duplicate_warning === null) {
+            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
+     */
+    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.
+        update_dupe_link: '.menu-link-mark-dupe, #change_duplicate_bug',
+        // The rendered duplicate information.
+        dupe_span: '#mark-duplicate-text'
+    },
+    ATTRS: {
+        io_provider: {
+
+        },
+        // The launchpad client entry for the current bug.
+        lp_bug_entry: {
+            value: null
+        },
+        // The link used to update the duplicate bug number.
+        update_dupe_link: {
+        },
+        // The rendered duplicate information.
+        dupe_span: {
+        },
+        // Div containing duplicates of this bug.
+        duplicates_div: {
+            getter: function() {
+                return Y.one('#portlet-duplicates');
+            }
+        },
+        // Override for testing.
+        anim_duration: {
+            value: 1
+        }
+    }
+});
+
+}, "0.1", {"requires": [
+    "base", "io", "oop", "node", "event", "json", "lazr.formoverlay",
+    "lazr.effects", "lp.app.widgets.expander",
+    "lp.app.formwidgets.resizing_textarea", "plugin"]});

=== modified file 'lib/lp/bugs/javascript/tests/test_bugtask_delete.html'
--- lib/lp/bugs/javascript/tests/test_bugtask_delete.html	2012-05-15 14:48:22 +0000
+++ lib/lp/bugs/javascript/tests/test_bugtask_delete.html	2012-07-24 03:47:21 +0000
@@ -26,7 +26,6 @@
 
       <!-- Dependencies -->
       <script type="text/javascript" src="../../../../../build/js/lp/app/client.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>
 

=== added file 'lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.html'
--- lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.html	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.html	2012-07-24 03:47:21 +0000
@@ -0,0 +1,89 @@
+<!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.mark_bug_duplicate 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/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>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../mark_bug_duplicate.js"></script>
+
+      <!-- Placeholder for any css asset for this module. -->
+      <!-- <link rel="stylesheet" href="../assets/lp.bugs.mark_bug_duplicate-core.css" /> -->
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_mark_bug_duplicate.js"></script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.bugs.mark_bug_duplicate.test</li>
+        </ul>
+        <div id="fixture">
+        </div>
+        <script type="text/x-template" id="no-existing-duplicate">
+            <div><ul id="duplicate-actions">
+                <li class="sprite bug-dupe" id="mark-duplicate-text">
+                    <a href="http://foo/+duplicate";
+                       class="menu-link-mark-dupe js-action">
+                        Mark as duplicate</a>
+                </li>
+            </ul></div>
+            <div id="duplicate-form-container"></div>
+            <div id="add-comment-form"></div>
+            <div id="portlet-duplicates"></div>
+        </script>
+        <script type="text/x-template" id="existing-duplicate">
+            <div><ul id="duplicate-actions">
+                <li class="" id="mark-duplicate-text">
+                    <a class="sprite edit action-icon"
+                       title="Edit or remove linked duplicate bug"
+                       id="change_duplicate_bug"
+                       href="http://foo/+duplicate";>Edit</a>
+                    Duplicate of <a href="http://foo/bugs/1234";>bug #1234
+                    </a>
+                </li>
+            </ul></div>
+            <div id="duplicate-form-container"></div>
+            <div id="add-comment-form"></div>
+        </script>
+    </body>
+</html>

=== added file 'lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.js'
--- lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_mark_bug_duplicate.js	2012-07-24 03:47:21 +0000
@@ -0,0 +1,273 @@
+/* Copyright (c) 2012 Canonical Ltd. All rights reserved. */
+
+YUI.add('lp.bugs.mark_bug_duplicate.test', function (Y) {
+
+    var tests = Y.namespace('lp.bugs.mark_bug_duplicate.test');
+    tests.suite = new Y.Test.Suite('lp.bugs.mark_bug_duplicate Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'lp.bugs.mark_bug_duplicate_tests',
+
+        setUp: function () {
+            window.LP = {
+                links: {},
+                cache: {
+                    bug: {
+                        self_link: 'api/devel/bugs/1',
+                        duplicate_of_link: null
+                    }
+                }
+            };
+            this.mockio = new Y.lp.testing.mockio.MockIo();
+            this.lp_client = new Y.lp.client.Launchpad({
+                io_provider: this.mockio
+            });
+            var bug_repr = window.LP.cache.bug;
+            this.lp_bug_entry = new Y.lp.client.Entry(
+                this.lp_client, bug_repr, bug_repr.self_link);
+        },
+
+        tearDown: function () {
+            Y.one('#fixture').empty(true);
+            delete this.lp_bug_entry;
+            delete this.mockio;
+            delete window.LP;
+        },
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.bugs.mark_bug_duplicate,
+                "Could not locate the lp.bugs.mark_bug_duplicate module");
+        },
+
+        _createWidget: function(existing_duplicate) {
+            var fixture_id;
+            if (existing_duplicate) {
+                fixture_id = "existing-duplicate";
+            } else {
+                fixture_id = "no-existing-duplicate";
+            }
+            Y.one('#fixture').appendChild(
+                Y.Node.create(Y.one('#' + fixture_id).getContent()));
+            var widget = new Y.lp.bugs.mark_bug_duplicate.MarkBugDuplicate({
+                srcNode: '#duplicate-actions',
+                lp_bug_entry: this.lp_bug_entry,
+                anim_duration: 0,
+                io_provider: this.mockio
+            });
+            Y.Assert.areEqual(
+                'http://foo/+duplicate/++form++',
+                this.mockio.last_request.url);
+            this.mockio.success({
+                responseText: this._fake_duplicate_form(),
+                responseHeaders: {'Content-Type': 'text/html'}});
+            return widget;
+        },
+
+        _fake_duplicate_form: function() {
+            return [
+                '<div>',
+                '<input type="text" value="" name="field.duplicateof"',
+                'id="field.duplicateof" class="textType">',
+                '</div>'
+            ].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.mark_bug_duplicate.MarkBugDuplicate,
+                this.widget,
+                "Mark bug duplicate failed to be instantiated");
+            var url = this.widget.get('update_dupe_link').get('href');
+            Y.Assert.areEqual('http://foo/+duplicate', url);
+            Y.Assert.isNotNull(
+                Y.one('#mark-duplicate-text a.menu-link-mark-dupe'));
+        },
+
+        // The widget is created when there are bug duplicates.
+        test_widget_creation_duplicate_exists: function() {
+            this.widget = this._createWidget(true);
+            Y.Assert.isInstanceOf(
+                Y.lp.bugs.mark_bug_duplicate.MarkBugDuplicate,
+                this.widget,
+                "Mark bug duplicate failed to be instantiated");
+            var url = this.widget.get('update_dupe_link').get('href');
+            Y.Assert.areEqual('http://foo/+duplicate', url);
+        },
+
+        // The duplicate entry form renders and submits the expected data.
+        _assert_duplicate_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('update_dupe_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('.lazr-pos').simulate('click');
+            Y.Assert.areEqual(
+                '/api/devel/bugs/1',
+                this.mockio.last_request.url);
+            var expected_link = '{}';
+            if (bug_id !== '') {
+                expected_link =
+                    '{"duplicate_of_link":"api/devel/bugs/' + bug_id + '"}';
+            }
+            Y.Assert.areEqual(
+                expected_link, this.mockio.last_request.config.data);
+        },
+
+        // Submitting a bug dupe works as expected.
+        test_duplicate_form_submission_success: function() {
+            this.widget = this._createWidget(false);
+            var success_called = false;
+            this.widget.update_bug_duplicate_success =
+                function(updated_entry, new_dup_url, new_dup_id) {
+                    Y.Assert.areEqual(
+                        expected_updated_entry.duplicate_of_link,
+                        updated_entry.duplicate_of_link);
+                    Y.Assert.areEqual('api/devel/bugs/3', new_dup_url);
+                    Y.Assert.areEqual(3, new_dup_id);
+                    success_called = true;
+                };
+            this._assert_duplicate_form_submission(3);
+            var expected_updated_entry = {
+                lp_original_uri: 'api/devel/bugs/1',
+                duplicate_of_link: 'api/devel/bugs/3',
+                self_link: 'api/devel/bugs/1'};
+            this.mockio.last_request.successJSON(expected_updated_entry);
+            Y.Assert.isTrue(success_called);
+        },
+
+        // A submission failure is handled as expected.
+        test_duplicate_form_submission_failure: function() {
+            this.widget = this._createWidget(false);
+            var failure_called = false;
+            this.widget.update_bug_duplicate_failure =
+                function(response, old_dup_url, new_dup_id) {
+                    Y.Assert.areEqual(
+                        'There was an error', response.responseText);
+                    Y.Assert.areEqual(null, old_dup_url);
+                    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',
+                responseHeaders: {'Content-Type': 'text/html'}});
+            Y.Assert.isTrue(failure_called);
+        },
+
+        // Submitting a dupe removal request works as expected.
+        test_duplicate_form_submission_remove_dupe: function() {
+            this.widget = this._createWidget(true);
+            var success_called = false;
+            this.widget.update_bug_duplicate_success =
+                function(updated_entry, new_dup_url, new_dup_id) {
+                    Y.Assert.areEqual(expected_updated_entry, updated_entry);
+                    Y.Assert.areEqual(null, new_dup_url);
+                    Y.Assert.areEqual('', new_dup_id);
+                    success_called = true;
+                };
+            this._assert_duplicate_form_submission('');
+            var expected_updated_entry =
+                '{"duplicate_of_link":""}';
+            this.mockio.success({
+                responseText: expected_updated_entry,
+                responseHeaders: {'Content-Type': 'text/html'}});
+            Y.Assert.isTrue(success_called);
+        },
+
+        // The mark bug duplicate success function works as expected.
+        test_update_bug_duplicate_success: function() {
+            this.widget = this._createWidget(false);
+            var data = {
+                self_link: 'api/devel/bugs/1'};
+            var new_bug_entry = new Y.lp.client.Entry(
+                this.lp_client, data, data.self_link);
+            this.widget.update_bug_duplicate_success(
+                new_bug_entry, 'api/devel/bugs/3', 3);
+            // Test the updated bug entry.
+            Y.Assert.areEqual(
+                'api/devel/bugs/3',
+                this.widget.get('lp_bug_entry').get('duplicate_of_link'));
+            // Test the Change Duplicate link.
+            Y.Assert.isNotNull(
+                Y.one('#mark-duplicate-text #change_duplicate_bug'));
+            // Test the duplicate warning message.
+            Y.Assert.isNotNull(Y.one('#warning-comment-on-duplicate'));
+            // Any previously listed duplicates are removed.
+            Y.Assert.isNull(Y.one('#portlet-duplicates'));
+        },
+
+        // The remove bug duplicate success function works as expected.
+        test_remove_bug_duplicate_success: function() {
+            this.widget = this._createWidget(true);
+            var data = {
+                self_link: 'api/devel/bugs/1'};
+            var new_bug_entry = new Y.lp.client.Entry(
+                this.lp_client, data, data.self_link);
+            this.widget.update_bug_duplicate_success(new_bug_entry, null, '');
+            // Test the updated bug entry.
+            Y.Assert.isNull(
+                this.widget.get('lp_bug_entry').get('duplicate_of_link'));
+            // Test the Mark as Duplicate link.
+            Y.Assert.isNotNull(
+                Y.one('#mark-duplicate-text .menu-link-mark-dupe'));
+            // Test the duplicate warning message is gone.
+            Y.Assert.isNull(Y.one('#warning-comment-on-duplicate'));
+        },
+
+        // The 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'));
+        }
+    }));
+
+}, '0.1', {
+    requires: [
+        'test', 'lp.testing.helpers', 'event', 'node-event-simulate',
+        'console', 'lp.client', 'lp.testing.mockio', 'lp.anim',
+        'lp.bugs.mark_bug_duplicate']
+});


Follow ups