Merge lp:~yellow/launchpad/accordion-client-1 into lp:launchpad

Proposed by Gary Poster
Status: Merged
Approved by: Benji York
Approved revision: no longer in the source branch.
Merged at revision: 12682
Proposed branch: lp:~yellow/launchpad/accordion-client-1
Merge into: lp:launchpad
Diff against target: 2488 lines (+2223/-25)
17 files modified
buildout-templates/bin/combine-css.in (+5/-1)
lib/lp/app/javascript/lp.js (+43/-13)
lib/lp/app/javascript/tests/test_accordionoverlay.html (+26/-0)
lib/lp/bugs/browser/structuralsubscription.py (+4/-1)
lib/lp/bugs/browser/tests/test_expose.py (+12/-1)
lib/lp/bugs/templates/bug-subscription-list.pt (+17/-2)
lib/lp/bugs/templates/bugtarget-subscription-list.pt (+17/-2)
lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css (+0/-1)
lib/lp/registry/browser/product.py (+17/-1)
lib/lp/registry/help/structural-subscription-name.html (+30/-0)
lib/lp/registry/help/structural-subscription-tags.html (+28/-0)
lib/lp/registry/javascript/structural-subscription.js (+1189/-0)
lib/lp/registry/javascript/tests/test_structural_subscription.html (+63/-0)
lib/lp/registry/javascript/tests/test_structural_subscription.js (+751/-0)
lib/lp/registry/templates/product-index.pt (+14/-0)
lib/lp/registry/templates/product-portlet-license-missing.pt (+1/-1)
lib/lp/services/inlinehelp/javascript/inlinehelp.js (+6/-2)
To merge this branch: bzr merge lp:~yellow/launchpad/accordion-client-1
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+54715@code.launchpad.net

Commit message

[r=gmb][no-qa] Add client-side changes for the new structural subscription functionality.

Description of the change

This merges the current state of the yellow-squad's client-side work into devel. These changes are feature-flagged.

A UI review will happen later, based on the feature-flagged experience, after we get more a bit more polish done.

= Small changes =

buildout-templates/bin/combine-css.in : the previous css was also from this effort; we now include the separate css files.

lib/lp/app/javascript/lp.js :
 * Clean up wrapper_div to code to put pertinent code in a self-contained function
 * toggle_collapsible : add a UI clean up to close any collapsable siblings when you open one.

lib/lp/bugs/browser/structuralsubscription.py , lib/lp/bugs/browser/tests/test_expose.py : we use the mainsite url of the susbcription target so that the links can be nicer; and we add a url to the subscriber so we can provide those links.

(Removed) lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css : This was a vestigial bit of code from previous inclusions of the accordion for this effort.

lib/lp/registry/templates/product-portlet-license-missing.pt : tal:content to tal:replace because it is using 'structure,' and the replacement value has an anchor in it. With 'content' there was an <a> within an <a>.

= Accordion overlay =

lib/lp/app/javascript/tests/test_accordionoverlay.html : Test html page
lib/lp/registry/javascript/structural-subscription.js : This is the meat of the code. namespace.setup is the entry point for the "add" story.
lib/lp/registry/javascript/tests/test_structural_subscription.html : hook up for test
lib/lp/registry/javascript/tests/test_structural_subscription.js : Test code.

Note that we have no Windmill integration tests between the client and server. Do we need those to land? If so, I suspect we'll need some training. Bac writes on IRC:
"""regarding windmill, i think we need something. for instance, the unit tests in test_subscription_links work happily even if the bits to invoke the JS setup are missing from the page template....i think we need windmill to just show the links are visible and have js-action class"""

== Help test ==

lib/lp/registry/help/structural-subscription-name.html
lib/lp/registry/help/structural-subscription-tags.html
lib/lp/bugs/help/structural-subscription-name.html
lib/lp/bugs/help/structural-subscription-tags.html

These are the help texts for the accordion overlay. We use the overlay in the main site and the bugs site, so we have symlinks to keep them text in sync.

== Show structural subscriptions for a bug ==

This is part of the "unsubscribe in anger" story.

lib/lp/bugs/templates/bug-subscription-list.pt : adds code to initialize the JS. It will also eventually want to have the code to display the information about the other kinds of subscriptions.
lib/lp/registry/javascript/structural-subscription.js : namespace.setup_bug_subscriptions is the entry point for the edit story, shared by the section below.
lib/lp/registry/javascript/tests/test_structural_subscription.js : Test code. Note that it tests the overlay, and not the display code used by the editing pages. We really need some tests for that. Stating that as a requirement for landing would make sense to me. ;-)

= Edit your structural subscriptions for a given target =

lib/lp/bugs/templates/bugtarget-subscription-list.pt : this is essentially identical to lib/lp/bugs/templates/bug-subscription-list.pt . It will eventually also want an ability to add a subscription.
See previous section for the other files.

= Add feature-flag protected bug link to product page =

lib/lp/registry/browser/product.py
lib/lp/registry/templates/product-index.pt

= lib/lp/services/inlinehelp/javascript/inlinehelp.js =

As the comment says, we want this function to to be idempotent so that we can hook up help links after the form overlay has been rendered, which may be after the normal mochikit event handler has run.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :
Download full text (6.6 KiB)

Hi Gary,

Whew, done. There are 20-odd items that need addressing but most of them
are quite small. Bear in mind that some of these may be addressed in the
next branch, but I thought it better to make note of them now just in
case. I'm marking the branch as r=me as I think all of these can be
addressed fairly simply and I don't want to block the landing. If any
major revisions are made someone else can add their vote beside mine.

