← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/bug-772754-other-subscribers-subscribers into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/bug-772754-other-subscribers-subscribers into lp:launchpad with lp:~danilo/launchpad/bug-772754-other-subscribers-lp-names as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #772754 in Launchpad itself: "After better-bug-notification changes, list of bug subscribers is confusing"
  https://bugs.launchpad.net/launchpad/+bug/772754

For more details, see:
https://code.launchpad.net/~danilo/launchpad/bug-772754-other-subscribers-subscribers/+merge/64179

= Bug 772754: Other subscribers list, part 3 =

This is part of ongoing work for providing the "other subscribers" list as indicated in mockup https://launchpadlibrarian.net/71552495/all-in-one.png attached to bug 772754 by Gary.

This branch continues on the previous branches to provide methods to add/remove subscribers and add an unsubscribe action for a subscriber when needed.

It is comprehensively tested.

== Tests ==

lp/bugs/javascript/tests/test_subscribers_list.html

== Demo and Q/A ==

N/A

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/javascript/subscribers_list.js
  lib/lp/bugs/javascript/tests/test_subscribers_list.html
  lib/lp/bugs/javascript/tests/test_subscribers_list.js
-- 
https://code.launchpad.net/~danilo/launchpad/bug-772754-other-subscribers-subscribers/+merge/64179
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/bug-772754-other-subscribers-subscribers into lp:launchpad.
=== modified file 'lib/lp/bugs/javascript/subscribers_list.js'
--- lib/lp/bugs/javascript/subscribers_list.js	2011-06-10 13:23:27 +0000
+++ lib/lp/bugs/javascript/subscribers_list.js	2011-06-10 13:23:28 +0000
@@ -81,7 +81,9 @@
     section : 'subscribers-section',
     list: 'subscribers-list',
     subscriber: 'subscriber',
