← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~gary/launchpad/muteteamsub-ui into lp:launchpad/db-devel

 

Gary Poster has proposed merging lp:~gary/launchpad/muteteamsub-ui into lp:launchpad/db-devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~gary/launchpad/muteteamsub-ui/+merge/56419

This branch adds a UI for muting team subscriptions, building on the work Graham and I have done previously.

The Python changes are what I needed to get the UI working.

 * I added a "muted" method.  It returns the date it was muted for the person, or None if it is not.
 * I stopped "mute" from returning the mute.  I couldn't get lazr.restful to deal with it properly, and it was not in the interface and didn't seem necessary.
 * I removed the webservice bobs from the mute interface because they were not being used (it was not imported into the bug package's webservice.py) and it was not necessary for them to be exported.
 * lib/lp/bugs/browser/structuralsubscription.py exposes the new mute info, and lib/lp/bugs/browser/tests/test_expose.py adds pertinent tests.

The JavaScript in lib/lp/registry/javascript/structural-subscription.js has the meat of the work.
 * I changed edit_subscription_handler to use the currently preferred approach to get the main container by id and then the components by class.  I don't remember if I needed to do this or if it just seemed like a good idea at the time. :-/
 * I made some linty changes per our lint (78 char line length) and Crockford's JS linter (he advocates no single line funcs, and does stuff about semi-colons).
 * make_delete_handler now takes a node rather than an id, which seemed a bit more elegant to me.
 * make_delete_handler had messed up its unsubscribe node in some refactoring or other.  I fixed it (but still no test).
 * The meat of the change is to add mute handling, as you'd expect, which is primarily in make_mute_handler and handle_mute.
 * While I added the mute code, I also changed how we draw subscriptions to use a string for a template, gradually gathered, rather than nodes one by one.  As I say in a comment, this is because whitespace was stripped from left and right when I tried to use Y.Node.create with spaces at the beginning or end of a string.

I also added JS tests for the new mute functionality.  To do so, I reused some code from earlier tests, which I factored out into helpers (monkeypatch_LP and make_lp_client_stub).  We could probably use those more, but this branch was big enough.
-- 
https://code.launchpad.net/~gary/launchpad/muteteamsub-ui/+merge/56419
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~gary/launchpad/muteteamsub-ui into lp:launchpad/db-devel.
=== modified file 'lib/lp/bugs/browser/structuralsubscription.py'
--- lib/lp/bugs/browser/structuralsubscription.py	2011-04-01 20:26:38 +0000
+++ lib/lp/bugs/browser/structuralsubscription.py	2011-04-05 17:41:14 +0000
@@ -446,7 +446,9 @@
                     subscriber, rootsite='mainsite'),
                 subscriber_title=subscriber.title,
                 subscriber_is_team=is_team,
-                user_is_team_admin=user_is_team_admin,))
+                user_is_team_admin=user_is_team_admin,
+                can_mute=filter.isMuteAllowed(user),
+                is_muted=filter.muted(user) is not None))
     info = info.values()
     info.sort(key=lambda item: item['target_url'])
     IJSONRequestCache(request).objects['subscription_info'] = info

=== modified file 'lib/lp/bugs/browser/tests/test_expose.py'
--- lib/lp/bugs/browser/tests/test_expose.py	2011-03-31 15:04:53 +0000
+++ lib/lp/bugs/browser/tests/test_expose.py	2011-04-05 17:41:14 +0000
@@ -251,8 +251,10 @@
         self.assertEqual(len(target_info['filters']), 1) # One filter.
         filter_info = target_info['filters'][0]
         self.assertEqual(filter_info['filter'], sub.bug_filters[0])