One thing that I did notice was that certain things (like the
trailing-comma-before-closing-brace problem, see [6]) should have been
picked up by our JS linter (which I think runs as part of `make lint.`
Could you re-run that for me and, if they're not being picked up, file a
bug?

[1]

236 + Y.on('domready', function() {
237 + module.setup_bug_subscriptions(
238 + {content_box: "#structural-subscription-content-box"})

This line needs a semicolon.

239 + });

And this need to be outdented by four spaces to avoid it looking like
it's closing the above JS Object.

[2]

236 + Y.on('domready', function() {
237 + module.setup_bug_subscriptions(
238 + {content_box: "#structural-subscription-content-box"})
239 + });

Same comments as [1].

[3]

395 + Bugs can be tagged to help the project categorise the bug report. You
396 + can filter your subscription based on one or more tags. If you can choose
397 + to <em>Match all tags</em> then your filter will only match bugs that
398 + have each of those tags you enter here.

Should the end of line 396 read "If you choose" rather than "If you can
choose..."?

[4]

476 + // The list may be undefined in some cases.
477 + return list && list.indexOf(target) != -1;

We should use isArray() explicitly here:

    return Y.Lang.isArray(list) && list.indexOf(target) != -1;

[5]

479 +namespace.list_contains = list_contains;

This seems to be a common pattern. Why not just make the function part
of the namespace in the first place:

namespace.list_contains = function(list, target) {
    ///...
};

(This is a common pattern elsewhere in the LP JS, though I don't know if
it's considered a standard). I'll not bother highlighting the other
cases; they're easy enough to find.

[6]

495 + statuses: [],
496 + };

The comma before the closing brace on an object literal causes problems
with some browsers (Opera and some versions of WebKit, IIRC); it should
be removed.

[7]

569 + on: {failure: overlay_error_handler.getFailureHandler()},

The trailing comma needs to die for the same reason as in [6].

[8]

575 +namespace._delete_filter = delete_filter

Needs a semicolon.

[9]

611 +namespace._add_bug_filter = add_bug_filter

So does this.

[10]

633 +function edit_subscription_handler(context, form_data) {

This function needs some documentation commentary.

[11]

649 +function create_overlay(content_box_id, overlay_id, submit_button,
650 + submit_callback) {

As does this one.

[12]

691 + alert('"Subscribe to bug mail" link not found.');

We shouldn't be using the alert() here any more. Y.fail would seem more
appropriate.

[13]...

Read more...

review: Approve (code)
Revision history for this message
Benji York (benji) wrote :

> Whew, done. There are 20-odd items that need addressing but most of them
> are quite small.
[snip]
> One thing that I did notice was that certain things (like the
> trailing-comma-before-closing-brace problem, see [6]) should have been
> picked up by our JS linter (which I think runs as part of `make lint.`
> Could you re-run that for me and, if they're not being picked up, file a
> bug?

The JS linter was indeed not functioning. I filed bug 742619 which has
already provoked a pocketlint fix to be released.

In response to the broken linter I used Crockford's JSLint to guide me
in revision 12660.

A large number of missing semicolons and extra commas were
inserted/removed.

All == and != were changed do === and !==, respectively.

Most uses of string subscripts (foo['bar']) were changed to attribute
access (foo.bar).

Loops over arrays were changed from using the for-in syntax to instead
use traditional indexed for loops.

Added a few missing "var" declarations.

Instead of writing up an exhaustive justification for these changes I'll
let Mr. Crockford explain: http://javascript.crockford.com/code.html

I'll address each of the non-lint comments in a subsequent note.

Revision history for this message
Graham Binns (gmb) wrote :

I'm happy with the lint changes.

review: Approve (code)
Revision history for this message
Benji York (benji) wrote :
Download full text (5.5 KiB)

Revision 12662 has the below-mentioned changes.

> [5]
>
> 479 +namespace.list_contains = list_contains;
>
> This seems to be a common pattern. Why not just make the function part
> of the namespace in the first place:
>
> namespace.list_contains = function(list, target) {
> ///...
> };
>
> (This is a common pattern elsewhere in the LP JS, though I don't know if
> it's considered a standard). I'll not bother highlighting the other
> cases; they're easy enough to find.

This was done to decrease the identifier length. Otherwise instead of
just referencing "list_contains" in the body of the module, we'd have to
use "namespace.list_contains" throughout. The only reason the function
is needed outside of the namespace is for testing so the one-liner to
expose it to tests seems a reasonable trade-off.

> [10]
>
> 633 +function edit_subscription_handler(context, form_data) {
>
> This function needs some documentation commentary.

Done.

>
> [11]
>
> 649 +function create_overlay(content_box_id, overlay_id, submit_button,
> 650 + submit_callback) {

Done.

> [12]
>
> 691 + alert('"Subscribe to bug mail" link not found.');
>
> We shouldn't be using the alert() here any more. Y.fail would seem more
> appropriate.

Fixed.

> [13]
>
> 698 + e.preventDefault();
>
> There's a been a bug about the use of e.preventDefault() and
> e.stopPropagation() not working properly with some browsers (probably IE
> but ISTR some WebKit browsers had problems too). For safety we should
> use e.halt(), since in all the cases where one of {preventDefault,
> stopPropagation} fails, the other works fine, and e.halt() calls both.
> It's probably worth replacing all instances of preventDefault() in this
> JS with halt().

Fixed.

> [14]
>
> 708 +function clear_overlay(content_node) {
>
> This needs documentation.

Done.

> [15]
>
> 902 + * @param node The node to collapse.
> 903 + */
> 904 +function collapse_node(node, user_cfg) {
>
> user_cfg needs to be documented here, too.

Gary and I have been following the (self-imposed) guideline that only
functions meant for "public" consumption require the javadoc-like
documentation so I fixed the asymmetry in parameter documentation by
removing the docs for the other parameter.

> [16]
>
> 939 +/**
> 940 + * Expand the node and set its arrow to 'collapsed'
> 941 + * @param node The node to collapse.
> 942 + */
> 943 +function expand_node(node, user_cfg) {
>
> Same as [15].

Same fix as [15].

> [17]
>
> 977 +/**
> 978 + * Construct the overlay and populate it with the add/edit form.
> 979 + *
> 980 + * @method setup_overlay
> 981 + * @param {String} content_box_id Id of the element on the page where
> 982 + * the overlay is anchored.
> 983 + * @return {String} overlay_id Id of the constructed overlay element.
> 984 + */
> 985 +function setup_overlay(content_box_id, hide_recipient_picker) {
>
> There's no overlay_id parameter, so you can drop the documentation for
> it.

Same fix as [15].

> [18]
>
> 1242 + // if we want to allow editing of the recipient, do this
> instead
...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'buildout-templates/bin/combine-css.in'
2--- buildout-templates/bin/combine-css.in 2011-03-18 22:47:24 +0000
3+++ buildout-templates/bin/combine-css.in 2011-03-28 19:31:27 +0000
4@@ -11,6 +11,9 @@
5 from lazr.js.build import ComboFile
6 from lazr.js.combo import combine_files
7
8+# This constant helps us meet maximum line-length goals.
9+GALLERY_ACCORDION = 'yui3-gallery/gallery-accordion/assets/'
10+
11
12 root = os.path.abspath('.')
13 root = os.path.normpath(${buildout:directory|path-repr})
14@@ -34,7 +37,8 @@
15 'lazr/build/picker/assets/skins/sam/picker.css',
16 'lazr/build/activator/assets/skins/sam/activator.css',
17 'lazr/build/choiceedit/assets/choiceedit-core.css',
18- 'yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css',
19+ GALLERY_ACCORDION + 'gallery-accordion-core.css',
20+ GALLERY_ACCORDION + 'skins/sam/gallery-accordion-skin.css',
21 'build/sprite.css',
22 # This one goes at the end because it's our main stylesheet and should
23 # take precedence over the others.
24
25=== modified file 'lib/lp/app/javascript/lp.js'
26--- lib/lp/app/javascript/lp.js 2010-11-10 15:33:47 +0000
27+++ lib/lp/app/javascript/lp.js 2011-03-28 19:31:27 +0000
28@@ -62,15 +62,20 @@
29 Y.lp.toggle_collapsible = function(collapsible) {
30 // Find the collapse icon and wrapper div for this collapsible.
31 var icon = collapsible.one('.collapseIcon');
32- var wrapper_div = collapsible.one('.collapseWrapper');
33-
34- // If either the wrapper or the icon is null, raise an error.
35- if (wrapper_div === null) {
36- Y.fail("Collapsible has no wrapper div.");
37- }
38- if (icon === null) {
39- Y.fail("Collapsible has no icon.");
40- }
41+
42+ function get_wrapper_div(node) {
43+ var wrapper_div = node.one('.collapseWrapper');
44+
45+ // If either the wrapper or the icon is null, raise an error.
46+ if (wrapper_div === null) {
47+ Y.fail("Collapsible has no wrapper div.");
48+ }
49+ if (icon === null) {
50+ Y.fail("Collapsible has no icon.");
51+ }
52+ return wrapper_div;
53+ }
54+ var wrapper_div = get_wrapper_div(collapsible);
55
56 // Work out the target icon and animation based on the state of
57 // the collapse wrapper. We ignore the current state of the icon
58@@ -80,12 +85,16 @@
59 // state.
60 var target_icon;
61 var target_anim;
62+ var expanding;
63 if (wrapper_div.hasClass('lazr-closed')) {
64- // The wrapper is collapsed.
65+ // The wrapper is collapsed; expand it and collapse all its
66+ // siblings if it's in an accordion.
67+ expanding = true;
68 target_anim = Y.lazr.effects.slide_out(wrapper_div);
69 target_icon = "/@@/treeExpanded";
70 } else {
71- // The wrapper is open.
72+ // The wrapper is open; just collapse it.
73+ expanding = false;
74 target_anim = Y.lazr.effects.slide_in(wrapper_div);
75 target_icon = "/@@/treeCollapsed";
76 }
77@@ -93,6 +102,27 @@
78 // Run the animation and set the icon src correctly.
79 target_anim.run();
80 icon.set('src', target_icon);
81+
82+ // Work out if the collapsible is in an accordion and process
83+ // the siblings accordingly if the current collapsible is being
84+ // expanded.
85+ var parent_node = collapsible.get('parentNode');
86+ var in_accordion = parent_node.hasClass('accordion');
87+ if (in_accordion && expanding) {
88+ var sibling_target_icon = "/@@/treeCollapsed";
89+ Y.each(parent_node.all('.collapsible'), function(sibling) {
90+ // We only actually collapse the sibling if it's not our
91+ // current collapsible.
92+ if (sibling != collapsible) {
93+ var sibling_wrapper_div = get_wrapper_div(sibling);
94+ var sibling_icon = sibling.one('.collapseIcon');
95+ var sibling_target_anim = Y.lazr.effects.slide_in(
96+ sibling_wrapper_div);
97+ sibling_target_anim.run();
98+ sibling_icon.set('src', sibling_target_icon);
99+ }
100+ });
101+ }
102 };
103
104 /**
105@@ -107,8 +137,8 @@
106 // Try to grab the legend in the usual way.
107 var legend = collapsible.one('legend');
108 if (legend === null) {
109- // If it's null, this might be a collapsible div, not fieldset,
110- // so try to grap the div's "legend".
111+ // If it's null, this might be a collapsible div, not
112+ // fieldset, so try to grap the div's "legend".
113 legend = collapsible.one('.config-options');
114 }
115 if (legend === null ||
116
117=== added file 'lib/lp/app/javascript/tests/test_accordionoverlay.html'
118--- lib/lp/app/javascript/tests/test_accordionoverlay.html 1970-01-01 00:00:00 +0000
119+++ lib/lp/app/javascript/tests/test_accordionoverlay.html 2011-03-28 19:31:27 +0000
120@@ -0,0 +1,26 @@
121+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
122+<html>
123+ <head>
124+ <title>Launchpad accordionoverlay</title>
125+
126+ <!-- YUI 3.0 Setup -->
127+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
128+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
129+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
130+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
131+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
132+ <link rel="stylesheet" href="../../../../canonical/launchpad/javascript/test.css" />
133+
134+ <!-- The module under test -->
135+ <script type="text/javascript" src="../accordionoverlay.js"></script>
136+
137+ <!-- The test suite -->
138+ <script type="text/javascript" src="test_accordionoverlay.js"></script>
139+ </head>
140+
141+ <body class="yui3-skin-sam">
142+ <div id="form_overlay_example"></div>
143+ <div id="log"></div>
144+ </body>
145+
146+</html>
147
148=== modified file 'lib/lp/bugs/browser/structuralsubscription.py'
149--- lib/lp/bugs/browser/structuralsubscription.py 2011-03-23 19:19:43 +0000
150+++ lib/lp/bugs/browser/structuralsubscription.py 2011-03-28 19:31:27 +0000
151@@ -402,7 +402,8 @@
152 record = info.get(target)
153 if record is None:
154 record = dict(target_title=target.title,
155- target_url=absoluteURL(target, request),
156+ target_url=canonical_url(
157+ target, rootsite='mainsite'),
158 filters=[])
159 info[target] = record
160 subscriber = subscription.subscriber
161@@ -413,6 +414,8 @@
162 record['filters'].append(dict(
163 filter=filter,
164 subscriber_link=absoluteURL(subscriber, api_request),
165+ subscriber_url = canonical_url(
166+ subscriber, rootsite='mainsite'),
167 subscriber_title=subscriber.title,
168 subscriber_is_team=is_team,
169 user_is_team_admin=user_is_team_admin,))
170
171=== modified file 'lib/lp/bugs/browser/tests/test_expose.py'
172--- lib/lp/bugs/browser/tests/test_expose.py 2011-03-23 19:15:41 +0000
173+++ lib/lp/bugs/browser/tests/test_expose.py 2011-03-28 19:31:27 +0000
174@@ -18,6 +18,7 @@
175 from zope.interface import implements
176 from zope.traversing.browser import absoluteURL
177
178+from canonical.launchpad.webapp.publisher import canonical_url
179 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
180 from canonical.testing.layers import DatabaseFunctionalLayer
181 from lp.bugs.browser.structuralsubscription import (
182@@ -150,7 +151,8 @@
183 target_info = info[0]
184 self.assertEqual(target_info['target_title'], target.title)
185 self.assertEqual(
186- target_info['target_url'], absoluteURL(target, request))
187+ target_info['target_url'], canonical_url(
188+ target, rootsite='mainsite'))
189 self.assertEqual(len(target_info['filters']), 1) # One filter.
190 filter_info = target_info['filters'][0]
191 self.assertEqual(filter_info['filter'], sub.bug_filters[0])
192@@ -160,6 +162,9 @@
193 self.assertEqual(
194 filter_info['subscriber_link'],
195 absoluteURL(team, IWebServiceClientRequest(request)))
196+ self.assertEqual(
197+ filter_info['subscriber_url'],
198+ canonical_url(team, rootsite='mainsite'))
199
200 def test_team_member_subscription(self):
201 # Make a team subscription where the user is not an admin, and
202@@ -179,6 +184,9 @@
203 self.assertEqual(
204 filter_info['subscriber_link'],
205 absoluteURL(team, IWebServiceClientRequest(request)))
206+ self.assertEqual(
207+ filter_info['subscriber_url'],
208+ canonical_url(team, rootsite='mainsite'))
209
210 def test_self_subscription(self):
211 # Make a subscription directly for the user and see what we record.
212@@ -195,3 +203,6 @@
213 self.assertEqual(
214 filter_info['subscriber_link'],
215 absoluteURL(user, IWebServiceClientRequest(request)))
216+ self.assertEqual(
217+ filter_info['subscriber_url'],
218+ canonical_url(user, rootsite='mainsite'))
219
220=== added symlink 'lib/lp/bugs/help/structural-subscription-name.html'
221=== target is u'../../registry/help/structural-subscription-name.html'
222=== added symlink 'lib/lp/bugs/help/structural-subscription-tags.html'
223=== target is u'../../registry/help/structural-subscription-tags.html'
224=== modified file 'lib/lp/bugs/templates/bug-subscription-list.pt'
225--- lib/lp/bugs/templates/bug-subscription-list.pt 2011-02-22 22:05:16 +0000
226+++ lib/lp/bugs/templates/bug-subscription-list.pt 2011-03-28 19:31:27 +0000
227@@ -10,16 +10,31 @@
228 i18n:domain="malone"
229 >
230
231+<head>
232+ <tal:head-epilogue metal:fill-slot="head_epilogue">
233+ <script type="text/javascript">
234+ LPS.use('lp.registry.structural_subscription', function(Y) {
235+ module = Y.lp.registry.structural_subscription;
236+ Y.on('domready', function() {
237+ module.setup_bug_subscriptions(
238+ {content_box: "#structural-subscription-content-box"})
239+ });
240+ });
241+ </script>
242+
243+ </tal:head-epilogue>
244+</head>
245 <body>
246 <div metal:fill-slot="main">
247
248 <div id="maincontent">
249 <div id="nonportlets" class="readable">
250+ <div id="subscription-listing"></div>
251+
252+ <div id="structural-subscription-content-box"></div>
253
254 </div>
255 </div>
256-
257 </div>
258-
259 </body>
260 </html>
261
262=== modified file 'lib/lp/bugs/templates/bugtarget-subscription-list.pt'
263--- lib/lp/bugs/templates/bugtarget-subscription-list.pt 2011-03-11 21:31:10 +0000
264+++ lib/lp/bugs/templates/bugtarget-subscription-list.pt 2011-03-28 19:31:27 +0000
265@@ -10,16 +10,31 @@
266 i18n:domain="malone"
267 >
268
269+<head>
270+ <tal:head-epilogue metal:fill-slot="head_epilogue">
271+ <script type="text/javascript">
272+ LPS.use('lp.registry.structural_subscription', function(Y) {
273+ module = Y.lp.registry.structural_subscription;
274+ Y.on('domready', function() {
275+ module.setup_bug_subscriptions(
276+ {content_box: "#structural-subscription-content-box"})
277+ });
278+ });
279+ </script>
280+
281+ </tal:head-epilogue>
282+</head>
283 <body>
284 <div metal:fill-slot="main">
285
286 <div id="maincontent">
287 <div id="nonportlets" class="readable">
288+ <div id="subscription-listing"></div>
289+
290+ <div id="structural-subscription-content-box"></div>
291
292 </div>
293 </div>
294-
295 </div>
296-
297 </body>
298 </html>
299
300=== removed file 'lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css'
301--- lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css 2011-02-14 20:14:47 +0000
302+++ lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css 1970-01-01 00:00:00 +0000
303@@ -1,1 +0,0 @@
304-.yui3-accordion{width:100%;height:100%;overflow:hidden;position:relative;}.yui3-accordion-item{position:relative;cursor:pointer;width:100%;}.yui3-accordion-item .yui3-widget-hd{overflow:hidden;}.yui3-accordion-item .yui3-widget-bd{cursor:default;overflow:hidden;position:relative;}.yui3-accordion-item-icons{position:relative;float:right;overflow:hidden;padding:1px;height:25px;}.yui3-accordion-item-icon,.yui3-accordion-item-iconexpanded,.yui3-accordion-item-iconalwaysvisible,.yui3-accordion-item-iconclose{width:22px;height:22px;}.yui3-accordion-item-icon,.yui3-accordion-item-label{float:left;}.yui3-accordion-item-label{position:relative;top:4px;_height:22px;}.yui3-accordion-item-iconexpanded,.yui3-accordion-item-iconalwaysvisible,.yui3-accordion-item-iconclose{float:left;}.yui3-accordion-item-iconclose-hidden{display:none;}.yui3-skin-sam .yui3-accordion{border:1px solid #93B2CC;}.yui3-skin-sam .yui3-accordion-item .yui3-widget-hd{background-image:url(accordion_sprite.png);background-position:0 0;border:1px solid #93B2CC;height:25px;}.yui3-skin-sam .yui3-accordion-item-icon,.yui3-skin-sam .yui3-accordion-item-iconexpanded,.yui3-skin-sam .yui3-accordion-item-iconalwaysvisible,.yui3-skin-sam .yui3-accordion-item-iconclose{background-repeat:no-repeat;}.yui3-skin-sam .yui3-accordion-item-icon{background-image:url(accordion_sprite.png);background-position:center -25px;_background-position:center -27px;}.yui3-skin-sam .yui3-accordion-item-label{color:#444;}.yui3-skin-sam .yui3-accordion-item-label{text-decoration:none;background:transparent;overflow:hidden;color:#444;font-weight:bold;}.yui3-skin-sam .yui3-accordion-item-label:hover{text-decoration:underline;}.yui3-skin-sam .yui3-accordion-item-iconalwaysvisible,.yui3-skin-sam .yui3-accordion-item-iconalwaysvisible-off{background-image:url(accordion_sprite.png);background-position:0 -85px;_background-position:0 -87px;}.yui3-skin-sam .yui3-accordion-item-iconalwaysvisible-on{background-image:url(accordion_sprite.png);background-position:0 -55px;_background-position:0 -57px;}.yui3-skin-sam .yui3-accordion-item-iconexpanded,.yui3-skin-sam .yui3-accordion-item-iconexpanded-off{background-image:url(accordion_sprite.png);background-position:0 -175px;_background-position:0 -177px;}.yui3-skin-sam .yui3-accordion-item-iconexpanded-off:hover{background-image:url(accordion_sprite.png);background-position:0 -205px;}.yui3-skin-sam .yui3-accordion-item-iconexpanded-on{background-image:url(accordion_sprite.png);background-position:0 -115px;_background-position:0 -117px;}.yui3-skin-sam .yui3-accordion-item-iconexpanded-on:hover{background-image:url(accordion_sprite.png);background-position:0 -145px;}.yui3-skin-sam .yui3-accordion-item-iconexpanded-expanding{background-image:url(wait_expand.gif);background-position:0 center;}.yui3-skin-sam .yui3-accordion-item-iconexpanded-collapsing{background-image:url(wait_collapse.gif);background-position:0 center;}.yui3-skin-sam .yui3-accordion-item-iconclose{background-image:url(accordion_sprite.png);background-position:0 -235px;_background-position:0 -237px;}.yui3-skin-sam .yui3-accordion-proxyel-visible{border-color:blue;color:white;font-weight:bold;background-color:red;opacity:.7;filter:alpha(opacity = 70);}
305
306=== modified file 'lib/lp/registry/browser/product.py'
307--- lib/lp/registry/browser/product.py 2011-03-22 19:17:42 +0000
308+++ lib/lp/registry/browser/product.py 2011-03-28 19:31:27 +0000
309@@ -193,6 +193,7 @@
310 from lp.registry.interfaces.productseries import IProductSeries
311 from lp.registry.interfaces.series import SeriesStatus
312 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
313+from lp.services import features
314 from lp.services.fields import (
315 PillarAliases,
316 PublicPersonChoice,
317@@ -583,7 +584,22 @@
318 usedfor = IProductActionMenu
319 facet = 'overview'
320 title = 'Actions'
321- links = ('edit', 'review_license', 'administer', 'subscribe')
322+
323+ @property
324+ def links(self):
325+ links = ['edit', 'review_license', 'administer']
326+ use_advanced_features = features.getFeatureFlag(
327+ 'advanced-structural-subscriptions.enabled')
328+ if use_advanced_features:
329+ links.append('subscribe_to_bug_mail')
330+ else:
331+ links.append('subscribe')
332+ return links
333+
334+ @enabled_with_permission('launchpad.AnyPerson')
335+ def subscribe_to_bug_mail(self):
336+ text = 'Subscribe to bug mail'
337+ return Link('#', text, icon='add', hidden=True)
338
339
340 class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
341
342=== added file 'lib/lp/registry/help/structural-subscription-name.html'
343--- lib/lp/registry/help/structural-subscription-name.html 1970-01-01 00:00:00 +0000
344+++ lib/lp/registry/help/structural-subscription-name.html 2011-03-28 19:31:27 +0000
345@@ -0,0 +1,30 @@
346+<html>
347+ <head>
348+ <title>Why do I need a "Subscription name"?</title>
349+ <link rel="stylesheet" type="text/css"
350+ href="/+icing/yui/cssreset/reset.css" />
351+ <link rel="stylesheet" type="text/css"
352+ href="/+icing/yui/cssfonts/fonts.css" />
353+ <link rel="stylesheet" type="text/css"
354+ href="/+icing/yui/cssbase/base.css" />
355+ </head>
356+ <body>
357+ <h1>Why do I need a "Subscription name"?</h1>
358+
359+ <p>
360+ You may have more than one structural subscription per project, each
361+ with different criteria. You may provide a name in order to identify
362+ them in the future in order to edit or delete them.
363+ </p>
364+ <p>
365+ Also, each email generated by this subscription will quote the name
366+ you choose, both in the email body and as a header
367+ (<code>X-Launchpad-Subscription</code>), making it easy to filter
368+ your bug mail.
369+ </p>
370+ <p>
371+ So, please provide a short, descriptive name to help you remember this
372+ subscription.
373+ </p>
374+ </body>
375+</html>
376
377=== added file 'lib/lp/registry/help/structural-subscription-tags.html'
378--- lib/lp/registry/help/structural-subscription-tags.html 1970-01-01 00:00:00 +0000
379+++ lib/lp/registry/help/structural-subscription-tags.html 2011-03-28 19:31:27 +0000
380@@ -0,0 +1,28 @@
381+<html>
382+ <head>
383+ <title>Help for filtering on tags</title>
384+ <link rel="stylesheet" type="text/css"
385+ href="/+icing/yui/cssreset/reset.css" />
386+ <link rel="stylesheet" type="text/css"
387+ href="/+icing/yui/cssfonts/fonts.css" />
388+ <link rel="stylesheet" type="text/css"
389+ href="/+icing/yui/cssbase/base.css" />
390+ </head>
391+ <body>
392+ <h1>Help for filtering on tags</h1>
393+
394+ <p>
395+ Bugs can be tagged to help the project categorise the bug report. You
396+ can filter your subscription based on one or more tags. If you choose
397+ to <em>Match all tags</em> then your filter will only match bugs that
398+ have each of those tags you enter here.
399+ </p>
400+ <p>
401+ If you select <em>Match any tags</em> then bugs that have one or more of
402+ the tags you specify will be found.
403+ </p>
404+ <p>
405+ Enter the tags as a space-separated list.
406+ </p>
407+ </body>
408+</html>
409
410=== added file 'lib/lp/registry/javascript/structural-subscription.js'
411--- lib/lp/registry/javascript/structural-subscription.js 1970-01-01 00:00:00 +0000
412+++ lib/lp/registry/javascript/structural-subscription.js 2011-03-28 19:31:27 +0000
413@@ -0,0 +1,1189 @@
414+/* Copyright 2011 Canonical Ltd. This software is licensed under the
415+ * GNU Affero General Public License version 3 (see the file LICENSE).
416+ *
417+ * Form overlay widgets and subscriber handling for structural subscriptions.
418+ *
419+ * @module registry
420+ * @submodule structural_subscription
421+ */
422+
423+YUI.add('lp.registry.structural_subscription', function(Y) {
424+
425+var namespace = Y.namespace('lp.registry.structural_subscription');
426+
427+var INNER_HTML = 'innerHTML',
428+ VALUE = 'value';
429+
430+var FILTER_COMMENTS = 'filter-comments',
431+ FILTER_WRAPPER = 'filter-wrapper',
432+ ACCORDION_WRAPPER = 'accordion-wrapper',
433+ ADDED_OR_CLOSED = 'added-or-closed',
434+ ADDED_OR_CHANGED = 'added-or-changed',
435+ ADVANCED_FILTER = 'advanced-filter',
436+ MATCH_ALL = 'match-all',
437+ MATCH_ANY = 'match-any',
438+ SS_COLLAPSIBLE = 'ss-collapsible'
439+ ;
440+
441+var add_subscription_overlay;
442+var cancel_button_html =
443+ '<button type="button" name="field.actions.cancel" ' +
444+ 'class="lazr-neg lazr-btn" >Cancel</button>';
445+
446+namespace.lp_client = undefined;
447+
448+/*
449+ * An object representing the global actions portlet.
450+ *
451+ */
452+var PortletTarget = function() {};
453+Y.augment(PortletTarget, Y.Event.Target);
454+namespace.portlet = new PortletTarget();
455+
456+function subscription_success() {
457+ // TODO Should there be some success notification?
458+ add_subscription_overlay.hide();
459+}
460+
461+var overlay_error_handler = new Y.lp.client.ErrorHandler();
462+overlay_error_handler.showError = function(error_msg) {
463+ add_subscription_overlay.showError(error_msg);
464+};
465+
466+/**
467+ * Does the list contain the target?
468+ *
469+ * @private
470+ * @method list_contains
471+ * @param {List} list The list to search.
472+ * @param {String} target The target of interest.
473+ */
474+function list_contains(list, target) {
475+ // The list may be undefined in some cases.
476+ return Y.Lang.isArray(list) && list.indexOf(target) !== -1;
477+};
478+
479+// Expose to tests.
480+namespace._list_contains = list_contains;
481+
482+/**
483+ * Reformat the data returned from the add/edit form into something acceptable
484+ * to send as a PATCH.
485+ */
486+function extract_form_data(form_data) {
487+ if (form_data === 'this is a test') {
488+ // This is a short-circuit to make testing easier.
489+ return {};
490+ }
491+ var patch_data = {
492+ description: Y.Lang.trim(form_data.name[0]),
493+ tags: [],
494+ find_all_tags: false,
495+ importances: [],
496+ statuses: [],
497+ };
498+
499+ // Set the notification level.
500+ var added_or_closed = list_contains(form_data.events, ADDED_OR_CLOSED);
501+ var filter_comments = list_contains(form_data.filters, FILTER_COMMENTS);
502+
503+ // Chattiness: Lifecycle < Details < Discussion.
504+ if (added_or_closed) {
505+ patch_data.bug_notification_level = 'Lifecycle';
506+ } else if (!filter_comments) {
507+ patch_data.bug_notification_level = 'Discussion';
508+ } else {
509+ patch_data.bug_notification_level = 'Details';
510+ }
511+
512+ // Set the tags, importances, and statuses. Only do this if
513+ // ADDED_OR_CHANGED and ADVANCED_FILTER are selected.
514+ var advanced_filter = (!added_or_closed &&
515+ list_contains(form_data.filters, ADVANCED_FILTER));
516+ if (advanced_filter) {
517+ // Tags are a list with one element being a space-separated string.
518+ var tags = form_data.tags[0];
519+ if (Y.Lang.isValue(tags) && tags !== '') {
520+ patch_data.tags = tags.toLowerCase().split(' ');
521+ }
522+ patch_data.find_all_tags =
523+ list_contains(form_data.tag_match, MATCH_ALL);
524+ if (form_data.importances.length > 0) {
525+ patch_data.importances = form_data.importances;
526+ }
527+ if (form_data.statuses.length > 0) {
528+ patch_data.statuses = form_data.statuses;
529+ }
530+ } else {
531+ // clear out the tags, statuses, and importances in case this is an
532+ // edit.
533+ patch_data.tags = patch_data.importances = patch_data.statuses = [];
534+ }
535+ return patch_data;
536+}
537+
538+// Expose in the namespace for testing purposes.
539+namespace._extract_form_data = extract_form_data;
540+
541+/**
542+ * Given a bug filter, update it with information extracted from a form.
543+ *
544+ * @private
545+ * @method patch_bug_filter
546+ * @param {Object} bug_filter The bug filter.
547+ * @param {Object} form_data The data returned from the form submission.
548+ * @param {Object} on Event handlers to override the defaults.
549+ */
550+function patch_bug_filter(bug_filter, form_data, on) {
551+ var patch_data = extract_form_data(form_data);
552+
553+ var config = {
554+ on: Y.merge({
555+ success: subscription_success,
556+ failure: overlay_error_handler.getFailureHandler()
557+ }, on)
558+ };
559+ namespace.lp_client.patch(bug_filter.self_link, patch_data, config);
560+}
561+namespace.patch_bug_filter = patch_bug_filter;
562+
563+/**
564+ * Delete the given filter
565+ */
566+function delete_filter(filter) {
567+ var y_config = {
568+ method: "POST",
569+ headers: {'X-HTTP-Method-Override': 'DELETE'},
570+ on: {failure: overlay_error_handler.getFailureHandler()}
571+ };
572+ Y.io(filter.self_link, y_config);
573+}
574+
575+// Exported for testing.
576+namespace._delete_filter = delete_filter;
577+
578+/**
579+ * Create a new structural subscription filter.
580+ *
581+ * @method create_structural_subscription filter
582+ * @param {Object} who Link to the user or team to be subscribed.
583+ * @param {Object} form_data The data returned from the form submission.
584+ */
585+function add_bug_filter(who, form_data) {
586+ var config = {
587+ on: {success: function (bug_filter) {
588+ // If we fail to PATCH the new bug filter, DELETE it.
589+ var on = {failure: function () {
590+ // We use the namespace binding so tests can override
591+ // these functions.
592+ namespace._delete_filter(bug_filter);
593+ // Call the failure handler to report the original
594+ // error to the user.
595+ overlay_error_handler.getFailureHandler()
596+ .apply(this, arguments);
597+ }};
598+ patch_bug_filter(bug_filter.getAttrs(), form_data, on);
599+ },
600+ failure: overlay_error_handler.getFailureHandler()
601+ },
602+ parameters: {
603+ subscriber: who
604+ }
605+ };
606+
607+ namespace.lp_client.named_post(LP.cache.context.self_link,
608+ 'addBugSubscriptionFilter', config);
609+}
610+
611+// Exported for testing.
612+namespace._add_bug_filter = add_bug_filter;
613+
614+/**
615+ * Given the form data from a user, save the subscription.
616+ *
617+ * @private
618+ * @method save_subscription
619+ * @param {Object} form_data The data generated by the form submission.
620+ */
621+
622+function save_subscription(form_data) {
623+ var who;
624+ if (form_data.recipient[0] === 'user') {
625+ who = LP.links.me;
626+ } else {
627+ // There can be only one.
628+ who = form_data.team[0];
629+ }
630+ add_bug_filter(who, form_data);
631+}
632+namespace.save_subscription = save_subscription;
633+
634+/**
635+ * Handle the activation of the edit subscription link.
636+ */
637+function edit_subscription_handler(context, form_data) {
638+ var on = {success: function (new_data) {
639+ var filter = new_data.getAttrs();
640+ var description_node = Y.one(
641+ '#filter-description-'+context.filter_id.toString());
642+ description_node.set(
643+ INNER_HTML, render_filter_description(filter));
644+ var name_node = Y.one(
645+ '#filter-name-'+context.filter_id.toString());
646+ name_node.set(
647+ INNER_HTML, render_filter_name(context.filter_info, filter));
648+ add_subscription_overlay.hide();
649+ }};
650+ patch_bug_filter(context.filter_info.filter, form_data, on);
651+}
652+
653+/**
654+ * Populate the overlay element with the contents of the add/edit form.
655+ */
656+function create_overlay(content_box_id, overlay_id, submit_button,
657+ submit_callback) {
658+ // Create the overlay.
659+ add_subscription_overlay = new Y.lazr.FormOverlay({
660+ headerContent:
661+ '<h2 id="subscription-overlay-title">Add a mail subscription '+
662+ 'for '+LP.cache.context.title + ' bugs</h2>',
663+ form_content: Y.one(overlay_id),
664+ centered: true,
665+ visible: false,
666+ form_submit_button: submit_button,
667+ form_cancel_button: Y.Node.create(cancel_button_html),
668+ form_submit_callback: submit_callback
669+ });
670+ add_subscription_overlay.render(content_box_id);
671+ // Prevent cruft from hanging around upon closing.
672+ function clean_up(e) {
673+ var filter_wrapper = Y.one('#' + FILTER_WRAPPER);
674+ filter_wrapper.hide();
675+ collapse_node(filter_wrapper);
676+ }
677+ add_subscription_overlay.get('form_cancel_button').on(
678+ 'click', clean_up);
679+ add_subscription_overlay.get('form_submit_button').on(
680+ 'click', clean_up);
681+ add_subscription_overlay.on('cancel', clean_up);
682+}
683+
684+/*
685+ * Modify the DOM to insert a link or two into the global actions portlet.
686+ * If structural subscriptions already exist then a 'modify' link is
687+ * added. Otherwise, just the 'add' link is put into the portlet.
688+ *
689+ * @method setup_subscription_links
690+ * @param {String} overlay_id Id of the overlay element.
691+ * @param {String} content_box_id Id of the element on the page where
692+ * the overlay is anchored.
693+ */
694+function setup_subscription_links(overlay_id, content_box_id) {
695+ // Modify the menu-link-subscribe-to-bug-mail link to be visible.
696+ var link = Y.one('.menu-link-subscribe_to_bug_mail');
697+ if (!Y.Lang.isValue(link)) {
698+ Y.fail('"Subscribe to bug mail" link not found.');
699+ }
700+ link.removeClass('invisible-link');
701+ link.addClass('visible-link');
702+ link.on('click', function(e) {
703+ // Only proceed if the form content is already available.
704+ if (add_subscription_overlay) {
705+ e.halt();
706+ // We always set up the overlay as a blank canvas, in case it was
707+ // used before.
708+ clear_overlay(Y.one(content_box_id));
709+ add_subscription_overlay.show();
710+ }
711+ });
712+ link.addClass('js-action');
713+} // setup_subscription_links
714+
715+/**
716+ * Reset the overlay form to initial values.
717+ */
718+function clear_overlay(content_node) {
719+ set_recipient(content_node, false, undefined);
720+ content_node.one('[name="name"]').set('value', '');
721+ set_checkboxes(
722+ content_node, LP.cache.statuses, LP.cache.statuses);
723+ set_checkboxes(
724+ content_node, LP.cache.importances, LP.cache.importances);
725+ content_node.one('[name="tags"]').set('value', '');
726+ set_radio_buttons(
727+ content_node, [MATCH_ALL, MATCH_ANY], MATCH_ALL);
728+ set_radio_buttons(
729+ content_node, [ADDED_OR_CLOSED, ADDED_OR_CHANGED], ADDED_OR_CLOSED);
730+ set_checkboxes(
731+ content_node, [FILTER_COMMENTS, ADVANCED_FILTER], []);
732+ collapse_node(Y.one('#' + ACCORDION_WRAPPER), {duration: 0});
733+ collapse_node(Y.one('#' + FILTER_WRAPPER), {duration: 0});
734+}
735+
736+/**
737+ * Make a table cell.
738+ *
739+ * @private
740+ * @method make_cell
741+ * @param {Object} item Item to be placed in the cell.
742+ * @param {String} name Name of the control.
743+ */
744+function make_cell(item, name) {
745+ return '<td style="padding-left:3px"><label><input type="checkbox" ' +
746+ 'name="' + name +'" ' +
747+ 'value="' + item + '" checked="checked">' +
748+ item + '</label><td>';
749+}
750+/**
751+ * Make a table.
752+ *
753+ * @private
754+ * @method make_table
755+ * @param {Object} list List of items to be put in the table.
756+ * @param {String} name Name of the control.
757+ * @param {Int} num_cols The number of columns for the table to use.
758+ */
759+function make_table(list, name, num_cols) {
760+ var html = '<table>';
761+ var i;
762+ for (i=0; i<list.length; i++) {
763+ if (i % num_cols === 0) {
764+ if (i !== 0) {
765+ html += '</tr>';
766+ }
767+ html += '<tr>';
768+ }
769+ html += make_cell(list[i], name);
770+ }
771+ html += '</tr></table>';
772+ return html;
773+}
774+
775+/**
776+ * Make selector controls, the links for 'Select all' and
777+ * 'Select none' that appear within elements with many checkboxes.
778+ *
779+ * @private
780+ * @method make_selector_controls
781+ * @param {String} parent Name of the parent.
782+ * @return {Object} Hash with 'all_name', 'none_name', and 'html' keys.
783+ */
784+function make_selector_controls(parent) {
785+ var rv = {};
786+ rv.all_name = parent + '-select-all';
787+ rv.none_name = parent + '-select-none';
788+ rv.html = '<div id="'+ parent + '-selectors" '+
789+ 'style="margin-left: 10px;margin-bottom: 10px">' +
790+ ' <a href="#" id="' + rv.all_name +
791+ '">Select all</a> &nbsp;' +
792+ ' <a href="#" id="' + rv.none_name +
793+ '">Select none</a>' +
794+ '</div>';
795+
796+ return rv;
797+}
798+namespace.make_selector_controls = make_selector_controls;
799+
800+/**
801+ * Construct a handler closure for select all/none links.
802+ */
803+function make_select_handler(node, all, checked_value) {
804+ return function(e) {
805+ e.halt();
806+ Y.each(all, function(value) {
807+ get_input_by_value(node, value).set('checked', checked_value);
808+ });
809+ };
810+}
811+
812+/**
813+ * Create the accordion.
814+ *
815+ * @method create_accordion
816+ * @param {String} overlay_id Id of the overlay element.
817+ * @param {Object} content_node Node where the overlay is anchored.
818+ * @return {Object} accordion The accordion just created.
819+ */
820+function create_accordion(overlay_id, content_node) {
821+ var accordion = new Y.Accordion({
822+ useAnimation: true,
823+ collapseOthersOnExpand: true,
824+ visible: false
825+ });
826+
827+ accordion.render(overlay_id);
828+
829+ var statuses_ai,
830+ importances_ai,
831+ tags_ai;
832+
833+ // Build tags pane.
834+ tags_ai = new Y.AccordionItem( {
835+ label: "Tags",
836+ expanded: false,
837+ alwaysVisible: false,
838+ id: "tags_ai",
839+ contentHeight: {method: "auto"}
840+ } );
841+
842+ tags_ai.set("bodyContent",
843+ '<div>\n' +
844+ '<div>\n' +
845+ ' <input type="radio" name="tag_match" value="' +
846+ MATCH_ALL + '" checked> Match all tags\n' +
847+ ' <input type="radio" name="tag_match" value="' +
848+ MATCH_ANY + '"> Match any tags\n' +
849+ '</div>\n' +
850+ '<div style="padding-bottom:10px;">\n' +
851+ ' <input type="text" name="tags" size="60"/>\n' +
852+ ' <a target="help"'+
853+ ' href="/+help/structural-subscription-tags.html" ' +
854+ ' class="sprite maybe">&nbsp;'+
855+ '<span class="invisible-link">Structural subscription tags '+
856+ ' help</span></a>\n ' +
857+ '</div>\n' +
858+ '</div>\n');
859+
860+ accordion.addItem(tags_ai);
861+
862+ // Build importances pane.
863+ importances_ai = new Y.AccordionItem( {
864+ label: "Importances",
865+ expanded: false,
866+ alwaysVisible: false,
867+ id: "importances_ai",
868+ contentHeight: {method: "auto"}
869+ } );
870+ var importances = LP.cache.importances;
871+ var selectors = make_selector_controls('importances');
872+ var importances_html = '<div id="importances-wrapper">' +
873+ selectors.html +
874+ make_table(importances, 'importances', 4) +
875+ '</div>';
876+ importances_ai.set("bodyContent", importances_html);
877+ accordion.addItem(importances_ai);
878+ // Wire up the 'all' and 'none' selectors.
879+ var all_link = content_node.one('#' + selectors.all_name);
880+ var none_link = Y.one('#' + selectors.none_name);
881+ var node = content_node.one('#importances-wrapper');
882+ var select_all_handler = make_select_handler(node, importances, true);
883+ var select_none_handler = make_select_handler(node, importances, false);
884+ all_link.on('click', select_all_handler);
885+ none_link.on('click', select_none_handler);
886+
887+ // Build statuses pane.
888+ statuses_ai = new Y.AccordionItem( {
889+ label: "Statuses",
890+ expanded: false,
891+ alwaysVisible: false,
892+ id: "statuses_ai",
893+ contentHeight: {method: "auto"}
894+ } );
895+ var statuses = LP.cache.statuses;
896+ selectors = make_selector_controls('statuses');
897+ var status_html = '<div id="statuses-wrapper">' +
898+ selectors.html + make_table(statuses, 'statuses', 3)+
899+ '</div>';
900+ statuses_ai.set("bodyContent", status_html);
901+ accordion.addItem(statuses_ai);
902+ all_link = content_node.one('#' + selectors.all_name);
903+ none_link = Y.one('#' + selectors.none_name);
904+ node = content_node.one('#statuses-wrapper');
905+ select_all_handler = make_select_handler(node, statuses, true);
906+ select_none_handler = make_select_handler(node, statuses, false);
907+ all_link.on('click', select_all_handler);
908+ none_link.on('click', select_none_handler);
909+
910+ return accordion;
911+}
912+
913+/**
914+ * Collapse the node and set its arrow to 'collapsed'
915+ */
916+function collapse_node(node, user_cfg) {
917+ if (user_cfg && user_cfg.duration === 0) {
918+ node.setStyles({
919+ height: 0,
920+ visibility: 'hidden',
921+ overflow: 'hidden'
922+ // Don't set display: none because then the node won't be taken
923+ // into account and the rendering will sometimes jiggle
924+ // horizontally when the node is opened.
925+ });
926+ node.addClass('lazr-closed').removeClass('lazr-opened');
927+ return;
928+ }
929+ var anim = Y.lazr.effects.slide_in(node, user_cfg);
930+ // XXX: BradCrittenden 2011-03-03 bug=728457 : This fix for
931+ // resizing needs to be incorporated into lazr.effects. When that
932+ // is done it should be removed from here.
933+ anim.on("start", function() {
934+ node.setStyles({
935+ visibility: 'visible'
936+ });
937+ });
938+ anim.on("end", function() {
939+ node.setStyles({
940+ height: 0,
941+ visibility: 'hidden',
942+ display: null
943+ // Don't set display: none because then the node won't be taken
944+ // into account and the rendering will sometimes jiggle
945+ // horizontally when the node is opened.
946+ });
947+ });
948+ anim.run();
949+}
950+
951+/**
952+ * Expand the node and set its arrow to 'collapsed'
953+ */
954+function expand_node(node, user_cfg) {
955+ if (user_cfg && user_cfg.duration === 0) {
956+ node.setStyles({
957+ height: 'auto',
958+ visibility: 'visible',
959+ overflow: null, // Inherit.
960+ display: null // Inherit.
961+ });
962+ node.addClass('lazr-opened').removeClass('lazr-closed');
963+ return;
964+ }
965+ // Set the node to 'hidden' so that the proper size can be found.
966+ node.setStyles({
967+ visibility: 'hidden'
968+ });
969+ var anim = Y.lazr.effects.slide_out(node, user_cfg);
970+ // XXX: BradCrittenden 2011-03-03 bug=728457 : This fix for
971+ // resizing needs to be incorporated into lazr.effects. When that
972+ // is done it should be removed from here.
973+ anim.on("start", function() {
974+ // Set the node to 'visible' for the beginning of the animation.
975+ node.setStyles({
976+ visibility: 'visible'
977+ });
978+ });
979+ anim.on("end", function() {
980+ // Change the height to auto when the animation completes.
981+ node.setStyles({
982+ height: 'auto'
983+ });
984+ });
985+ anim.run();
986+}
987+
988+/**
989+ * Construct the overlay and populate it with the add/edit form.
990+ */
991+function setup_overlay(content_box_id, hide_recipient_picker) {
992+ var content_node = Y.one(content_box_id);
993+ var container = Y.Node.create('<div id="overlay-container"></div>');
994+ var accordion_overlay_id = 'accordion-overlay';
995+ var teams = LP.cache.administratedTeams;
996+ var no_recipient_picker =
997+ ' <input type="hidden" name="recipient" value="user">\n' +
998+ ' <span>Yourself</span>\n',
999+ recipient_picker =
1000+ ' <input type="radio" name="recipient" value="user"\n'+
1001+ ' id="structural-subscription-recipient-user" checked>\n'+
1002+ ' <label for="structural-subscription-recipient-user">\n'+
1003+ ' Yourself</label><br>\n' +
1004+ ' <input type="radio" name="recipient"\n'+
1005+ ' id="structural-subscription-recipient-team"\n'+
1006+ ' value="team">\n'+
1007+ ' <label for="structural-subscription-recipient-team">One of\n'+
1008+ ' the teams you administer</label><br>\n' +
1009+ ' <dl style="margin-left:25px;">\n' +
1010+ ' <dt></dt>\n' +
1011+ ' <dd>\n' +
1012+ ' <select name="team" id="structural-subscription-teams">\n'+
1013+ ' </select>\n' +
1014+ ' </dd>\n' +
1015+ ' </dl>\n',
1016+ control_code =
1017+ '<dl>\n' +
1018+ ' <dt>Bug mail recipient</dt>\n' +
1019+ ' <dd>\n' +
1020+ ((!hide_recipient_picker && teams.length > 0) ?
1021+ recipient_picker : no_recipient_picker) +
1022+ ' </dd>\n' +
1023+ ' <dt>Subscription name</dt>\n' +
1024+ ' <dd>\n' +
1025+ ' <input type="text" name="name">\n' +
1026+ ' <a target="help" class="sprite maybe"\n' +
1027+ ' href="/+help/structural-subscription-name.html">&nbsp;\n' +
1028+ ' <span class="invisible-link">Structural subscription\n'+
1029+ ' description help</span></a>\n ' +
1030+ ' </dd>\n' +
1031+ ' <dt>Receive mail for bugs affecting\n'+
1032+ ' <span id="structural-subscription-context-title">\n'+
1033+ ' '+LP.cache.context.title+'</span> that</dt>\n' +
1034+ ' <dd>\n' +
1035+ ' <div id="events">\n' +
1036+ ' <input type="radio" name="events"\n' +
1037+ ' value="' + ADDED_OR_CLOSED + '"\n'+
1038+ ' id="' + ADDED_OR_CLOSED + '" checked>\n'+
1039+ ' <label for="'+ADDED_OR_CLOSED+'">are added or '+
1040+ ' closed</label>\n'+
1041+ ' <br>\n' +
1042+ ' <input type="radio" name="events"\n'+
1043+ ' value="' + ADDED_OR_CHANGED + '"\n' +
1044+ ' id="' + ADDED_OR_CHANGED + '">\n'+
1045+ ' <label for="'+ADDED_OR_CHANGED+'">are added or changed in\n'+
1046+ ' any way\n'+
1047+ ' <em id="'+ADDED_OR_CHANGED+'-more">(more options...)</em>\n'+
1048+ ' </label>\n' +
1049+ ' </div>\n' +
1050+ ' <div id="' + FILTER_WRAPPER + '" class="ss-collapsible">\n' +
1051+ ' <dl style="margin-left:25px;">\n' +
1052+ ' <dt></dt>\n' +
1053+ ' <dd>\n' +
1054+ ' <input type="checkbox" name="filters"\n' +
1055+ ' value="' + FILTER_COMMENTS + '"\n'+
1056+ ' id="'+FILTER_COMMENTS+'">\n' +
1057+ ' <label for="'+FILTER_COMMENTS+'">Don\'t send mail about\n'+
1058+ ' comments</label><br>\n' +
1059+ ' <input type="checkbox" name="filters"\n' +
1060+ ' value="' + ADVANCED_FILTER + '"\n' +
1061+ ' id="' + ADVANCED_FILTER + '">\n' +
1062+ ' <label for="'+ADVANCED_FILTER+'">Bugs must match this\n'+
1063+ ' filter <em id="'+ADVANCED_FILTER+'-more">(...)</em>\n'+
1064+ ' </label><br>\n' +
1065+ ' <div id="' + ACCORDION_WRAPPER + '" \n' +
1066+ ' class="' + SS_COLLAPSIBLE + '">\n' +
1067+ ' <dl>\n' +
1068+ ' <dt></dt>\n' +
1069+ ' <dd style="margin-left:25px;">\n' +
1070+ ' <div id="' + accordion_overlay_id + '"\n' +
1071+ ' style="position:relative; '+
1072+ 'overflow:hidden;"></div>\n' +
1073+ ' </dd>\n' +
1074+ ' </dl>\n' +
1075+ ' </div> \n' +
1076+ ' </dd>\n' +
1077+ ' </dl>\n' +
1078+ ' </div> \n' +
1079+ ' </dd>\n' +
1080+ ' <dt></dt>\n' +
1081+ '</dl>';
1082+
1083+ content_node.appendChild(container);
1084+ container.appendChild(Y.Node.create(control_code));
1085+
1086+ var accordion = create_accordion(
1087+ '#' + accordion_overlay_id, content_node);
1088+
1089+ // Set up click handlers for the events radio buttons.
1090+ var radio_group = Y.all('#events input');
1091+ radio_group.on(
1092+ 'change',
1093+ function() {handle_change(ADDED_OR_CHANGED, FILTER_WRAPPER);});
1094+
1095+ // And a listener for advanced filter selection.
1096+ var advanced_filter = Y.one('#' + ADVANCED_FILTER);
1097+ advanced_filter.on(
1098+ 'change',
1099+ function() {handle_change(ADVANCED_FILTER, ACCORDION_WRAPPER);});
1100+ // Populate the team drop down from LP.cache data, if appropriate.
1101+ if (!hide_recipient_picker && teams.length > 0) {
1102+ var select = Y.one('#structural-subscription-teams');
1103+ var i;
1104+ var team;
1105+ for (i=0; i<teams.length; i++) {
1106+ team = teams[i];
1107+ var option = Y.Node.create('<option></option>');
1108+ option.set(INNER_HTML, team.title);
1109+ option.set(VALUE, team.link);
1110+ select.appendChild(option);
1111+ }
1112+ select.on(
1113+ 'focus',
1114+ function () {
1115+ Y.one('input[value="team"][name="recipient"]').set(
1116+ 'checked', true);
1117+ }
1118+ );
1119+ }
1120+ return '#' + container._node.id;
1121+} // setup_overlay
1122+// Expose in the namespace for testing purposes.
1123+namespace._setup_overlay = setup_overlay;
1124+
1125+function handle_change(control_name, div_name, user_cfg) {
1126+ // Expand or collapse the node depending on the control.
1127+ // user_cfg is passed to expand_node or collapse_node, and is
1128+ // useful to set the duration.
1129+ var ctl = Y.one('#' + control_name);
1130+ var more = Y.one('#' + control_name + '-more');
1131+ var div = Y.one('#' + div_name);
1132+ var checked = ctl.get('checked');
1133+ if (checked) {
1134+ expand_node(div, user_cfg);
1135+ more.setStyle('display', 'none');
1136+ } else {
1137+ collapse_node(div, user_cfg);
1138+ more.setStyle('display', null);
1139+ }
1140+}
1141+
1142+/*
1143+ * Create the LP client.
1144+ *
1145+ * @method setup_client
1146+ */
1147+function setup_client() {
1148+ namespace.lp_client = new Y.lp.client.Launchpad();
1149+} // setup_client
1150+
1151+/*
1152+ * External entry point for configuring the structual subscription.
1153+ * @method setup_bug_subscriptions
1154+ * @param {Object} config Object literal of config name/value pairs.
1155+ * config.content_box is the name of an element on the page where
1156+ * the overlay will be anchored.
1157+ */
1158+namespace.setup_bug_subscriptions = function(config) {
1159+ validate_config(config);
1160+ Y.on('domready', function() {
1161+ if (Y.Lang.isValue(config.lp_client)) {
1162+ // Tests can specify an lp_client if they want to.
1163+ namespace.lp_client = config.lp_client;
1164+ } else {
1165+ // Setup the Launchpad client.
1166+ setup_client();
1167+ }
1168+
1169+ var overlay_id = setup_overlay(config.content_box, true);
1170+ var submit_button = Y.Node.create(
1171+ '<button type="submit" name="field.actions.create" ' +
1172+ 'value="Save Changes" class="lazr-pos lazr-btn" '+
1173+ '>OK</button>');
1174+ // This is a bit of an odd approach, but it lets us retrofit code
1175+ // without a large refactoring. When edit_subscription_handler is
1176+ // called, context.filter_info will have the information about the
1177+ // filter that is being edited.
1178+ var context = {};
1179+ create_overlay(config.content_box, overlay_id, submit_button,
1180+ function (form_data) {
1181+ return edit_subscription_handler(context, form_data);});
1182+ fill_in_bug_subscriptions(config, context);
1183+ // We need to initialize the help links. They may have already been
1184+ // initialized except for the ones we added, so setupHelpTrigger
1185+ // is idempotent. Notice that this is old MochiKit code.
1186+ forEach(findHelpLinks(), setupHelpTrigger);
1187+ }, window);
1188+};
1189+
1190+function get_input_by_value(node, value) {
1191+ // XXX broken: this should also care about input name because some values
1192+ // repeat in other areas of the form
1193+ return node.one('input[value="'+value+'"]');
1194+}
1195+
1196+
1197+/**
1198+ * Set the value of a set of checkboxes to the provided values.
1199+ */
1200+function set_checkboxes(node, all, checked) {
1201+ // Clear all the checkboxes.
1202+ Y.each(all, function (value) {
1203+ get_input_by_value(node, value).set('checked', false);
1204+ });
1205+ // Check the checkboxes that are supposed to be checked.
1206+ Y.each(checked, function (value) {
1207+ get_input_by_value(node, value).set('checked', true);
1208+ });
1209+}
1210+
1211+/**
1212+ * Set the value of a select box to the provided value.
1213+ */
1214+function set_options(node, name, value) {
1215+ var select = node.one('select[name="team"]');
1216+ Y.each(select.get('options'), function (option) {
1217+ option.set('selected', option.get('value')===value);
1218+ });
1219+}
1220+
1221+/**
1222+ * Set the value of a set of radio buttons to the provided value.
1223+ */
1224+function set_radio_buttons(node, all, value) {
1225+ set_checkboxes(node, all, [value]);
1226+}
1227+
1228+/**
1229+ * Set the values of the recipient select box and radio buttons.
1230+ */
1231+function set_recipient(node, is_team, team_link) {
1232+ if (LP.cache.administratedTeams.length > 0) {
1233+ get_input_by_value(node, 'user').set('checked', !is_team);
1234+ get_input_by_value(node, 'team').set('checked', is_team);
1235+ set_options(node, 'teams',
1236+ team_link || LP.cache.administratedTeams[0].link);
1237+ }
1238+}
1239+
1240+/**
1241+ * Return an edit handler for the specified filter.
1242+ */
1243+function make_edit_handler(subscription, filter_info, filter_id,
1244+ config, context) {
1245+ // subscription is the filter's subscription.
1246+ // filter_info is the filter's information (from subscription.filters).
1247+ // filter_id is the numerical id for the filter, unique on the page.
1248+ // config is the configuration object used for the entire assembly of the
1249+ // page.
1250+ // context is a way to communicate to the shared edit handler what filter
1251+ // should be updated.
1252+ return function(e) {
1253+ // Only proceed if the form content is already available.
1254+ if (add_subscription_overlay) {
1255+ e.halt();
1256+ var content_node = Y.one(config.content_box),
1257+ teams = LP.cache.administratedTeams,
1258+ filter = filter_info.filter,
1259+ is_lifecycle = filter.bug_notification_level==='Lifecycle',
1260+ recipient_label = content_node.one(
1261+ 'input[name="recipient"] + span'),
1262+ statuses = filter.statuses,
1263+ importances = filter.importances;
1264+ if (filter_info.subscriber_is_team) {
1265+ var i;
1266+ for (i=0; i<teams.length; i++) {
1267+ if (teams[i].link === filter_info.subscriber_link){
1268+ recipient_label.set(INNER_HTML, teams[i].title);
1269+ break;
1270+ }
1271+ }
1272+ } else {
1273+ recipient_label.set(INNER_HTML, 'Yourself');
1274+ }
1275+ content_node.one('[name="name"]').set('value',filter.description);
1276+ if (is_lifecycle) {
1277+ statuses = LP.cache.statuses;
1278+ importances = LP.cache.importances;
1279+ } else {
1280+ // An absence of values is equivalent to all values.
1281+ if (statuses.length === 0) {
1282+ statuses = LP.cache.statuses;
1283+ }
1284+ if (importances.length === 0) {
1285+ importances = LP.cache.importances;
1286+ }
1287+ }
1288+ set_checkboxes(content_node, LP.cache.statuses, statuses);
1289+ set_checkboxes(
1290+ content_node, LP.cache.importances, importances);
1291+ content_node.one('[name="tags"]').set(
1292+ 'value', is_lifecycle ? '' : filter.tags.join(' '));
1293+ set_radio_buttons(
1294+ content_node, [MATCH_ALL, MATCH_ANY],
1295+ filter.find_all_tags ? MATCH_ALL : MATCH_ANY);
1296+ var has_advanced_filters = !is_lifecycle && (
1297+ filter.statuses.length ||
1298+ filter.importances.length ||
1299+ filter.tags.length) > 0,
1300+ filters = has_advanced_filters ? [ADVANCED_FILTER] : [],
1301+ event = ADDED_OR_CHANGED;
1302+ // Chattiness: Lifecycle < Details < Discussion.
1303+ switch (filter.bug_notification_level) {
1304+ case 'Lifecycle':
1305+ event = ADDED_OR_CLOSED;
1306+ filters = [];
1307+ break;
1308+ case 'Details':
1309+ filters.push(FILTER_COMMENTS);
1310+ break;
1311+ // case 'Discussion': This is already handled/the default.
1312+ // default: If we get here then it is a programmer error.
1313+ }
1314+ set_radio_buttons(
1315+ content_node, [ADDED_OR_CLOSED, ADDED_OR_CHANGED], event);
1316+ set_checkboxes(
1317+ content_node, [FILTER_COMMENTS, ADVANCED_FILTER], filters);
1318+ handle_change(ADDED_OR_CHANGED, FILTER_WRAPPER, {duration: 0});
1319+ handle_change(ADVANCED_FILTER, ACCORDION_WRAPPER, {duration: 0});
1320+ context.filter_info = filter_info;
1321+ context.filter_id = filter_id;
1322+ var title = subscription.target_title;
1323+ Y.one('#structural-subscription-context-title').set(
1324+ INNER_HTML, title);
1325+ Y.one('#subscription-overlay-title').set(
1326+ INNER_HTML, 'Edit subscription for '+title+' bugs');
1327+ add_subscription_overlay.show();
1328+ }
1329+ };
1330+}
1331+
1332+/**
1333+ * Construct a handler for an unsubscribe link.
1334+ */
1335+function make_delete_handler(filter, filter_id, subscriber_id) {
1336+ var error_handler = new Y.lp.client.ErrorHandler();
1337+ error_handler.showError = function(error_msg) {
1338+ var unsubscribe_node = Y.one('#unsubscribe-'+filter_id.toString());
1339+ Y.lp.app.errors.display_error(unsubscribe_node, error_msg);
1340+ };
1341+ return function() {
1342+ var y_config = {
1343+ method: "POST",
1344+ headers: {'X-HTTP-Method-Override': 'DELETE'},
1345+ on: {success: function(transactionid, response, args){
1346+ var subscriber = Y.one(
1347+ '#subscription-'+subscriber_id.toString()),
1348+ node = subscriber,
1349+ filters = subscriber.all('.subscription-filter');
1350+ if (!filters.isEmpty()) {
1351+ node = Y.one(
1352+ '#subscription-filter-'+filter_id.toString());
1353+ }
1354+ collapse_node(node);
1355+ },
1356+ failure: error_handler.getFailureHandler()
1357+ }
1358+ };
1359+ Y.io(filter.self_link, y_config);
1360+ };
1361+}
1362+
1363+/**
1364+ * Attach activation (click) handlers to all of the edit links on the page.
1365+ */
1366+function wire_up_edit_links(config, context) {
1367+ var listing = Y.one('#subscription-listing');
1368+ var subscription_info = LP.cache.subscription_info;
1369+ var filter_id = 0;
1370+ var i;
1371+ var j;
1372+ for (i=0; i<subscription_info.length; i++) {
1373+ var sub = subscription_info[i];
1374+ for (j=0; j<sub.filters.length; j++) {
1375+ var filter_info = sub.filters[j];
1376+ if (!filter_info.subscriber_is_team ||
1377+ filter_info.user_is_team_admin) {
1378+ var edit_link = Y.one('#edit-'+filter_id.toString());
1379+ var edit_handler = make_edit_handler(
1380+ sub, filter_info, filter_id, config, context);
1381+ edit_link.on('click', edit_handler);
1382+ var delete_link = Y.one('#unsubscribe-'+filter_id.toString());
1383+ var delete_handler = make_delete_handler(
1384+ filter_info.filter, filter_id, i);
1385+ delete_link.on('click', delete_handler);
1386+ }
1387+ filter_id += 1;
1388+ }
1389+ }
1390+}
1391+
1392+/**
1393+ * Populate the subscription list DOM element with subscription descriptions.
1394+ */
1395+function fill_in_bug_subscriptions(config, context) {
1396+ validate_config(config);
1397+ var listing = Y.one('#subscription-listing');
1398+ var subscription_info = LP.cache.subscription_info;
1399+ var html = '<div class="yui-g"><div id="structural-subscriptions">';
1400+ var filter_id = 0;
1401+ var i;
1402+ var j;
1403+ for (i=0; i<subscription_info.length; i++) {
1404+ var sub = subscription_info[i];
1405+ html +=
1406+ '<div style="margin-top: 2em; padding: 0 1em 1em 1em; '+
1407+ ' border: 1px solid #ddd;"'+
1408+ ' id="subscription-'+i.toString()+'">'+
1409+ ' <span style="float: left; margin-top: -0.6em; padding: 0 1ex;'+
1410+ ' background-color: #fff;">Subscriptions to'+
1411+ ' <a href="'+sub.target_url+'">'+sub.target_title+'</a>'+
1412+ ' </span>';
1413+
1414+ for (j=0; j<sub.filters.length; j++) {
1415+ var filter = sub.filters[j].filter;
1416+ // We put the filters in the cache so that the patch mechanism
1417+ // can automatically find them and update them on a successful
1418+ // edit. This makes it possible to open up a filter after an edit
1419+ // and see the information you expect to see.
1420+ LP.cache['structural-subscription-filter-'+filter_id.toString()] =
1421+ filter;
1422+ html +=
1423+ '<div style="margin: 1em 0em 0em 1em"'+
1424+ ' id="subscription-filter-'+filter_id.toString()+'"'+
1425+ ' class="subscription-filter">'+
1426+ ' <div style="margin-top: 1em">'+
1427+ ' <strong id="filter-name-'+
1428+ filter_id.toString()+'">'+
1429+ render_filter_name(sub.filters[j], filter)+'</strong>';
1430+ if (!sub.filters[j].subscriber_is_team ||
1431+ sub.filters[j].user_is_team_admin) {
1432+ // User can edit the subscription.
1433+ html +=
1434+ '<span style="float: right">'+
1435+ '<a href="#" class="sprite modify edit js-action"'+
1436+ ' id="edit-'+filter_id.toString()+'">'+
1437+ ' Edit this subscription</a> or '+
1438+ '<a href="#" class="sprite modify remove js-action"'+
1439+ ' id="unsubscribe-'+filter_id.toString()+'">'+
1440+ ' Unsubscribe</a></span>';
1441+ } else {
1442+ // User cannot edit the subscription, because this is a
1443+ // team and the user does not have admin privileges.
1444+ html +=
1445+ '<span style="float: right"><em>'+
1446+ 'You do not have privileges to change this subscription'+
1447+ '</em></span>';
1448+ }
1449+ html += '</div>';
1450+ html +=
1451+ '<div style="padding-left: 1em"'+
1452+ ' id="filter-description-'+filter_id.toString()+'">'+
1453+ render_filter_description(filter)+'</div>';
1454+
1455+ html += '</div>';
1456+ filter_id += 1;
1457+ }
1458+
1459+ // We can remove this once we enforce at least one filter per
1460+ // subscription.
1461+ if (subscription_info[i].filters.length === 0) {
1462+ html += '<strong>All messages</strong>';
1463+ }
1464+ html += '</div>';
1465+ }
1466+ html += '</div></div>';
1467+ listing.appendChild(Y.Node.create(html));
1468+
1469+ wire_up_edit_links(config, context);
1470+}
1471+
1472+/**
1473+ * Construct a one-line textual description of a filter's name.
1474+ */
1475+function render_filter_name(filter_info, filter) {
1476+ var description;
1477+ if (filter.description) {
1478+ description = '"'+filter.description+'"';
1479+ } else {
1480+ description = '(unnamed)';
1481+ }
1482+ if (filter_info.subscriber_is_team) {
1483+ return '<a href="'+filter_info.subscriber_url+'">'+
1484+ filter_info.subscriber_title+"</a> subscription: "+description;
1485+ } else {
1486+ return 'Your subscription: '+description;
1487+ }
1488+}
1489+
1490+/**
1491+ * Construct a textual description of all of filter's properties.
1492+ */
1493+function render_filter_description(filter) {
1494+ var html = '';
1495+ var filter_items = '';
1496+ // Format status conditions.
1497+ if (filter.statuses.length !== 0) {
1498+ filter_items += '<li> have status ' +
1499+ filter.statuses.join(', ');
1500+ }
1501+
1502+ // Format importance conditions.
1503+ if (filter.importances.length !== 0) {
1504+ filter_items += '<li> are of importance ' +
1505+ filter.importances.join(', ');
1506+ }
1507+
1508+ // Format tag conditions.
1509+ if (filter.tags.length !== 0) {
1510+ filter_items += '<li> are tagged with ';
1511+ if (filter.find_all_tags) {
1512+ filter_items += '<strong>all</strong>';
1513+ } else {
1514+ filter_items += '<strong>any</strong>';
1515+ }
1516+ filter_items += ' of these tags: ' +
1517+ filter.tags.join(', ');
1518+ }
1519+
1520+ // If there were any conditions to list, stich them in with an
1521+ // intro.
1522+ if (filter_items !== '') {
1523+ html += 'You are subscribed to bugs that'+
1524+ '<ul class="bulleted">'+filter_items+'</ul>';
1525+ }
1526+
1527+ // Format event details.
1528+ if (filter.bug_notification_level === 'Discussion') {
1529+ html += 'You will recieve an email when any change '+
1530+ 'is made or a comment is added.';
1531+ } else if (filter.bug_notification_level === 'Details') {
1532+ html += 'You will recieve an email when any changes '+
1533+ 'are made to the bug. Bug comments will not be sent.';
1534+ } else if (filter.bug_notification_level === 'Lifecycle') {
1535+ html += 'You will recieve an email when bugs are '+
1536+ 'opened or closed.';
1537+ }
1538+ return html;
1539+}
1540+
1541+/**
1542+ * Check the configuration for obvious faults.
1543+ */
1544+function validate_config(config) {
1545+ if (!Y.Lang.isValue(config)) {
1546+ throw new Error(
1547+ 'Missing config for structural_subscription.');
1548+ }
1549+ if (!Y.Lang.isValue(config.content_box)) {
1550+ throw new Error(
1551+ 'Structural_subscription configuration has ' +
1552+ 'undefined properties.');
1553+ }
1554+}
1555+
1556+// Expose in the namespace for testing purposes.
1557+namespace._validate_config = validate_config;
1558+
1559+/*
1560+ * External entry point for configuring the structual subscription.
1561+ * @method setup
1562+ * @param {Object} config Object literal of config name/value pairs.
1563+ * config.content_box is the name of an element on the page where
1564+ * the overlay will be anchored.
1565+ */
1566+namespace.setup = function(config) {
1567+ validate_config(config);
1568+
1569+ // If the user is not logged in, then we need to defer to the
1570+ // default behaviour.
1571+ if (LP.links.me === undefined) {
1572+ return;
1573+ }
1574+ if (Y.Lang.isValue(config.lp_client)) {
1575+ // Tests can specify an lp_client if they want to.
1576+ namespace.lp_client = config.lp_client;
1577+ } else {
1578+ // Setup the Launchpad client.
1579+ setup_client();
1580+ }
1581+ // Create the overlay.
1582+ var overlay_id = setup_overlay(config.content_box);
1583+ // Create the subscription links on the page.
1584+ setup_subscription_links(overlay_id, config.content_box);
1585+
1586+ var submit_button = Y.Node.create(
1587+ '<button type="submit" name="field.actions.create" ' +
1588+ 'value="Create subscription" class="lazr-pos lazr-btn" '+
1589+ '>OK</button>');
1590+ create_overlay(config.content_box, overlay_id, submit_button,
1591+ save_subscription);
1592+ // We need to initialize the help links. They may have already been
1593+ // initialized except for the ones we added, so setupHelpTrigger
1594+ // is idempotent. Notice that this is old MochiKit code.
1595+ forEach(findHelpLinks(), setupHelpTrigger);
1596+
1597+}; // setup
1598+
1599+}, '0.1', {requires: [
1600+ 'dom', 'node', 'lazr.anim', 'lazr.formoverlay', 'lazr.overlay',
1601+ 'lazr.effects', 'lp.app.errors', 'lp.client', 'gallery-accordion'
1602+ ]});
1603
1604=== added file 'lib/lp/registry/javascript/tests/test_structural_subscription.html'
1605--- lib/lp/registry/javascript/tests/test_structural_subscription.html 1970-01-01 00:00:00 +0000
1606+++ lib/lp/registry/javascript/tests/test_structural_subscription.html 2011-03-28 19:31:27 +0000
1607@@ -0,0 +1,63 @@
1608+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
1609+<html>
1610+ <head>
1611+ <title>Structural Subscription Overlay</title>
1612+
1613+ <!-- YUI 3.0 Setup -->
1614+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
1615+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
1616+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
1617+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
1618+ <link rel="stylesheet"
1619+ href="../../../../canonical/launchpad/icing/lazr/build/testing/assets/testlogger.css"/>
1620+
1621+ <!-- Dependency -->
1622+ <script type="text/javascript"
1623+ src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
1624+ <script type="text/javascript"
1625+ src="../../../../canonical/launchpad/icing/lazr/build/testing/testing.js"></script>
1626+ <script type="text/javascript"
1627+ src="../../../../canonical/launchpad/icing/lazr/build/testing/mockio.js"></script>
1628+ <script type="text/javascript"
1629+ src="../../../../canonical/launchpad/icing/lazr/build/overlay/overlay.js">
1630+ </script>
1631+ <script type="text/javascript"
1632+ src="../../../../canonical/launchpad/icing/lazr/build/formoverlay/formoverlay.js">
1633+ </script>
1634+ <script type="text/javascript"
1635+ src="../../../../canonical/launchpad/icing/lazr/build/choiceedit/choiceedit.js">
1636+ </script>
1637+ <script type="text/javascript"
1638+ src="../../../app/javascript/client.js"></script>
1639+ <script type="text/javascript"
1640+ src="../../../contrib/javascript/yui3-gallery/gallery-accordion/gallery-accordion.js">
1641+ </script>
1642+ <script type="text/javascript"
1643+ src="../../../../canonical/launchpad/icing/MochiKit.js"></script>
1644+ <script type="text/javascript"
1645+ src="../../../services/inlinehelp/javascript/inlinehelp.js"></script>
1646+
1647+
1648+ <!-- The module under test -->
1649+ <script type="text/javascript" src="../structural-subscription.js"></script>
1650+
1651+ <!-- The test suite -->
1652+ <script type="text/javascript" src="test_structural_subscription.js"></script>
1653+
1654+ <!-- Test layout -->
1655+ <link rel="stylesheet"
1656+ href="../../../../canonical/launchpad/javascript/test.css" />
1657+ <style type="text/css">
1658+ /* CSS classes specific to this test */
1659+ .unseen { display: none; }
1660+ </style>
1661+</head>
1662+<body class="yui3-skin-sam">
1663+ <div>
1664+ This exists to stop tests from breaking.
1665+ <a href="#" class="menu-link-subscribe_to_bug_mail"
1666+ >A link, a link, my kingdom for a link</a>
1667+ </div>
1668+ <div id="log"></div>
1669+</body>
1670+</html>
1671
1672=== added file 'lib/lp/registry/javascript/tests/test_structural_subscription.js'
1673--- lib/lp/registry/javascript/tests/test_structural_subscription.js 1970-01-01 00:00:00 +0000
1674+++ lib/lp/registry/javascript/tests/test_structural_subscription.js 2011-03-28 19:31:27 +0000
1675@@ -0,0 +1,751 @@
1676+/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
1677+
1678+YUI({
1679+ base: '../../../../canonical/launchpad/icing/yui/',
1680+ filter: 'raw',
1681+ combine: false,
1682+ fetchCSS: false
1683+ }).use('test', 'console', 'node', 'lp.client',
1684+ 'lp.registry.structural_subscription', function(Y) {
1685+
1686+ var suite = new Y.Test.Suite("Structural subscription overlay tests");
1687+
1688+ var context;
1689+ var test_case;
1690+
1691+ // Local aliases
1692+ var Assert = Y.Assert,
1693+ ArrayAssert = Y.ArrayAssert,
1694+ module = Y.lp.registry.structural_subscription;
1695+
1696+ // Expected content box.
1697+ var content_box_name = 'ss-content-box';
1698+ var content_box_id = '#' + content_box_name;
1699+
1700+ var target_link_class = '.menu-link-subscribe_to_bug_mail';
1701+
1702+ function array_compare(a,b) {
1703+ if (a.length != b.length)
1704+ return false;
1705+ a.sort();
1706+ b.sort();
1707+ for (i in a) {
1708+ if (a[i] != b[i])
1709+ return false;
1710+ }
1711+ return true;
1712+ }
1713+
1714+ function create_test_node() {
1715+ return Y.Node.create(
1716+ '<div id="test-content">' +
1717+ ' <div id="' + content_box_name + '"></div>' +
1718+ '</div>');
1719+ }
1720+
1721+ function remove_test_node() {
1722+ Y.one('body').removeChild(Y.one('#test-content'));
1723+ }
1724+
1725+ function test_checked(list, expected) {
1726+ var item, i;
1727+ var length = list.size();
1728+ for (i=0; i < length; i++) {
1729+ item = list.item(i);
1730+ if (item.get('checked') != expected)
1731+ return false;
1732+ }
1733+ return true;
1734+ }
1735+
1736+ test_case = new Y.Test.Case({
1737+ name: 'structural_subscription_overlay',
1738+
1739+ _should: {
1740+ error: {
1741+ test_setup_config_none: new Error(
1742+ 'Missing config for structural_subscription.'),
1743+ test_setup_config_no_content_box: new Error(
1744+ 'Structural_subscription configuration has undefined '+
1745+ 'properties.')
1746+ }
1747+ },
1748+
1749+ setUp: function() {
1750+ // Monkeypatch LP to avoid network traffic and to allow
1751+ // insertion of test data.
1752+ window.LP = {
1753+ links: {},
1754+ cache: {}
1755+ };
1756+ LP.cache.context = {
1757+ title: 'Test Project',
1758+ self_link: 'https://launchpad.dev/api/test_project'
1759+ };
1760+ LP.cache.administratedTeams = [];
1761+ LP.cache.importances = [];
1762+ LP.cache.statuses = [];
1763+
1764+ this.configuration = {
1765+ content_box: content_box_id,
1766+ };
1767+ this.content_node = create_test_node();
1768+ Y.one('body').appendChild(this.content_node);
1769+ },
1770+
1771+ tearDown: function() {
1772+ //delete this.configuration;
1773+ remove_test_node();
1774+ delete this.content_node;
1775+ delete this.configuration.lp_client;
1776+ delete this.content_node;
1777+ },
1778+
1779+ test_setup_config_none: function() {
1780+ // The config passed to setup may not be null.
1781+ module.setup();
1782+ },
1783+
1784+ test_setup_config_no_content_box: function() {
1785+ // The config passed to setup must contain a content_box.
1786+ module.setup({});
1787+ },
1788+
1789+ test_anonymous: function() {
1790+ // The link should not be shown to anonymous users so
1791+ // 'setup' should not do anything in that case. If it
1792+ // were successful, the lp_client would be defined after
1793+ // setup is called.
1794+ LP.links.me = undefined;
1795+ Assert.isUndefined(module.lp_client);
1796+ module.setup(this.configuration);
1797+ Assert.isUndefined(module.lp_client);
1798+ },
1799+
1800+ test_logged_in_user: function() {
1801+ // If there is a logged-in user, setup is successful
1802+ LP.links.me = 'https://launchpad.dev/api/~someone';
1803+ Assert.isUndefined(module.lp_client);
1804+ module.setup(this.configuration);
1805+ Assert.isNotUndefined(module.lp_client);
1806+ },
1807+
1808+ test_list_contains: function() {
1809+ // Validate that the list_contains function actually reports
1810+ // whether or not an element is in a list.
1811+ var list = ['a', 'b', 'c'];
1812+ Assert.isTrue(module._list_contains(list, 'b'));
1813+ Assert.isFalse(module._list_contains(list, 'd'));
1814+ Assert.isFalse(module._list_contains([], 'a'));
1815+ Assert.isTrue(module._list_contains(['a', 'a'], 'a'));
1816+ Assert.isFalse(module._list_contains([], ''));
1817+ Assert.isFalse(module._list_contains([], null));
1818+ Assert.isFalse(module._list_contains(['a'], null));
1819+ Assert.isFalse(module._list_contains([]));
1820+ },
1821+
1822+ test_make_selector_controls: function() {
1823+ // Verify the creation of select all/none controls.
1824+ var selectors = module.make_selector_controls('sharona');
1825+ Assert.areEqual('sharona-select-all', selectors['all_name']);
1826+ Assert.areEqual('sharona-select-none', selectors['none_name']);
1827+ Assert.areEqual(
1828+ '<div id="sharona-selectors"',
1829+ selectors['html'].slice(0, 27));
1830+ }
1831+ });
1832+ suite.add(test_case);
1833+
1834+ test_case = new Y.Test.Case({
1835+ name: 'Structural Subscription Overlay save_subscription',
1836+
1837+ _should: {
1838+ error: {}
1839+ },
1840+
1841+ setUp: function() {
1842+ // Monkeypatch LP to avoid network traffic and to allow
1843+ // insertion of test data.
1844+ window.LP = {
1845+ links: {},
1846+ cache: {}
1847+ };
1848+ Y.lp.client.Launchpad = function() {};
1849+ Y.lp.client.Launchpad.prototype.named_post =
1850+ function(url, func, config) {
1851+ context.url = url;
1852+ context.func = func;
1853+ context.config = config;
1854+ // No need to call the on.success handler.
1855+ };
1856+ LP.cache.context = {
1857+ title: 'Test Project',
1858+ self_link: 'https://launchpad.dev/api/test_project'
1859+ };
1860+ LP.links.me = 'https://launchpad.dev/api/~someone';
1861+ LP.cache.administratedTeams = [];
1862+ LP.cache.importances = [];
1863+ LP.cache.statuses = [];
1864+
1865+ this.configuration = {
1866+ content_box: content_box_id
1867+ };
1868+ this.content_node = create_test_node();
1869+ Y.one('body').appendChild(this.content_node);
1870+
1871+ this.bug_filter = {
1872+ lp_original_uri:
1873+ '/api/devel/firefox/+subscription/mark/+filter/28'
1874+ };
1875+ this.form_data = {
1876+ recipient: ['user']
1877+ };
1878+ context = {};
1879+ },
1880+
1881+ tearDown: function() {
1882+ delete this.configuration;
1883+ remove_test_node();
1884+ delete this.content_node;
1885+ },
1886+
1887+ test_user_recipient: function() {
1888+ // When the user selects themselves as the recipient, the current
1889+ // user's URI is used as the recipient value.
1890+ module.setup(this.configuration);
1891+ this.form_data.recipient = ['user'];
1892+ module.save_subscription(this.form_data);
1893+ Assert.areEqual(
1894+ LP.links.me,
1895+ context.config.parameters.subscriber);
1896+ },
1897+
1898+ test_team_recipient: function() {
1899+ // When the user selects a team as the recipient, the selected
1900+ // team's URI is used as the recipient value.
1901+ module.setup(this.configuration);
1902+ this.form_data.recipient = ['team'];
1903+ this.form_data.team = ['https://launchpad.dev/api/~super-team'];
1904+ module.save_subscription(this.form_data);
1905+ Assert.areEqual(
1906+ this.form_data.team[0],
1907+ context.config.parameters.subscriber);
1908+ }
1909+ });
1910+ suite.add(test_case);
1911+
1912+ test_case = new Y.Test.Case({
1913+ name: 'Structural Subscription interaction tests',
1914+
1915+ _should: {
1916+ error: {
1917+ }
1918+ },
1919+
1920+ setUp: function() {
1921+ // Monkeypatch LP to avoid network traffic and to allow
1922+ // insertion of test data.
1923+ window.LP = {
1924+ links: {},
1925+ cache: {}
1926+ };
1927+
1928+ LP.cache.context = {
1929+ title: 'Test Project',
1930+ self_link: 'https://launchpad.dev/api/test_project'
1931+ };
1932+ LP.cache.administratedTeams = [];
1933+ LP.cache.importances = [];
1934+ LP.cache.statuses = [];
1935+ LP.links.me = 'https://launchpad.dev/api/~someone';
1936+
1937+ var lp_client = function() {};
1938+ this.configuration = {
1939+ content_box: content_box_id,
1940+ lp_client: lp_client
1941+ };
1942+
1943+ this.content_node = create_test_node();
1944+ Y.one('body').appendChild(this.content_node);
1945+ },
1946+
1947+ tearDown: function() {
1948+ remove_test_node();
1949+ delete this.content_node;
1950+ },
1951+
1952+ test_setup_overlay: function() {
1953+ // At the outset there should be no overlay.
1954+ var overlay = Y.one('#accordion-overlay');
1955+ Assert.isNull(overlay);
1956+ module.setup(this.configuration);
1957+ // After the setup the overlay should be in the DOM.
1958+ overlay = Y.one('#accordion-overlay');
1959+ Assert.isNotNull(overlay);
1960+ var header = Y.one(content_box_id).one('h2');
1961+ Assert.areEqual(
1962+ 'Add a mail subscription for Test Project bugs',
1963+ header.get('text'));
1964+ },
1965+
1966+ test_initial_state: function() {
1967+ // When initialized the <div> elements for the filter
1968+ // wrapper and the accordion wrapper should be collapsed.
1969+ module.setup(this.configuration);
1970+ // Simulate a click on the link to open the overlay.
1971+ var link = Y.one('.menu-link-subscribe_to_bug_mail');
1972+ Y.Event.simulate(
1973+ Y.Node.getDOMNode(link), 'click');
1974+ var filter_wrapper = Y.one('#filter-wrapper');
1975+ var accordion_wrapper = Y.one('#accordion-wrapper');
1976+ Assert.isTrue(filter_wrapper.hasClass('lazr-closed'));
1977+ Assert.isTrue(accordion_wrapper.hasClass('lazr-closed'));
1978+ },
1979+
1980+ test_added_or_changed_toggles: function() {
1981+ // Test that the filter wrapper opens and closes in
1982+ // response to the added_or_changed radio button.
1983+ module.setup(this.configuration);
1984+ // Simulate a click on the link to open the overlay.
1985+ var link = Y.one('.menu-link-subscribe_to_bug_mail');
1986+ Y.Event.simulate(
1987+ Y.Node.getDOMNode(link), 'click');
1988+ var added_changed = Y.one('#added-or-changed');
1989+ Assert.isFalse(added_changed.get('checked'));
1990+ var filter_wrapper = Y.one('#filter-wrapper');
1991+ // Initially closed.
1992+ Assert.isTrue(filter_wrapper.hasClass('lazr-closed'));
1993+ // Opens when selected.
1994+ Y.Event.simulate(Y.Node.getDOMNode(added_changed), 'click');
1995+ this.wait(function() {
1996+ Assert.isTrue(filter_wrapper.hasClass('lazr-opened'));
1997+ }, 500);
1998+ // Closes when deselected.
1999+ Y.Event.simulate(
2000+ Y.Node.getDOMNode(Y.one('#added-or-closed')), 'click');
2001+ this.wait(function() {
2002+ Assert.isTrue(filter_wrapper.hasClass('lazr-closed'));
2003+ }, 500);
2004+ },
2005+
2006+ test_advanced_filter_toggles: function() {
2007+ // Test that the accordion wrapper opens and closes in
2008+ // response to the advanced filter check box.
2009+ module.setup(this.configuration);
2010+ // Simulate a click on the link to open the overlay.
2011+ var link = Y.one('.menu-link-subscribe_to_bug_mail');
2012+ Y.Event.simulate(
2013+ Y.Node.getDOMNode(link), 'click');
2014+ var added_changed = Y.one('#added-or-changed');
2015+ added_changed.set('checked', true);
2016+
2017+ // Initially closed.
2018+ var advanced_filter = Y.one('#advanced-filter');
2019+ Assert.isFalse(advanced_filter.get('checked'));
2020+ var accordion_wrapper = Y.one('#accordion-wrapper');
2021+ this.wait(function() {
2022+ Assert.isTrue(accordion_wrapper.hasClass('lazr-closed'));
2023+ }, 500);
2024+ // Opens when selected.
2025+ advanced_filter.set('checked') = true;
2026+ this.wait(function() {
2027+ Assert.isTrue(accordion_wrapper.hasClass('lazr-opened'));
2028+ }, 500);
2029+ // Closes when deselected.
2030+ advanced_filter.set('checked') = false;
2031+ this.wait(function() {
2032+ Assert.isTrue(accordion_wrapper.hasClass('lazr-closed'));
2033+ }, 500);
2034+ },
2035+
2036+ test_importances_select_all_none: function() {
2037+ // Test the select all/none functionality for the importances
2038+ // accordion pane.
2039+ module.setup(this.configuration);
2040+ var checkboxes = Y.all('input[name="importances"]');
2041+ var select_all = Y.one('#importances-select-all');
2042+ var select_none = Y.one('#importances-select-none');
2043+ Assert.isTrue(test_checked(checkboxes, true));
2044+ // Simulate a click on the select_none control.
2045+ Y.Event.simulate(Y.Node.getDOMNode(select_none), 'click');
2046+ Assert.isTrue(test_checked(checkboxes, false));
2047+ // Simulate a click on the select_all control.
2048+ Y.Event.simulate(Y.Node.getDOMNode(select_all), 'click');
2049+ Assert.isTrue(test_checked(checkboxes, true));
2050+ },
2051+
2052+ test_statuses_select_all_none: function() {
2053+ // Test the select all/none functionality for the statuses
2054+ // accordion pane.
2055+ module.setup(this.configuration);
2056+ var checkboxes = Y.all('input[name="statuses"]');
2057+ var select_all = Y.one('#statuses-select-all');
2058+ var select_none = Y.one('#statuses-select-none');
2059+ Assert.isTrue(test_checked(checkboxes, true));
2060+ // Simulate a click on the select_none control.
2061+ Y.Event.simulate(Y.Node.getDOMNode(select_none), 'click');
2062+ Assert.isTrue(test_checked(checkboxes, false));
2063+ // Simulate a click on the select_all control.
2064+ Y.Event.simulate(Y.Node.getDOMNode(select_all), 'click');
2065+ Assert.isTrue(test_checked(checkboxes, true));
2066+ }
2067+
2068+ });
2069+ suite.add(test_case);
2070+
2071+ test_case = new Y.Test.Case({
2072+ // Test the setup method.
2073+ name: 'Structural Subscription error handling',
2074+
2075+ _should: {
2076+ error: {
2077+ }
2078+ },
2079+
2080+ setUp: function() {
2081+ // Monkeypatch LP to avoid network traffic and to allow
2082+ // insertion of test data.
2083+ window.LP = {
2084+ links: {},
2085+ cache: {}
2086+ };
2087+
2088+ LP.cache.context = {
2089+ title: 'Test Project',
2090+ self_link: 'https://launchpad.dev/api/test_project'
2091+ };
2092+ LP.cache.administratedTeams = [];
2093+ LP.cache.importances = ['Unknown', 'Critical', 'High', 'Medium',
2094+ 'Low', 'Wishlist', 'Undecided'];
2095+ LP.cache.statuses = ['New', 'Incomplete', 'Opinion',
2096+ 'Invalid', 'Won\'t Fix', 'Expired',
2097+ 'Confirmed', 'Triaged', 'In Progress',
2098+ 'Fix Committed', 'Fix Released', 'Unknown'];
2099+ LP.links.me = 'https://launchpad.dev/api/~someone';
2100+
2101+ var lp_client = function() {};
2102+ this.configuration = {
2103+ content_box: content_box_id,
2104+ lp_client: lp_client
2105+ };
2106+
2107+ this.content_node = create_test_node();
2108+ Y.one('body').appendChild(this.content_node);
2109+ },
2110+
2111+ tearDown: function() {
2112+ remove_test_node();
2113+ delete this.content_node;
2114+ },
2115+
2116+ test_overlay_error_handling_adding: function() {
2117+ // Verify that errors generated during adding of a filter are
2118+ // displayed to the user.
2119+ this.configuration.lp_client.named_post =
2120+ function(url, func, config) {
2121+ config.on.failure(true, true);
2122+ };
2123+ module.setup(this.configuration);
2124+ // After the setup the overlay should be in the DOM.
2125+ overlay = Y.one('#accordion-overlay');
2126+ Assert.isNotNull(overlay);
2127+ submit_button = Y.one('.yui3-lazr-formoverlay-actions button');
2128+ Y.Event.simulate(Y.Node.getDOMNode(submit_button), 'click');
2129+
2130+ var error_box = Y.one('.yui3-lazr-formoverlay-errors');
2131+ Assert.areEqual(
2132+ 'The following errors were encountered: ',
2133+ error_box.get('text'));
2134+ },
2135+
2136+ test_overlay_error_handling_patching: function() {
2137+ // Verify that errors generated during patching of a filter are
2138+ // displayed to the user.
2139+ var original_delete_filter = module._delete_filter;
2140+ module._delete_filter = function() {};
2141+ this.configuration.lp_client.patch =
2142+ function(bug_filter, data, config) {
2143+ config.on.failure(true, true);
2144+ };
2145+ var bug_filter = {
2146+ 'getAttrs': function() { return {}; }
2147+ };
2148+ this.configuration.lp_client.named_post =
2149+ function(url, func, config) {
2150+ config.on.success(bug_filter);
2151+ };
2152+ module.setup(this.configuration);
2153+ // After the setup the overlay should be in the DOM.
2154+ overlay = Y.one('#accordion-overlay');
2155+ Assert.isNotNull(overlay);
2156+ submit_button = Y.one('.yui3-lazr-formoverlay-actions button');
2157+ Y.Event.simulate(Y.Node.getDOMNode(submit_button), 'click');
2158+
2159+ // Put this stubbed function back.
2160+ module._delete_filter = original_delete_filter;
2161+
2162+ var error_box = Y.one('.yui3-lazr-formoverlay-errors');
2163+ Assert.areEqual(
2164+ 'The following errors were encountered: ',
2165+ error_box.get('text'));
2166+ }
2167+
2168+ });
2169+ suite.add(test_case);
2170+
2171+ suite.add(new Y.Test.Case({
2172+ name: 'Structural Subscription: deleting failed filters',
2173+
2174+ _should: {error: {}},
2175+
2176+ setUp: function() {
2177+ // Monkeypatch LP to avoid network traffic and to allow
2178+ // insertion of test data.
2179+ this.original_lp = window.LP;
2180+ window.LP = {
2181+ links: {},
2182+ cache: {}
2183+ };
2184+ LP.cache.context = {
2185+ self_link: 'https://launchpad.dev/api/test_project'
2186+ };
2187+ LP.links.me = 'https://launchpad.dev/api/~someone';
2188+ LP.cache.administratedTeams = [];
2189+ },
2190+
2191+ tearDown: function() {
2192+ window.LP = this.original_lp;
2193+ },
2194+
2195+ test_delete_on_patch_failure: function() {
2196+ // Creating a filter is a two step process. First it is created
2197+ // and then patched. If the PATCH fails, then we should DELETE
2198+ // the undifferentiated filter.
2199+
2200+ // First we inject our own delete_filter implementation that just
2201+ // tells us that it was called.
2202+ var original_delete_filter = module._delete_filter;
2203+ var delete_called = false;
2204+ module._delete_filter = function() {
2205+ delete_called = true;
2206+ };
2207+ var patch_failed = false;
2208+
2209+ var TestBugFilter = function() {};
2210+ TestBugFilter.prototype = {
2211+ 'getAttrs': function () {
2212+ return {};
2213+ },
2214+ };
2215+
2216+ // Now we need an lp_client that will appear to succesfully create
2217+ // the filter but then fail to patch it.
2218+ var TestClient = function() {};
2219+ TestClient.prototype = {
2220+ 'named_post': function (uri, operation_name, config) {
2221+ if (operation_name === 'addBugSubscriptionFilter') {
2222+ config.on.success(new TestBugFilter());
2223+ } else {
2224+ throw new Error('unexpected operation');
2225+ }
2226+ },
2227+ 'patch': function(uri, representation, config, headers) {
2228+ config.on.failure(true, {'status':400});
2229+ patch_failed = true;
2230+ },
2231+ };
2232+ module.lp_client = new TestClient();
2233+
2234+ // OK, we're ready to add the bug filter and let the various
2235+ // handlers be called.
2236+ module._add_bug_filter(LP.links.me, 'this is a test');
2237+ // Put some functions back.
2238+ module._delete_filter = original_delete_filter;
2239+
2240+ // Delete should have been called and the patch has failed.
2241+ Assert.isTrue(delete_called);
2242+ Assert.isTrue(patch_failed);
2243+ },
2244+
2245+ }));
2246+
2247+ suite.add(new Y.Test.Case({
2248+ name: 'Structural Subscription validate_config',
2249+
2250+ _should: {
2251+ error: {
2252+ test_setup_config_none: new Error(
2253+ 'Missing config for structural_subscription.'),
2254+ test_setup_config_no_content_box: new Error(
2255+ 'Structural_subscription configuration has undefined '+
2256+ 'properties.')
2257+ }
2258+ },
2259+
2260+ // Included in _should/error above.
2261+ test_setup_config_none: function() {
2262+ // The config passed to setup may not be null.
2263+ module._validate_config();
2264+ },
2265+
2266+ // Included in _should/error above.
2267+ test_setup_config_no_content_box: function() {
2268+ // The config passed to setup must contain a content_box.
2269+ module._validate_config({});
2270+ }
2271+ }));
2272+
2273+ suite.add(new Y.Test.Case({
2274+ name: 'Structural Subscription extract_form_data',
2275+
2276+ // Verify that all the different values of the structural subscription
2277+ // add/edit form are correctly extracted by the extract_form_data
2278+ // function.
2279+
2280+ _should: {
2281+ error: {
2282+ }
2283+ },
2284+
2285+ test_extract_description: function() {
2286+ var form_data = {
2287+ name: ['filter description'],
2288+ events: [],
2289+ filters: [],
2290+ };
2291+ var patch_data = module._extract_form_data(form_data);
2292+ Assert.areEqual(patch_data.description, form_data.name[0]);
2293+ },
2294+
2295+ test_extract_description_trim: function() {
2296+ // Any leading or trailing whitespace is stripped from the
2297+ // description.
2298+ var form_data = {
2299+ name: [' filter description '],
2300+ events: [],
2301+ filters: [],
2302+ };
2303+ var patch_data = module._extract_form_data(form_data);
2304+ Assert.areEqual('filter description', patch_data.description);
2305+ },
2306+
2307+ test_extract_chattiness_lifecycle: function() {
2308+ var form_data = {
2309+ name: [],
2310+ events: ['added-or-closed'],
2311+ filters: [],
2312+ };
2313+ var patch_data = module._extract_form_data(form_data);
2314+ Assert.areEqual(
2315+ patch_data.bug_notification_level, 'Lifecycle');
2316+ },
2317+
2318+ test_extract_chattiness_discussion: function() {
2319+ var form_data = {
2320+ name: [],
2321+ events: [],
2322+ filters: ['filter-comments'],
2323+ };
2324+ var patch_data = module._extract_form_data(form_data);
2325+ Assert.areEqual(
2326+ patch_data.bug_notification_level, 'Details');
2327+ },
2328+
2329+ test_extract_chattiness_details: function() {
2330+ var form_data = {
2331+ name: [],
2332+ events: [],
2333+ filters: [],
2334+ };
2335+ var patch_data = module._extract_form_data(form_data);
2336+ Assert.areEqual(
2337+ patch_data.bug_notification_level, 'Discussion');
2338+ },
2339+
2340+ test_extract_tags: function() {
2341+ var form_data = {
2342+ name: [],
2343+ events: [],
2344+ filters: ['advanced-filter'],
2345+ tags: ['one two THREE'],
2346+ tag_match: [''],
2347+ importances: [],
2348+ statuses: [],
2349+ };
2350+ var patch_data = module._extract_form_data(form_data);
2351+ // Note that the tags are converted to lower case.
2352+ ArrayAssert.itemsAreEqual(
2353+ patch_data.tags, ['one', 'two', 'three']);
2354+ },
2355+
2356+ test_extract_find_all_tags_true: function() {
2357+ var form_data = {
2358+ name: [],
2359+ events: [],
2360+ filters: ['advanced-filter'],
2361+ tags: ['tag'],
2362+ tag_match: ['match-all'],
2363+ importances: [],
2364+ statuses: [],
2365+ };
2366+ var patch_data = module._extract_form_data(form_data);
2367+ Assert.isTrue(patch_data.find_all_tags);
2368+ },
2369+
2370+ test_extract_find_all_tags_false: function() {
2371+ var form_data = {
2372+ name: [],
2373+ events: [],
2374+ filters: ['advanced-filter'],
2375+ tags: ['tag'],
2376+ tag_match: [],
2377+ importances: [],
2378+ statuses: [],
2379+ };
2380+ var patch_data = module._extract_form_data(form_data);
2381+ Assert.isFalse(patch_data.find_all_tags);
2382+ },
2383+
2384+ test_all_values_set: function() {
2385+ // We need all the values to be set (even if empty) because
2386+ // PATCH expects a set of changes to make and any unspecified
2387+ // attributes will retain the previous value.
2388+ var form_data = {
2389+ name: [],
2390+ events: [],
2391+ filters: [],
2392+ tags: ['tag'],
2393+ tag_match: ['match-all'],
2394+ importances: ['importance1'],
2395+ statuses: ['status1'],
2396+ };
2397+ var patch_data = module._extract_form_data(form_data);
2398+ // Since advanced-filter isn't set, all the advanced values should
2399+ // be empty/false despite the form values.
2400+ Assert.isFalse(patch_data.find_all_tags);
2401+ ArrayAssert.isEmpty(patch_data.tags)
2402+ ArrayAssert.isEmpty(patch_data.importances)
2403+ ArrayAssert.isEmpty(patch_data.statuses)
2404+ },
2405+
2406+ }));
2407+
2408+ // Lock, stock, and two smoking barrels.
2409+ var handle_complete = function(data) {
2410+ var status_node = Y.Node.create(
2411+ '<p id="complete">Test status: complete</p>');
2412+ Y.one('body').appendChild(status_node);
2413+ };
2414+ Y.Test.Runner.on('complete', handle_complete);
2415+ Y.Test.Runner.add(suite);
2416+
2417+ // The following two lines may be commented out for debugging but
2418+ // must be restored before being checked in or the tests will fail
2419+ // in the test runner.
2420+ var console = new Y.Console({newestOnTop: false});
2421+ console.render('#log');
2422+
2423+ Y.on('domready', function() {
2424+ Y.Test.Runner.run();
2425+ });
2426+});
2427
2428=== modified file 'lib/lp/registry/templates/product-index.pt'
2429--- lib/lp/registry/templates/product-index.pt 2011-03-14 15:31:05 +0000
2430+++ lib/lp/registry/templates/product-index.pt 2011-03-28 19:31:27 +0000
2431@@ -32,6 +32,16 @@
2432 </style>
2433 </noscript>
2434
2435+ <script type="text/javascript"
2436+ tal:condition="
2437+ request/features/advanced-structural-subscriptions.enabled">
2438+ LPS.use('lp.registry.structural_subscription', function(Y) {
2439+ module = Y.lp.registry.structural_subscription;
2440+ Y.on('domready', function() {
2441+ module.setup({content_box: "#structural-subscription-content-box"});
2442+ });
2443+ });
2444+ </script>
2445 </tal:head-epilogue>
2446 </head>
2447
2448@@ -246,6 +256,10 @@
2449
2450 <div tal:content="structure context/@@+portlet-coming-sprints" />
2451 </div>
2452+ <div class="yui-u">
2453+ <div id="structural-subscription-content-box"></div>
2454+ </div>
2455+
2456 </div>
2457 </tal:main>
2458
2459
2460=== modified file 'lib/lp/registry/templates/product-portlet-license-missing.pt'
2461--- lib/lp/registry/templates/product-portlet-license-missing.pt 2009-08-11 12:43:24 +0000
2462+++ lib/lp/registry/templates/product-portlet-license-missing.pt 2011-03-28 19:31:27 +0000
2463@@ -13,7 +13,7 @@
2464 src="/@@/expiration-large" />
2465 This project needs to have its licensing information entered.
2466 <br />Select the appropriate license checkboxes from
2467- <a tal:content="structure context/menu:overview/edit/fmt:link-icon" />.
2468+ <a tal:replace="structure context/menu:overview/edit/fmt:link-icon" />.
2469 </div>
2470 </div>
2471 </tal:root>
2472
2473=== modified file 'lib/lp/services/inlinehelp/javascript/inlinehelp.js'
2474--- lib/lp/services/inlinehelp/javascript/inlinehelp.js 2010-05-10 22:21:41 +0000
2475+++ lib/lp/services/inlinehelp/javascript/inlinehelp.js 2011-03-28 19:31:27 +0000
2476@@ -50,8 +50,12 @@
2477 'class="help"' attribute if it is missing, and connect the
2478 necessary event handlers.
2479 */
2480- addElementClass(elem, 'help');
2481- connect(elem, 'onclick', handleClickOnHelp);
2482+ // We want this to be idempotent, so we treat the 'help' class as a
2483+ // marker.
2484+ if (!hasElementClass(elem, 'help')) {
2485+ addElementClass(elem, 'help');
2486+ connect(elem, 'onclick', handleClickOnHelp);
2487+ }
2488 }
2489
2490