-    no_subscribers: 'no-subscribers-indicator'
+    no_subscribers: 'no-subscribers-indicator',
+    actions: 'subscriber-actions',
+    unsubscribe: 'unsubscribe-action'
 };
 
 /**
@@ -102,6 +104,20 @@
 
 
 /**
+ * Checks if the subscription level is one of the acceptable ones.
+ * Throws an error if not, otherwise returns true.
+ */
+function checkSubscriptionLevel(level) {
+    if (!subscriber_levels.hasOwnProperty(level)) {
+        Y.error(
+            'Level "' + level + '" is not an acceptable subscription level.');
+    }
+    return true;
+}
+
+
+
+/**
  * Manages entire subscribers' list for a single bug.
  *
  * If the passed in container_box is not present, or if there are multiple
@@ -286,4 +302,245 @@
     }
 };
 
-}, "0.1", {"requires": ["node", "lazr.anim", "lp.client"]});
+/**
+ * Get a string value usable as the ID for the node based on
+ * the subscriber name.
+ */
+SubscribersList.prototype._getNodeIdForSubscriberName = function(name) {
+    return CSS_CLASSES.subscriber + '-' + Y.lp.names.launchpad_to_css(name);
+};
+
+/**
+ * Validate and sanitize a subscriber object.
+ * It must have at least a `name` attribute.
+ * If `display_name` is not set, the value from `name` is used instead.
+ *
+ * @method _validateSubscriber
+ * @param subscriber {Object} Object containing `name`, `display_name`,
+ *    `web_link` and `is_team` indicator for the subscriber.
+ *    If `display_name` is undefined, sets it to the same value as `name`.
+ *    If `web_link` is not set, sets it to "/~name".
+ * @return {Object} Modified `subscriber` object.
+ */
+SubscribersList.prototype._validateSubscriber = function(subscriber) {
+    if (!Y.Lang.isString(subscriber.name)) {
+        Y.error('No `name` passed in `subscriber`.');
+    }
+    if (!Y.Lang.isString(subscriber.display_name)) {
+        // Default to `name` for display_name.
+        subscriber.display_name = subscriber.name;
+    }
+    if (!Y.Lang.isString(subscriber.web_link)) {
+        // Default to `/~name` for web_link.
+        subscriber.web_link = '/~' + subscriber.name;
+    }
+    return subscriber;
+};
+
+/**
+ * Creates and returns a node for the `subscriber`.
+ *
+ * It makes a link using subscriber.display_name as the link text,
+ * and linking to /~`subscriber.name`.
+ * Everything is wrapped in a div.subscriber node.
+ *
+ * @method _createSubscriberNode
+ * @param subscriber {Object} Object containing `name`, `display_name`
+ *    `web_link` and `is_team` attributes.
+ * @return {Object} Node containing a subscriber link.
+ */
+SubscribersList.prototype._createSubscriberNode = function(subscriber) {
+    var subscriber_node = Y.Node.create('<div></div>')
+        .addClass(CSS_CLASSES.subscriber);
+
+    var subscriber_link = Y.Node.create('<a></a>');
+    subscriber_link.set('href', subscriber.web_link);
+
+    var subscriber_text = Y.Node.create('<span></span>')
+        .addClass('sprite')
+        .set('text', subscriber.display_name);
+    if (subscriber.is_team === true) {
+        subscriber_text.addClass('team');
+    } else {
+        subscriber_text.addClass('person');
+    }
+    subscriber_link.appendChild(subscriber_text);
+
+    subscriber_node.appendChild(subscriber_link);
+    return subscriber_node;
+};
+
+/**
+ * Add or change a subscriber in the subscribers list.
+ *
+ * If subscriber is already in the list and in a different subscription
+ * level section, it is moved to the appropriate section indicated by
+ * `level` parameter.  If subscriber is already in the list and subscribed
+ * at the same level, nothing happens.
+ *
+ * @method addSubscriber
+ * @param subscriber {Object} Object containing `name`, `display_name`
+ *    `web_link` and `is_team` attributes describing the subscriber.
+ * @param level {String} Level of the subscription.
+ * @param config {Object} Object containing potential 'unsubscribe' callback
+ *     in the `unsubscribe_callback` attribute.
+ */
+SubscribersList.prototype.addSubscriber = function(subscriber, level,
+                                                   config) {
+    checkSubscriptionLevel(level);
+    subscriber = this._validateSubscriber(subscriber);
+
+    var section_node = this._getOrCreateSection(level);
+    var list_node = section_node.one('.' + CSS_CLASSES.list);
+
+    var subscriber_id = this._getNodeIdForSubscriberName(subscriber.name);
+    var subscriber_node = this.container_node.one('#' + subscriber_id);
+
+    if (subscriber_node === null) {
+        subscriber_node = this._createSubscriberNode(subscriber);
+        subscriber_node.set('id', subscriber_id);
+        // Insert the subscriber at the start of the list.
+        list_node.prepend(subscriber_node);
+        // Add the unsubscribe action if needed.
+        if (Y.Lang.isValue(config) &&
+            Y.Lang.isFunction(config.unsubscribe_callback)) {
+            this.addUnsubscribeAction(
+                subscriber, config.unsubscribe_callback);
+        }
+    } else {
+        // Move the subscriber node from the existing section to the new one.
+        var existing_section = subscriber_node.ancestor(
+            '.' + CSS_CLASSES.section);
+        if (existing_section === null) {
+            Y.error("Matching subscriber node doesn't seem to be in any " +
+                    "subscribers list sections.");
+        }
+        if (existing_section !== section_node) {
+            // We do not destroy the node so we can insert it into
+            // the appropriate position.
+            subscriber_node.remove();
+            this._removeSectionNodeIfEmpty(existing_section);
+            // Insert the subscriber at the start of the list.
+            list_node.prepend(subscriber_node);
+        }
+        // else:
+        //   Subscriber is already there in the same section. A no-op.
+    }
+
+    return subscriber_node;
+};
+
+/**
+ * Get a subscriber node for the passed in subscriber.
+ *
+ * If subscriber is not in the list already, it fails with an exception.
+ *
+ * @method _getSubscriberNode
+ * @param subscriber {Object} Object containing at least `name`
+ *     for the subscriber.
+ */
+SubscribersList.prototype._getSubscriberNode = function(subscriber) {
+    subscriber = this._validateSubscriber(subscriber);
+
+    var subscriber_id = this._getNodeIdForSubscriberName(subscriber.name);
+    var subscriber_node = this.container_node.one('#' + subscriber_id);
+
+    if (subscriber_node === null) {
+        Y.error('Subscriber is not present in the subscribers list. ' +
+                'Please call addSubscriber(subscriber) first.');
+    }
+    return subscriber_node;
+};
+
+/**
+ * Create a subscriber actions node to hold actions like unsubscribe.
+ * If the node already exists, returns it instead.
+ *
+ * @method _getOrCreateActionsNode
+ * @param subscriber_node {Object} Node for a particular subscriber.
+ * @return {Object} A node suitable for putting subscriber actions in.
+ */
+SubscribersList.prototype._getOrCreateActionsNode = function(subscriber_node)
+{
+    var actions_node = subscriber_node.one('.' + CSS_CLASSES.actions);
+    if (actions_node === null) {
+        // Create a node to hold all the actions.
+        actions_node = Y.Node.create('<span></span>')
+            .addClass(CSS_CLASSES.actions)
+            .setStyle('float', 'right');
+        subscriber_node.appendChild(actions_node);
+    }
+    return actions_node;
+};
+
+/**
+ * Adds an unsubscribe action for the subscriber.
+ *
+ * It creates a separate actions node which will hold any actions
+ * (including unsubscribe one), and creates a "remove" link with the
+ * on.click action set to call `callback` function with subscriber
+ * passed in as the parameter.
+ *
+ * If `subscriber` does not have at least the `name` attribute,
+ * an exception is thrown.
+ * If `callback` is not a function, it throws an exception.
+ *
+ * @method addUnsubscribeAction
+ * @param subscriber {Object} Object containing `name`, `display_name`
+ *    `web_link` and `is_team` attributes describing the subscriber.
+ * @param callback {Function} Function to call on clicking the unsubscribe
+ *     button.  It will be passed `this` (a SubscribersList) as the first,
+ *     and `subscriber` as the second parameter.
+ */
+SubscribersList.prototype.addUnsubscribeAction = function(subscriber,
+                                                          callback) {
+    subscriber = this._validateSubscriber(subscriber);
+    if (!Y.Lang.isFunction(callback)) {
+        Y.error('Passed in callback for unsubscribe action ' +
+                'is not a function.');
+    }
+    var subscriber_node = this._getSubscriberNode(subscriber);
+    var actions_node = this._getOrCreateActionsNode(subscriber_node);
+    var unsubscribe_node = actions_node.one('.' + CSS_CLASSES.unsubscribe);
+    if (unsubscribe_node === null) {
+        unsubscribe_node = Y.Node.create('<a></a>')
+            .addClass(CSS_CLASSES.unsubscribe)
+            .set('href', '+subscribe')
+            .set('title', 'Unsubscribe ' + subscriber.display_name);
+        unsubscribe_node.appendChild(
+            Y.Node.create('<img></img>')
+                .set('src', '/@@/remove')
+                .set('alt', 'Remove'));
+        var subscriber_list = this;
+        unsubscribe_node.on('click', function(e) {
+            e.halt();
+            callback(subscriber_list, subscriber);
+        });
+        actions_node.appendChild(unsubscribe_node);
+    }
+};
+
+/**
+ * Remove a subscriber node for the `subscriber`.
+ *
+ * If subscriber is not in the list already, it fails with an exception.
+ *
+ * @method removeSubscriber
+ * @param subscriber {Object} Object containing at least `name`
+ *     for the subscriber.
+ */
+SubscribersList.prototype.removeSubscriber = function(subscriber) {
+    subscriber = this._validateSubscriber(subscriber);
+    var subscriber_node = this._getSubscriberNode(subscriber);
+    var existing_section = subscriber_node.ancestor(
+        '.' + CSS_CLASSES.section);
+    subscriber_node.remove(true);
+    if (existing_section === null) {
+        Y.error("Matching subscriber node doesn't seem to be in any " +
+                "subscribers list sections.");
+    }
+    this._removeSectionNodeIfEmpty(existing_section);
+};
+
+
+}, "0.1", {"requires": ["node", "lazr.anim", "lp.client", "lp.names"]});