-        self.failUnless(filter_info['subscriber_is_team'])
-        self.failUnless(filter_info['user_is_team_admin'])
+        self.assertTrue(filter_info['subscriber_is_team'])
+        self.assertTrue(filter_info['user_is_team_admin'])
+        self.assertTrue(filter_info['can_mute'])
+        self.assertFalse(filter_info['is_muted'])
         self.assertEqual(filter_info['subscriber_title'], team.title)
         self.assertEqual(
             filter_info['subscriber_link'],
@@ -273,8 +275,10 @@
         expose_user_subscriptions_to_js(user, [sub], request)
         info = IJSONRequestCache(request).objects['subscription_info']
         filter_info = info[0]['filters'][0]
-        self.failUnless(filter_info['subscriber_is_team'])
-        self.failIf(filter_info['user_is_team_admin'])
+        self.assertTrue(filter_info['subscriber_is_team'])
+        self.assertFalse(filter_info['user_is_team_admin'])
+        self.assertTrue(filter_info['can_mute'])
+        self.assertFalse(filter_info['is_muted'])
         self.assertEqual(filter_info['subscriber_title'], team.title)
         self.assertEqual(
             filter_info['subscriber_link'],
@@ -283,6 +287,20 @@
             filter_info['subscriber_url'],
             canonical_url(team, rootsite='mainsite'))
 
+    def test_muted_team_member_subscription(self):
+        user = self.factory.makePerson()
+        target = self.factory.makeProduct()
+        request = LaunchpadTestRequest()
+        team = self.factory.makeTeam(members=[user])
+        with person_logged_in(team.teamowner):
+            sub = target.addBugSubscription(team, team.teamowner)
+        sub.bug_filters.one().mute(user)
+        expose_user_subscriptions_to_js(user, [sub], request)
+        info = IJSONRequestCache(request).objects['subscription_info']
+        filter_info = info[0]['filters'][0]
+        self.assertTrue(filter_info['can_mute'])
+        self.assertTrue(filter_info['is_muted'])
+
     def test_self_subscription(self):
         # Make a subscription directly for the user and see what we record.
         user = self.factory.makePerson()
@@ -293,8 +311,10 @@
         expose_user_subscriptions_to_js(user, [sub], request)
         info = IJSONRequestCache(request).objects['subscription_info']
         filter_info = info[0]['filters'][0]
-        self.failIf(filter_info['subscriber_is_team'])
+        self.assertFalse(filter_info['subscriber_is_team'])
         self.assertEqual(filter_info['subscriber_title'], user.title)
+        self.assertFalse(filter_info['can_mute'])
+        self.assertFalse(filter_info['is_muted'])
         self.assertEqual(
             filter_info['subscriber_link'],
             absoluteURL(user, IWebServiceClientRequest(request)))

=== modified file 'lib/lp/bugs/interfaces/bugsubscriptionfilter.py'
--- lib/lp/bugs/interfaces/bugsubscriptionfilter.py	2011-04-01 17:32:41 +0000
+++ lib/lp/bugs/interfaces/bugsubscriptionfilter.py	2011-04-05 17:41:14 +0000
@@ -9,7 +9,6 @@
     "IBugSubscriptionFilterMute",
     ]
 
-
 from lazr.restful.declarations import (
     call_with,
     export_as_webservice_entry,
@@ -18,7 +17,6 @@
     export_write_operation,
     exported,
     operation_for_version,
-    operation_parameters,
     REQUEST_USER,
     )
 from lazr.restful.fields import Reference
@@ -41,7 +39,6 @@
 from lp.bugs.interfaces.structuralsubscription import (
     IStructuralSubscription,
     )
