launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #03986
[Merge] lp:~wallyworld/launchpad/blueprint-subscriptions into lp:launchpad
Ian Booth has proposed merging lp:~wallyworld/launchpad/blueprint-subscriptions into lp:launchpad with lp:~wallyworld/launchpad/refactor-bugs-subscriber-javascript as a prerequisite.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/blueprint-subscriptions/+merge/64010
The is branch #4 in a series of changes to improve blueprint subscription functionality. It builds on the work done previously which:
- exposes blueprint subscribe/unsubscribe methods to the web service
- refactors the blueprint tales to allow snippets of subscription data to be accessible to the ajax client
- refactors some bugs javascript to allow sharing of code with this branch
This branch adds support for subscribing and unsubscribing from blueprints using ajax, including solving bug 50875 - "it is not possible to unsubscribe teams from blueprints."
== Implementation ==
The previous work means this branch can be entirely javascript. The implementation is based on the work done for the bugs subscriber portlet. However it is simpler since there's no direct vs indirect subscriptions. Plus there's significant changes compared to the bugs implementation. This branch delivers:
- ajax support for subscribing oneself or another person/team to the blueprint
- ajax support to unsubscribe a person/team/oneself via clicking the "Remove" icon next to the person in the subscribers list
- legacy support for editing a subscrription
This branch delivers usable functionality, but there's still work to do though as detailed below.
When subscribing to a blueprint, the subscription can be marked as "essential". This is supported in the following way:
- the initial ajax subscription will be non essential
- once subscribed, the subscription can be edited (via a link to a separate page) to turn the essential flag on/off as required.
The editing of the subscription is as per current behaviour.
The next branch will provide ajax form support for creating and maintaining subscriptions, removing the need for the old style html forms.
Because this branch was based on the work done for bug subscriptions, it has picked up the some of the same issues. The method for determining whether a user can unsubscribe another user/team is virtually unchanged from the bugs implementation. This code is buggy and it is possible in some limited circumstances a user will incorrectly be told they cannot unsubscribe someone when they should be able to. This occurs when the number of users in a team is greater than 500 and the current user is not in the first batch. This issue is currently being fixed for the bugs case. I will port across the fix once done.
Because of the above bug, and the fact that another branch or two or three will be required to finish the work, I've put this new ajax functionality behind a feature flag - disclosure.enhanced_blueprint_subscriptions.enabled
== Screenshot ==
http://people.canonical.com/~ianb/blueprint-subscription-portal.png
The screenshot shows how after subscribing oneself using the ajax link, a blue Edit Subscription link is provided to allow the subscription to be edited. It also shows the Remove icon for the subscribed user.
== Tests ==
The current tests for editing subscriptions via html forms were run. Additional javascript tests were written (test_subscription_portlet.js) to test aspects of the ajax behaviour:
- setting up the portlet infarstructure
- subscribing the current user
- unsubscribing the current user
- editing a subscription
It is not feasible to easily write javascript tests for the 'subscribe someone else' functionality. This uses a picker and the necessary internals to stub out the io calls are not exposed. The equivalent bugs tests are done in Windmill. Given the javascript and other aspects will be changing in the next branch, I propose delaying the completion of full test coverage until subsequent branches are delivered.
== Lint ==
jslint: No problem found in '/home/ian/projects/lp-branches/devel-sandbox/lib/lp/blueprints/javascript/blueprint_index.js'.
jslint: No problem found in '/home/ian/projects/lp-branches/devel-sandbox/lib/lp/blueprints/javascript/blueprint_index_portlets.js'.
jslint: No problem found in '/home/ian/projects/lp-branches/devel-sandbox/lib/lp/blueprints/javascript/tests/test_subscription_portlet.js'.
--
https://code.launchpad.net/~wallyworld/launchpad/blueprint-subscriptions/+merge/64010
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/blueprint-subscriptions into lp:launchpad.
=== added directory 'lib/lp/blueprints/javascript'
=== added file 'lib/lp/blueprints/javascript/blueprint_index.js'
--- lib/lp/blueprints/javascript/blueprint_index.js 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/javascript/blueprint_index.js 2011-06-16 02:58:38 +0000
@@ -0,0 +1,20 @@
+/* Copyright 2011 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Form overlay widgets and subscriber handling for blueprint pages.
+ *
+ * @module blueprints
+ * @submodule blueprint_index
+ */
+
+YUI.add('lp.blueprints.blueprint_index', function(Y) {
+
+var namespace = Y.namespace('lp.blueprints.blueprint_index');
+
+namespace.setup_blueprint_index = function() {
+ // Register the YUI event handlers to respond to events generated when
+ // loading the subscription portlet.
+ Y.lp.blueprints.blueprint_index.portlets.setup_portlet_handlers();
+};
+
+}, "0.1", {"requires": ["base", "lp.blueprints.blueprint_index.portlets"]});
=== added file 'lib/lp/blueprints/javascript/blueprint_index_portlets.js'
--- lib/lp/blueprints/javascript/blueprint_index_portlets.js 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/javascript/blueprint_index_portlets.js 2011-06-16 02:58:38 +0000
@@ -0,0 +1,853 @@
+/* Copyright 2011 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Form overlay widgets and subscriber handling for blueprint pages.
+ *
+ * @module blueprints
+ * @submodule blueprint_index.portlets
+ */
+
+YUI.add('lp.blueprints.blueprint_index.portlets', function(Y) {
+
+var namespace = Y.namespace('lp.blueprints.blueprint_index.portlets');
+
+// The IO module to use.
+var YIO = Y;
+
+// The launchpad js client used.
+var lp_client;
+
+// The launchpad client entry for the current blueprint.
+var lp_blueprint_entry;
+
+// The blueprint itself, taken from cache.
+var blueprint_repr;
+
+var subscription_labels = Y.lp.app.subscriber.subscription_labels;
+
+// The set of subscriber CSS IDs as a JSON struct.
+var subscriber_ids;
+
+// We need to reset the onclick handler for the subscribe link until the
+// edit subscription overlays are done. So we need to keep a reference to the
+// current value.
+var subscribe_link_handler = undefined;
+
+/*
+ * An object representing the blueprint subscribers portlet.
+ *
+ * Since the portlet loads via XHR and inline subscribing
+ * depends on that portlet being loaded, setup a custom
+ * event object, to provide a hook for initializing subscription
+ * link callbacks after custom events.
+ */
+var PortletTarget = function() {};
+Y.augment(PortletTarget, Y.Event.Target);
+namespace.portlet = new PortletTarget();
+
+/*
+ * Create the lp client and bug entry if we haven't done so already.
+ *
+ * @method setup_client_and_bug
+ */
+function setup_client_and_blueprint() {
+ lp_client = new Y.lp.client.Launchpad();
+
+ if (blueprint_repr === undefined) {
+ blueprint_repr = LP.cache.context;
+ lp_blueprint_entry = new Y.lp.client.Entry(
+ lp_client, blueprint_repr, blueprint_repr.self_link);
+ }
+}
+
+namespace.load_subscribers_portlet = function(
+ subscription_link, subscription_link_handler) {
+ if (Y.UA.ie) {
+ return null;
+ }
+
+ Y.one('#subscribers-portlet-spinner').setStyle('display', 'block');
+
+ function hide_spinner() {
+ Y.one('#subscribers-portlet-spinner').setStyle('display', 'none');
+ // Fire a custom event to notify that the initial click
+ // handler on subscription_link set above should be
+ // cleared.
+ if (namespace) {
+ namespace.portlet.fire(
+ 'blueprints:portletloadfailed', subscription_link_handler);
+ }
+ }
+
+ function setup_portlet(transactionid, response, args) {
+ hide_spinner();
+ Y.one('#portlet-subscribers')
+ .appendChild(Y.Node.create(response.responseText));
+
+ // Fire a custom portlet loaded event to notify when
+ // it's safe to setup subscriber link callbacks.
+ namespace.portlet.fire('blueprints:portletloaded');
+ }
+
+ var config = {on: {success: setup_portlet,
+ failure: hide_spinner}};
+ var url = Y.one('#subscribers-content-link').getAttribute('href');
+ YIO.io(url, config);
+};
+
+
+namespace.setup_portlet_handlers = function() {
+ namespace.portlet.subscribe('blueprints:portletloaded', function() {
+ load_subscriber_ids();
+ });
+ /*
+ * If the subscribers portlet fails to load, clear any
+ * click handlers, so the normal subscribe page can be reached.
+ */
+ namespace.portlet.subscribe('blueprints:portletloadfailed', function(click_handler) {
+ click_handler.detach();
+ });
+ namespace.portlet.subscribe('blueprints:portletsubscriberidsloaded', function() {
+ var subscription = get_subscribe_self_subscription();
+ setup_subscribe_me_handler(subscription);
+ setup_subscribe_someone_else_handler(subscription);
+ setup_unsubscribe_icon_handlers();
+ });
+
+
+ /*
+ * Subscribing someone else requires loading a grayed out
+ * username into the DOM until the subscribe action completes.
+ * There are a couple XHR requests in check_can_be_unsubscribed
+ * before the subscribe work can be done, so fire a custom event
+ * blueprints:nameloaded and do the work here when the event fires.
+ */
+ namespace.portlet.subscribe('blueprints:nameloaded', function(subscription) {
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.clearProgressUI = function() {
+ var temp_link = Y.one('#temp-username');
+ if (temp_link) {
+ var temp_parent = temp_link.get('parentNode');
+ temp_parent.removeChild(temp_link);
+ }
+ };
+ error_handler.showError = function(error_msg) {
+ Y.lp.app.errors.display_error(
+ Y.one('.menu-link-addsubscriber'), error_msg);
+ };
+
+ var config = {
+ on: {
+ success: function(result) {
+ subscription.set('web_link', result.get('self_link'));
+ var temp_link = Y.one('#temp-username');
+ var temp_spinner = Y.one('#temp-name-spinner');
+ temp_link.removeChild(temp_spinner);
+ var person = subscription.get('person');
+ add_user_name_link(subscription);
+ Y.on('contentready', function() {
+ var temp_parent = temp_link.get('parentNode');
+ temp_parent.removeChild(temp_link);
+ }, '.' + person.get('css_name'));
+ },
+ failure: error_handler.getFailureHandler()
+ },
+ parameters: {
+ person: Y.lp.client.get_absolute_uri(
+ subscription.get('person').get('escaped_uri')),
+ suppress_notify: false
+ }
+ };
+ lp_client.named_post(blueprint_repr.self_link, 'subscribe', config);
+ });
+};
+
+function load_subscriber_ids() {
+ function on_success(transactionid, response, args) {
+ subscriber_ids = Y.JSON.parse(response.responseText);
+
+ // Fire a custom event to trigger the setting-up of the
+ // subscription handlers.
+ namespace.portlet.fire('blueprints:portletsubscriberidsloaded');
+ }
+
+ var config = {on: {success: on_success}};
+ var url = Y.one(
+ '#subscribers-ids-link').getAttribute('href');
+ YIO.io(url, config);
+}
+
+/*
+ * Set click handlers for unsubscribe remove icons.
+ *
+ * @method setup_unsubscribe_icon_handlers
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+ */
+function setup_unsubscribe_icon_handlers() {
+ var subscription = new Y.lp.app.subscriber.Subscription({
+ link: Y.one('.menu-link-subscription'),
+ spinner: Y.one('#sub-unsub-spinner'),
+ subscriber: new Y.lp.app.subscriber.Subscriber({
+ uri: LP.links.me,
+ subscriber_ids: subscriber_ids
+ })
+ });
+
+ Y.on('click', function(e) {
+ e.halt();
+ unsubscribe_user_via_icon(e.target, subscription);
+ }, '.unsub-icon');
+}
+
+/*
+ * Set up and return a Subscription object for the direct subscription
+ * link.
+ */
+function get_subscribe_self_subscription() {
+ setup_client_and_blueprint();
+ var subscription = new Y.lp.app.subscriber.Subscription({
+ link: Y.one('.menu-link-subscription'),
+ spinner: Y.one('#sub-unsub-spinner'),
+ subscriber: new Y.lp.app.subscriber.Subscriber({
+ uri: LP.links.me,
+ subscriber_ids: subscriber_ids
+ })
+ });
+
+ subscription.set('can_be_unsubscribed', true);
+ subscription.set('person', subscription.get('subscriber'));
+ subscription.set('is_team', false);
+ return subscription;
+}
+
+/*
+ * Initialize callbacks for subscribe/unsubscribe links.
+ *
+ * @method setup_subscription_link_handlers
+ */
+function setup_subscribe_me_handler(subscription) {
+ if (LP.links.me === undefined) {
+ return;
+ }
+
+ if (subscription.is_node()) {
+ var subscribe_node = subscription.get('link');
+ var parent = subscribe_node.get('parentNode');
+ var is_subscribed = parent.hasClass('subscribed-true');
+ if (!is_subscribed) {
+ // We need to ensure we don't attach more than one handler so
+ // detach any existing one.
+ if (subscribe_link_handler !== undefined ) {
+ subscribe_link_handler.detach();
+ }
+ subscribe_link_handler = subscribe_node.on('click', function(e) {
+ e.halt();
+ subscription.set('can_be_unsubscribed', true);
+ subscription.set('person', subscription.get('subscriber'));
+ subscription.set('is_team', false);
+ var parent = e.target.get('parentNode');
+ if (parent.hasClass('subscribed-false')) {
+ subscribe_current_user(subscription);
+ }
+ else {
+ unsubscribe_current_user(subscription);
+ }
+ });
+ subscribe_node.addClass('js-action');
+ }
+ }
+}
+
+/*
+ * Add the user name to the subscriber's list.
+ *
+ * @method add_user_name_link
+ */
+function add_user_name_link(subscription) {
+ // Be paranoid about display_name, since timeouts or other errors
+ // could mean display_name wasn't set on initialization.
+ subscription.get('person').set_display_name(function () {
+ _add_user_name_link(subscription);
+ });
+}
+
+function _add_user_name_link(subscription) {
+ var person = subscription.get('person');
+ var display_user_function = function(link_node) {
+ var subscribers = Y.one('#subscribers-links');
+ if (subscription.is_current_user_subscribing()) {
+ // If this is the current user, then top post the name.
+ subscribers.insertBefore(
+ link_node, subscribers.get('firstChild'));
+ } else {
+ var next = get_next_subscriber_node(subscription);
+ if (next) {
+ subscribers.insertBefore(link_node, next);
+ } else {
+ // Handle the case of no subscribers.
+ var none_subscribers = Y.one('#none-subscribers');
+ if (none_subscribers) {
+ var none_parent = none_subscribers.get('parentNode');
+ none_parent.removeChild(none_subscribers);
+ }
+ subscribers.appendChild(link_node);
+ }
+ }
+
+ // Set the click handler if adding a remove icon.
+ if (subscription.can_be_unsubscribed_by_user()) {
+ var remove_icon =
+ Y.one('#unsubscribe-icon-' + person.get('css_name'));
+ remove_icon.on('click', function(e) {
+ e.halt();
+ unsubscribe_user_via_icon(e.target, subscription);
+ });
+ }
+ Y.lazr.anim.green_flash({node: link_node}).run();
+ };
+ build_user_link_html(subscription, display_user_function);
+}
+
+/*
+ * Unsubscribe a user from this blueprint when a remove icon is clicked.
+ *
+ * @method unsubscribe_user_via_icon
+ * @param icon {Node} The remove icon that was clicked.
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+*/
+function unsubscribe_user_via_icon(icon, subscription) {
+ icon.set('src', '/@@/spinner');
+
+ var user_uri = get_user_uri_from_icon(icon);
+ var person = new Y.lp.app.subscriber.Subscriber({
+ uri: user_uri,
+ subscriber_ids: subscriber_ids
+ });
+ subscription.set('person', person);
+
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.clearProgressUI = function () {
+ icon.set('src', '/@@/remove');
+ // Grab the icon again to reset to click handler.
+ var unsubscribe_icon = Y.one(
+ '#unsubscribe-icon-' + person.get('css_name'));
+ unsubscribe_icon.on('click', function(e) {
+ e.halt();
+ unsubscribe_user_via_icon(e.target, subscription);
+ });
+
+ };
+ error_handler.showError = function (error_msg) {
+ var flash_node = Y.one('.' + person.get('css_name'));
+ Y.lp.app.errors.display_error(flash_node, error_msg);
+
+ };
+
+ var subscription_link = subscription.get('link');
+ var config = {
+ on: {
+ success: function(id, response, args) {
+ Y.lp.app.subscribers_list.remove_user_link(person);
+ set_subscription_link_parent_class(subscription_link, false);
+ if (subscription.is_current_user_subscribing()) {
+ subscription.disable_spinner(
+ subscription_labels.SUBSCRIBE);
+ setup_subscribe_me_handler(subscription);
+ }
+ },
+
+ failure: error_handler.getFailureHandler()
+ }
+ };
+ if (!subscription.is_current_user_subscribing()) {
+ config.parameters = {
+ person: Y.lp.client.get_absolute_uri(user_uri)
+ };
+ }
+ lp_client.named_post(blueprint_repr.self_link, 'unsubscribe', config);
+}
+
+/*
+ * Subscribe the current user via the LP API.
+ *
+ * @method subscribe_current_user
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+ */
+function subscribe_current_user(subscription) {
+ subscription.enable_spinner('Subscribing...');
+ var subscription_link = subscription.get('link');
+ var subscriber = subscription.get('subscriber');
+
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.clearProgressUI = function () {
+ subscription.disable_spinner();
+ };
+ error_handler.showError = function (error_msg) {
+ Y.lp.app.errors.display_error(subscription_link, error_msg);
+ };
+
+ var config = {
+ on: {
+ success: function(result) {
+ subscription.set('web_link', result.get('self_link'));
+ if (subscribe_link_handler !== undefined ) {
+ subscribe_link_handler.detach();
+ }
+ subscription.disable_spinner(
+ subscription_labels.EDIT);
+ var subscribe_node = subscription.get('link');
+ subscribe_node.removeClass('remove')
+ .removeClass('js-action')
+ .addClass('edit');
+
+ set_subscription_link_parent_class(subscription_link, true);
+
+ // Handle the case where the subscriber's list displays
+ // "No subscribers."
+ var empty_subscribers = Y.one("#none-subscribers");
+ if (empty_subscribers) {
+ var parent = empty_subscribers.get('parentNode');
+ parent.removeChild(empty_subscribers);
+ }
+
+ add_user_name_link(subscription);
+ // Only when the link is added to the page, indicate success.
+ Y.on('contentready', function() {
+ var flash_node = Y.one('.' + subscriber.get('css_name'));
+ var anim = Y.lazr.anim.green_flash({ node: flash_node });
+ anim.run();
+ }, '.' + subscriber.get('css_name'));
+ },
+
+ failure: error_handler.getFailureHandler()
+ },
+
+ parameters: {
+ person: Y.lp.client.get_absolute_uri(
+ subscriber.get('escaped_uri'))
+ }
+ };
+ lp_client.named_post(blueprint_repr.self_link, 'subscribe', config);
+}
+
+/*
+ * Unsubscribe the current user via the LP API.
+ *
+ * @method unsubscribe_current_user
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+ */
+function unsubscribe_current_user(subscription) {
+ subscription.enable_spinner('Unsubscribing...');
+ var subscription_link = subscription.get('link');
+ var subscriber = subscription.get('subscriber');
+
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.clearProgressUI = function () {
+ subscription.disable_spinner();
+ };
+ error_handler.showError = function (error_msg) {
+ Y.lp.app.errors.display_error(subscription_link, error_msg);
+ };
+
+ var subscriber_link = Y.lp.client.get_absolute_uri(
+ subscriber.get('escaped_uri'));
+ var config = {
+ on: {
+ success: function(client) {
+ subscription.disable_spinner(
+ subscription_labels.SUBSCRIBE);
+ set_subscription_link_parent_class(
+ subscription_link, false);
+ Y.lp.app.subscribers_list.remove_user_link(subscriber);
+ },
+
+ failure: error_handler.getFailureHandler()
+ },
+
+ parameters: { person: subscriber_link }
+ };
+
+ lp_client.named_post(blueprint_repr.self_link, 'unsubscribe', config);
+}
+
+/*
+ * Initialize click handler for the subscribe someone else link.
+ *
+ * @method setup_subscribe_someone_else_handler
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+ */
+function setup_subscribe_someone_else_handler(subscription) {
+ if (LP.links.me === undefined) {
+ return;
+ }
+ var config = {
+ header: 'Subscribe someone else',
+ step_title: 'Search',
+ picker_activator: '.menu-link-addsubscriber'
+ };
+
+ config.save = function(result) {
+ subscribe_someone_else(result, subscription);
+ };
+ var picker = Y.lp.app.picker.create('ValidPersonOrTeam', config);
+}
+
+/*
+ * Build the HTML for a user link for the subscribers list.
+ *
+ * @method build_user_link_html
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+ * @param display_user_function {Object} A function to display the user node.
+ */
+function build_user_link_html(subscription, display_user_function) {
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.showError = function (error_msg) {
+ var subscription_link = subscription.get('link');
+ Y.lp.app.errors.display_error(subscription_link, error_msg);
+
+ };
+
+ var config = {
+ on: {
+ success: function(id, response) {
+ var html = Y.Node.create(response.responseText);
+
+ // Override the node ids so that the remove icon is wired up
+ // correctly.
+ var person = subscription.get('person');
+ var css_name = person.get('css_name');
+ html.set('id', 'subscription-' + css_name);
+ var icon_link = html.one('a:nth-child(3)');
+ icon_link.set('id', 'unsubscribe-' + css_name);
+ icon_link.one('img')
+ .set('id', 'unsubscribe-icon-' + css_name);
+ display_user_function(html);
+ },
+
+ failure: error_handler.getFailureHandler()
+ }
+ };
+ // We need to retrieve the html to display from the server since we need
+ // to use specification subscription attributes not available on the
+ // client.
+ var url_base = subscription.get('web_link').replace('/api/devel', '');
+ var url = url_base + '/+blueprint-subscriber-row';
+ YIO.io(url, config);
+}
+
+/*
+ * Returns the next node in alphabetical order after the subscriber
+ * node now being added. No node is returned to append to end of list.
+ *
+ * The name can appear in one of two different lists. 1) The list of
+ * subscribers that can be unsubscribed by the current user, and
+ * 2) the list of subscribers that cannot be unsubscribed.
+ *
+ * @method get_next_subscriber_node
+ * @param subscription_link {Node} The sub/unsub link.
+ * @return {Node} The node appearing next in the subscriber list or
+ * undefined if no node is next.
+ */
+function get_next_subscriber_node(subscription) {
+ var full_name = subscription.get('person').get('full_display_name');
+ var can_be_unsubscribed = subscription.can_be_unsubscribed_by_user();
+ var nodes_by_name = {};
+ var unsubscribables = [];
+ var not_unsubscribables = [];
+
+ // Use the list of subscribers pulled from the DOM to have sortable
+ // lists of unsubscribable vs. not unsubscribable person links.
+ var all_subscribers = Y.all('#subscribers-links div');
+ if (all_subscribers.size() > 0) {
+ all_subscribers.each(function(sub_link) {
+ if (sub_link.getAttribute('id') !== 'temp-username') {
+ // User's displayname is found via the link's "name"
+ // attribute.
+ var sub_link_name = sub_link.one('a').getAttribute('name');
+ nodes_by_name[sub_link_name] = sub_link;
+ if (sub_link.one('img.unsub-icon')) {
+ unsubscribables.push(sub_link_name);
+ } else {
+ not_unsubscribables.push(sub_link_name);
+ }
+ }
+ });
+
+ // Add the current subscription.
+ if (can_be_unsubscribed) {
+ unsubscribables.push(full_name);
+ } else {
+ not_unsubscribables.push(full_name);
+ }
+ unsubscribables.sort();
+ not_unsubscribables.sort();
+ } else {
+ // If there is no all_subscribers, then we're dealing with
+ // the printed None, so return.
+ return undefined;
+ }
+
+ var i;
+ if ((!unsubscribables && !not_unsubscribables) ||
+ // If A) neither list exists, B) the user belongs in the second
+ // list but the second list doesn't exist, or C) user belongs in the
+ // first list and the second doesn't exist, return no node to append.
+ (!can_be_unsubscribed && !not_unsubscribables) ||
+ (can_be_unsubscribed && unsubscribables && !not_unsubscribables)) {
+ return undefined;
+ } else if (
+ // If the user belongs in the first list, and the first list
+ // doesn't exist, but the second one does, return the first node
+ // in the second list.
+ can_be_unsubscribed && !unsubscribables && not_unsubscribables) {
+ return nodes_by_name[not_unsubscribables[0]];
+ } else if (can_be_unsubscribed) {
+ // If the user belongs in the first list, loop the list for position.
+ for (i=0; i<unsubscribables.length; i++) {
+ if (unsubscribables[i] === full_name) {
+ if (i+1 < unsubscribables.length) {
+ return nodes_by_name[unsubscribables[i+1]];
+ // If the current link should go at the end of the first
+ // list and we're at the end of that list, return the
+ // first node of the second list. Due to earlier checks
+ // we can be sure this list exists.
+ } else if (i+1 >= unsubscribables.length) {
+ return nodes_by_name[not_unsubscribables[0]];
+ }
+ }
+ }
+ } else if (!can_be_unsubscribed) {
+ // If user belongs in the second list, loop the list for position.
+ for (i=0; i<not_unsubscribables.length; i++) {
+ if (not_unsubscribables[i] === full_name) {
+ if (i+1 < not_unsubscribables.length) {
+ return nodes_by_name[not_unsubscribables[i+1]];
+ } else {
+ return undefined;
+ }
+ }
+ }
+ }
+}
+
+/*
+ * Traverse the DOM of a given remove icon to find
+ * the user's link. Returns a URI of the form "/~username".
+ *
+ * @method get_user_uri_from_icon
+ * @param icon {Node} The node representing a remove icon.
+ * @return user_uri {String} The user's uri, without the hostname.
+ */
+function get_user_uri_from_icon(icon) {
+ var parent_div = icon.get('parentNode').get('parentNode');
+ // This should be parent_div.firstChild, but because of #text
+ // and cross-browser issues, using the YUI query syntax is
+ // safer here.
+ var user_uri = parent_div.one('a:nth-child(2)').getAttribute('href');
+
+ // Strip the domain off. We just want a path.
+ var host_start = user_uri.indexOf('//');
+ if (host_start !== -1) {
+ var host_end = user_uri.indexOf('/', host_start+2);
+ return user_uri.substring(host_end, user_uri.length);
+ }
+
+ return user_uri;
+}
+
+/*
+ * Set the class on subscription link's parentNode.
+ *
+ * This is used to reset the class used by the
+ * click handler to know which link was clicked.
+ *
+ * @method set_subscription_link_parent_class
+ * @param subscription_link {Node} The sub/unsub link.
+ * @param subscribed {Boolean} The sub/unsub'ed flag for the class.
+ */
+function set_subscription_link_parent_class(user_link, subscribed) {
+ var parent = user_link.get('parentNode');
+ if (subscribed) {
+ parent.removeClass('subscribed-false');
+ parent.addClass('subscribed-true');
+ } else {
+ parent.removeClass('subscribed-true');
+ parent.addClass('subscribed-false');
+ }
+}
+
+/*
+ * Subscribe a person or team other than the current user.
+ * This is a callback for the subscribe someone else picker.
+ *
+ * @method subscribe_someone_else
+ * @result {Object} The object representing a person returned by the API.
+ */
+function subscribe_someone_else(result, subscription) {
+ var person = new Y.lp.app.subscriber.Subscriber({
+ uri: result.api_uri,
+ display_name: result.title,
+ subscriber_ids: subscriber_ids
+ });
+ subscription.set('person', person);
+
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.showError = function(error_msg) {
+ Y.lp.app.errors.display_error(
+ Y.one('.menu-link-addsubscriber'), error_msg);
+ };
+
+ if (subscription.is_already_subscribed()) {
+ error_handler.showError(
+ subscription.get('person').get('full_display_name') +
+ ' has already been subscribed');
+ } else {
+ check_can_be_unsubscribed(subscription);
+ }
+}
+
+/*
+ * Check if the current user can unsubscribe the person
+ * being subscribed.
+ *
+ * This must be done in JavaScript, since the subscription
+ * hasn't completed yet, and so, can_be_unsubscribed_by_user
+ * cannot be used.
+ *
+ * @method check_can_be_unsubscribed
+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
+ */
+function check_can_be_unsubscribed(subscription) {
+ var error_handler = new Y.lp.client.ErrorHandler();
+ error_handler.showError = function (error_msg) {
+ Y.lp.app.errors.display_error(
+ Y.one('.menu-link-addsubscriber'), error_msg);
+ };
+
+ var config = {
+ on: {
+ success: function(result) {
+ var is_team = result.get('is_team');
+ subscription.set('is_team', is_team);
+ var final_config = {
+ on: {
+ success: function(result) {
+ var team_member = false;
+ var i;
+ for (i=0; i<result.entries.length; i++) {
+ if (result.entries[i].get('member_link') ===
+ Y.lp.client.get_absolute_uri(
+ subscription.get(
+ 'subscriber').get('uri'))) {
+ team_member = true;
+ }
+ }
+
+ if (team_member) {
+ subscription.set('can_be_unsubscribed', true);
+ add_temp_user_name(subscription);
+ } else {
+ subscription.set(
+ 'can_be_unsubscribed', false);
+ add_temp_user_name(subscription);
+ }
+ },
+
+ failure: error_handler.getFailureHandler()
+ }
+ };
+
+ if (is_team) {
+ // Get a list of members to see if current user
+ // is a team member.
+ var members = result.get(
+ 'members_details_collection_link');
+ lp_client.get(members, final_config);
+ } else {
+ subscription.set('can_be_unsubscribed', false);
+ add_temp_user_name(subscription);
+ }
+ },
+
+ failure: error_handler.getFailureHandler()
+ }
+ };
+ var uri = Y.lp.client.get_absolute_uri(
+ subscription.get('person').get('escaped_uri'));
+ lp_client.get(uri, config);
+}
+
+/*
+ * Add a grayed out, temporary user name when subscribing
+ * someone else.
+ *
+ * @method add_temp_user_name
+ * @param subscription_link {Node} The sub/unsub link.
+ */
+function add_temp_user_name(subscription) {
+ // Be paranoid about display_name, since timeouts or other errors
+ // could mean display_name wasn't set on initialization.
+ subscription.get('person').set_display_name(function () {
+ _add_temp_user_name(subscription);
+ });
+}
+
+function _add_temp_user_name(subscription) {
+ var display_name = subscription.get('person').get('display_name');
+ var img_src;
+ if (subscription.is_team()) {
+ img_src = '/@@/teamgray';
+ } else {
+ img_src = '/@@/persongray';
+ }
+
+ // The <span>...</span> below must *not* be <span/>. On FF (maybe
+ // others, but at least on FF 3.0.11) will then not notice any
+ // following sibling nodes, like the spinner image.
+ var link_node = Y.Node.create([
+ '<div id="temp-username"> ',
+ ' <img alt="" width="14" height="14" />',
+ ' <span>Other Display Name</span>',
+ ' <img id="temp-name-spinner" src="/@@/spinner" alt="" ',
+ ' style="position:absolute;right:8px" /></div>'].join(''));
+ link_node.one('img').set('src', img_src);
+ link_node.replaceChild(
+ document.createTextNode(display_name),
+ link_node.one('span'));
+
+ var subscribers = Y.one('#subscribers-links');
+ var next = get_next_subscriber_node(subscription);
+ if (next) {
+ subscribers.insertBefore(link_node, next);
+ } else {
+ // Handle the case of no subscribers.
+ var none_subscribers = Y.one('#none-subscribers');
+ if (none_subscribers) {
+ var none_parent = none_subscribers.get('parentNode');
+ none_parent.removeChild(none_subscribers);
+ }
+ subscribers.appendChild(link_node);
+ }
+
+ // Fire a custom event to know it's safe to begin
+ // any actual subscribing work.
+ namespace.portlet.fire('blueprints:nameloaded', subscription);
+}
+
+/**
+ * Set up and configure the module.
+ */
+ namespace.setup_config = function(config) {
+ if (config.yio !== undefined) {
+ //We can be given an alternative IO provider for use in tests.
+ YIO = config.yio;
+ }
+};
+
+}, "0.1", {"requires": ["base", "oop", "node", "event", "io-base",
+ "json-parse", "substitute", "widget-position-ext",
+ "lazr.formoverlay", "lazr.anim", "lazr.base",
+ "lazr.overlay", "lazr.choiceedit", "lp.app.picker",
+ "lp.client",
+ "lp.client.plugins", "lp.app.subscriber",
+ "lp.app.subscribers_list", "lp.app.errors"]});
=== added directory 'lib/lp/blueprints/javascript/tests'
=== added file 'lib/lp/blueprints/javascript/tests/test_subscription_portlet.html'
--- lib/lp/blueprints/javascript/tests/test_subscription_portlet.html 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/javascript/tests/test_subscription_portlet.html 2011-06-16 02:58:38 +0000
@@ -0,0 +1,47 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html>
+ <head>
+ <title>Blueprint subscription portlet</title>
+
+ <!-- YUI 3.0 Setup -->
+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.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" />
+
+ <!-- Some required dependencies -->
+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/client.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/errors.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/picker.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/widgets.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/subscriber.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/subscribers_list.js"></script>
+
+ <!-- The module under test -->
+ <script type="text/javascript" src="../blueprint_index_portlets.js"></script>
+
+ <!-- The test suite -->
+ <script type="text/javascript" src="test_subscription_portlet.js"></script>
+</head>
+<body class="yui3-skin-sam">
+ <div id="portlet-subscribers" class="portlet vertical">
+ <div class="section">
+ <div id="sub-unsub-spinner">Subscribing...</div>
+ <a class="menu-link-addsubscriber"
+ href="https://blueprints.launchpad.dev/foo/+spec/bar/+addsubscriber">Subscribe someone else</a>
+ </div>
+ <a id="subscribers-ids-link"
+ href="https://blueprints.launchpad.dev/foo/+spec/bar/+blueprint-portlet-subscribers-ids"></a>
+ <a id="subscribers-content-link"
+ href="https://blueprints.launchpad.dev/foo/+spec/bar/+blueprint-portlet-subscribers-content"></a>
+ <div id="subscribers-portlet-spinner"
+ style="text-align: center; display: none">
+ <img src="/@@/spinner" />
+ </div>
+ </div>
+ <div id="log"></div>
+</body>
+</html>
=== added file 'lib/lp/blueprints/javascript/tests/test_subscription_portlet.js'
--- lib/lp/blueprints/javascript/tests/test_subscription_portlet.js 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/javascript/tests/test_subscription_portlet.js 2011-06-16 02:58:38 +0000
@@ -0,0 +1,285 @@
+/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
+
+YUI({
+ base: '../../../../canonical/launchpad/icing/yui/',
+ filter: 'raw',
+ combine: false,
+ fetchCSS: false
+ }).use('test', 'console', 'node', 'event', 'event-simulate',
+ 'lp.client', 'lp.app.subscriber', 'lp.app.subscribers_list',
+ 'lp.blueprints.blueprint_index.portlets',
+ function(Y) {
+
+var suite = new Y.Test.Suite("lp.blueprints.subscriber_portlet Tests");
+var module = Y.lp.blueprints.blueprint_index.portlets;
+module.setup_portlet_handlers();
+
+/*
+ * A wrapper for the Y.Event.simulate() function. The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(widget, selector, evtype, options) {
+ var rawnode = Y.Node.getDOMNode(widget.one(selector));
+ Y.Event.simulate(rawnode, evtype, options);
+}
+
+/**
+ * A stub io handler.
+ */
+function IOStub(test_case){
+ if (!(this instanceof IOStub)) {
+ throw new Error("Constructor called as a function");
+ }
+ this.calls = [];
+ this.responses = [];
+ this.io = function(url, config) {
+ this.calls.push(url);
+ var response = {responseText: ''};
+ // We may have been passed text to use in the response.
+ if (Y.Lang.isValue(arguments.callee.responseText)) {
+ response.responseText = arguments.callee.responseText;
+ } else if (this.responses.length>0) {
+ response.responseText = this.responses.shift();
+ }
+
+ // We currently only support calling the success handler.
+ config.on.success(undefined, response, arguments.callee.args);
+ // After calling the handler, resume the test.
+ if (Y.Lang.isFunction(arguments.callee.doAfter)) {
+ test_case.resume(arguments.callee.doAfter);
+ }
+ };
+}
+
+suite.add(new Y.Test.Case({
+ name: "lp.blueprints.subscriber_portlet",
+
+ load_portlet: function() {
+ var subscription_link = Y.one('.menu-link-subscription');
+ var subscription_link_handler = undefined;
+ // Until edit subscription overlays are supported we don't want to
+ // load the ajax subscribers portal on the html subscription edit
+ // page.
+ var load_subscribers_portlet = true;
+ if (subscription_link) {
+ load_subscribers_portlet = !subscription_link.hasClass('nolink');
+ subscription_link_handler = subscription_link.on(
+ 'click', function(e) { e.preventDefault(); });
+ }
+ if (load_subscribers_portlet) {
+ Y.lp.blueprints.blueprint_index.portlets.load_subscribers_portlet(
+ subscription_link, subscription_link_handler);
+ }
+ },
+
+ setUp: function() {
+ // Some common data required for each test.
+ this.MY_WEB_LINK = 'https://blueprints.launchpad.dev/foo/+spec/bar';
+ this.MY_NAME = "ME";
+ this.ME = new Y.lp.client.Entry();
+ this.ME.addAttr('display_name', {value: "ME"});
+ this.SUBSCRIBER_ROW_HTML =
+ '<div class="subscriber" id="subscription-subscriber-12">' +
+ ' <a href="/foo/+spec/bar/+subscription/me">' +
+ ' <img alt="" src="/@@/subscriber-inessential"' +
+ ' title="Normal subscriber." />' +
+ ' </a>' +
+ ' <a href="/~me">ME</a>' +
+ ' <a href="+subscribe" id="unsubscribe-subscriber-12"' +
+ ' title="Unsubscribe ME" class="unsub-icon">' +
+ ' <img></img>' +
+ ' </a>' +
+ '</div>';
+
+ window.LP = {
+ links: { me: "/~" + this.MY_NAME },
+ cache: {
+ context: {
+ self_link: 'https://launchpad.dev/api/devel/foo/+spec/bar/'
+ }
+ }
+ };
+ // A container to allow us to specify what results to return for
+ // named_post and get calls during the test. Each subsequent call
+ // takes its return data from the front of the array.
+ window.expected_results = {
+ get_results: [],
+ named_post_results: []
+ };
+
+ // We need to stub out Launchpad client named_post and get operations
+ // so that we can record what requests were made and provide test data
+ // back to the caller.
+ Y.lp.client.Launchpad = function() {};
+ Y.lp.client.Launchpad.prototype.named_post =
+ function(url, func, config) {
+ LP.cache.named_post_call_data = {
+ called_url: url,
+ called_func: func,
+ called_config: config
+ };
+ // our setup assumes success, so we just do the
+ // success callback.
+ var result = '';
+ if (!Y.Lang.isArray(expected_results.named_post_results)) {
+ result = expected_results.named_post_results;
+ } else {
+ if (expected_results.named_post_results.length > 0) {
+ result = expected_results.named_post_results.shift();
+ }
+ }
+ config.on.success(result);
+ };
+ Y.lp.client.Launchpad.prototype.get =
+ function(url, config) {
+ LP.cache.get_call_data = {
+ called_url: url
+ };
+ var result = '';
+ if (!Y.Lang.isArray(expected_results.get_results)) {
+ result = expected_results.get_results;
+ } else {
+ if (expected_results.get_results.length > 0) {
+ result = expected_results.get_results.shift();
+ }
+ }
+ config.on.success(result);
+ };
+
+ this.root_node = Y.one('#portlet-subscribers');
+ this.config = {};
+ this.config.yio = new IOStub(this);
+ module.setup_config(this.config);
+ },
+
+ removeNode: function(selector) {
+ var node = Y.one(selector);
+ if (node) {
+ node.remove(true);
+ }
+ },
+
+ tearDown: function() {
+ delete window.LP;
+ delete window.expected_results;
+ this.removeNode('#subscribe-node');
+ this.removeNode('#subscribers');
+ this.removeNode('.yui3-overlay');
+ },
+
+ check_portlet_loaded: function() {
+ Y.ArrayAssert.itemsAreEqual(
+ [this.MY_WEB_LINK+'/+blueprint-portlet-subscribers-content',
+ this.MY_WEB_LINK+'/+blueprint-portlet-subscribers-ids'],
+ this.config.yio.calls);
+ },
+
+ /**
+ * Create an initial page state where the current user is either
+ * subscribed or unsubscribed to the blueprint.
+ */
+ setup_me: function(is_subscribed) {
+ var subscribe_section = Y.one(".portlet .section");
+ var subscribe_node = Y.Node.create("<div id='subscribe-node'></div>");
+ subscribe_node.addClass('subscribed-'+is_subscribed);
+ var subscribe_link = Y.Node.create("<a>Subscribe</a>");
+ subscribe_link.addClass('menu-link-subscription');
+ subscribe_node.appendChild(subscribe_link);
+ subscribe_section.prepend(subscribe_node);
+ var subscriber_html='';
+ if (is_subscribed) {
+ subscriber_html = this.SUBSCRIBER_ROW_HTML;
+ }
+ var portlet_content =
+ '<div id="subscribers">' +
+ ' <h2>Subscribers</h2>' +
+ ' <div id="subscribers-links">' +
+ subscriber_html +
+ ' </div>'+
+ '</div>';
+ this.config.yio.responses = [
+ portlet_content, '{"me": "subscriber-12"}'];
+ expected_results.get_results = this.ME;
+ this.load_portlet();
+ this.check_portlet_loaded();
+ },
+
+ /**
+ * Create an initial page state where the current user is unsubscribed to
+ * the blueprint.
+ */
+ setup_me_unsubscribed: function() {
+ this.setup_me(false);
+ },
+
+ /**
+ * Create an initial page state where the current user is subscribed to
+ * the blueprint.
+ */
+ setup_me_subscribed: function() {
+ this.setup_me(true);
+ var subscribers = Y.one('#subscribers-links');
+ Y.Assert.isNotNull(
+ subscribers.one(".subscriber a:nth-child(2)[href='/~me']"));
+ },
+
+ test_subscribe_me: function() {
+ // The Subscribe link works if the user is unsubscribed.
+ this.setup_me_unsubscribed();
+ expected_results.get_results = this.ME;
+ var result = new Y.lp.client.Entry();
+ result.set('self_link',
+ LP.cache.context.self_link+'/+subscription/me');
+ expected_results.named_post_results = [result];
+
+ this.config.yio.responses = [this.SUBSCRIBER_ROW_HTML];
+
+ simulate(this.root_node, '.menu-link-subscription', 'click');
+ Y.Assert.areEqual(LP.cache.context.self_link,
+ LP.cache.named_post_call_data.called_url);
+ Y.Assert.areEqual('subscribe',
+ LP.cache.named_post_call_data.called_func);
+ var subscribers = Y.one('#subscribers-links');
+ Y.Assert.isNotNull(
+ subscribers.one(".subscriber a:nth-child(2)[href='/~me']"));
+ },
+
+ test_edit_my_subscription: function() {
+ // If the user is subscribed, the subscribe link is not ajaxified.
+ this.setup_me_subscribed();
+ var subscribe_node = Y.one('.menu-link-subscription');
+ Y.Assert.isFalse(subscribe_node.hasClass('js-action'));
+ },
+
+ test_unsubscribe_me: function() {
+ // The unsubscribe link works if the user is subscribed.
+ this.setup_me_subscribed();
+ simulate(this.root_node, '.unsub-icon', 'click');
+ Y.Assert.areEqual(LP.cache.context.self_link,
+ LP.cache.named_post_call_data.called_url);
+ Y.Assert.areEqual('unsubscribe',
+ LP.cache.named_post_call_data.called_func);
+ Y.on('contentready', function() {
+ Y.Assert.isNotNull(Y.one("#none-subscribers"));
+ }, '#subscribers');
+ }
+}));
+
+// Lock, stock, and two smoking barrels.
+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 yui_console = new Y.Console({
+ newestOnTop: false
+});
+yui_console.render('#log');
+
+Y.on('domready', function() {
+ Y.Test.Runner.run();
+});
+});
=== modified file 'lib/lp/blueprints/templates/specification-index.pt'
--- lib/lp/blueprints/templates/specification-index.pt 2011-04-14 22:33:16 +0000
+++ lib/lp/blueprints/templates/specification-index.pt 2011-06-16 02:58:38 +0000
@@ -319,8 +319,8 @@
</div>
<script type="text/javascript">
- LPS.use('lazr.anim', 'lp.ui', function(Y) {
-
+ LPS.use('lazr.anim', 'lp.ui', 'lp.blueprints.blueprint_index', function(Y) {
+ Y.lp.blueprints.blueprint_index.setup_blueprint_index();
Y.on('lp:context:implementation_status:changed', function(e) {
var icon = Y.one('#informational-icon');
if (e.new_value == 'Informational') {
=== modified file 'lib/lp/blueprints/templates/specification-portlet-subscribers.pt'
--- lib/lp/blueprints/templates/specification-portlet-subscribers.pt 2011-06-16 02:58:37 +0000
+++ lib/lp/blueprints/templates/specification-portlet-subscribers.pt 2011-06-16 02:58:38 +0000
@@ -5,13 +5,56 @@
class="portlet vertical"
id="portlet-subscribers"
metal:define-macro="custom"
+ tal:define="features request/features"
>
<div class="section" tal:define="context_menu context/menu:context"
metal:define-slot="heading">
<div
tal:attributes="class view/current_user_subscription_class"
tal:content="structure context_menu/subscription/render" />
+ <div id="sub-unsub-spinner">Subscribing...</div>
<div tal:content="structure context_menu/addsubscriber/render" />
</div>
- <div tal:replace="structure context/@@+blueprint-portlet-subscribers-content" />
+<a id="subscribers-ids-link"
+ tal:define="blueprint context"
+ tal:attributes="href blueprint/fmt:url/+blueprint-portlet-subscribers-ids"></a>
+<a id="subscribers-content-link"
+ tal:define="blueprint context"
+ tal:attributes="href blueprint/fmt:url/+blueprint-portlet-subscribers-content"></a>
+<div id="subscribers-portlet-spinner"
+ style="text-align: center; display: none">
+<img src="/@@/spinner" />
+</div>
+<div tal:condition="features/disclosure.enhanced_blueprint_subscriptions.enabled">
+<script type="text/javascript">
+LPS.use('io-base', 'node', 'lp.blueprints.blueprint_index.portlets', function(Y) {
+ // Must be done inline here to ensure the load event fires.
+ // This is a work around for a YUI3 issue with event handling.
+ var subscription_link = Y.one('.menu-link-subscription');
+ var subscription_link_handler = undefined;
+ // Until edit subscription overlays are supported we don't want to load
+ // the ajax subscribers portal on the html subscription edit page.
+ var load_subscribers_portlet = true;
+ if (subscription_link) {
+ load_subscribers_portlet = !subscription_link.hasClass('nolink');
+ subscription_link_handler = subscription_link.on(
+ 'click', function(e) { e.preventDefault(); });
+ }
+
+ if (load_subscribers_portlet) {
+ Y.on('domready', function() {
+ if (Y.lp.blueprints.blueprint_index.portlets) {
+ Y.lp.blueprints.blueprint_index.portlets.load_subscribers_portlet(
+ subscription_link, subscription_link_handler);
+ }
+ });
+ }
+});
+</script>
+<noscript>
+<div tal:replace="structure context/@@+blueprint-portlet-subscribers-content"/>
+</noscript>
+</div>
+<div tal:condition="not: features/disclosure.enhanced_blueprint_subscriptions.enabled"
+ tal:replace="structure context/@@+blueprint-portlet-subscribers-content"/>
</div>
=== modified file 'lib/lp/blueprints/templates/specification-subscriber-row.pt'
--- lib/lp/blueprints/templates/specification-subscriber-row.pt 2011-06-16 02:58:37 +0000
+++ lib/lp/blueprints/templates/specification-subscriber-row.pt 2011-06-16 02:58:38 +0000
@@ -37,6 +37,7 @@
<a tal:attributes="href subscription/person/fmt:url:blueprints"
tal:content="subscription/person/fmt:displayname"
/>
+ <tal:block condition="request/features/disclosure.enhanced_blueprint_subscriptions.enabled">
<a tal:define="user request/lp:person"
tal:condition="python: subscription.canBeUnsubscribedByUser(user)"
href="+subscribe"
@@ -47,5 +48,6 @@
<img class="unsub-icon" src="/@@/remove" alt="Remove"
tal:attributes="id string:unsubscribe-icon-${subscription/css_name}" />
</a>
+ </tal:block>
</div>
</tal:root>
=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py 2011-06-11 03:42:08 +0000
+++ lib/lp/services/features/flags.py 2011-06-16 02:58:38 +0000
@@ -114,6 +114,10 @@
'boolean',
('Enables ranking by pillar affiliation in the person picker.'),
''),
+ ('disclosure.enhanced_blueprint_subscriptions.enabled',
+ 'boolean',
+ ('Enables improved blueprint subscription features.'),
+ ''),
])
# The set of all flag names that are documented.