Merge lp:~wallyworld/launchpad/blueprint-subscriptions into lp:launchpad

Proposed by Ian Booth
Status: Work in progress
Proposed branch: lp:~wallyworld/launchpad/blueprint-subscriptions
Merge into: lp:launchpad
Prerequisite: lp:~wallyworld/launchpad/refactor-bugs-subscriber-javascript
Diff against target: 1337 lines (+1257/-3)
8 files modified
lib/lp/blueprints/javascript/blueprint_index.js (+20/-0)
lib/lp/blueprints/javascript/blueprint_index_portlets.js (+853/-0)
lib/lp/blueprints/javascript/tests/test_subscription_portlet.html (+47/-0)
lib/lp/blueprints/javascript/tests/test_subscription_portlet.js (+285/-0)
lib/lp/blueprints/templates/specification-index.pt (+2/-2)
lib/lp/blueprints/templates/specification-portlet-subscribers.pt (+44/-1)
lib/lp/blueprints/templates/specification-subscriber-row.pt (+2/-0)
lib/lp/services/features/flags.py (+4/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/blueprint-subscriptions
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+64010@code.launchpad.net

Description of the change

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'.

To post a comment you must log in.

Unmerged revisions

13183. By Ian Booth

Merge from trunk

13182. By Ian Booth

Merged refactor-bugs-subscriber-javascript into blueprint-subscriptions.

13181. By Ian Booth

Merged refactor-bugs-subscriber-javascript into blueprint-subscriptions.

13180. By Ian Booth

Merged refactor-bugs-subscriber-javascript into blueprint-subscriptions.

13179. By Ian Booth

Add feature flag and updates tests

13178. By Ian Booth

Add feature flag and updates tests

13177. By Ian Booth

Fix some bugs and add tests

13176. By Ian Booth

Merged refactor-bugs-subscriber-javascript into blueprint-subscriptions.

13175. By Ian Booth

Add some javascript tests

13174. By Ian Booth

Merge from trunk

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'lib/lp/blueprints/javascript'
2=== added file 'lib/lp/blueprints/javascript/blueprint_index.js'
3--- lib/lp/blueprints/javascript/blueprint_index.js 1970-01-01 00:00:00 +0000
4+++ lib/lp/blueprints/javascript/blueprint_index.js 2011-06-16 02:58:38 +0000
5@@ -0,0 +1,20 @@
6+/* Copyright 2011 Canonical Ltd. This software is licensed under the
7+ * GNU Affero General Public License version 3 (see the file LICENSE).
8+ *
9+ * Form overlay widgets and subscriber handling for blueprint pages.
10+ *
11+ * @module blueprints
12+ * @submodule blueprint_index
13+ */
14+
15+YUI.add('lp.blueprints.blueprint_index', function(Y) {
16+
17+var namespace = Y.namespace('lp.blueprints.blueprint_index');
18+
19+namespace.setup_blueprint_index = function() {
20+ // Register the YUI event handlers to respond to events generated when
21+ // loading the subscription portlet.
22+ Y.lp.blueprints.blueprint_index.portlets.setup_portlet_handlers();
23+};
24+
25+}, "0.1", {"requires": ["base", "lp.blueprints.blueprint_index.portlets"]});
26
27=== added file 'lib/lp/blueprints/javascript/blueprint_index_portlets.js'
28--- lib/lp/blueprints/javascript/blueprint_index_portlets.js 1970-01-01 00:00:00 +0000
29+++ lib/lp/blueprints/javascript/blueprint_index_portlets.js 2011-06-16 02:58:38 +0000
30@@ -0,0 +1,853 @@
31+/* Copyright 2011 Canonical Ltd. This software is licensed under the
32+ * GNU Affero General Public License version 3 (see the file LICENSE).
33+ *
34+ * Form overlay widgets and subscriber handling for blueprint pages.
35+ *
36+ * @module blueprints
37+ * @submodule blueprint_index.portlets
38+ */
39+
40+YUI.add('lp.blueprints.blueprint_index.portlets', function(Y) {
41+
42+var namespace = Y.namespace('lp.blueprints.blueprint_index.portlets');
43+
44+// The IO module to use.
45+var YIO = Y;
46+
47+// The launchpad js client used.
48+var lp_client;
49+
50+// The launchpad client entry for the current blueprint.
51+var lp_blueprint_entry;
52+
53+// The blueprint itself, taken from cache.
54+var blueprint_repr;
55+
56+var subscription_labels = Y.lp.app.subscriber.subscription_labels;
57+
58+// The set of subscriber CSS IDs as a JSON struct.
59+var subscriber_ids;
60+
61+// We need to reset the onclick handler for the subscribe link until the
62+// edit subscription overlays are done. So we need to keep a reference to the
63+// current value.
64+var subscribe_link_handler = undefined;
65+
66+/*
67+ * An object representing the blueprint subscribers portlet.
68+ *
69+ * Since the portlet loads via XHR and inline subscribing
70+ * depends on that portlet being loaded, setup a custom
71+ * event object, to provide a hook for initializing subscription
72+ * link callbacks after custom events.
73+ */
74+var PortletTarget = function() {};
75+Y.augment(PortletTarget, Y.Event.Target);
76+namespace.portlet = new PortletTarget();
77+
78+/*
79+ * Create the lp client and bug entry if we haven't done so already.
80+ *
81+ * @method setup_client_and_bug
82+ */
83+function setup_client_and_blueprint() {
84+ lp_client = new Y.lp.client.Launchpad();
85+
86+ if (blueprint_repr === undefined) {
87+ blueprint_repr = LP.cache.context;
88+ lp_blueprint_entry = new Y.lp.client.Entry(
89+ lp_client, blueprint_repr, blueprint_repr.self_link);
90+ }
91+}
92+
93+namespace.load_subscribers_portlet = function(
94+ subscription_link, subscription_link_handler) {
95+ if (Y.UA.ie) {
96+ return null;
97+ }
98+
99+ Y.one('#subscribers-portlet-spinner').setStyle('display', 'block');
100+
101+ function hide_spinner() {
102+ Y.one('#subscribers-portlet-spinner').setStyle('display', 'none');
103+ // Fire a custom event to notify that the initial click
104+ // handler on subscription_link set above should be
105+ // cleared.
106+ if (namespace) {
107+ namespace.portlet.fire(
108+ 'blueprints:portletloadfailed', subscription_link_handler);
109+ }
110+ }
111+
112+ function setup_portlet(transactionid, response, args) {
113+ hide_spinner();
114+ Y.one('#portlet-subscribers')
115+ .appendChild(Y.Node.create(response.responseText));
116+
117+ // Fire a custom portlet loaded event to notify when
118+ // it's safe to setup subscriber link callbacks.
119+ namespace.portlet.fire('blueprints:portletloaded');
120+ }
121+
122+ var config = {on: {success: setup_portlet,
123+ failure: hide_spinner}};
124+ var url = Y.one('#subscribers-content-link').getAttribute('href');
125+ YIO.io(url, config);
126+};
127+
128+
129+namespace.setup_portlet_handlers = function() {
130+ namespace.portlet.subscribe('blueprints:portletloaded', function() {
131+ load_subscriber_ids();
132+ });
133+ /*
134+ * If the subscribers portlet fails to load, clear any
135+ * click handlers, so the normal subscribe page can be reached.
136+ */
137+ namespace.portlet.subscribe('blueprints:portletloadfailed', function(click_handler) {
138+ click_handler.detach();
139+ });
140+ namespace.portlet.subscribe('blueprints:portletsubscriberidsloaded', function() {
141+ var subscription = get_subscribe_self_subscription();
142+ setup_subscribe_me_handler(subscription);
143+ setup_subscribe_someone_else_handler(subscription);
144+ setup_unsubscribe_icon_handlers();
145+ });
146+
147+
148+ /*
149+ * Subscribing someone else requires loading a grayed out
150+ * username into the DOM until the subscribe action completes.
151+ * There are a couple XHR requests in check_can_be_unsubscribed
152+ * before the subscribe work can be done, so fire a custom event
153+ * blueprints:nameloaded and do the work here when the event fires.
154+ */
155+ namespace.portlet.subscribe('blueprints:nameloaded', function(subscription) {
156+ var error_handler = new Y.lp.client.ErrorHandler();
157+ error_handler.clearProgressUI = function() {
158+ var temp_link = Y.one('#temp-username');
159+ if (temp_link) {
160+ var temp_parent = temp_link.get('parentNode');
161+ temp_parent.removeChild(temp_link);
162+ }
163+ };
164+ error_handler.showError = function(error_msg) {
165+ Y.lp.app.errors.display_error(
166+ Y.one('.menu-link-addsubscriber'), error_msg);
167+ };
168+
169+ var config = {
170+ on: {
171+ success: function(result) {
172+ subscription.set('web_link', result.get('self_link'));
173+ var temp_link = Y.one('#temp-username');
174+ var temp_spinner = Y.one('#temp-name-spinner');
175+ temp_link.removeChild(temp_spinner);
176+ var person = subscription.get('person');
177+ add_user_name_link(subscription);
178+ Y.on('contentready', function() {
179+ var temp_parent = temp_link.get('parentNode');
180+ temp_parent.removeChild(temp_link);
181+ }, '.' + person.get('css_name'));
182+ },
183+ failure: error_handler.getFailureHandler()
184+ },
185+ parameters: {
186+ person: Y.lp.client.get_absolute_uri(
187+ subscription.get('person').get('escaped_uri')),
188+ suppress_notify: false
189+ }
190+ };
191+ lp_client.named_post(blueprint_repr.self_link, 'subscribe', config);
192+ });
193+};
194+
195+function load_subscriber_ids() {
196+ function on_success(transactionid, response, args) {
197+ subscriber_ids = Y.JSON.parse(response.responseText);
198+
199+ // Fire a custom event to trigger the setting-up of the
200+ // subscription handlers.
201+ namespace.portlet.fire('blueprints:portletsubscriberidsloaded');
202+ }
203+
204+ var config = {on: {success: on_success}};
205+ var url = Y.one(
206+ '#subscribers-ids-link').getAttribute('href');
207+ YIO.io(url, config);
208+}
209+
210+/*
211+ * Set click handlers for unsubscribe remove icons.
212+ *
213+ * @method setup_unsubscribe_icon_handlers
214+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
215+ */
216+function setup_unsubscribe_icon_handlers() {
217+ var subscription = new Y.lp.app.subscriber.Subscription({
218+ link: Y.one('.menu-link-subscription'),
219+ spinner: Y.one('#sub-unsub-spinner'),
220+ subscriber: new Y.lp.app.subscriber.Subscriber({
221+ uri: LP.links.me,
222+ subscriber_ids: subscriber_ids
223+ })
224+ });
225+
226+ Y.on('click', function(e) {
227+ e.halt();
228+ unsubscribe_user_via_icon(e.target, subscription);
229+ }, '.unsub-icon');
230+}
231+
232+/*
233+ * Set up and return a Subscription object for the direct subscription
234+ * link.
235+ */
236+function get_subscribe_self_subscription() {
237+ setup_client_and_blueprint();
238+ var subscription = new Y.lp.app.subscriber.Subscription({
239+ link: Y.one('.menu-link-subscription'),
240+ spinner: Y.one('#sub-unsub-spinner'),
241+ subscriber: new Y.lp.app.subscriber.Subscriber({
242+ uri: LP.links.me,
243+ subscriber_ids: subscriber_ids
244+ })
245+ });
246+
247+ subscription.set('can_be_unsubscribed', true);
248+ subscription.set('person', subscription.get('subscriber'));
249+ subscription.set('is_team', false);
250+ return subscription;
251+}
252+
253+/*
254+ * Initialize callbacks for subscribe/unsubscribe links.
255+ *
256+ * @method setup_subscription_link_handlers
257+ */
258+function setup_subscribe_me_handler(subscription) {
259+ if (LP.links.me === undefined) {
260+ return;
261+ }
262+
263+ if (subscription.is_node()) {
264+ var subscribe_node = subscription.get('link');
265+ var parent = subscribe_node.get('parentNode');
266+ var is_subscribed = parent.hasClass('subscribed-true');
267+ if (!is_subscribed) {
268+ // We need to ensure we don't attach more than one handler so
269+ // detach any existing one.
270+ if (subscribe_link_handler !== undefined ) {
271+ subscribe_link_handler.detach();
272+ }
273+ subscribe_link_handler = subscribe_node.on('click', function(e) {
274+ e.halt();
275+ subscription.set('can_be_unsubscribed', true);
276+ subscription.set('person', subscription.get('subscriber'));
277+ subscription.set('is_team', false);
278+ var parent = e.target.get('parentNode');
279+ if (parent.hasClass('subscribed-false')) {
280+ subscribe_current_user(subscription);
281+ }
282+ else {
283+ unsubscribe_current_user(subscription);
284+ }
285+ });
286+ subscribe_node.addClass('js-action');
287+ }
288+ }
289+}
290+
291+/*
292+ * Add the user name to the subscriber's list.
293+ *
294+ * @method add_user_name_link
295+ */
296+function add_user_name_link(subscription) {
297+ // Be paranoid about display_name, since timeouts or other errors
298+ // could mean display_name wasn't set on initialization.
299+ subscription.get('person').set_display_name(function () {
300+ _add_user_name_link(subscription);
301+ });
302+}
303+
304+function _add_user_name_link(subscription) {
305+ var person = subscription.get('person');
306+ var display_user_function = function(link_node) {
307+ var subscribers = Y.one('#subscribers-links');
308+ if (subscription.is_current_user_subscribing()) {
309+ // If this is the current user, then top post the name.
310+ subscribers.insertBefore(
311+ link_node, subscribers.get('firstChild'));
312+ } else {
313+ var next = get_next_subscriber_node(subscription);
314+ if (next) {
315+ subscribers.insertBefore(link_node, next);
316+ } else {
317+ // Handle the case of no subscribers.
318+ var none_subscribers = Y.one('#none-subscribers');
319+ if (none_subscribers) {
320+ var none_parent = none_subscribers.get('parentNode');
321+ none_parent.removeChild(none_subscribers);
322+ }
323+ subscribers.appendChild(link_node);
324+ }
325+ }
326+
327+ // Set the click handler if adding a remove icon.
328+ if (subscription.can_be_unsubscribed_by_user()) {
329+ var remove_icon =
330+ Y.one('#unsubscribe-icon-' + person.get('css_name'));
331+ remove_icon.on('click', function(e) {
332+ e.halt();
333+ unsubscribe_user_via_icon(e.target, subscription);
334+ });
335+ }
336+ Y.lazr.anim.green_flash({node: link_node}).run();
337+ };
338+ build_user_link_html(subscription, display_user_function);
339+}
340+
341+/*
342+ * Unsubscribe a user from this blueprint when a remove icon is clicked.
343+ *
344+ * @method unsubscribe_user_via_icon
345+ * @param icon {Node} The remove icon that was clicked.
346+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
347+*/
348+function unsubscribe_user_via_icon(icon, subscription) {
349+ icon.set('src', '/@@/spinner');
350+
351+ var user_uri = get_user_uri_from_icon(icon);
352+ var person = new Y.lp.app.subscriber.Subscriber({
353+ uri: user_uri,
354+ subscriber_ids: subscriber_ids
355+ });
356+ subscription.set('person', person);
357+
358+ var error_handler = new Y.lp.client.ErrorHandler();
359+ error_handler.clearProgressUI = function () {
360+ icon.set('src', '/@@/remove');
361+ // Grab the icon again to reset to click handler.
362+ var unsubscribe_icon = Y.one(
363+ '#unsubscribe-icon-' + person.get('css_name'));
364+ unsubscribe_icon.on('click', function(e) {
365+ e.halt();
366+ unsubscribe_user_via_icon(e.target, subscription);
367+ });
368+
369+ };
370+ error_handler.showError = function (error_msg) {
371+ var flash_node = Y.one('.' + person.get('css_name'));
372+ Y.lp.app.errors.display_error(flash_node, error_msg);
373+
374+ };
375+
376+ var subscription_link = subscription.get('link');
377+ var config = {
378+ on: {
379+ success: function(id, response, args) {
380+ Y.lp.app.subscribers_list.remove_user_link(person);
381+ set_subscription_link_parent_class(subscription_link, false);
382+ if (subscription.is_current_user_subscribing()) {
383+ subscription.disable_spinner(
384+ subscription_labels.SUBSCRIBE);
385+ setup_subscribe_me_handler(subscription);
386+ }
387+ },
388+
389+ failure: error_handler.getFailureHandler()
390+ }
391+ };
392+ if (!subscription.is_current_user_subscribing()) {
393+ config.parameters = {
394+ person: Y.lp.client.get_absolute_uri(user_uri)
395+ };
396+ }
397+ lp_client.named_post(blueprint_repr.self_link, 'unsubscribe', config);
398+}
399+
400+/*
401+ * Subscribe the current user via the LP API.
402+ *
403+ * @method subscribe_current_user
404+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
405+ */
406+function subscribe_current_user(subscription) {
407+ subscription.enable_spinner('Subscribing...');
408+ var subscription_link = subscription.get('link');
409+ var subscriber = subscription.get('subscriber');
410+
411+ var error_handler = new Y.lp.client.ErrorHandler();
412+ error_handler.clearProgressUI = function () {
413+ subscription.disable_spinner();
414+ };
415+ error_handler.showError = function (error_msg) {
416+ Y.lp.app.errors.display_error(subscription_link, error_msg);
417+ };
418+
419+ var config = {
420+ on: {
421+ success: function(result) {
422+ subscription.set('web_link', result.get('self_link'));
423+ if (subscribe_link_handler !== undefined ) {
424+ subscribe_link_handler.detach();
425+ }
426+ subscription.disable_spinner(
427+ subscription_labels.EDIT);
428+ var subscribe_node = subscription.get('link');
429+ subscribe_node.removeClass('remove')
430+ .removeClass('js-action')
431+ .addClass('edit');
432+
433+ set_subscription_link_parent_class(subscription_link, true);
434+
435+ // Handle the case where the subscriber's list displays
436+ // "No subscribers."
437+ var empty_subscribers = Y.one("#none-subscribers");
438+ if (empty_subscribers) {
439+ var parent = empty_subscribers.get('parentNode');
440+ parent.removeChild(empty_subscribers);
441+ }
442+
443+ add_user_name_link(subscription);
444+ // Only when the link is added to the page, indicate success.
445+ Y.on('contentready', function() {
446+ var flash_node = Y.one('.' + subscriber.get('css_name'));
447+ var anim = Y.lazr.anim.green_flash({ node: flash_node });
448+ anim.run();
449+ }, '.' + subscriber.get('css_name'));
450+ },
451+
452+ failure: error_handler.getFailureHandler()
453+ },
454+
455+ parameters: {
456+ person: Y.lp.client.get_absolute_uri(
457+ subscriber.get('escaped_uri'))
458+ }
459+ };
460+ lp_client.named_post(blueprint_repr.self_link, 'subscribe', config);
461+}
462+
463+/*
464+ * Unsubscribe the current user via the LP API.
465+ *
466+ * @method unsubscribe_current_user
467+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
468+ */
469+function unsubscribe_current_user(subscription) {
470+ subscription.enable_spinner('Unsubscribing...');
471+ var subscription_link = subscription.get('link');
472+ var subscriber = subscription.get('subscriber');
473+
474+ var error_handler = new Y.lp.client.ErrorHandler();
475+ error_handler.clearProgressUI = function () {
476+ subscription.disable_spinner();
477+ };
478+ error_handler.showError = function (error_msg) {
479+ Y.lp.app.errors.display_error(subscription_link, error_msg);
480+ };
481+
482+ var subscriber_link = Y.lp.client.get_absolute_uri(
483+ subscriber.get('escaped_uri'));
484+ var config = {
485+ on: {
486+ success: function(client) {
487+ subscription.disable_spinner(
488+ subscription_labels.SUBSCRIBE);
489+ set_subscription_link_parent_class(
490+ subscription_link, false);
491+ Y.lp.app.subscribers_list.remove_user_link(subscriber);
492+ },
493+
494+ failure: error_handler.getFailureHandler()
495+ },
496+
497+ parameters: { person: subscriber_link }
498+ };
499+
500+ lp_client.named_post(blueprint_repr.self_link, 'unsubscribe', config);
501+}
502+
503+/*
504+ * Initialize click handler for the subscribe someone else link.
505+ *
506+ * @method setup_subscribe_someone_else_handler
507+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
508+ */
509+function setup_subscribe_someone_else_handler(subscription) {
510+ if (LP.links.me === undefined) {
511+ return;
512+ }
513+ var config = {
514+ header: 'Subscribe someone else',
515+ step_title: 'Search',
516+ picker_activator: '.menu-link-addsubscriber'
517+ };
518+
519+ config.save = function(result) {
520+ subscribe_someone_else(result, subscription);
521+ };
522+ var picker = Y.lp.app.picker.create('ValidPersonOrTeam', config);
523+}
524+
525+/*
526+ * Build the HTML for a user link for the subscribers list.
527+ *
528+ * @method build_user_link_html
529+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
530+ * @param display_user_function {Object} A function to display the user node.
531+ */
532+function build_user_link_html(subscription, display_user_function) {
533+ var error_handler = new Y.lp.client.ErrorHandler();
534+ error_handler.showError = function (error_msg) {
535+ var subscription_link = subscription.get('link');
536+ Y.lp.app.errors.display_error(subscription_link, error_msg);
537+
538+ };
539+
540+ var config = {
541+ on: {
542+ success: function(id, response) {
543+ var html = Y.Node.create(response.responseText);
544+
545+ // Override the node ids so that the remove icon is wired up
546+ // correctly.
547+ var person = subscription.get('person');
548+ var css_name = person.get('css_name');
549+ html.set('id', 'subscription-' + css_name);
550+ var icon_link = html.one('a:nth-child(3)');
551+ icon_link.set('id', 'unsubscribe-' + css_name);
552+ icon_link.one('img')
553+ .set('id', 'unsubscribe-icon-' + css_name);
554+ display_user_function(html);
555+ },
556+
557+ failure: error_handler.getFailureHandler()
558+ }
559+ };
560+ // We need to retrieve the html to display from the server since we need
561+ // to use specification subscription attributes not available on the
562+ // client.
563+ var url_base = subscription.get('web_link').replace('/api/devel', '');
564+ var url = url_base + '/+blueprint-subscriber-row';
565+ YIO.io(url, config);
566+}
567+
568+/*
569+ * Returns the next node in alphabetical order after the subscriber
570+ * node now being added. No node is returned to append to end of list.
571+ *
572+ * The name can appear in one of two different lists. 1) The list of
573+ * subscribers that can be unsubscribed by the current user, and
574+ * 2) the list of subscribers that cannot be unsubscribed.
575+ *
576+ * @method get_next_subscriber_node
577+ * @param subscription_link {Node} The sub/unsub link.
578+ * @return {Node} The node appearing next in the subscriber list or
579+ * undefined if no node is next.
580+ */
581+function get_next_subscriber_node(subscription) {
582+ var full_name = subscription.get('person').get('full_display_name');
583+ var can_be_unsubscribed = subscription.can_be_unsubscribed_by_user();
584+ var nodes_by_name = {};
585+ var unsubscribables = [];
586+ var not_unsubscribables = [];
587+
588+ // Use the list of subscribers pulled from the DOM to have sortable
589+ // lists of unsubscribable vs. not unsubscribable person links.
590+ var all_subscribers = Y.all('#subscribers-links div');
591+ if (all_subscribers.size() > 0) {
592+ all_subscribers.each(function(sub_link) {
593+ if (sub_link.getAttribute('id') !== 'temp-username') {
594+ // User's displayname is found via the link's "name"
595+ // attribute.
596+ var sub_link_name = sub_link.one('a').getAttribute('name');
597+ nodes_by_name[sub_link_name] = sub_link;
598+ if (sub_link.one('img.unsub-icon')) {
599+ unsubscribables.push(sub_link_name);
600+ } else {
601+ not_unsubscribables.push(sub_link_name);
602+ }
603+ }
604+ });
605+
606+ // Add the current subscription.
607+ if (can_be_unsubscribed) {
608+ unsubscribables.push(full_name);
609+ } else {
610+ not_unsubscribables.push(full_name);
611+ }
612+ unsubscribables.sort();
613+ not_unsubscribables.sort();
614+ } else {
615+ // If there is no all_subscribers, then we're dealing with
616+ // the printed None, so return.
617+ return undefined;
618+ }
619+
620+ var i;
621+ if ((!unsubscribables && !not_unsubscribables) ||
622+ // If A) neither list exists, B) the user belongs in the second
623+ // list but the second list doesn't exist, or C) user belongs in the
624+ // first list and the second doesn't exist, return no node to append.
625+ (!can_be_unsubscribed && !not_unsubscribables) ||
626+ (can_be_unsubscribed && unsubscribables && !not_unsubscribables)) {
627+ return undefined;
628+ } else if (
629+ // If the user belongs in the first list, and the first list
630+ // doesn't exist, but the second one does, return the first node
631+ // in the second list.
632+ can_be_unsubscribed && !unsubscribables && not_unsubscribables) {
633+ return nodes_by_name[not_unsubscribables[0]];
634+ } else if (can_be_unsubscribed) {
635+ // If the user belongs in the first list, loop the list for position.
636+ for (i=0; i<unsubscribables.length; i++) {
637+ if (unsubscribables[i] === full_name) {
638+ if (i+1 < unsubscribables.length) {
639+ return nodes_by_name[unsubscribables[i+1]];
640+ // If the current link should go at the end of the first
641+ // list and we're at the end of that list, return the
642+ // first node of the second list. Due to earlier checks
643+ // we can be sure this list exists.
644+ } else if (i+1 >= unsubscribables.length) {
645+ return nodes_by_name[not_unsubscribables[0]];
646+ }
647+ }
648+ }
649+ } else if (!can_be_unsubscribed) {
650+ // If user belongs in the second list, loop the list for position.
651+ for (i=0; i<not_unsubscribables.length; i++) {
652+ if (not_unsubscribables[i] === full_name) {
653+ if (i+1 < not_unsubscribables.length) {
654+ return nodes_by_name[not_unsubscribables[i+1]];
655+ } else {
656+ return undefined;
657+ }
658+ }
659+ }
660+ }
661+}
662+
663+/*
664+ * Traverse the DOM of a given remove icon to find
665+ * the user's link. Returns a URI of the form "/~username".
666+ *
667+ * @method get_user_uri_from_icon
668+ * @param icon {Node} The node representing a remove icon.
669+ * @return user_uri {String} The user's uri, without the hostname.
670+ */
671+function get_user_uri_from_icon(icon) {
672+ var parent_div = icon.get('parentNode').get('parentNode');
673+ // This should be parent_div.firstChild, but because of #text
674+ // and cross-browser issues, using the YUI query syntax is
675+ // safer here.
676+ var user_uri = parent_div.one('a:nth-child(2)').getAttribute('href');
677+
678+ // Strip the domain off. We just want a path.
679+ var host_start = user_uri.indexOf('//');
680+ if (host_start !== -1) {
681+ var host_end = user_uri.indexOf('/', host_start+2);
682+ return user_uri.substring(host_end, user_uri.length);
683+ }
684+
685+ return user_uri;
686+}
687+
688+/*
689+ * Set the class on subscription link's parentNode.
690+ *
691+ * This is used to reset the class used by the
692+ * click handler to know which link was clicked.
693+ *
694+ * @method set_subscription_link_parent_class
695+ * @param subscription_link {Node} The sub/unsub link.
696+ * @param subscribed {Boolean} The sub/unsub'ed flag for the class.
697+ */
698+function set_subscription_link_parent_class(user_link, subscribed) {
699+ var parent = user_link.get('parentNode');
700+ if (subscribed) {
701+ parent.removeClass('subscribed-false');
702+ parent.addClass('subscribed-true');
703+ } else {
704+ parent.removeClass('subscribed-true');
705+ parent.addClass('subscribed-false');
706+ }
707+}
708+
709+/*
710+ * Subscribe a person or team other than the current user.
711+ * This is a callback for the subscribe someone else picker.
712+ *
713+ * @method subscribe_someone_else
714+ * @result {Object} The object representing a person returned by the API.
715+ */
716+function subscribe_someone_else(result, subscription) {
717+ var person = new Y.lp.app.subscriber.Subscriber({
718+ uri: result.api_uri,
719+ display_name: result.title,
720+ subscriber_ids: subscriber_ids
721+ });
722+ subscription.set('person', person);
723+
724+ var error_handler = new Y.lp.client.ErrorHandler();
725+ error_handler.showError = function(error_msg) {
726+ Y.lp.app.errors.display_error(
727+ Y.one('.menu-link-addsubscriber'), error_msg);
728+ };
729+
730+ if (subscription.is_already_subscribed()) {
731+ error_handler.showError(
732+ subscription.get('person').get('full_display_name') +
733+ ' has already been subscribed');
734+ } else {
735+ check_can_be_unsubscribed(subscription);
736+ }
737+}
738+
739+/*
740+ * Check if the current user can unsubscribe the person
741+ * being subscribed.
742+ *
743+ * This must be done in JavaScript, since the subscription
744+ * hasn't completed yet, and so, can_be_unsubscribed_by_user
745+ * cannot be used.
746+ *
747+ * @method check_can_be_unsubscribed
748+ * @param subscription {Object} A Y.lp.app.subscriber.Subscription object.
749+ */
750+function check_can_be_unsubscribed(subscription) {
751+ var error_handler = new Y.lp.client.ErrorHandler();
752+ error_handler.showError = function (error_msg) {
753+ Y.lp.app.errors.display_error(
754+ Y.one('.menu-link-addsubscriber'), error_msg);
755+ };
756+
757+ var config = {
758+ on: {
759+ success: function(result) {
760+ var is_team = result.get('is_team');
761+ subscription.set('is_team', is_team);
762+ var final_config = {
763+ on: {
764+ success: function(result) {
765+ var team_member = false;
766+ var i;
767+ for (i=0; i<result.entries.length; i++) {
768+ if (result.entries[i].get('member_link') ===
769+ Y.lp.client.get_absolute_uri(
770+ subscription.get(
771+ 'subscriber').get('uri'))) {
772+ team_member = true;
773+ }
774+ }
775+
776+ if (team_member) {
777+ subscription.set('can_be_unsubscribed', true);
778+ add_temp_user_name(subscription);
779+ } else {
780+ subscription.set(
781+ 'can_be_unsubscribed', false);
782+ add_temp_user_name(subscription);
783+ }
784+ },
785+
786+ failure: error_handler.getFailureHandler()
787+ }
788+ };
789+
790+ if (is_team) {
791+ // Get a list of members to see if current user
792+ // is a team member.
793+ var members = result.get(
794+ 'members_details_collection_link');
795+ lp_client.get(members, final_config);
796+ } else {
797+ subscription.set('can_be_unsubscribed', false);
798+ add_temp_user_name(subscription);
799+ }
800+ },
801+
802+ failure: error_handler.getFailureHandler()
803+ }
804+ };
805+ var uri = Y.lp.client.get_absolute_uri(
806+ subscription.get('person').get('escaped_uri'));
807+ lp_client.get(uri, config);
808+}
809+
810+/*
811+ * Add a grayed out, temporary user name when subscribing
812+ * someone else.
813+ *
814+ * @method add_temp_user_name
815+ * @param subscription_link {Node} The sub/unsub link.
816+ */
817+function add_temp_user_name(subscription) {
818+ // Be paranoid about display_name, since timeouts or other errors
819+ // could mean display_name wasn't set on initialization.
820+ subscription.get('person').set_display_name(function () {
821+ _add_temp_user_name(subscription);
822+ });
823+}
824+
825+function _add_temp_user_name(subscription) {
826+ var display_name = subscription.get('person').get('display_name');
827+ var img_src;
828+ if (subscription.is_team()) {
829+ img_src = '/@@/teamgray';
830+ } else {
831+ img_src = '/@@/persongray';
832+ }
833+
834+ // The <span>...</span> below must *not* be <span/>. On FF (maybe
835+ // others, but at least on FF 3.0.11) will then not notice any
836+ // following sibling nodes, like the spinner image.
837+ var link_node = Y.Node.create([
838+ '<div id="temp-username"> ',
839+ ' <img alt="" width="14" height="14" />',
840+ ' <span>Other Display Name</span>',
841+ ' <img id="temp-name-spinner" src="/@@/spinner" alt="" ',
842+ ' style="position:absolute;right:8px" /></div>'].join(''));
843+ link_node.one('img').set('src', img_src);
844+ link_node.replaceChild(
845+ document.createTextNode(display_name),
846+ link_node.one('span'));
847+
848+ var subscribers = Y.one('#subscribers-links');
849+ var next = get_next_subscriber_node(subscription);
850+ if (next) {
851+ subscribers.insertBefore(link_node, next);
852+ } else {
853+ // Handle the case of no subscribers.
854+ var none_subscribers = Y.one('#none-subscribers');
855+ if (none_subscribers) {
856+ var none_parent = none_subscribers.get('parentNode');
857+ none_parent.removeChild(none_subscribers);
858+ }
859+ subscribers.appendChild(link_node);
860+ }
861+
862+ // Fire a custom event to know it's safe to begin
863+ // any actual subscribing work.
864+ namespace.portlet.fire('blueprints:nameloaded', subscription);
865+}
866+
867+/**
868+ * Set up and configure the module.
869+ */
870+ namespace.setup_config = function(config) {
871+ if (config.yio !== undefined) {
872+ //We can be given an alternative IO provider for use in tests.
873+ YIO = config.yio;
874+ }
875+};
876+
877+}, "0.1", {"requires": ["base", "oop", "node", "event", "io-base",
878+ "json-parse", "substitute", "widget-position-ext",
879+ "lazr.formoverlay", "lazr.anim", "lazr.base",
880+ "lazr.overlay", "lazr.choiceedit", "lp.app.picker",
881+ "lp.client",
882+ "lp.client.plugins", "lp.app.subscriber",
883+ "lp.app.subscribers_list", "lp.app.errors"]});
884
885=== added directory 'lib/lp/blueprints/javascript/tests'
886=== added file 'lib/lp/blueprints/javascript/tests/test_subscription_portlet.html'
887--- lib/lp/blueprints/javascript/tests/test_subscription_portlet.html 1970-01-01 00:00:00 +0000
888+++ lib/lp/blueprints/javascript/tests/test_subscription_portlet.html 2011-06-16 02:58:38 +0000
889@@ -0,0 +1,47 @@
890+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
891+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
892+<html>
893+ <head>
894+ <title>Blueprint subscription portlet</title>
895+
896+ <!-- YUI 3.0 Setup -->
897+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
898+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
899+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
900+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
901+ <link rel="stylesheet" href="../../../../canonical/launchpad/javascript/test.css" />
902+
903+ <!-- Some required dependencies -->
904+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
905+ <script type="text/javascript" src="../../../app/javascript/client.js"></script>
906+ <script type="text/javascript" src="../../../app/javascript/errors.js"></script>
907+ <script type="text/javascript" src="../../../app/javascript/picker.js"></script>
908+ <script type="text/javascript" src="../../../app/javascript/widgets.js"></script>
909+ <script type="text/javascript" src="../../../app/javascript/subscriber.js"></script>
910+ <script type="text/javascript" src="../../../app/javascript/subscribers_list.js"></script>
911+
912+ <!-- The module under test -->
913+ <script type="text/javascript" src="../blueprint_index_portlets.js"></script>
914+
915+ <!-- The test suite -->
916+ <script type="text/javascript" src="test_subscription_portlet.js"></script>
917+</head>
918+<body class="yui3-skin-sam">
919+ <div id="portlet-subscribers" class="portlet vertical">
920+ <div class="section">
921+ <div id="sub-unsub-spinner">Subscribing...</div>
922+ <a class="menu-link-addsubscriber"
923+ href="https://blueprints.launchpad.dev/foo/+spec/bar/+addsubscriber">Subscribe someone else</a>
924+ </div>
925+ <a id="subscribers-ids-link"
926+ href="https://blueprints.launchpad.dev/foo/+spec/bar/+blueprint-portlet-subscribers-ids"></a>
927+ <a id="subscribers-content-link"
928+ href="https://blueprints.launchpad.dev/foo/+spec/bar/+blueprint-portlet-subscribers-content"></a>
929+ <div id="subscribers-portlet-spinner"
930+ style="text-align: center; display: none">
931+ <img src="/@@/spinner" />
932+ </div>
933+ </div>
934+ <div id="log"></div>
935+</body>
936+</html>
937
938=== added file 'lib/lp/blueprints/javascript/tests/test_subscription_portlet.js'
939--- lib/lp/blueprints/javascript/tests/test_subscription_portlet.js 1970-01-01 00:00:00 +0000
940+++ lib/lp/blueprints/javascript/tests/test_subscription_portlet.js 2011-06-16 02:58:38 +0000
941@@ -0,0 +1,285 @@
942+/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
943+
944+YUI({
945+ base: '../../../../canonical/launchpad/icing/yui/',
946+ filter: 'raw',
947+ combine: false,
948+ fetchCSS: false
949+ }).use('test', 'console', 'node', 'event', 'event-simulate',
950+ 'lp.client', 'lp.app.subscriber', 'lp.app.subscribers_list',
951+ 'lp.blueprints.blueprint_index.portlets',
952+ function(Y) {
953+
954+var suite = new Y.Test.Suite("lp.blueprints.subscriber_portlet Tests");
955+var module = Y.lp.blueprints.blueprint_index.portlets;
956+module.setup_portlet_handlers();
957+
958+/*
959+ * A wrapper for the Y.Event.simulate() function. The wrapper accepts
960+ * CSS selectors and Node instances instead of raw nodes.
961+ */
962+function simulate(widget, selector, evtype, options) {
963+ var rawnode = Y.Node.getDOMNode(widget.one(selector));
964+ Y.Event.simulate(rawnode, evtype, options);
965+}
966+
967+/**
968+ * A stub io handler.
969+ */
970+function IOStub(test_case){
971+ if (!(this instanceof IOStub)) {
972+ throw new Error("Constructor called as a function");
973+ }
974+ this.calls = [];
975+ this.responses = [];
976+ this.io = function(url, config) {
977+ this.calls.push(url);
978+ var response = {responseText: ''};
979+ // We may have been passed text to use in the response.
980+ if (Y.Lang.isValue(arguments.callee.responseText)) {
981+ response.responseText = arguments.callee.responseText;
982+ } else if (this.responses.length>0) {
983+ response.responseText = this.responses.shift();
984+ }
985+
986+ // We currently only support calling the success handler.
987+ config.on.success(undefined, response, arguments.callee.args);
988+ // After calling the handler, resume the test.
989+ if (Y.Lang.isFunction(arguments.callee.doAfter)) {
990+ test_case.resume(arguments.callee.doAfter);
991+ }
992+ };
993+}
994+
995+suite.add(new Y.Test.Case({
996+ name: "lp.blueprints.subscriber_portlet",
997+
998+ load_portlet: function() {
999+ var subscription_link = Y.one('.menu-link-subscription');
1000+ var subscription_link_handler = undefined;
1001+ // Until edit subscription overlays are supported we don't want to
1002+ // load the ajax subscribers portal on the html subscription edit
1003+ // page.
1004+ var load_subscribers_portlet = true;
1005+ if (subscription_link) {
1006+ load_subscribers_portlet = !subscription_link.hasClass('nolink');
1007+ subscription_link_handler = subscription_link.on(
1008+ 'click', function(e) { e.preventDefault(); });
1009+ }
1010+ if (load_subscribers_portlet) {
1011+ Y.lp.blueprints.blueprint_index.portlets.load_subscribers_portlet(
1012+ subscription_link, subscription_link_handler);
1013+ }
1014+ },
1015+
1016+ setUp: function() {
1017+ // Some common data required for each test.
1018+ this.MY_WEB_LINK = 'https://blueprints.launchpad.dev/foo/+spec/bar';
1019+ this.MY_NAME = "ME";
1020+ this.ME = new Y.lp.client.Entry();
1021+ this.ME.addAttr('display_name', {value: "ME"});
1022+ this.SUBSCRIBER_ROW_HTML =
1023+ '<div class="subscriber" id="subscription-subscriber-12">' +
1024+ ' <a href="/foo/+spec/bar/+subscription/me">' +
1025+ ' <img alt="" src="/@@/subscriber-inessential"' +
1026+ ' title="Normal subscriber." />' +
1027+ ' </a>' +
1028+ ' <a href="/~me">ME</a>' +
1029+ ' <a href="+subscribe" id="unsubscribe-subscriber-12"' +
1030+ ' title="Unsubscribe ME" class="unsub-icon">' +
1031+ ' <img></img>' +
1032+ ' </a>' +
1033+ '</div>';
1034+
1035+ window.LP = {
1036+ links: { me: "/~" + this.MY_NAME },
1037+ cache: {
1038+ context: {
1039+ self_link: 'https://launchpad.dev/api/devel/foo/+spec/bar/'
1040+ }
1041+ }
1042+ };
1043+ // A container to allow us to specify what results to return for
1044+ // named_post and get calls during the test. Each subsequent call
1045+ // takes its return data from the front of the array.
1046+ window.expected_results = {
1047+ get_results: [],
1048+ named_post_results: []
1049+ };
1050+
1051+ // We need to stub out Launchpad client named_post and get operations
1052+ // so that we can record what requests were made and provide test data
1053+ // back to the caller.
1054+ Y.lp.client.Launchpad = function() {};
1055+ Y.lp.client.Launchpad.prototype.named_post =
1056+ function(url, func, config) {
1057+ LP.cache.named_post_call_data = {
1058+ called_url: url,
1059+ called_func: func,
1060+ called_config: config
1061+ };
1062+ // our setup assumes success, so we just do the
1063+ // success callback.
1064+ var result = '';
1065+ if (!Y.Lang.isArray(expected_results.named_post_results)) {
1066+ result = expected_results.named_post_results;
1067+ } else {
1068+ if (expected_results.named_post_results.length > 0) {
1069+ result = expected_results.named_post_results.shift();
1070+ }
1071+ }
1072+ config.on.success(result);
1073+ };
1074+ Y.lp.client.Launchpad.prototype.get =
1075+ function(url, config) {
1076+ LP.cache.get_call_data = {
1077+ called_url: url
1078+ };
1079+ var result = '';
1080+ if (!Y.Lang.isArray(expected_results.get_results)) {
1081+ result = expected_results.get_results;
1082+ } else {
1083+ if (expected_results.get_results.length > 0) {
1084+ result = expected_results.get_results.shift();
1085+ }
1086+ }
1087+ config.on.success(result);
1088+ };
1089+
1090+ this.root_node = Y.one('#portlet-subscribers');
1091+ this.config = {};
1092+ this.config.yio = new IOStub(this);
1093+ module.setup_config(this.config);
1094+ },
1095+
1096+ removeNode: function(selector) {
1097+ var node = Y.one(selector);
1098+ if (node) {
1099+ node.remove(true);
1100+ }
1101+ },
1102+
1103+ tearDown: function() {
1104+ delete window.LP;
1105+ delete window.expected_results;
1106+ this.removeNode('#subscribe-node');
1107+ this.removeNode('#subscribers');
1108+ this.removeNode('.yui3-overlay');
1109+ },
1110+
1111+ check_portlet_loaded: function() {
1112+ Y.ArrayAssert.itemsAreEqual(
1113+ [this.MY_WEB_LINK+'/+blueprint-portlet-subscribers-content',
1114+ this.MY_WEB_LINK+'/+blueprint-portlet-subscribers-ids'],
1115+ this.config.yio.calls);
1116+ },
1117+
1118+ /**
1119+ * Create an initial page state where the current user is either
1120+ * subscribed or unsubscribed to the blueprint.
1121+ */
1122+ setup_me: function(is_subscribed) {
1123+ var subscribe_section = Y.one(".portlet .section");
1124+ var subscribe_node = Y.Node.create("<div id='subscribe-node'></div>");
1125+ subscribe_node.addClass('subscribed-'+is_subscribed);
1126+ var subscribe_link = Y.Node.create("<a>Subscribe</a>");
1127+ subscribe_link.addClass('menu-link-subscription');
1128+ subscribe_node.appendChild(subscribe_link);
1129+ subscribe_section.prepend(subscribe_node);
1130+ var subscriber_html='';
1131+ if (is_subscribed) {
1132+ subscriber_html = this.SUBSCRIBER_ROW_HTML;
1133+ }
1134+ var portlet_content =
1135+ '<div id="subscribers">' +
1136+ ' <h2>Subscribers</h2>' +
1137+ ' <div id="subscribers-links">' +
1138+ subscriber_html +
1139+ ' </div>'+
1140+ '</div>';
1141+ this.config.yio.responses = [
1142+ portlet_content, '{"me": "subscriber-12"}'];
1143+ expected_results.get_results = this.ME;
1144+ this.load_portlet();
1145+ this.check_portlet_loaded();
1146+ },
1147+
1148+ /**
1149+ * Create an initial page state where the current user is unsubscribed to
1150+ * the blueprint.
1151+ */
1152+ setup_me_unsubscribed: function() {
1153+ this.setup_me(false);
1154+ },
1155+
1156+ /**
1157+ * Create an initial page state where the current user is subscribed to
1158+ * the blueprint.
1159+ */
1160+ setup_me_subscribed: function() {
1161+ this.setup_me(true);
1162+ var subscribers = Y.one('#subscribers-links');
1163+ Y.Assert.isNotNull(
1164+ subscribers.one(".subscriber a:nth-child(2)[href='/~me']"));
1165+ },
1166+
1167+ test_subscribe_me: function() {
1168+ // The Subscribe link works if the user is unsubscribed.
1169+ this.setup_me_unsubscribed();
1170+ expected_results.get_results = this.ME;
1171+ var result = new Y.lp.client.Entry();
1172+ result.set('self_link',
1173+ LP.cache.context.self_link+'/+subscription/me');
1174+ expected_results.named_post_results = [result];
1175+
1176+ this.config.yio.responses = [this.SUBSCRIBER_ROW_HTML];
1177+
1178+ simulate(this.root_node, '.menu-link-subscription', 'click');
1179+ Y.Assert.areEqual(LP.cache.context.self_link,
1180+ LP.cache.named_post_call_data.called_url);
1181+ Y.Assert.areEqual('subscribe',
1182+ LP.cache.named_post_call_data.called_func);
1183+ var subscribers = Y.one('#subscribers-links');
1184+ Y.Assert.isNotNull(
1185+ subscribers.one(".subscriber a:nth-child(2)[href='/~me']"));
1186+ },
1187+
1188+ test_edit_my_subscription: function() {
1189+ // If the user is subscribed, the subscribe link is not ajaxified.
1190+ this.setup_me_subscribed();
1191+ var subscribe_node = Y.one('.menu-link-subscription');
1192+ Y.Assert.isFalse(subscribe_node.hasClass('js-action'));
1193+ },
1194+
1195+ test_unsubscribe_me: function() {
1196+ // The unsubscribe link works if the user is subscribed.
1197+ this.setup_me_subscribed();
1198+ simulate(this.root_node, '.unsub-icon', 'click');
1199+ Y.Assert.areEqual(LP.cache.context.self_link,
1200+ LP.cache.named_post_call_data.called_url);
1201+ Y.Assert.areEqual('unsubscribe',
1202+ LP.cache.named_post_call_data.called_func);
1203+ Y.on('contentready', function() {
1204+ Y.Assert.isNotNull(Y.one("#none-subscribers"));
1205+ }, '#subscribers');
1206+ }
1207+}));
1208+
1209+// Lock, stock, and two smoking barrels.
1210+var handle_complete = function(data) {
1211+ status_node = Y.Node.create(
1212+ '<p id="complete">Test status: complete</p>');
1213+ Y.one('body').appendChild(status_node);
1214+ };
1215+Y.Test.Runner.on('complete', handle_complete);
1216+Y.Test.Runner.add(suite);
1217+
1218+var yui_console = new Y.Console({
1219+ newestOnTop: false
1220+});
1221+yui_console.render('#log');
1222+
1223+Y.on('domready', function() {
1224+ Y.Test.Runner.run();
1225+});
1226+});
1227
1228=== modified file 'lib/lp/blueprints/templates/specification-index.pt'
1229--- lib/lp/blueprints/templates/specification-index.pt 2011-04-14 22:33:16 +0000
1230+++ lib/lp/blueprints/templates/specification-index.pt 2011-06-16 02:58:38 +0000
1231@@ -319,8 +319,8 @@
1232 </div>
1233
1234 <script type="text/javascript">
1235- LPS.use('lazr.anim', 'lp.ui', function(Y) {
1236-
1237+ LPS.use('lazr.anim', 'lp.ui', 'lp.blueprints.blueprint_index', function(Y) {
1238+ Y.lp.blueprints.blueprint_index.setup_blueprint_index();
1239 Y.on('lp:context:implementation_status:changed', function(e) {
1240 var icon = Y.one('#informational-icon');
1241 if (e.new_value == 'Informational') {
1242
1243=== modified file 'lib/lp/blueprints/templates/specification-portlet-subscribers.pt'
1244--- lib/lp/blueprints/templates/specification-portlet-subscribers.pt 2011-06-16 02:58:37 +0000
1245+++ lib/lp/blueprints/templates/specification-portlet-subscribers.pt 2011-06-16 02:58:38 +0000
1246@@ -5,13 +5,56 @@
1247 class="portlet vertical"
1248 id="portlet-subscribers"
1249 metal:define-macro="custom"
1250+ tal:define="features request/features"
1251 >
1252 <div class="section" tal:define="context_menu context/menu:context"
1253 metal:define-slot="heading">
1254 <div
1255 tal:attributes="class view/current_user_subscription_class"
1256 tal:content="structure context_menu/subscription/render" />
1257+ <div id="sub-unsub-spinner">Subscribing...</div>
1258 <div tal:content="structure context_menu/addsubscriber/render" />
1259 </div>
1260- <div tal:replace="structure context/@@+blueprint-portlet-subscribers-content" />
1261+<a id="subscribers-ids-link"
1262+ tal:define="blueprint context"
1263+ tal:attributes="href blueprint/fmt:url/+blueprint-portlet-subscribers-ids"></a>
1264+<a id="subscribers-content-link"
1265+ tal:define="blueprint context"
1266+ tal:attributes="href blueprint/fmt:url/+blueprint-portlet-subscribers-content"></a>
1267+<div id="subscribers-portlet-spinner"
1268+ style="text-align: center; display: none">
1269+<img src="/@@/spinner" />
1270+</div>
1271+<div tal:condition="features/disclosure.enhanced_blueprint_subscriptions.enabled">
1272+<script type="text/javascript">
1273+LPS.use('io-base', 'node', 'lp.blueprints.blueprint_index.portlets', function(Y) {
1274+ // Must be done inline here to ensure the load event fires.
1275+ // This is a work around for a YUI3 issue with event handling.
1276+ var subscription_link = Y.one('.menu-link-subscription');
1277+ var subscription_link_handler = undefined;
1278+ // Until edit subscription overlays are supported we don't want to load
1279+ // the ajax subscribers portal on the html subscription edit page.
1280+ var load_subscribers_portlet = true;
1281+ if (subscription_link) {
1282+ load_subscribers_portlet = !subscription_link.hasClass('nolink');
1283+ subscription_link_handler = subscription_link.on(
1284+ 'click', function(e) { e.preventDefault(); });
1285+ }
1286+
1287+ if (load_subscribers_portlet) {
1288+ Y.on('domready', function() {
1289+ if (Y.lp.blueprints.blueprint_index.portlets) {
1290+ Y.lp.blueprints.blueprint_index.portlets.load_subscribers_portlet(
1291+ subscription_link, subscription_link_handler);
1292+ }
1293+ });
1294+ }
1295+});
1296+</script>
1297+<noscript>
1298+<div tal:replace="structure context/@@+blueprint-portlet-subscribers-content"/>
1299+</noscript>
1300+</div>
1301+<div tal:condition="not: features/disclosure.enhanced_blueprint_subscriptions.enabled"
1302+ tal:replace="structure context/@@+blueprint-portlet-subscribers-content"/>
1303 </div>
1304
1305=== modified file 'lib/lp/blueprints/templates/specification-subscriber-row.pt'
1306--- lib/lp/blueprints/templates/specification-subscriber-row.pt 2011-06-16 02:58:37 +0000
1307+++ lib/lp/blueprints/templates/specification-subscriber-row.pt 2011-06-16 02:58:38 +0000
1308@@ -37,6 +37,7 @@
1309 <a tal:attributes="href subscription/person/fmt:url:blueprints"
1310 tal:content="subscription/person/fmt:displayname"
1311 />
1312+ <tal:block condition="request/features/disclosure.enhanced_blueprint_subscriptions.enabled">
1313 <a tal:define="user request/lp:person"
1314 tal:condition="python: subscription.canBeUnsubscribedByUser(user)"
1315 href="+subscribe"
1316@@ -47,5 +48,6 @@
1317 <img class="unsub-icon" src="/@@/remove" alt="Remove"
1318 tal:attributes="id string:unsubscribe-icon-${subscription/css_name}" />
1319 </a>
1320+ </tal:block>
1321 </div>
1322 </tal:root>
1323
1324=== modified file 'lib/lp/services/features/flags.py'
1325--- lib/lp/services/features/flags.py 2011-06-11 03:42:08 +0000
1326+++ lib/lp/services/features/flags.py 2011-06-16 02:58:38 +0000
1327@@ -114,6 +114,10 @@
1328 'boolean',
1329 ('Enables ranking by pillar affiliation in the person picker.'),
1330 ''),
1331+ ('disclosure.enhanced_blueprint_subscriptions.enabled',
1332+ 'boolean',
1333+ ('Enables improved blueprint subscription features.'),
1334+ ''),
1335 ])
1336
1337 # The set of all flag names that are documented.