-from lp.registry.interfaces.person import IPerson
 from lp.services.fields import (
     PersonChoice,
     SearchTag,
@@ -110,24 +107,25 @@
     """Methods on `IBugSubscriptionFilter` that can be called by anyone."""
 
     @call_with(person=REQUEST_USER)
-    @operation_parameters(
-        person=Reference(IPerson, title=_('Person'), required=True))
     @export_read_operation()
     @operation_for_version('devel')
     def isMuteAllowed(person):
         """Return True if this filter can be muted for `person`."""
 
     @call_with(person=REQUEST_USER)
-    @operation_parameters(
-        person=Reference(IPerson, title=_('Person'), required=True))
+    @export_read_operation()
+    @operation_for_version('devel')
+    def muted(person):
+        """Return date muted if this filter was muted for `person`, or None.
+        """
+
+    @call_with(person=REQUEST_USER)
     @export_write_operation()
     @operation_for_version('devel')
     def mute(person):
         """Add a mute for `person` to this filter."""
 
     @call_with(person=REQUEST_USER)
-    @operation_parameters(
-        person=Reference(IPerson, title=_('Person'), required=True))
     @export_write_operation()
     @operation_for_version('devel')
     def unmute(person):
@@ -155,13 +153,12 @@
 class IBugSubscriptionFilterMute(Interface):
     """A mute on an IBugSubscriptionFilter."""
 
-    export_as_webservice_entry()
-
     person = PersonChoice(
         title=_('Person'), required=True, vocabulary='ValidPersonOrTeam',
         readonly=True, description=_("The person subscribed."))
     filter = Reference(
         IBugSubscriptionFilter, title=_("Subscription filter"),
+        required=True, readonly=True,
         description=_("The subscription filter to be muted."))
     date_created = Datetime(
         title=_("The date on which the mute was created."), required=False,

=== modified file 'lib/lp/bugs/model/bugsubscriptionfilter.py'
--- lib/lp/bugs/model/bugsubscriptionfilter.py	2011-04-04 12:30:20 +0000
+++ lib/lp/bugs/model/bugsubscriptionfilter.py	2011-04-05 17:41:14 +0000
@@ -259,6 +259,15 @@
             self.structural_subscription.subscriber.isTeam() and
             person.inTeam(self.structural_subscription.subscriber))
 
+    def muted(self, person):
+        store = Store.of(self)
+        existing_mutes = store.find(
+            BugSubscriptionFilterMute,
+            BugSubscriptionFilterMute.filter_id == self.id,
+            BugSubscriptionFilterMute.person_id == person.id)
+        if not existing_mutes.is_empty():
+            return existing_mutes.one().date_created
+
     def mute(self, person):
         """See `IBugSubscriptionFilter`."""
         if not self.isMuteAllowed(person):
@@ -270,14 +279,11 @@
             BugSubscriptionFilterMute,
             BugSubscriptionFilterMute.filter_id == self.id,
             BugSubscriptionFilterMute.person_id == person.id)
-        if not existing_mutes.is_empty():
-            return existing_mutes.one()
-        else:
+        if existing_mutes.is_empty():
             mute = BugSubscriptionFilterMute()
             mute.person = person
             mute.filter = self.id
             store.add(mute)
-            return mute
 
     def unmute(self, person):
         """See `IBugSubscriptionFilter`."""

=== modified file 'lib/lp/bugs/tests/test_structuralsubscription.py'
--- lib/lp/bugs/tests/test_structuralsubscription.py	2011-04-01 17:24:42 +0000
+++ lib/lp/bugs/tests/test_structuralsubscription.py	2011-04-05 17:41:14 +0000
@@ -733,8 +733,11 @@
             BugSubscriptionFilterMute.filter == filter_id,
             BugSubscriptionFilterMute.person == person_id)
         self.assertTrue(mutes.is_empty())
+        self.assertFalse(self.filter.muted(self.team_member))
         self.filter.mute(self.team_member)
+        self.assertTrue(self.filter.muted(self.team_member))
         store.flush()
+        self.assertFalse(mutes.is_empty())
 
     def test_unmute_removes_mute(self):
         # BugSubscriptionFilter.unmute() removes any mute for a given
@@ -749,7 +752,9 @@
             BugSubscriptionFilterMute.filter == filter_id,
             BugSubscriptionFilterMute.person == person_id)
         self.assertFalse(mutes.is_empty())
+        self.assertTrue(self.filter.muted(self.team_member))
         self.filter.unmute(self.team_member)
+        self.assertFalse(self.filter.muted(self.team_member))
         store.flush()
         self.assertTrue(mutes.is_empty())
 

