← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/advanced-bug-subscription-cleanup into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/advanced-bug-subscription-cleanup into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #740627 in Launchpad itself: "Fold "Tell me when" section in bug subscription overlay when unsubscribe options are selected"
  https://bugs.launchpad.net/launchpad/+bug/740627
  Bug #770460 in Launchpad itself: "Subscription level picker animation sometimes leaves picker broken."
  https://bugs.launchpad.net/launchpad/+bug/770460

For more details, see:
https://code.launchpad.net/~danilo/launchpad/advanced-bug-subscription-cleanup/+merge/59235

= Bug 740627 and bug 770460 =

The logic that shows/hides the options to set different levels of subscriptions on bug subscription overlay is very unreliable at the moment.  Animation is also very jerky and unstable.

Some of the problems are that reloading the overlay sometimes doesn't load the animation handlers at all, or that wrong options are shown depending on the conditions.

The root cause is that some code expected at most one subscription overlay, and yet more than one was created because of the mute functionality.

Sorry for the over-sized branch, but over half is unit tests.

== Proposed fix ==

 * Rename bug_subscription.js to bug_notification_level.js and refactor it so it works correctly and tries to make at least a bit of sense of the Python-generated web form we are using; extensive test coverage is provided

 * In bugtask_index_portlets.js, ensure that we never try to create multiple overlays because that adds identical elements into the page DOM which then causes problems in existing code which can't handle it; I still do not provide any tests for this, but I am working on that in a separate branch

== Implementation details ==

Other notes:

 * I am not changing this to allow more than one overlay because it's too ingrained in the code using Y.one/Y.all where we'd need the parent/container node instead
 * In bugtask_index_portlets.js I do away with a hack where we were not using loadFormContentAndRender() and instead copy-pasted the code; we use Y.on('contentready',...) to wait for elements to load and ensure no empty form is shown.
 * clean_up_level_div() is not necessary anymore since it was a work-around for the problem when slide-out was still happening (and continuing) when slide-in was requested; this is now fixed properly

== Tests ==

lib/lp/bugs/javascript/tests/test_bug_notification_level.html

== Demo and Q/A ==

Set feature flag:
  'malone.advanced-subscriptions.enabled	default	1	on'

https://bugs.launchpad.dev/firefox/+bug/1/+subscribe
https://bugs.launchpad.dev/firefox/+bug/1/ (and play with Subscribe/Mute links)

For mute link to show, eg. set assignee to yourself.

Demo:

  https://devpad.canonical.com/~danilo/screencasts/bug-notification-level-anim.ogv

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/javascript/bug_notification_level.js
  lib/lp/bugs/javascript/tests/test_bug_notification_level.html
  lib/lp/bugs/javascript/bugtask_index_portlets.js
  lib/lp/bugs/templates/bug-subscription.pt
  lib/lp/bugs/javascript/tests/test_bug_notification_level.js