=== modified file 'lib/lp/bugs/javascript/tests/test_subscribers_list.html'
--- lib/lp/bugs/javascript/tests/test_subscribers_list.html	2011-04-28 13:32:29 +0000
+++ lib/lp/bugs/javascript/tests/test_subscribers_list.html	2011-06-10 13:23:28 +0000
@@ -20,6 +20,8 @@
       src="../../../app/javascript/client.js"></script>
     <script type="text/javascript"
       src="../../../app/javascript/errors.js"></script>
+    <script type="text/javascript"
+      src="../../../app/javascript/lp-names.js"></script>
 
     <!-- Pre-requisite -->
     <script type="text/javascript"

=== modified file 'lib/lp/bugs/javascript/tests/test_subscribers_list.js'
--- lib/lp/bugs/javascript/tests/test_subscribers_list.js	2011-06-10 13:23:27 +0000
+++ lib/lp/bugs/javascript/tests/test_subscribers_list.js	2011-06-10 13:23:28 +0000
@@ -816,6 +816,426 @@
 }));
 
 
+
+/**
+ * Test adding of subscribers and relevant helper methods.
+ */
+suite.add(new Y.Test.Case({
+    name: 'SubscribersList.addSubscriber() test',
+
+    setUp: function() {
+        this.root = Y.Node.create('<div></div>');
+        Y.one('body').appendChild(this.root);
+    },
+
+    tearDown: function() {
+        this.root.remove();
+    },
+
+    _should: {
+        error: {
+            test_validateSubscriber_no_name_error:
+            new Error('No `name` passed in `subscriber`.'),
+            test_addSubscriber_incorrect_level:
+            new Error(
+                'Level "Test" is not an acceptable subscription level.'),
+            test_addSubscriber_not_in_section_error:
+            new Error(
+                "Matching subscriber node doesn't seem to be in any " +
+                    "subscribers list sections.")
+        }
+    },
+
+    test_getNodeIdForSubscriberName: function() {
+        // Returns a CSS class name to use as the ID for subscribers
+        // prefixed with 'subscriber-'.
+        // Uses launchpad_to_css for escaping (eg. it replaces '+' with '_y').
+        var subscribers_list = setUpSubscribersList(this.root);
+        Y.Assert.areEqual(
+            'subscriber-danilo_y',
+            subscribers_list._getNodeIdForSubscriberName('danilo+'));
+    },
+
+    test_validateSubscriber: function() {
+        // Ensures a passed in subscriber object has at least the
+        // `name` attribute.  Presets display_name and web_link
+        // values based on it.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        subscriber = subscribers_list._validateSubscriber(subscriber);
+        Y.Assert.areEqual('user', subscriber.name);
+        Y.Assert.areEqual('user', subscriber.display_name);
+        Y.Assert.areEqual('/~user', subscriber.web_link);
+    },
+
+    test_validateSubscriber_no_name_error: function() {
+        // When no name attribute is present, an exception is thrown.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { };
+        subscribers_list._validateSubscriber(subscriber);
+    },
+
+    test_validateSubscriber_no_overriding: function() {
+        // Attributes display_name and web_link are not overridden if
+        // already set.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = {
+            name: 'user',
+            display_name: 'User Name',
+            web_link: 'http://launchpad.net/'
+        };
+        subscriber = subscribers_list._validateSubscriber(subscriber);
+        Y.Assert.areEqual('user', subscriber.name);
+        Y.Assert.areEqual('User Name', subscriber.display_name);
+        Y.Assert.areEqual('http://launchpad.net/', subscriber.web_link);
+    },
+
+    test_createSubscriberNode: function() {
+        // When passed a subscriber object, it constructs a node
+        // containing a link to the subscriber (using web_link for the
+        // link target, and display name for the text).
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = {
+            name: 'user',
+            display_name: 'User Name',
+            web_link: 'http://launchpad.net/~user'
+        };
+        var node = subscribers_list._createSubscriberNode(subscriber);
+        Y.Assert.isTrue(node.hasClass('subscriber'));
+
+        var link = node.one('a');
+        Y.Assert.areEqual('http://launchpad.net/~user', link.get('href'));
+
+        var text = link.one('span');
+        Y.Assert.areEqual('User Name', text.get('text'));
+        Y.Assert.isTrue(text.hasClass('sprite'));
+        Y.Assert.isTrue(text.hasClass('person'));
+    },
+
+    test_createSubscriberNode_team: function() {
+        // When passed a subscriber object which has is_team === true,
+        // a constructed node uses a 'sprite team' CSS classes instead
+        // of 'sprite person' for display.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = {
+            name: 'team',
+            display_name: 'Team Name',
+            web_link: 'http://launchpad.net/~team',
+            is_team: true
+        };
+        var node = subscribers_list._createSubscriberNode(subscriber);
+        var link_text = node.one('a span');
+        Y.Assert.isTrue(link_text.hasClass('sprite'));
+        Y.Assert.isTrue(link_text.hasClass('team'));
+    },
+
+    test_addSubscriber: function() {
+        // When there is no subscriber in the subscriber list,
+        // new node is constructed and appropriate section is added.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var node = subscribers_list.addSubscriber(
+            { name: 'user' }, 'Details');
+
+        // Node is constructed using _createSubscriberNode.
+        Y.Assert.isTrue(node.hasClass('subscriber'));
+        // And the ID is set inside addSubscriber() method.
+        Y.Assert.areEqual('subscriber-user', node.get('id'));
+
+        // And it nested in the subscribers-list of a 'Details' section.
+        var list_node = node.ancestor('.subscribers-list');
+        Y.Assert.isNotNull(list_node);
+        var section_node = list_node.ancestor('.subscribers-section-details');
+        Y.Assert.isNotNull(section_node);
+    },
+
+    test_addSubscriber_incorrect_level: function() {
+        // When an incorrect level is passed in, an exception is thrown.
+        var subscribers_list = setUpSubscribersList(this.root);
+        subscribers_list.addSubscriber(
+            { name: 'user' }, 'Test');
+    },
+
+    test_addSubscriber_change_level: function() {
+        // addSubscriber also allows changing a subscribtion level
+        // for a subscriber when they are moved to a different section.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var node = subscribers_list.addSubscriber(
+            { name: 'user' }, 'Details');
+        Y.Assert.isNotNull(node.ancestor('.subscribers-section-details'));
+
+        // Move the subscriber to lifecycle section.
+        node = subscribers_list.addSubscriber(
+            { name: 'user' }, 'Lifecycle');
+        // It's now in 'Lifecycle' section.
+        Y.Assert.isNotNull(node.ancestor('.subscribers-section-lifecycle'));
+        // And 'Details' section is removed.
+        Y.Assert.isNull(subscribers_list._getSection('Details'));
+    },
+
+    test_addSubscriber_not_in_section_error: function() {
+        // addSubscriber throws an exception if a subscriber node is found,
+        // but it is not properly nested inside a subscribers-section node.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var node = Y.Node.create('<div></div>')
+            .set('id', 'subscriber-user');
+        // We hack the node directly into the entire subscribers list node.
+        subscribers_list.container_node.appendChild(node);
+
+        // And addSubscriber now throws an exception.
+        subscribers_list.addSubscriber(
+            { name: 'user' }, 'Details');
+    },
+
+    test_addSubscriber_ordering: function() {
+        // With multiple subscribers being added to the same section,
+        // the last one is listed first.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var node1 = subscribers_list.addSubscriber(
+            { name: 'user1' }, 'Details');
+        var node2 = subscribers_list.addSubscriber(
+            { name: 'user2' }, 'Details');
+
+        var list_node = subscribers_list._getSection('Details')
+            .one('.subscribers-list');
+        var all_subscribers = list_node.all('.subscriber');
+
+        var returned_nodes = [];
+        var index;
+        for (index = 0; index < all_subscribers.size(); index++) {
+            returned_nodes.push(all_subscribers.item(index));
+        }
+        Y.ArrayAssert.itemsAreSame(
+            [node2, node1],
+            returned_nodes);
+    },
+
+    test_addSubscriber_unsubscribe_callback: function() {
+        // When config.unsubscribe_callback is passed in,
+        // addUnsubscribeAction(subscriber, callback) is
+        // called as well.
+
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        var callback = function() {};
+
+        var callback_setup = false;
+        subscribers_list.addUnsubscribeAction = function(
+            unsub_subscriber, unsub_callback) {
+            callback_setup = true;
+            Y.Assert.areSame(subscriber, unsub_subscriber);
+            Y.Assert.areSame(callback, unsub_callback);
+        };
+        subscribers_list.addSubscriber(subscriber, 'Details',
+                                       { unsubscribe_callback: callback });
+        // Setting up a callback was performed.
+        Y.Assert.isTrue(callback_setup);
+    }
+
+}));
+
+
+/**
+ * Test adding of unsubscribe action for a subscriber, removal of subscribers
+ * and relevant helper methods.
+ */
+suite.add(new Y.Test.Case({
+    name: 'SubscribersList.addUnsubscribeAction() and ' +
+        'removeSubscriber() test',
+
+    setUp: function() {
+        this.root = Y.Node.create('<div></div>');
+        Y.one('body').appendChild(this.root);
+    },
+
+    tearDown: function() {
+        this.root.remove();
+    },
+
+    _should: {
+        error: {
+            test_getSubscriberNode_error:
+            new Error('Subscriber is not present in the subscribers list. ' +
+                      'Please call addSubscriber(subscriber) first.'),
+            test_addUnsubscribeAction_error:
+            new Error('Passed in callback for unsubscribe action ' +
+                      'is not a function.'),
+            test_removeSubscriber_error:
+            new Error(
+                'Subscriber is not present in the subscribers list. ' +
+                    'Please call addSubscriber(subscriber) first.'),
+            test_removeSubscriber_not_in_section_error:
+            new Error(
+                "Matching subscriber node doesn't seem to be in any " +
+                    "subscribers list sections.")
+        }
+    },
+
+    test_getSubscriberNode: function() {
+        // Gets a subscriber node from the subscribers list when present.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        var node = subscribers_list.addSubscriber(subscriber, 'Details');
+        Y.Assert.areSame(
+            node, subscribers_list._getSubscriberNode(subscriber));
+    },
+
+    test_getSubscriberNode_error: function() {
+        // When subscriber node is not present, throws an error.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        subscribers_list._getSubscriberNode(subscriber);
+    },
+
+    test_getOrCreateActionsNode: function() {
+        // When no actions node is present, one is created, appended
+        // to the subscriber node, and returned.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber_node = subscribers_list.addSubscriber(
+            { name: 'user' }, "Discussion");
+        var actions_node = subscribers_list._getOrCreateActionsNode(
+            subscriber_node);
+        // Newly created node has 'subscriber-actions' CSS class.
+        Y.Assert.isTrue(actions_node.hasClass('subscriber-actions'));
+
+        // It is also nested inside the subscriber_node.
+        Y.Assert.areSame(subscriber_node, actions_node.get('parentNode'));
+    },
+
+    test_getOrCreateActionsNode_already_exists: function() {
+        // When actions node is present, it is returned.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber_node = subscribers_list.addSubscriber(
+            { name: 'user' }, "Discussion");
+        var old_actions_node = subscribers_list._getOrCreateActionsNode(
+            subscriber_node);
+        var new_actions_node = subscribers_list._getOrCreateActionsNode(
+            subscriber_node);
+        Y.Assert.areSame(old_actions_node, new_actions_node);
+    },
+
+    test_addUnsubscribeAction_node: function() {
+        // Adding an unsubscribe action creates an unsubscribe icon
+        // nested inside the actions node for the subscriber.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user', display_name: 'User Name' };
+        var callback = function() {};
+
+        var subscriber_node = subscribers_list.addSubscriber(
+            subscriber, "Discussion");
+        subscribers_list.addUnsubscribeAction(subscriber, callback);
+        // An actions node is created.
+        var actions_node = subscriber_node.one('.subscriber-actions');
+        Y.Assert.isNotNull(actions_node);
+        // It contains an A tag for the unsubscribe action.
+        var unsub_node = actions_node.one('a.unsubscribe-action');
+        Y.Assert.isNotNull(unsub_node);
+        Y.Assert.areEqual('Unsubscribe User Name', unsub_node.get('title'));
+        var unsub_icon = unsub_node.one('img');
+        Y.Assert.isNotNull(unsub_icon);
+        Y.Assert.areEqual('Remove', unsub_icon.get('alt'));
+        // Getting a URI returns an absolute one, and with this being run
+        // from the local file system, that's what we get.
+        Y.Assert.areEqual('file:///@@/remove', unsub_icon.get('src'));
+    },
+
+    test_addUnsubscribeAction_node_exists: function() {
+        // When an unsubscribe node already exists, a new one is not created.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user', display_name: 'User Name' };
+        var callback = function() {};
+        var subscriber_node = subscribers_list.addSubscriber(
+            subscriber, "Discussion");
+        subscribers_list.addUnsubscribeAction(subscriber, callback);
+        var unsub_node = subscriber_node.one('a.unsubscribe-action');
+
+        subscribers_list.addUnsubscribeAction(subscriber, callback);
+        var unsub_nodes = subscriber_node.all('a.unsubscribe-action');
+        Y.Assert.areEqual(1, unsub_nodes.size());
+        Y.Assert.areSame(unsub_node, unsub_nodes.item(0));
+    },
+
+    test_addUnsubscribeAction_error: function() {
+        // Adding an unsubscribe action with callback not a function
+        // fails with an exception.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        var subscriber_node = subscribers_list.addSubscriber(
+            subscriber, "Discussion");
+        subscribers_list.addUnsubscribeAction(subscriber, "not-function");
+    },
+
+    test_addUnsubscribeAction_callback_on_click: function() {
+        // When unsubscribe link is clicked, callback is activated
+        // and passed in the subscribers_list and subscriber parameters.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user', display_name: 'User Name' };
+
+        var callback_called = false;
+        var callback = function(my_list, my_subscriber) {
+            callback_called = true;
+            Y.Assert.areSame(subscribers_list, my_list);
+            Y.Assert.areSame(subscriber, my_subscriber);
+        };
+        var subscriber_node = subscribers_list.addSubscriber(
+            subscriber, "Discussion");
+        subscribers_list.addUnsubscribeAction(subscriber, callback);
+        var unsub_node = subscriber_node.one('a.unsubscribe-action');
+        unsub_node.simulate('click');
+
+        Y.Assert.isTrue(callback_called);
+    },
+
+    test_removeSubscriber_error: function() {
+        // Removing a non-existent subscriber fails with an error.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        subscribers_list.removeSubscriber(subscriber);
+    },
+
+    test_removeSubscriber_section_removed: function() {
+        // Removing a subscriber works when the subscriber is in the list.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        var subscriber_node = subscribers_list.addSubscriber(
+            subscriber, 'Details');
+        var section_node = subscriber_node.ancestor('.subscribers-section');
+        subscribers_list.removeSubscriber(subscriber);
+        // Entire section is removed along with the subscriber.
+        Y.Assert.areEqual(0, _getAllSections(subscribers_list).length);
+    },
+
+    test_removeSubscriber_section_remains: function() {
+        // Removing a subscriber works when the subscriber is in the list.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var subscriber = { name: 'user' };
+        var other_node = subscribers_list.addSubscriber(
+            { name: 'other' }, 'Details');
+        var subscriber_node = subscribers_list.addSubscriber(
+            subscriber, 'Details');
+        var section_node = subscriber_node.ancestor('.subscribers-section');
+        subscribers_list.removeSubscriber(subscriber);
+        // Section is not removed because it still has 'other' subscriber.
+        var all_sections = _getAllSections(subscribers_list);
+        Y.Assert.areEqual(1, all_sections.length);
+        // User is removed.
+        Y.Assert.isNull(all_sections[0].one('#subscriber-user'));
+        // Other is still in the list.
+        Y.Assert.areSame(
+            other_node, all_sections[0].one('#subscriber-other'));
+    },
+
+    test_removeSubscriber_not_in_section_error: function() {
+        // If subscriber is not in a section, an exception is thrown.
+        var subscribers_list = setUpSubscribersList(this.root);
+        var node = Y.Node.create('<div></div>')
+            .set('id', 'subscriber-user');
+        // We hack the node directly into the entire subscribers list node.
+        subscribers_list.container_node.appendChild(node);
+        subscribers_list.removeSubscriber({ name: 'user' });
+    }
+}));
+
+
 var handle_complete = function(data) {
     status_node = Y.Node.create(
         '<p id="complete">Test status: complete</p>');