=== modified file 'lib/lp/registry/javascript/structural-subscription.js'
--- lib/lp/registry/javascript/structural-subscription.js	2011-04-04 16:22:20 +0000
+++ lib/lp/registry/javascript/structural-subscription.js	2011-04-05 17:41:14 +0000
@@ -249,17 +249,17 @@
  */
 function edit_subscription_handler(context, form_data) {
     var has_errors = check_for_errors_in_overlay(add_subscription_overlay);
-    var filter_id = '#filter-description-'+context.filter_id.toString();
+    var filter_node = Y.one(
+        '#subscription-filter-'+context.filter_id.toString());
     if (has_errors) {
         return false;
     }
     var on = {success: function (new_data) {
         var filter = new_data.getAttrs();
-        var description_node = Y.one(filter_id);
-        description_node
+        filter_node.one('.filter-description')
             .empty()
             .appendChild(create_filter_description(filter));
-        description_node.ancestor('.subscription-filter').one('.filter-name')
+        filter_node.one('.filter-name')
             .empty()
             .appendChild(render_filter_title(context.filter_info, filter));
         add_subscription_overlay.hide();
@@ -652,7 +652,8 @@
         '        description help</span></a> ' +
         '  </dd>' +
         '  <dt>Receive mail for bugs affecting' +
-        '    <span id="structural-subscription-context-title"></span> that</dt>' +
+        '    <span id="structural-subscription-context-title"></span> '+
+        '    that</dt>' +
         '  <dd>' +
         '    <div id="events">' +
         '      <input type="radio" name="events"' +
@@ -690,7 +691,8 @@
         '                <dt></dt>' +
         '                <dd style="margin-left:25px;">' +
         '                    <div id="accordion-overlay"' +
-        '                        style="position:relative; overflow:hidden;"></div>' +
+        '                        style="position:relative; '+
+                                        'overflow:hidden;"></div>' +
         '                </dd>' +
         '            </dl>' +
         '        </div> ' +
@@ -1030,10 +1032,10 @@
 /**
  * Construct a handler for an unsubscribe link.
  */
-function make_delete_handler(filter, filter_id, subscriber_id) {
+function make_delete_handler(filter, node, subscriber_id) {
     var error_handler = new Y.lp.client.ErrorHandler();
     error_handler.showError = function(error_msg) {
-      var unsubscribe_node = Y.one('#unsubscribe-'+filter_id.toString());
+      var unsubscribe_node = node.one('a.delete-subscription');
       Y.lp.app.errors.display_error(unsubscribe_node, error_msg);
     };
     return function() {
@@ -1046,9 +1048,8 @@
                     var to_collapse = subscriber;
                     var filters = subscriber.all('.subscription-filter');
                     if (!filters.isEmpty()) {
-                        to_collapse = Y.one(
-                            '#subscription-filter-'+filter_id.toString());
-                        }
+                        to_collapse = node;
+                    }
                     collapse_node(to_collapse);
                     },
                  failure: error_handler.getFailureHandler()
@@ -1059,6 +1060,39 @@
 }
 
 /**
+ * Construct a handler for a mute link.
+ */
+function make_mute_handler(filter_info, node){
+    var error_handler = new Y.lp.client.ErrorHandler();
+    error_handler.showError = function(error_msg) {
+      var mute_node = node.one('a.mute-subscription');
+      Y.lp.app.errors.display_error(mute_node, error_msg);
+    };
+    return function() {
+        var fname;
+        if (filter_info.is_muted) {
+            fname = 'unmute';
+        } else {
+            fname = 'mute';
+        }
+        var config = {
+            on: {success: function(){
+                    if (fname === 'mute') {
+                        filter_info.is_muted = true;
+                    } else {
+                        filter_info.is_muted = false;
+                    }
+                    handle_mute(node, filter_info.is_muted);
+                    },
+                 failure: error_handler.getFailureHandler()
+                }
+            };
+        namespace.lp_client.named_post(filter_info.filter.self_link,
+            fname, config);
+    };
+}
+
+/**
  * Attach activation (click) handlers to all of the edit links on the page.
  */
 function wire_up_edit_links(config) {
@@ -1071,17 +1105,20 @@
         var sub = subscription_info[i];
         for (j=0; j<sub.filters.length; j++) {
             var filter_info = sub.filters[j];
+            var node = Y.one('#subscription-filter-'+filter_id.toString());
+            if (filter_info.can_mute) {
+                var mute_link = node.one('a.mute-subscription');
+                mute_link.on('click', make_mute_handler(filter_info, node));
+            }
             if (!filter_info.subscriber_is_team ||
                 filter_info.user_is_team_admin) {
-                var node = Y.one(
-                    '#subscription-filter-'+filter_id.toString());
                 var edit_link = node.one('a.edit-subscription');
                 var edit_handler = make_edit_handler(
                     sub, filter_info, filter_id, config);
                 edit_link.on('click', edit_handler);
                 var delete_link = node.one('a.delete-subscription');
                 var delete_handler = make_delete_handler(
-                    filter_info.filter, filter_id, i);
+                    filter_info.filter, node, i);
                 delete_link.on('click', delete_handler);
             }
             filter_id += 1;
@@ -1090,6 +1127,26 @@
 }
 
 /**
+ * For a given filter node, set it up properly based on mute state.
+ */
+function handle_mute(node, muted) {
+    var control = node.one('a.mute-subscription');
+    var label = node.one('em.mute-label');
+    var description = node.one('.filter-description');
+    if (muted) {
+        control.set('text', 'Receive emails from this subscription');
+        control.replaceClass('no', 'yes');
+        label.setStyle('display', null);
+        description.setStyle('color', '#bbb');
+    } else {
+        control.set('text', 'Do not receive emails from this subscription');
+        control.replaceClass('yes', 'no');
+        label.setStyle('display', 'none');
+        description.setStyle('color', null);
+    }
+}
+
+/**
  * Populate the subscription list DOM element with subscription descriptions.
  */
 function fill_in_bug_subscriptions(config) {
@@ -1133,31 +1190,43 @@
             filter_node.appendChild(Y.Node.create(
                 '<strong class="filter-name"></strong>'))
                 .appendChild(render_filter_title(sub.filters[j], filter));
-
-            if (!sub.filters[j].subscriber_is_team ||
-                sub.filters[j].user_is_team_admin) {
+            if (sub.filters[j].can_mute) {
+                filter_node.appendChild(Y.Node.create(
+                    '<em class="mute-label" style="padding-left: 1em;">You '+
+                    'do not receive emails from this subscription.</em>'));
+            }
+            var can_edit = (!sub.filters[j].subscriber_is_team ||
+                            sub.filters[j].user_is_team_admin);
+            // Whitespace is stripped from the left and right of the string
+            // when you make a node, so we have to build the string with the
+            // intermediate whitespace and then create the node at the end.
+            var control_template = '';
+            if (sub.filters[j].can_mute) {
+                control_template += (
+                    '<a href="#" class="sprite js-action '+
+                    'mute-subscription"></a>');
+                if (can_edit) {
+                    control_template += ' or ';
+                }
+            }
+            if (can_edit) {
                 // User can edit the subscription.
-                filter_node.appendChild(Y.Node.create(
-                    '<span style="float: right">'+
+                control_template += (
                     '<a href="#" class="sprite modify edit js-action '+
-                    '    edit-subscription">'+
-                    '  Edit this subscription</a> or '+
+                    '    edit-subscription">Edit this subscription</a> or '+
                     '<a href="#" class="sprite modify remove js-action '+
-                    '    delete-subscription">'+
-                    '  Unsubscribe</a></span>'));
-            } else {
-                // User cannot edit the subscription, because this is a
-                // team and the user does not have admin privileges.
-                filter_node.appendChild(Y.Node.create(
-                    '<span style="float: right"><em>'+
-                    'You do not have privileges to change this subscription'+
-                    '</em></span>'));
+                    '    delete-subscription">Unsubscribe</a>');
             }
-
-            filter_node.appendChild(Y.Node.create(
-                '<div style="padding-left: 1em"></div>')
-                .set('id', 'filter-description-'+filter_id.toString()))
+            filter_node.appendChild(Y.Node.create(
+                '<span style="float: right"></span>')
+                ).appendChild(Y.Node.create(control_template));
+            filter_node.appendChild(Y.Node.create(
+                '<div style="padding-left: 1em" '+
+                'class="filter-description"></div>'))
                 .appendChild(create_filter_description(filter));
+            if (sub.filters[j].can_mute) {
+                handle_mute(filter_node, sub.filters[j].is_muted);
+            }
 
             filter_id += 1;
         }
@@ -1246,13 +1315,13 @@
     // Format event details.
     var events; // When will email be sent?
     if (filter.bug_notification_level === 'Discussion') {
-        events = 'You will recieve an email when any change '+
+        events = 'You will receive an email when any change '+
             'is made or a comment is added.';
     } else if (filter.bug_notification_level === 'Details') {
-        events = 'You will recieve an email when any changes '+
+        events = 'You will receive an email when any changes '+
             'are made to the bug.  Bug comments will not be sent.';
     } else if (filter.bug_notification_level === 'Lifecycle') {
-        events = 'You will recieve an email when bugs are '+
+        events = 'You will receive an email when bugs are '+
             'opened or closed.';
     } else {
         throw new Error('Unrecognized events.');

=== modified file 'lib/lp/registry/javascript/tests/test_structural_subscription.js'
--- lib/lp/registry/javascript/tests/test_structural_subscription.js	2011-04-04 15:59:28 +0000
+++ lib/lp/registry/javascript/tests/test_structural_subscription.js	2011-04-05 17:41:14 +0000
@@ -22,6 +22,10 @@
     var content_box_name = 'ss-content-box';
     var content_box_id = '#' + content_box_name;
 
+    // Listing node.
+    var subscription_listing_name = 'subscription-listing';
+    var subscription_listing_id = '#' + subscription_listing_name;
+
     var target_link_class = '.menu-link-subscribe_to_bug_mail';
 
     function array_compare(a,b) {
@@ -36,10 +40,13 @@
         return true;
     }
 
-    function create_test_node() {
+    function create_test_node(include_listing) {
         return Y.Node.create(
                 '<div id="test-content">' +
                 '  <div id="' + content_box_name + '"></div>' +
+                (include_listing
+                 ? ('  <div id="' + subscription_listing_name + '"></div>')
+                 : '') +
                 '</div>');
     }
 
@@ -58,6 +65,61 @@
         return true;
     }
 
+    function monkeypatch_LP() {
+          // Monkeypatch LP to avoid network traffic and to allow
+          // insertion of test data.
+          var original_lp = window.LP
+          window.LP = {
+            links: {},
+            cache: {}
+          };
+
+          LP.cache.context = {
+            title: 'Test Project',
+            self_link: 'https://launchpad.dev/api/test_project'
+          };
+          LP.cache.administratedTeams = [];
+          LP.cache.importances = ['Unknown', 'Critical', 'High', 'Medium',
+                                  'Low', 'Wishlist', 'Undecided'];
+          LP.cache.statuses = ['New', 'Incomplete', 'Opinion',
+                               'Invalid', 'Won\'t Fix', 'Expired',
+                               'Confirmed', 'Triaged', 'In Progress',
+                               'Fix Committed', 'Fix Released', 'Unknown'];
+          LP.links.me = 'https://launchpad.dev/api/~someone';
+          return original_lp;
+    }
+
+    function LPClient(){
+        if (!(this instanceof arguments.callee))
+            throw new Error("Constructor called as a function");
+        this.received = []
+        // We create new functions every time because we allow them to be
+        // configured.
+        this.named_post = function(url, func, config) {
+            this._call('named_post', config, arguments);
+        };
+        this.patch = function(bug_filter, data, config) {
+            this._call('patch', config, arguments);
+        }
+    };
+    LPClient.prototype._call = function(name, config, args) {
+        this.received.push(
+            [name, Array.prototype.slice.call(args)]);
+        if (!Y.Lang.isValue(args.callee.args))
+            throw new Error("Set call_args on "+name);
+        if (Y.Lang.isValue(args.callee.fail) && args.callee.fail) {
+            config.on.failure.apply(undefined, args.callee.args);
+        } else {
+            config.on.success.apply(undefined, args.callee.args);
+        }
+    };
+    // DELETE uses Y.io directly as of this writing, so we cannot stub it
+    // here.
+
+    function make_lp_client_stub() {
+        return new LPClient();
+    }
+
     test_case = new Y.Test.Case({
         name: 'structural_subscription_overlay',
 
@@ -467,28 +529,11 @@
         setUp: function() {
           // Monkeypatch LP to avoid network traffic and to allow
           // insertion of test data.
-          window.LP = {
-            links: {},
-            cache: {}
-          };
-
-          LP.cache.context = {
-            title: 'Test Project',
-            self_link: 'https://launchpad.dev/api/test_project'
-          };
-          LP.cache.administratedTeams = [];
-          LP.cache.importances = ['Unknown', 'Critical', 'High', 'Medium',
-                                  'Low', 'Wishlist', 'Undecided'];
-          LP.cache.statuses = ['New', 'Incomplete', 'Opinion',
-                               'Invalid', 'Won\'t Fix', 'Expired',
-                               'Confirmed', 'Triaged', 'In Progress',
-                               'Fix Committed', 'Fix Released', 'Unknown'];
-          LP.links.me = 'https://launchpad.dev/api/~someone';
-
-          var lp_client = function() {};
+          this.original_lp = monkeypatch_LP();
+
           this.configuration = {
               content_box: content_box_id,
-              lp_client: lp_client
+              lp_client: make_lp_client_stub()
           };
 
           this.content_node = create_test_node();
@@ -496,17 +541,16 @@
         },
 
         tearDown: function() {
-          remove_test_node();
-          delete this.content_node;
+            window.LP = this.original_lp;
+            remove_test_node();
+            delete this.content_node;
         },
 
         test_overlay_error_handling_adding: function() {
             // Verify that errors generated during adding of a filter are
             // displayed to the user.
-            this.configuration.lp_client.named_post =
-                function(url, func, config) {
-                config.on.failure(true, true);
-                };
+            this.configuration.lp_client.named_post.fail = true;
+            this.configuration.lp_client.named_post.args = [true, true];
             module.setup(this.configuration);
             module._show_add_overlay(this.configuration);
             // After the setup the overlay should be in the DOM.
@@ -526,17 +570,10 @@
             // displayed to the user.
             var original_delete_filter = module._delete_filter;
             module._delete_filter = function() {};
-            this.configuration.lp_client.patch =
-                function(bug_filter, data, config) {
-                    config.on.failure(true, true);
-                };
-            var bug_filter = {
-                'getAttrs': function() { return {}; }
-            };
-            this.configuration.lp_client.named_post =
-                function(url, func, config) {
-                    config.on.success(bug_filter);
-                };
+            this.configuration.lp_client.patch.fail = true;
+            this.configuration.lp_client.patch.args = [true, true];
+            this.configuration.lp_client.named_post.args = [
+                {'getAttrs': function() { return {}; }}];
             module.setup(this.configuration);
             module._show_add_overlay(this.configuration);
             // After the setup the overlay should be in the DOM.
@@ -794,6 +831,127 @@
 
     }));
 
+    suite.add(new Y.Test.Case({
+        name: 'Structural Subscription mute team subscriptions',
+
+        // Verify that the mute controls and labels on the edit block
+        // render and interact properly
+
+        _should: {
+            error: {
+                }
+            },
+
+        setUp: function() {
+            // Monkeypatch LP to avoid network traffic and to allow
+            // insertion of test data.
+            this.original_lp = monkeypatch_LP();
+            this.test_node = create_test_node(true);
+            Y.one('body').appendChild(this.test_node);
+            this.lp_client = make_lp_client_stub();
+            LP.cache.subscription_info = [
+                {target_url: 'http://example.com',
+                 target_title:'Example project',
+                 filters: [
+                    {filter: {
+                        statuses: [],
+                        importances: [],
+                        tags: [],
+                        find_all_tags: true,
+                        bug_notification_level: 'Discussion',
+                        self_link: 'http://example.com/a_filter'
+                        },
+                    can_mute: true,
+                    is_muted: false,
+                    subscriber_is_team: true,
+                    subscriber_url: 'http://example.com/subscriber',
+                    subscriber_title: 'Thidwick',
+                    user_is_team_admin: false,
+                    }
+                    ]
+                }
+                ]
+        },
+
+        tearDown: function() {
+            remove_test_node();
+            window.LP = this.original_lp;
+        },
+
+        test_not_muted_rendering: function() {
+            module.setup_bug_subscriptions(
+                {content_box: content_box_id,
+                 lp_client: this.lp_client});
+            var listing = this.test_node.one(subscription_listing_id);
+            var filter_node = listing.one('#subscription-filter-0');
+            Assert.isNotNull(filter_node);
+            var mute_label_node = filter_node.one('.mute-label');
+            Assert.isNotNull(mute_label_node);
+            Assert.areEqual(mute_label_node.getStyle('display'), 'none');
+            var mute_link = filter_node.one('a.mute-subscription');
+            Assert.isNotNull(mute_link);
+            Assert.isTrue(mute_link.hasClass('no'));
+        },
+
+        test_muted_rendering: function() {
+            LP.cache.subscription_info[0].filters[0].is_muted = true;
+            module.setup_bug_subscriptions(
+                {content_box: content_box_id,
+                 lp_client: this.lp_client});
+            var listing = this.test_node.one(subscription_listing_id);
+            var filter_node = listing.one('#subscription-filter-0');
+            Assert.isNotNull(filter_node);
+            var mute_label_node = filter_node.one('.mute-label');
+            Assert.isNotNull(mute_label_node);
+            Assert.areEqual(mute_label_node.getStyle('display'), 'inline');
+            var mute_link = filter_node.one('a.mute-subscription');
+            Assert.isNotNull(mute_link);
+            Assert.isTrue(mute_link.hasClass('yes'));
+        },
+
+        test_not_muted_toggle_muted: function() {
+            module.setup_bug_subscriptions(
+                {content_box: content_box_id,
+                 lp_client: this.lp_client});
+            var listing = this.test_node.one(subscription_listing_id);
+            var filter_node = listing.one('#subscription-filter-0');
+            var mute_label_node = filter_node.one('.mute-label');
+            var mute_link = filter_node.one('a.mute-subscription');
+            this.lp_client.named_post.args = []
+            Y.Event.simulate(Y.Node.getDOMNode(mute_link), 'click');
+            Assert.areEqual(this.lp_client.received[0][0], 'named_post');
+            Assert.areEqual(
+                this.lp_client.received[0][1][0],
+                'http://example.com/a_filter');
+            Assert.areEqual(
+                this.lp_client.received[0][1][1], 'mute');
+            Assert.areEqual(mute_label_node.getStyle('display'), 'inline');
+            Assert.isTrue(mute_link.hasClass('yes'));
+        },
+
+        test_muted_toggle_not_muted: function() {
+            LP.cache.subscription_info[0].filters[0].is_muted = true;
+            module.setup_bug_subscriptions(
+                {content_box: content_box_id,
+                 lp_client: this.lp_client});
+            var listing = this.test_node.one(subscription_listing_id);
+            var filter_node = listing.one('#subscription-filter-0');
+            var mute_label_node = filter_node.one('.mute-label');
+            var mute_link = filter_node.one('a.mute-subscription');
+            this.lp_client.named_post.args = []
+            Y.Event.simulate(Y.Node.getDOMNode(mute_link), 'click');
+            Assert.areEqual(this.lp_client.received[0][0], 'named_post');
+            Assert.areEqual(
+                this.lp_client.received[0][1][0],
+                'http://example.com/a_filter');
+            Assert.areEqual(
+                this.lp_client.received[0][1][1], 'unmute');
+            Assert.areEqual(mute_label_node.getStyle('display'), 'none');
+            Assert.isTrue(mute_link.hasClass('no'));
+        }
+
+    }));
+
     // Lock, stock, and two smoking barrels.
     var handle_complete = function(data) {
         var status_node = Y.Node.create(