-- 
https://code.launchpad.net/~danilo/launchpad/advanced-bug-subscription-cleanup/+merge/59235
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/advanced-bug-subscription-cleanup into lp:launchpad.
=== renamed file 'lib/lp/bugs/javascript/bug_subscription.js' => 'lib/lp/bugs/javascript/bug_notification_level.js'
--- lib/lp/bugs/javascript/bug_subscription.js	2011-04-22 11:12:12 +0000
+++ lib/lp/bugs/javascript/bug_notification_level.js	2011-04-27 14:53:35 +0000
@@ -1,65 +1,205 @@
 /* Copyright 2011 Canonical Ltd.  This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE).
  *
- * A bugtracker form overlay that can create a bugtracker within any page.
- *
- * @namespace Y.lp.bugs.bugtracker_overlay
- * @requires  dom, node, io-base, lazr.anim, lazr.formoverlay
- */
-YUI.add('lp.bugs.bug_subscription', function(Y) {
-var namespace = Y.namespace('lp.bugs.bug_subscription');
-var level_div;
-
-// XXX: gmb 2011-03-17 bug=728457
-//      This fix for resizing needs to be incorporated into
-//      lazr.effects. When that is done it should be removed from here.
-/*
- * Make sure that the bug_notification_level div is hidden properly .
- * @method clean_up_level_div
- */
-namespace.clean_up_level_div = function() {
-    if (Y.Lang.isValue(level_div)) {
-        level_div.setStyles({
-            height: 0,
-            visibility: 'hidden'
+ * Animation for IBugTask:+subscribe LaunchpadForm.
+ * Also used in "Edit subscription" advanced overlay.
+ *
+ * @namespace Y.lp.bugs.bug_notification_level
+ * @requires  dom, "node, lazr.anim, lazr.effects
+ */
+YUI.add('lp.bugs.bug_notification_level', function(Y) {
+var namespace = Y.namespace('lp.bugs.bug_notification_level');
+
+/**
+ * Should notification level options be shown for these conditions?
+ *
+ * @param value {String} Value of the selected radio box.  Special
+ *     value 'update-subscription' is used for the radio box that
+ *     indicates editing of the existing subscription.
+ * @param can_update_subscription {Boolean} Is there a radio box to
+ *     update the existing subscription.  If there is, it is the only
+ *     radio button that should show the notification level options.
+ * @returns {Boolean} True if notification level options should be shown
+ *     for this set of conditions.
+ */
+function is_notification_level_shown(value, can_update_subscription) {
+    // Is the new selected option the "subscribe me" option?
+    // It is when there is no existing subscription, and
+    // when either the selected radio box is for the current user.
+    var needs_to_subscribe = (
+        (can_update_subscription === false) &&
+            ('/~' + value === LP.links.me));
+
+    // Notification levels selection box is shown when either the
+    // radio box is for updating a subscription to set the level,
+    // or if a user wants to subscribe.
+    if ((value === 'update-subscription') || needs_to_subscribe) {
+        return true;
+    } else {
+        return false;
+    }
+}
+namespace._is_notification_level_shown = is_notification_level_shown;
+
+/**
+ * Is the change of the radio boxes such that the notification level options
+ * need toggling.
+ *
+ * @param current_value {String} Previously selected radio button value.
+ * @param new_value {String} Newly selected radio button value.
+ * @param can_update_subscription {Boolean} Is there a radio box to
+ *     update the existing subscription.  If there is, it is the only
+ *     radio button that should show the notification level options.
+ * @returns {Boolean} True if change from `current_value` to `new_value`
+ *     requires toggling the visibility of bug notification level options.
+ */
+function needs_toggling(current_value, new_value, can_update_subscription) {
+    if (current_value !== new_value) {
+        var was_shown = is_notification_level_shown(
+            current_value, can_update_subscription);
+        var should_be_shown = is_notification_level_shown(
+            new_value, can_update_subscription);
+        return was_shown !== should_be_shown;
+    } else {
+        return false;
+    }
+}
+namespace._needs_toggling = needs_toggling;
+
+/**
+ * Slide-out animation used in the toggle_field_visibility() needs
+ * to be stopped if someone quickly selects an option that triggers the
+ * slide-in animation.  We keep these globally to be able to stop
+ * the running animation. (Exposed for testing)
+ */
+namespace._slideout_animation = undefined;
+var slideout_running = false;
+
+/**
+ * Is the bug_notification_level visible in the current view.
+ * A global state that we alternate with toggle_field_visibility().
+ * (exposed for testing purposes).
+ */
+namespace._bug_notification_level_visible = true;
+
+/**
+ * Change the visibility of the bug notification level options.
+ * Uses appropriate animation for nicer effect.
+ *
+ * @param level_div {Object} A Y.Node to show/hide.
+ * @param quick_close {Boolean} Should animation be short-circuited?
+ *     Useful for initial set-up, and only allows closing the node.
+ */
+function toggle_field_visibility(level_div, quick_close) {
+    if (quick_close === true) {
+        level_div.setStyle('display', 'none');
+        level_div.addClass('lazr-closed');
+        namespace._bug_notification_level_visible = false;
+        return;
+    }
+    if (!namespace._bug_notification_level_visible) {
+        namespace._slideout_animation = Y.lazr.effects.slide_out(level_div);
+        namespace._slideout_animation.after('end', function () {
+            slideout_running = false;
         });
-    }
-};
-
-namespace.set_up_bug_notification_level_field = function() {
-    level_div = Y.one('.bug-notification-level-field');
-    var subscription_radio_buttons = Y.all(
-        'input[name=field.subscription]');
+        slideout_running = true;
+        namespace._slideout_animation.run();
+    } else {
+        if (Y.Lang.isValue(namespace._slideout_animation) &&
+            slideout_running) {
+            // It's currently expanding, stop that animation
+            // and slide in.
+            namespace._slideout_animation.stop();
+        }
+        Y.lazr.effects.slide_in(level_div).run();
+    }
+    namespace._bug_notification_level_visible = (
+        !namespace._bug_notification_level_visible);
+}
+namespace._toggle_field_visibility = toggle_field_visibility;
+
+/**
+ * Sets up an initial state for the bug notification level options,
+ * and returns the current state from the selected radio button.
+ *
+ * @param radio_buttons {NodeList} A list of radio in the level picker.
+ * @param level_div {Node} A node to hide if not needed.
+ * @returns {Object} An object containing a string `value` and a boolean
+ *     `has_update_subscription_button`.  `value` is undefined if no
+ *     radio box is selected.
+ */
+function initialize(radio_buttons, level_div) {
+    var state = {
+        value: undefined,
+        has_update_subscription_button: false
+    };
+
+    var checked_box = radio_buttons.filter(':checked').pop();
+    if (Y.Lang.isValue(checked_box)) {
+        state.value = checked_box.get('value');
+    }
+
+    // Is there a radio box for changing the bug notification level?
+    state.has_update_subscription_button = (
+        radio_buttons
+            .filter('[value="update-subscription"]')
+            .size() === 1);
+
+    // Level options are always initially shown in the form.
+    namespace._bug_notification_level_visible = true;
+
+    var should_be_shown = is_notification_level_shown(
+        state.value, state.has_update_subscription_button);
+    if (should_be_shown === false) {
+        toggle_field_visibility(level_div, true);
+    }
+
+    return state;
+}
+namespace._initialize = initialize;
+
+/**
+ * Set-up showing of bug notification levels as appropriate in the
+ * bug subscription form.
+ *
+ * This form is visible on either IBugTask:+subscribe page, or in the
+ * advanced subscription overlay on the IBugTask pages (when
+ * 'Edit subscription' or 'Unmute' is clicked).
+ */
+namespace.setup = function() {
+    var level_divs = Y.all('.bug-notification-level-field');
+    if (level_divs.size() > 1) {
+        // There can be no more than one advanced subscription overlay,
+        // or this code is going to break.
+        Y.error('There are multiple bug-notification-level-field nodes.');
+    }
+    var level_div = level_divs.pop();
+    var subscription_radio_buttons = Y.all('input[name=field.subscription]');
 
     // Only collapse the bug_notification_level field if the buttons are
     // available to display it again.
     if (Y.Lang.isValue(level_div) && subscription_radio_buttons.size() > 1) {
-        var slide_in_anim = Y.lazr.effects.slide_in(level_div);
-        slide_in_anim.on('end', namespace.clean_up_level_div);
-        var slide_out_anim = Y.lazr.effects.slide_out(level_div);
-        slide_out_anim.on('end', function() {
-            level_div.setStyles({
-                visibility: 'visible'
-            });
-        });
-        var checked_box = subscription_radio_buttons.filter(':checked').pop();
-        var current_value = checked_box.get('value');
-        slide_in_anim.run();
-        Y.each(subscription_radio_buttons, function(subscription_button) {
+        // Get current value from the selected radio box.
+        var current_state = initialize(subscription_radio_buttons, level_div);
+
+        subscription_radio_buttons.each(function(subscription_button) {
             subscription_button.on('click', function(e) {
                 var value = e.target.get('value');
-                if (value !== current_value) {
-                    if(value === 'update-subscription') {
-                        slide_out_anim.run();
-                    } else {
-                        slide_in_anim.run();
-                    }
-                    current_value = value;
+                if (needs_toggling(
+                      current_state.value, value,
+                      current_state.has_update_subscription_button)) {
+                    toggle_field_visibility(level_div);
                 }
+                current_state.value = value;
             });
         });
+        // Set-up done.
+        return true;
+    } else {
+        // Nothing was set-up.
+        return false;
     }
 };
 
-}, "0.1", {"requires": ["dom", "node", "io-base", "lazr.anim", "lazr.effects"
+}, "0.1", {"requires": ["dom", "node", "lazr.anim", "lazr.effects"
     ]});

=== modified file 'lib/lp/bugs/javascript/bugtask_index_portlets.js'
--- lib/lp/bugs/javascript/bugtask_index_portlets.js	2011-04-22 22:31:33 +0000
+++ lib/lp/bugs/javascript/bugtask_index_portlets.js	2011-04-27 14:53:35 +0000
@@ -273,6 +273,35 @@
     return mute_subscription;
 }
 
+
+/**
+ * We can have at most one advanced subscription overlay shown,
+ * so we keep it globally to be able to clean-up before constructing
+ * another one.
+ */
+var subscription_overlay;
+
+/**
+ * Cleans-up the existing subscription overlay (if any).
+ */
+function remove_subscription_overlay() {
+    if (Y.Lang.isValue(subscription_overlay)) {
+        subscription_overlay.get('boundingBox').remove();
+    }
+    subscription_overlay = undefined;
+}
+
+/**
+ * Creates and shows a new subscription overlay for the given subscription.
+ */
+function create_new_subscription_overlay(subscription) {
+    remove_subscription_overlay();
+    subscription_overlay = setup_advanced_subscription_overlay(
+        subscription);
+    load_and_show_advanced_subscription_overlay(
+        subscription, subscription_overlay);
+}
+
 /*
  * Set up the handlers for the mute / unmute link.
  */
@@ -295,10 +324,7 @@
             mute_subscription.enable_spinner('Muting...');
             mute_current_user(mute_subscription);
         } else {
-            var unmute_overlay = setup_advanced_subscription_overlay(
-               mute_subscription);
-            load_and_show_advanced_subscription_overlay(
-               mute_subscription, unmute_overlay);
+            create_new_subscription_overlay(mute_subscription);
         }
     });
 }
@@ -414,11 +440,6 @@
     }
 
     var subscription = get_subscribe_self_subscription();
-    var subscription_overlay;
-    if (namespace.use_advanced_subscriptions) {
-        subscription_overlay = setup_advanced_subscription_overlay(
-            subscription);
-    }
 
     if (subscription.is_node()) {
         subscription.get('link').on('click', function(e) {
@@ -428,8 +449,7 @@
             subscription.set('is_team', false);
             var parent = e.target.get('parentNode');
             if (namespace.use_advanced_subscriptions) {
-                load_and_show_advanced_subscription_overlay(
-                    subscription, subscription_overlay);
+                create_new_subscription_overlay(subscription);
             } else {
                 // Look for the false conditions of subscription, which
                 // is_direct_subscription, etc. don't report correctly,
@@ -657,21 +677,14 @@
         centered: true,
         visible: false
     });
-    // Register a couple of handlers to clean up the overlay when it's
-    // hidden.
-    subscription_overlay.get('form_cancel_button').on(
-        'click',
-        Y.lp.bugs.bug_subscription.clean_up_level_div);
-    subscription_overlay.on(
-        'cancel', Y.lp.bugs.bug_subscription.clean_up_level_div);
     subscription_overlay.render('#privacy-form-container');
     return subscription_overlay;
 }
 
 /*
  * Load the content for and display the advanced subscription overlay.
- * The call to show() the overlay happens in the success handler for
- * loading the form content. That way the overlay won't appear empty.
+ * The call to show() the overlay happens only when the form has been
+ * loaded. That way the overlay won't appear empty.
  *
  * @method load_and_show_advanced_subscription_overlay
  * @param subscription {Object} A Y.lp.bugs.subscriber.Subscription object.
@@ -681,46 +694,24 @@
 function load_and_show_advanced_subscription_overlay(subscription,
                                                      subscription_overlay) {
     subscription.enable_spinner('Loading...');
+    subscription_overlay.set(
+        'form_submit_callback', function(form_data) {
+            handle_advanced_subscription_overlay(form_data);
+            subscription_overlay.hide();
+    });
+
     var subscription_link_url = subscription.get(
         'link').get('href') + '/++form++';
-    subscription_overlay.set(
-        'form_submit_callback', function(form_data) {
-        handle_advanced_subscription_overlay(form_data);
-        Y.lp.bugs.bug_subscription.clean_up_level_div();
-        subscription_overlay.hide();
-    });
-
-    // Normally we'd just call loadFormContentAndRender() here, but we
-    // want to be able to have our own callback for when loading the
-    // content is complete.
-    function on_success(id, response) {
-        subscription_overlay.set(
-            'form_content', response.responseText);
-        subscription_overlay.renderUI();
-        subscription_overlay.bindUI();
-        subscription.disable_spinner();
-
-        // If the user has a mute on the bug we add some UI polish to
-        // the subscription overlay.
-        var mute_subscription = get_mute_subscription();
-        if (!Y.Lang.isNull(mute_subscription) &&
-            mute_subscription.get('is_muted')) {
-            Y.lp.bugs.bug_subscription.set_up_bug_notification_level_field();
-        }
-        subscription_overlay.show();
-    }
-    function on_failure(id, response, subscription_overlay) {
-        subscription_overlay.set(
-            'form_content',
-            "Sorry, an error occurred while loading the form.");
-        subscription_overlay.renderUI();
-        subscription.disable_spinner();
-        subscription_overlay.show();
-    }
-    var config = {
-        on: {success: on_success, failure: on_failure}
-    };
-    Y.io(subscription_link_url, config);
+    subscription_overlay.loadFormContentAndRender(subscription_link_url);
+
+    Y.on('contentready', function() {
+        Y.lp.bugs.bug_notification_level.setup();
+    }, '.bug-notification-level-field');
+
+    Y.on('contentready', function() {
+        subscription.disable_spinner();
+        subscription_overlay.show();
+    }, '.yui3-lazr-formoverlay-form');
 }
 
 /*
@@ -1509,4 +1500,4 @@
                         "lazr.overlay", "lazr.choiceedit", "lp.app.picker",
                         "lp.client",
                         "lp.client.plugins", "lp.bugs.subscriber",
-                        "lp.bugs.bug_subscription", "lp.app.errors"]});
+                        "lp.bugs.bug_notification_level", "lp.app.errors"]});

=== added file 'lib/lp/bugs/javascript/tests/test_bug_notification_level.html'
--- lib/lp/bugs/javascript/tests/test_bug_notification_level.html	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_notification_level.html	2011-04-27 14:53:35 +0000
@@ -0,0 +1,41 @@
+<html>
+  <head>
+    <title>Bug notification level set-up</title>
+
+     <!-- YUI 3.0 Setup -->
+    <script type="text/javascript"
+      src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
+    <script type="text/javascript"
+      src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/javascript/test.css" />
+
+    <script type="text/javascript"
+      src="../../../app/javascript/client.js"></script>
+
+    <!-- The module under test -->
+    <script type="text/javascript"
+      src="../bug_notification_level.js"></script>
+
+    <!-- The test suite -->
+    <script type="text/javascript"
+      src="test_bug_notification_level.js"></script>
+
+    <!-- Pretty up the sample html -->
+    <style type="text/css">
+      div#sample {margin:15px; width:200px; border:1px solid #999; padding:10px;}
+    </style>
+  </head>
+  <body class="yui3-skin-sam">
+    <!-- Example markup required by test suite -->
+
+    <!-- The test output -->
+    <div id="log"></div>
+  </body>
+</html>

=== added file 'lib/lp/bugs/javascript/tests/test_bug_notification_level.js'
--- lib/lp/bugs/javascript/tests/test_bug_notification_level.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_notification_level.js	2011-04-27 14:53:35 +0000
@@ -0,0 +1,414 @@
+YUI({
+    base: '../../../../canonical/launchpad/icing/yui/',
+    filter: 'raw', combine: false, fetchCSS: false
+    }).use('test', 'console', 'lp.bugs.bug_notification_level',
+           'node-event-simulate',
+           function(Y) {
+
+var suite = new Y.Test.Suite("lp.bugs.bug_notification_level Tests");
+var module = Y.lp.bugs.bug_notification_level;
+
+/**
+ * Test is_notification_level_shown() for a given set of
+ * conditions.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Is the selection of notification levels shown',
+
+    setUp: function () {
+        this.MY_NAME = "ME";
+        window.LP = { links: { me: "/~" + this.MY_NAME } };
+    },
+
+    tearDown: function() {
+        delete window.LP;
+    },
+
+    test_subscribe_me: function() {
+        // Person wants to subscribe so levels are shown:
+        // the selected radio box has a value of the username,
+        // and there is no option to update a subscription.
+        Y.Assert.isTrue(
+            module._is_notification_level_shown(this.MY_NAME, false));
+    },
+
+    test_unsubscribe_someone_else: function() {
+        // Not subscribed (thus no option to update a subscription)
+        // and wants to unsubscribe a team: levels are not shown.
+        Y.Assert.isFalse(
+            module._is_notification_level_shown('TEAM', false));
+    },
+
+    test_edit_subscription_me: function() {
+        // There is either an existing subscription, or bug mail
+        // is muted, so one can 'update existing subscription'.
+        // If unmute/unsubscribe options are chosen, no level
+        // options are shown.
+        Y.Assert.isFalse(
+            module._is_notification_level_shown(this.MY_NAME, true));
+    },
+
+    test_edit_subscription_update: function() {
+        // There is either an existing subscription, or bug mail
+        // is muted, so one can 'update existing subscription'.
+        // If 'update-subscription' option is chosen, level
+        // options are shown.
+        Y.Assert.isTrue(
+            module._is_notification_level_shown('update-subscription', true));
+    },
+
+    test_edit_subscription_someone_else: function() {
+        // There is either an existing subscription, or bug mail
+        // is muted, so one can 'update existing subscription'.
+        // If unsubscribe a team option is chosen, no level
+        // options are shown.
+        Y.Assert.isFalse(
+            module._is_notification_level_shown('TEAM', true));
+    }
+
+}));
+
+
+/**
+ * Test needs_toggling() which compares two sets of conditions and
+ * returns if the need for notification level has changed.
+ */
+suite.add(new Y.Test.Case({
+    name: 'State of the notification level visibility should change',
+
+    setUp: function () {
+        this.MY_NAME = "ME";
+        window.LP = { links: { me: "/~" + this.MY_NAME } };
+    },
+
+    tearDown: function() {
+        delete window.LP;
+    },
+
+    test_no_change: function() {
+        // Both current_value and new_value are identical.
+        Y.Assert.isFalse(
+            module._needs_toggling('value', 'value', false));
+        Y.Assert.isFalse(
+            module._needs_toggling('value', 'value', true));
+    },
+
+    test_unsubscribe_to_team: function() {
+        // Changing the option from 'unsubscribe me' (no levels shown)
+        // to 'unsubscribe team' (no levels shown) means no change.
+        Y.Assert.isFalse(
+            module._needs_toggling(this.MY_NAME, 'TEAM', true));
+    },
+
+    test_edit_subscription_to_team: function() {
+        // Changing the option from 'update-subscription' (levels shown)
+        // to 'unsubscribe team' (no levels shown) means a change.
+        Y.Assert.isTrue(
+            module._needs_toggling('update-subscription', 'TEAM', true));
+    }
+
+}));
+
+
+/**
+ * Test toggle_field_visibility() which shows/hides a node based on
+ * the value of bug_notification_level_visible value.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Toggle visibility of the notification levels with animations',
+
+    test_quick_close: function() {
+        // When quick_close===true, no animation happens and the
+        // node is hidden.
+        var node = Y.Node.create('<div></div>');
+        module._toggle_field_visibility(node, true);
+        Y.Assert.isTrue(node.hasClass('lazr-closed'));
+        Y.Assert.areEqual('none', node.getStyle('display'));
+        Y.Assert.isFalse(module._bug_notification_level_visible);
+        // Restore the default value.
+        module._bug_notification_level_visible = true;
+    },
+
+    test_hide_node: function() {
+        // Initially a node is shown, so 'toggling' makes it hidden.
+        var node = Y.Node.create('<div></div>');
+        module._toggle_field_visibility(node);
+        this.wait(function() {
+            // Wait for the animation to complete.
+            Y.Assert.isTrue(node.hasClass('lazr-closed'));
+            Y.Assert.isFalse(module._bug_notification_level_visible);
+        }, 500);
+        // Restore the default value.
+        module._bug_notification_level_visible = true;
+    },
+
+    test_show_node: function() {
+        // When the node is closed, toggling shows it.
+        module._bug_notification_level_visible = false;
+        var node = Y.Node.create('<div></div>');
+        module._toggle_field_visibility(node);
+        this.wait(function() {
+            // Wait for the animation to complete.
+            Y.Assert.isTrue(node.hasClass('lazr-opened'));
+            Y.Assert.isTrue(module._bug_notification_level_visible);
+        }, 500);
+    },
+
+    test_show_and_hide: function() {
+        // Showing and then quickly hiding the node stops the
+        // slide out animation for nicer rendering.
+        module._bug_notification_level_visible = false;
+        var node = Y.Node.create('<div></div>');
+        // This triggers the 'slide-out' animation.
+        module._toggle_field_visibility(node);
+        // Now we wait 100ms (<400ms for the animation) and
+        // trigger the 'slide-in' animation.
+        this.wait(function() {
+            module._toggle_field_visibility(node);
+            // The slide-out animation should be stopped now.
+            Y.Assert.isFalse(module._slideout_animation.get('running'));
+        }, 100);
+        // Restore the default value.
+        module._bug_notification_level_visible = true;
+    }
+
+}));
+
+
+/**
+ * Test initialize() which sets up the initial state as appropriate.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Test initial set-up of the level options display.',
+
+    setUp: function () {
+        this.MY_NAME = "ME";
+        window.LP = { links: { me: "/~" + this.MY_NAME } };
+    },
+
+    tearDown: function() {
+        delete window.LP;
+    },
+
+    createRadioButton: function(value, checked) {
+        if (checked === undefined) {
+            checked = false;
+        }
+        return Y.Node.create('<input type="radio"></input>')
+            .set('name', 'field.subscription')
+            .set('value', value)
+            .set('checked', checked);
+    },
+
+    test_bug_notification_level_default: function() {
+        // `bug_notification_level_visible` is always restored to true.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton(this.MY_NAME, true));
+        var radio_buttons = node.all('input[name=field.subscription]');
+
+        module._bug_notification_level_visible = false;
+        var state = module._initialize(radio_buttons, level_node);
+        Y.Assert.isTrue(module._bug_notification_level_visible);
+    },
+
+    test_value_undefined: function() {
+        // When there is no selected radio box, returned value is undefined.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton(this.MY_NAME));
+        node.appendChild(this.createRadioButton('TEAM'));
+        var radio_buttons = node.all('input[name=field.subscription]');
+
+        var state = module._initialize(radio_buttons, level_node);
+        Y.Assert.isUndefined(state.value);
+    },
+
+    test_value_selected: function() {
+        // When there is a selected radio box, returned value matches
+        // the value from that radio box.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton('VALUE', true));
+        node.appendChild(this.createRadioButton('TEAM'));
+        var radio_buttons = node.all('input[name=field.subscription]');
+
+        var state = module._initialize(radio_buttons, level_node);
+        Y.Assert.areEqual('VALUE', state.value);
+    },
+
+    test_has_update_subscription_button_false: function() {
+        // When there is no radio box with value 'update-subscription',
+        // returned state indicates that.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton(this.MY_NAME, true));
+        var radio_buttons = node.all('input[name=field.subscription]');
+        var state = module._initialize(radio_buttons, level_node);
+        Y.Assert.isFalse(state.has_update_subscription_button);
+    },
+
+    test_has_update_subscription_button_true: function() {
+        // When there is a radio box with value 'update-subscription',
+        // returned state indicates that.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton('update-subscription', true));
+        var radio_buttons = node.all('input[name=field.subscription]');
+        var state = module._initialize(radio_buttons, level_node);
+        Y.Assert.isTrue(state.has_update_subscription_button);
+    },
+
+    test_no_toggling_for_visible: function() {
+        // No toggling happens when options should be shown
+        // since that's the default.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton(this.MY_NAME, true));
+        var radio_buttons = node.all('input[name=field.subscription]');
+        module._initialize(radio_buttons, level_node);
+        Y.Assert.isFalse(level_node.hasClass('lazr-opened'));
+        Y.Assert.isFalse(level_node.hasClass('lazr-closed'));
+    },
+
+    test_toggling_for_hiding: function() {
+        // Quick toggling happens when options should be hidden.
+        var level_node = Y.Node.create('<div></div>');
+        var node = Y.Node.create('<div></div>');
+        node.appendChild(this.createRadioButton(this.MY_NAME, true));
+        node.appendChild(
+            this.createRadioButton('update-subscription', false));
+        var radio_buttons = node.all('input[name=field.subscription]');
+        module._initialize(radio_buttons, level_node);
+        Y.Assert.areEqual('none', level_node.getStyle('display'));
+        Y.Assert.isTrue(level_node.hasClass('lazr-closed'));
+    }
+
+}));
+
+
+/**
+ * Test setup() of level options display toggling.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Test initial set-up of the level options display.',
+
+    _should: {
+        error: {
+            test_multiple_nodes_with_level_options: new Error(
+                'There are multiple bug-notification-level-field nodes.')
+        }
+    },
+
+    setUp: function () {
+        this.MY_NAME = "ME";
+        window.LP = { links: { me: "/~" + this.MY_NAME } };
+        this.root = Y.one('body').appendChild(
+            Y.Node.create('<div></div>'));
+    },
+
+    tearDown: function() {
+        delete window.LP;
+        this.root.empty();
+    },
+
+    createRadioButton: function(value, checked) {
+        if (checked === undefined) {
+            checked = false;
+        }
+        return Y.Node.create('<input type="radio"></input>')
+            .set('name', 'field.subscription')
+            .set('value', value)
+            .set('checked', checked);
+    },
+
+    test_multiple_nodes_with_level_options: function() {
+        // Multiple nodes with bug notification level options
+        // make the set-up fail.
+        this.root.appendChild(
+            Y.Node.create('<div></div>')
+                .addClass('bug-notification-level-field'));
+        this.root.appendChild(
+            Y.Node.create('<div></div>')
+                .addClass('bug-notification-level-field'));
+        module.setup();
+    },
+
+    test_no_level_options: function() {
+        // When there are no level options, no animation is set-up.
+        var options_node = Y.Node.create('<div></div>');
+        options_node.appendChild(this.createRadioButton(this.MY_NAME, true));
+
+        this.root.appendChild(options_node);
+        Y.Assert.isFalse(module.setup());
+    },
+
+    test_single_option_no_animation: function() {
+        // When there is only a single option, no animation is set-up.
+        var level_node = Y.Node.create('<div></div>')
+            .addClass('bug-notification-level-field');
+        var options_node = Y.Node.create('<div></div>');
+        options_node.appendChild(this.createRadioButton(this.MY_NAME, true));
+
+        this.root.appendChild(options_node);
+        this.root.appendChild(level_node);
+        Y.Assert.isFalse(module.setup());
+    },
+
+    test_animation_set_up: function() {
+        // With multiple options (eg. "subscribe me", "unsubscribe team")
+        // toggling of visibility (with animation) is set-up for all items.
+        var level_node = Y.Node.create('<div></div>')
+            .addClass('bug-notification-level-field');
+
+        var subscribe_me = this.createRadioButton(this.MY_NAME, true);
+        var unsubscribe_team = this.createRadioButton('TEAM', false);
+
+        var options_node = Y.Node.create('<div></div>');
+        options_node.appendChild(subscribe_me);
+        options_node.appendChild(unsubscribe_team);
+
+        this.root.appendChild(options_node);
+        this.root.appendChild(level_node);
+
+        // Set-up is successful.
+        Y.Assert.isTrue(module.setup());
+
+        // Clicking the second option hides the initially shown
+        // notification level options.
+        unsubscribe_team.simulate('click');
+        this.wait(function() {
+            Y.Assert.isTrue(level_node.hasClass('lazr-closed'));
+            Y.Assert.isFalse(level_node.hasClass('lazr-opened'));
+            Y.Assert.isFalse(module._bug_notification_level_visible);
+            // Clicking it again does nothing.
+            unsubscribe_team.simulate('click');
+            Y.Assert.isFalse(module._bug_notification_level_visible);
+
+            // Clicking the other option slides the options out again.
+            subscribe_me.simulate('click');
+            this.wait(function() {
+                Y.Assert.isTrue(level_node.hasClass('lazr-opened'));
+                Y.Assert.isFalse(level_node.hasClass('lazr-closed'));
+                Y.Assert.isTrue(module._bug_notification_level_visible);
+            }, 500);
+        }, 500);
+    }
+
+}));
+
+var handle_complete = function(data) {
+    status_node = Y.Node.create(
+        '<p id="complete">Test status: complete</p>');
+    Y.one('body').appendChild(status_node);
+    };
+Y.Test.Runner.on('complete', handle_complete);
+Y.Test.Runner.add(suite);
+
+var console = new Y.Console({newestOnTop: false});
+console.render('#log');
+
+Y.on('domready', function() {
+    Y.Test.Runner.run();
+});
+});

=== modified file 'lib/lp/bugs/templates/bug-subscription.pt'
--- lib/lp/bugs/templates/bug-subscription.pt	2011-04-05 16:05:15 +0000
+++ lib/lp/bugs/templates/bug-subscription.pt	2011-04-27 14:53:35 +0000
@@ -15,13 +15,14 @@
             tal:condition="devmode"
             tal:content="string:var yui_base='${yui}';"></script>
 
-    <script type="text/javascript" tal:condition="view/user_is_muted">
-        LPS.use('base', 'node', 'oop', 'event', 'lp.bugs.bug_subscription',
+    <script type="text/javascript">
+        LPS.use('base', 'node', 'oop', 'event',
+                'lp.bugs.bug_notification_level',
             function(Y) {
-            Y.on(
-              'domready',
-              Y.lp.bugs.bug_subscription.set_up_bug_notification_level_field);
-        });
+                Y.on('domready', function () {
+                    Y.lp.bugs.bug_notification_level.setup();
+                });
+            });
     </script>
   </metal:block>