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
=== modified file 'buildout-templates/bin/combine-css.in'
--- buildout-templates/bin/combine-css.in 2011-03-18 22:47:24 +0000
+++ buildout-templates/bin/combine-css.in 2011-03-28 19:31:27 +0000
@@ -11,6 +11,9 @@
11from lazr.js.build import ComboFile11from lazr.js.build import ComboFile
12from lazr.js.combo import combine_files12from lazr.js.combo import combine_files
1313
14# This constant helps us meet maximum line-length goals.
15GALLERY_ACCORDION = 'yui3-gallery/gallery-accordion/assets/'
16
1417
15root = os.path.abspath('.')18root = os.path.abspath('.')
16root = os.path.normpath(${buildout:directory|path-repr})19root = os.path.normpath(${buildout:directory|path-repr})
@@ -34,7 +37,8 @@
34 'lazr/build/picker/assets/skins/sam/picker.css',37 'lazr/build/picker/assets/skins/sam/picker.css',
35 'lazr/build/activator/assets/skins/sam/activator.css',38 'lazr/build/activator/assets/skins/sam/activator.css',
36 'lazr/build/choiceedit/assets/choiceedit-core.css',39 'lazr/build/choiceedit/assets/choiceedit-core.css',
37 'yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css',40 GALLERY_ACCORDION + 'gallery-accordion-core.css',
41 GALLERY_ACCORDION + 'skins/sam/gallery-accordion-skin.css',
38 'build/sprite.css',42 'build/sprite.css',
39 # This one goes at the end because it's our main stylesheet and should43 # This one goes at the end because it's our main stylesheet and should
40 # take precedence over the others.44 # take precedence over the others.
4145
=== modified file 'lib/lp/app/javascript/lp.js'
--- lib/lp/app/javascript/lp.js 2010-11-10 15:33:47 +0000
+++ lib/lp/app/javascript/lp.js 2011-03-28 19:31:27 +0000
@@ -62,15 +62,20 @@
62 Y.lp.toggle_collapsible = function(collapsible) {62 Y.lp.toggle_collapsible = function(collapsible) {
63 // Find the collapse icon and wrapper div for this collapsible.63 // Find the collapse icon and wrapper div for this collapsible.
64 var icon = collapsible.one('.collapseIcon');64 var icon = collapsible.one('.collapseIcon');
65 var wrapper_div = collapsible.one('.collapseWrapper');65
6666 function get_wrapper_div(node) {
67 // If either the wrapper or the icon is null, raise an error.67 var wrapper_div = node.one('.collapseWrapper');
68 if (wrapper_div === null) {68
69 Y.fail("Collapsible has no wrapper div.");69 // If either the wrapper or the icon is null, raise an error.
70 }70 if (wrapper_div === null) {
71 if (icon === null) {71 Y.fail("Collapsible has no wrapper div.");
72 Y.fail("Collapsible has no icon.");72 }
73 }73 if (icon === null) {
74 Y.fail("Collapsible has no icon.");
75 }
76 return wrapper_div;
77 }
78 var wrapper_div = get_wrapper_div(collapsible);
7479
75 // Work out the target icon and animation based on the state of80 // Work out the target icon and animation based on the state of
76 // the collapse wrapper. We ignore the current state of the icon81 // the collapse wrapper. We ignore the current state of the icon
@@ -80,12 +85,16 @@
80 // state.85 // state.
81 var target_icon;86 var target_icon;
82 var target_anim;87 var target_anim;
88 var expanding;
83 if (wrapper_div.hasClass('lazr-closed')) {89 if (wrapper_div.hasClass('lazr-closed')) {
84 // The wrapper is collapsed.90 // The wrapper is collapsed; expand it and collapse all its
91 // siblings if it's in an accordion.
92 expanding = true;
85 target_anim = Y.lazr.effects.slide_out(wrapper_div);93 target_anim = Y.lazr.effects.slide_out(wrapper_div);
86 target_icon = "/@@/treeExpanded";94 target_icon = "/@@/treeExpanded";
87 } else {95 } else {
88 // The wrapper is open.96 // The wrapper is open; just collapse it.
97 expanding = false;
89 target_anim = Y.lazr.effects.slide_in(wrapper_div);98 target_anim = Y.lazr.effects.slide_in(wrapper_div);
90 target_icon = "/@@/treeCollapsed";99 target_icon = "/@@/treeCollapsed";
91 }100 }
@@ -93,6 +102,27 @@
93 // Run the animation and set the icon src correctly.102 // Run the animation and set the icon src correctly.
94 target_anim.run();103 target_anim.run();
95 icon.set('src', target_icon);104 icon.set('src', target_icon);
105
106 // Work out if the collapsible is in an accordion and process
107 // the siblings accordingly if the current collapsible is being
108 // expanded.
109 var parent_node = collapsible.get('parentNode');
110 var in_accordion = parent_node.hasClass('accordion');
111 if (in_accordion && expanding) {
112 var sibling_target_icon = "/@@/treeCollapsed";
113 Y.each(parent_node.all('.collapsible'), function(sibling) {
114 // We only actually collapse the sibling if it's not our
115 // current collapsible.
116 if (sibling != collapsible) {
117 var sibling_wrapper_div = get_wrapper_div(sibling);
118 var sibling_icon = sibling.one('.collapseIcon');
119 var sibling_target_anim = Y.lazr.effects.slide_in(
120 sibling_wrapper_div);
121 sibling_target_anim.run();
122 sibling_icon.set('src', sibling_target_icon);
123 }
124 });
125 }
96 };126 };
97127
98 /**128 /**
@@ -107,8 +137,8 @@
107 // Try to grab the legend in the usual way.137 // Try to grab the legend in the usual way.
108 var legend = collapsible.one('legend');138 var legend = collapsible.one('legend');
109 if (legend === null) {139 if (legend === null) {
110 // If it's null, this might be a collapsible div, not fieldset,140 // If it's null, this might be a collapsible div, not
111 // so try to grap the div's "legend".141 // fieldset, so try to grap the div's "legend".
112 legend = collapsible.one('.config-options');142 legend = collapsible.one('.config-options');
113 }143 }
114 if (legend === null ||144 if (legend === null ||
115145
=== added file 'lib/lp/app/javascript/tests/test_accordionoverlay.html'
--- lib/lp/app/javascript/tests/test_accordionoverlay.html 1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/tests/test_accordionoverlay.html 2011-03-28 19:31:27 +0000
@@ -0,0 +1,26 @@
1<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
2<html>
3 <head>
4 <title>Launchpad accordionoverlay</title>
5
6 <!-- YUI 3.0 Setup -->
7 <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
8 <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
9 <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
10 <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
11 <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
12 <link rel="stylesheet" href="../../../../canonical/launchpad/javascript/test.css" />
13
14 <!-- The module under test -->
15 <script type="text/javascript" src="../accordionoverlay.js"></script>
16
17 <!-- The test suite -->
18 <script type="text/javascript" src="test_accordionoverlay.js"></script>
19 </head>
20
21 <body class="yui3-skin-sam">
22 <div id="form_overlay_example"></div>
23 <div id="log"></div>
24 </body>
25
26</html>
027
=== modified file 'lib/lp/bugs/browser/structuralsubscription.py'
--- lib/lp/bugs/browser/structuralsubscription.py 2011-03-23 19:19:43 +0000
+++ lib/lp/bugs/browser/structuralsubscription.py 2011-03-28 19:31:27 +0000
@@ -402,7 +402,8 @@
402 record = info.get(target)402 record = info.get(target)
403 if record is None:403 if record is None:
404 record = dict(target_title=target.title,404 record = dict(target_title=target.title,
405 target_url=absoluteURL(target, request),405 target_url=canonical_url(
406 target, rootsite='mainsite'),
406 filters=[])407 filters=[])
407 info[target] = record408 info[target] = record
408 subscriber = subscription.subscriber409 subscriber = subscription.subscriber
@@ -413,6 +414,8 @@
413 record['filters'].append(dict(414 record['filters'].append(dict(
414 filter=filter,415 filter=filter,
415 subscriber_link=absoluteURL(subscriber, api_request),416 subscriber_link=absoluteURL(subscriber, api_request),
417 subscriber_url = canonical_url(
418 subscriber, rootsite='mainsite'),
416 subscriber_title=subscriber.title,419 subscriber_title=subscriber.title,
417 subscriber_is_team=is_team,420 subscriber_is_team=is_team,
418 user_is_team_admin=user_is_team_admin,))421 user_is_team_admin=user_is_team_admin,))
419422
=== modified file 'lib/lp/bugs/browser/tests/test_expose.py'
--- lib/lp/bugs/browser/tests/test_expose.py 2011-03-23 19:15:41 +0000
+++ lib/lp/bugs/browser/tests/test_expose.py 2011-03-28 19:31:27 +0000
@@ -18,6 +18,7 @@
18from zope.interface import implements18from zope.interface import implements
19from zope.traversing.browser import absoluteURL19from zope.traversing.browser import absoluteURL
2020
21from canonical.launchpad.webapp.publisher import canonical_url
21from canonical.launchpad.webapp.servers import LaunchpadTestRequest22from canonical.launchpad.webapp.servers import LaunchpadTestRequest
22from canonical.testing.layers import DatabaseFunctionalLayer23from canonical.testing.layers import DatabaseFunctionalLayer
23from lp.bugs.browser.structuralsubscription import (24from lp.bugs.browser.structuralsubscription import (
@@ -150,7 +151,8 @@
150 target_info = info[0]151 target_info = info[0]
151 self.assertEqual(target_info['target_title'], target.title)152 self.assertEqual(target_info['target_title'], target.title)
152 self.assertEqual(153 self.assertEqual(
153 target_info['target_url'], absoluteURL(target, request))154 target_info['target_url'], canonical_url(
155 target, rootsite='mainsite'))
154 self.assertEqual(len(target_info['filters']), 1) # One filter.156 self.assertEqual(len(target_info['filters']), 1) # One filter.
155 filter_info = target_info['filters'][0]157 filter_info = target_info['filters'][0]
156 self.assertEqual(filter_info['filter'], sub.bug_filters[0])158 self.assertEqual(filter_info['filter'], sub.bug_filters[0])
@@ -160,6 +162,9 @@
160 self.assertEqual(162 self.assertEqual(
161 filter_info['subscriber_link'],163 filter_info['subscriber_link'],
162 absoluteURL(team, IWebServiceClientRequest(request)))164 absoluteURL(team, IWebServiceClientRequest(request)))
165 self.assertEqual(
166 filter_info['subscriber_url'],
167 canonical_url(team, rootsite='mainsite'))
163168
164 def test_team_member_subscription(self):169 def test_team_member_subscription(self):
165 # Make a team subscription where the user is not an admin, and170 # Make a team subscription where the user is not an admin, and
@@ -179,6 +184,9 @@
179 self.assertEqual(184 self.assertEqual(
180 filter_info['subscriber_link'],185 filter_info['subscriber_link'],
181 absoluteURL(team, IWebServiceClientRequest(request)))186 absoluteURL(team, IWebServiceClientRequest(request)))
187 self.assertEqual(
188 filter_info['subscriber_url'],
189 canonical_url(team, rootsite='mainsite'))
182190
183 def test_self_subscription(self):191 def test_self_subscription(self):
184 # Make a subscription directly for the user and see what we record.192 # Make a subscription directly for the user and see what we record.
@@ -195,3 +203,6 @@
195 self.assertEqual(203 self.assertEqual(
196 filter_info['subscriber_link'],204 filter_info['subscriber_link'],
197 absoluteURL(user, IWebServiceClientRequest(request)))205 absoluteURL(user, IWebServiceClientRequest(request)))
206 self.assertEqual(
207 filter_info['subscriber_url'],
208 canonical_url(user, rootsite='mainsite'))
198209
=== added symlink 'lib/lp/bugs/help/structural-subscription-name.html'
=== target is u'../../registry/help/structural-subscription-name.html'
=== added symlink 'lib/lp/bugs/help/structural-subscription-tags.html'
=== target is u'../../registry/help/structural-subscription-tags.html'
=== modified file 'lib/lp/bugs/templates/bug-subscription-list.pt'
--- lib/lp/bugs/templates/bug-subscription-list.pt 2011-02-22 22:05:16 +0000
+++ lib/lp/bugs/templates/bug-subscription-list.pt 2011-03-28 19:31:27 +0000
@@ -10,16 +10,31 @@
10 i18n:domain="malone"10 i18n:domain="malone"
11>11>
1212
13<head>
14 <tal:head-epilogue metal:fill-slot="head_epilogue">
15 <script type="text/javascript">
16 LPS.use('lp.registry.structural_subscription', function(Y) {
17 module = Y.lp.registry.structural_subscription;
18 Y.on('domready', function() {
19 module.setup_bug_subscriptions(
20 {content_box: "#structural-subscription-content-box"})
21 });
22 });
23 </script>
24
25 </tal:head-epilogue>
26</head>
13<body>27<body>
14 <div metal:fill-slot="main">28 <div metal:fill-slot="main">
1529
16 <div id="maincontent">30 <div id="maincontent">
17 <div id="nonportlets" class="readable">31 <div id="nonportlets" class="readable">
32 <div id="subscription-listing"></div>
33
34 <div id="structural-subscription-content-box"></div>
1835
19 </div>36 </div>
20 </div>37 </div>
21
22 </div>38 </div>
23
24</body>39</body>
25</html>40</html>
2641
=== modified file 'lib/lp/bugs/templates/bugtarget-subscription-list.pt'
--- lib/lp/bugs/templates/bugtarget-subscription-list.pt 2011-03-11 21:31:10 +0000
+++ lib/lp/bugs/templates/bugtarget-subscription-list.pt 2011-03-28 19:31:27 +0000
@@ -10,16 +10,31 @@
10 i18n:domain="malone"10 i18n:domain="malone"
11>11>
1212
13<head>
14 <tal:head-epilogue metal:fill-slot="head_epilogue">
15 <script type="text/javascript">
16 LPS.use('lp.registry.structural_subscription', function(Y) {
17 module = Y.lp.registry.structural_subscription;
18 Y.on('domready', function() {
19 module.setup_bug_subscriptions(
20 {content_box: "#structural-subscription-content-box"})
21 });
22 });
23 </script>
24
25 </tal:head-epilogue>
26</head>
13<body>27<body>
14 <div metal:fill-slot="main">28 <div metal:fill-slot="main">
1529
16 <div id="maincontent">30 <div id="maincontent">
17 <div id="nonportlets" class="readable">31 <div id="nonportlets" class="readable">
32 <div id="subscription-listing"></div>
33
34 <div id="structural-subscription-content-box"></div>
1835
19 </div>36 </div>
20 </div>37 </div>
21
22 </div>38 </div>
23
24</body>39</body>
25</html>40</html>
2641
=== removed file 'lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css'
--- lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css 2011-02-14 20:14:47 +0000
+++ lib/lp/contrib/javascript/yui3-gallery/gallery-accordion/assets/skins/sam/gallery-accordion.css 1970-01-01 00:00:00 +0000
@@ -1,1 +0,0 @@
1.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);}
20
=== modified file 'lib/lp/registry/browser/product.py'
--- lib/lp/registry/browser/product.py 2011-03-22 19:17:42 +0000
+++ lib/lp/registry/browser/product.py 2011-03-28 19:31:27 +0000
@@ -193,6 +193,7 @@
193from lp.registry.interfaces.productseries import IProductSeries193from lp.registry.interfaces.productseries import IProductSeries
194from lp.registry.interfaces.series import SeriesStatus194from lp.registry.interfaces.series import SeriesStatus
195from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet195from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
196from lp.services import features
196from lp.services.fields import (197from lp.services.fields import (
197 PillarAliases,198 PillarAliases,
198 PublicPersonChoice,199 PublicPersonChoice,
@@ -583,7 +584,22 @@
583 usedfor = IProductActionMenu584 usedfor = IProductActionMenu
584 facet = 'overview'585 facet = 'overview'
585 title = 'Actions'586 title = 'Actions'
586 links = ('edit', 'review_license', 'administer', 'subscribe')587
588 @property
589 def links(self):
590 links = ['edit', 'review_license', 'administer']
591 use_advanced_features = features.getFeatureFlag(
592 'advanced-structural-subscriptions.enabled')
593 if use_advanced_features:
594 links.append('subscribe_to_bug_mail')
595 else:
596 links.append('subscribe')
597 return links
598
599 @enabled_with_permission('launchpad.AnyPerson')
600 def subscribe_to_bug_mail(self):
601 text = 'Subscribe to bug mail'
602 return Link('#', text, icon='add', hidden=True)
587603
588604
589class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,605class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
590606
=== added file 'lib/lp/registry/help/structural-subscription-name.html'
--- lib/lp/registry/help/structural-subscription-name.html 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/help/structural-subscription-name.html 2011-03-28 19:31:27 +0000
@@ -0,0 +1,30 @@
1<html>
2 <head>
3 <title>Why do I need a "Subscription name"?</title>
4 <link rel="stylesheet" type="text/css"
5 href="/+icing/yui/cssreset/reset.css" />
6 <link rel="stylesheet" type="text/css"
7 href="/+icing/yui/cssfonts/fonts.css" />
8 <link rel="stylesheet" type="text/css"
9 href="/+icing/yui/cssbase/base.css" />
10 </head>
11 <body>
12 <h1>Why do I need a "Subscription name"?</h1>
13
14 <p>
15 You may have more than one structural subscription per project, each
16 with different criteria. You may provide a name in order to identify
17 them in the future in order to edit or delete them.
18 </p>
19 <p>
20 Also, each email generated by this subscription will quote the name
21 you choose, both in the email body and as a header
22 (<code>X-Launchpad-Subscription</code>), making it easy to filter
23 your bug mail.
24 </p>
25 <p>
26 So, please provide a short, descriptive name to help you remember this
27 subscription.
28 </p>
29 </body>
30</html>
031
=== added file 'lib/lp/registry/help/structural-subscription-tags.html'
--- lib/lp/registry/help/structural-subscription-tags.html 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/help/structural-subscription-tags.html 2011-03-28 19:31:27 +0000
@@ -0,0 +1,28 @@
1<html>
2 <head>
3 <title>Help for filtering on tags</title>
4 <link rel="stylesheet" type="text/css"
5 href="/+icing/yui/cssreset/reset.css" />
6 <link rel="stylesheet" type="text/css"
7 href="/+icing/yui/cssfonts/fonts.css" />
8 <link rel="stylesheet" type="text/css"
9 href="/+icing/yui/cssbase/base.css" />
10 </head>
11 <body>
12 <h1>Help for filtering on tags</h1>
13
14 <p>
15 Bugs can be tagged to help the project categorise the bug report. You
16 can filter your subscription based on one or more tags. If you choose
17 to <em>Match all tags</em> then your filter will only match bugs that
18 have each of those tags you enter here.
19 </p>
20 <p>
21 If you select <em>Match any tags</em> then bugs that have one or more of
22 the tags you specify will be found.
23 </p>
24 <p>
25 Enter the tags as a space-separated list.
26 </p>
27 </body>
28</html>
029
=== added file 'lib/lp/registry/javascript/structural-subscription.js'
--- lib/lp/registry/javascript/structural-subscription.js 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/structural-subscription.js 2011-03-28 19:31:27 +0000
@@ -0,0 +1,1189 @@
1/* Copyright 2011 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 *
4 * Form overlay widgets and subscriber handling for structural subscriptions.
5 *
6 * @module registry
7 * @submodule structural_subscription
8 */
9
10YUI.add('lp.registry.structural_subscription', function(Y) {
11
12var namespace = Y.namespace('lp.registry.structural_subscription');
13
14var INNER_HTML = 'innerHTML',
15 VALUE = 'value';
16
17var FILTER_COMMENTS = 'filter-comments',
18 FILTER_WRAPPER = 'filter-wrapper',
19 ACCORDION_WRAPPER = 'accordion-wrapper',
20 ADDED_OR_CLOSED = 'added-or-closed',
21 ADDED_OR_CHANGED = 'added-or-changed',
22 ADVANCED_FILTER = 'advanced-filter',
23 MATCH_ALL = 'match-all',
24 MATCH_ANY = 'match-any',
25 SS_COLLAPSIBLE = 'ss-collapsible'
26 ;
27
28var add_subscription_overlay;
29var cancel_button_html =
30 '<button type="button" name="field.actions.cancel" ' +
31 'class="lazr-neg lazr-btn" >Cancel</button>';
32
33namespace.lp_client = undefined;
34
35/*
36 * An object representing the global actions portlet.
37 *
38 */
39var PortletTarget = function() {};
40Y.augment(PortletTarget, Y.Event.Target);
41namespace.portlet = new PortletTarget();
42
43function subscription_success() {
44 // TODO Should there be some success notification?
45 add_subscription_overlay.hide();
46}
47
48var overlay_error_handler = new Y.lp.client.ErrorHandler();
49overlay_error_handler.showError = function(error_msg) {
50 add_subscription_overlay.showError(error_msg);
51};
52
53/**
54 * Does the list contain the target?
55 *
56 * @private
57 * @method list_contains
58 * @param {List} list The list to search.
59 * @param {String} target The target of interest.
60 */
61function list_contains(list, target) {
62 // The list may be undefined in some cases.
63 return Y.Lang.isArray(list) && list.indexOf(target) !== -1;
64};
65
66// Expose to tests.
67namespace._list_contains = list_contains;
68
69/**
70 * Reformat the data returned from the add/edit form into something acceptable
71 * to send as a PATCH.
72 */
73function extract_form_data(form_data) {
74 if (form_data === 'this is a test') {
75 // This is a short-circuit to make testing easier.
76 return {};
77 }
78 var patch_data = {
79 description: Y.Lang.trim(form_data.name[0]),
80 tags: [],
81 find_all_tags: false,
82 importances: [],
83 statuses: [],
84 };
85
86 // Set the notification level.
87 var added_or_closed = list_contains(form_data.events, ADDED_OR_CLOSED);
88 var filter_comments = list_contains(form_data.filters, FILTER_COMMENTS);
89
90 // Chattiness: Lifecycle < Details < Discussion.
91 if (added_or_closed) {
92 patch_data.bug_notification_level = 'Lifecycle';
93 } else if (!filter_comments) {
94 patch_data.bug_notification_level = 'Discussion';
95 } else {
96 patch_data.bug_notification_level = 'Details';
97 }
98
99 // Set the tags, importances, and statuses. Only do this if
100 // ADDED_OR_CHANGED and ADVANCED_FILTER are selected.
101 var advanced_filter = (!added_or_closed &&
102 list_contains(form_data.filters, ADVANCED_FILTER));
103 if (advanced_filter) {
104 // Tags are a list with one element being a space-separated string.
105 var tags = form_data.tags[0];
106 if (Y.Lang.isValue(tags) && tags !== '') {
107 patch_data.tags = tags.toLowerCase().split(' ');
108 }
109 patch_data.find_all_tags =
110 list_contains(form_data.tag_match, MATCH_ALL);
111 if (form_data.importances.length > 0) {
112 patch_data.importances = form_data.importances;
113 }
114 if (form_data.statuses.length > 0) {
115 patch_data.statuses = form_data.statuses;
116 }
117 } else {
118 // clear out the tags, statuses, and importances in case this is an
119 // edit.
120 patch_data.tags = patch_data.importances = patch_data.statuses = [];
121 }
122 return patch_data;
123}
124
125// Expose in the namespace for testing purposes.
126namespace._extract_form_data = extract_form_data;
127
128/**
129 * Given a bug filter, update it with information extracted from a form.
130 *
131 * @private
132 * @method patch_bug_filter
133 * @param {Object} bug_filter The bug filter.
134 * @param {Object} form_data The data returned from the form submission.
135 * @param {Object} on Event handlers to override the defaults.
136 */
137function patch_bug_filter(bug_filter, form_data, on) {
138 var patch_data = extract_form_data(form_data);
139
140 var config = {
141 on: Y.merge({
142 success: subscription_success,
143 failure: overlay_error_handler.getFailureHandler()
144 }, on)
145 };
146 namespace.lp_client.patch(bug_filter.self_link, patch_data, config);
147}
148namespace.patch_bug_filter = patch_bug_filter;
149
150/**
151 * Delete the given filter
152 */
153function delete_filter(filter) {
154 var y_config = {
155 method: "POST",
156 headers: {'X-HTTP-Method-Override': 'DELETE'},
157 on: {failure: overlay_error_handler.getFailureHandler()}
158 };
159 Y.io(filter.self_link, y_config);
160}
161
162// Exported for testing.
163namespace._delete_filter = delete_filter;
164
165/**
166 * Create a new structural subscription filter.
167 *
168 * @method create_structural_subscription filter
169 * @param {Object} who Link to the user or team to be subscribed.
170 * @param {Object} form_data The data returned from the form submission.
171 */
172function add_bug_filter(who, form_data) {
173 var config = {
174 on: {success: function (bug_filter) {
175 // If we fail to PATCH the new bug filter, DELETE it.
176 var on = {failure: function () {
177 // We use the namespace binding so tests can override
178 // these functions.
179 namespace._delete_filter(bug_filter);
180 // Call the failure handler to report the original
181 // error to the user.
182 overlay_error_handler.getFailureHandler()
183 .apply(this, arguments);
184 }};
185 patch_bug_filter(bug_filter.getAttrs(), form_data, on);
186 },
187 failure: overlay_error_handler.getFailureHandler()
188 },
189 parameters: {
190 subscriber: who
191 }
192 };
193
194 namespace.lp_client.named_post(LP.cache.context.self_link,
195 'addBugSubscriptionFilter', config);
196}
197
198// Exported for testing.
199namespace._add_bug_filter = add_bug_filter;
200
201/**
202 * Given the form data from a user, save the subscription.
203 *
204 * @private
205 * @method save_subscription
206 * @param {Object} form_data The data generated by the form submission.
207 */
208
209function save_subscription(form_data) {
210 var who;
211 if (form_data.recipient[0] === 'user') {
212 who = LP.links.me;
213 } else {
214 // There can be only one.
215 who = form_data.team[0];
216 }
217 add_bug_filter(who, form_data);
218}
219namespace.save_subscription = save_subscription;
220
221/**
222 * Handle the activation of the edit subscription link.
223 */
224function edit_subscription_handler(context, form_data) {
225 var on = {success: function (new_data) {
226 var filter = new_data.getAttrs();
227 var description_node = Y.one(
228 '#filter-description-'+context.filter_id.toString());
229 description_node.set(
230 INNER_HTML, render_filter_description(filter));
231 var name_node = Y.one(
232 '#filter-name-'+context.filter_id.toString());
233 name_node.set(
234 INNER_HTML, render_filter_name(context.filter_info, filter));
235 add_subscription_overlay.hide();
236 }};
237 patch_bug_filter(context.filter_info.filter, form_data, on);
238}
239
240/**
241 * Populate the overlay element with the contents of the add/edit form.
242 */
243function create_overlay(content_box_id, overlay_id, submit_button,
244 submit_callback) {
245 // Create the overlay.
246 add_subscription_overlay = new Y.lazr.FormOverlay({
247 headerContent:
248 '<h2 id="subscription-overlay-title">Add a mail subscription '+
249 'for '+LP.cache.context.title + ' bugs</h2>',
250 form_content: Y.one(overlay_id),
251 centered: true,
252 visible: false,
253 form_submit_button: submit_button,
254 form_cancel_button: Y.Node.create(cancel_button_html),
255 form_submit_callback: submit_callback
256 });
257 add_subscription_overlay.render(content_box_id);
258 // Prevent cruft from hanging around upon closing.
259 function clean_up(e) {
260 var filter_wrapper = Y.one('#' + FILTER_WRAPPER);
261 filter_wrapper.hide();
262 collapse_node(filter_wrapper);
263 }
264 add_subscription_overlay.get('form_cancel_button').on(
265 'click', clean_up);
266 add_subscription_overlay.get('form_submit_button').on(
267 'click', clean_up);
268 add_subscription_overlay.on('cancel', clean_up);
269}
270
271/*
272 * Modify the DOM to insert a link or two into the global actions portlet.
273 * If structural subscriptions already exist then a 'modify' link is
274 * added. Otherwise, just the 'add' link is put into the portlet.
275 *
276 * @method setup_subscription_links
277 * @param {String} overlay_id Id of the overlay element.
278 * @param {String} content_box_id Id of the element on the page where
279 * the overlay is anchored.
280 */
281function setup_subscription_links(overlay_id, content_box_id) {
282 // Modify the menu-link-subscribe-to-bug-mail link to be visible.
283 var link = Y.one('.menu-link-subscribe_to_bug_mail');
284 if (!Y.Lang.isValue(link)) {
285 Y.fail('"Subscribe to bug mail" link not found.');
286 }
287 link.removeClass('invisible-link');
288 link.addClass('visible-link');
289 link.on('click', function(e) {
290 // Only proceed if the form content is already available.
291 if (add_subscription_overlay) {
292 e.halt();
293 // We always set up the overlay as a blank canvas, in case it was
294 // used before.
295 clear_overlay(Y.one(content_box_id));
296 add_subscription_overlay.show();
297 }
298 });
299 link.addClass('js-action');
300} // setup_subscription_links
301
302/**
303 * Reset the overlay form to initial values.
304 */
305function clear_overlay(content_node) {
306 set_recipient(content_node, false, undefined);
307 content_node.one('[name="name"]').set('value', '');
308 set_checkboxes(
309 content_node, LP.cache.statuses, LP.cache.statuses);
310 set_checkboxes(
311 content_node, LP.cache.importances, LP.cache.importances);
312 content_node.one('[name="tags"]').set('value', '');
313 set_radio_buttons(
314 content_node, [MATCH_ALL, MATCH_ANY], MATCH_ALL);
315 set_radio_buttons(
316 content_node, [ADDED_OR_CLOSED, ADDED_OR_CHANGED], ADDED_OR_CLOSED);
317 set_checkboxes(
318 content_node, [FILTER_COMMENTS, ADVANCED_FILTER], []);
319 collapse_node(Y.one('#' + ACCORDION_WRAPPER), {duration: 0});
320 collapse_node(Y.one('#' + FILTER_WRAPPER), {duration: 0});
321}
322
323/**
324 * Make a table cell.
325 *
326 * @private
327 * @method make_cell
328 * @param {Object} item Item to be placed in the cell.
329 * @param {String} name Name of the control.
330 */
331function make_cell(item, name) {
332 return '<td style="padding-left:3px"><label><input type="checkbox" ' +
333 'name="' + name +'" ' +
334 'value="' + item + '" checked="checked">' +
335 item + '</label><td>';
336}
337/**
338 * Make a table.
339 *
340 * @private
341 * @method make_table
342 * @param {Object} list List of items to be put in the table.
343 * @param {String} name Name of the control.
344 * @param {Int} num_cols The number of columns for the table to use.
345 */
346function make_table(list, name, num_cols) {
347 var html = '<table>';
348 var i;
349 for (i=0; i<list.length; i++) {
350 if (i % num_cols === 0) {
351 if (i !== 0) {
352 html += '</tr>';
353 }
354 html += '<tr>';
355 }
356 html += make_cell(list[i], name);
357 }
358 html += '</tr></table>';
359 return html;
360}
361
362/**
363 * Make selector controls, the links for 'Select all' and
364 * 'Select none' that appear within elements with many checkboxes.
365 *
366 * @private
367 * @method make_selector_controls
368 * @param {String} parent Name of the parent.
369 * @return {Object} Hash with 'all_name', 'none_name', and 'html' keys.
370 */
371function make_selector_controls(parent) {
372 var rv = {};
373 rv.all_name = parent + '-select-all';
374 rv.none_name = parent + '-select-none';
375 rv.html = '<div id="'+ parent + '-selectors" '+
376 'style="margin-left: 10px;margin-bottom: 10px">' +
377 ' <a href="#" id="' + rv.all_name +
378 '">Select all</a> &nbsp;' +
379 ' <a href="#" id="' + rv.none_name +
380 '">Select none</a>' +
381 '</div>';
382
383 return rv;
384}
385namespace.make_selector_controls = make_selector_controls;
386
387/**
388 * Construct a handler closure for select all/none links.
389 */
390function make_select_handler(node, all, checked_value) {
391 return function(e) {
392 e.halt();
393 Y.each(all, function(value) {
394 get_input_by_value(node, value).set('checked', checked_value);
395 });
396 };
397}
398
399/**
400 * Create the accordion.
401 *
402 * @method create_accordion
403 * @param {String} overlay_id Id of the overlay element.
404 * @param {Object} content_node Node where the overlay is anchored.
405 * @return {Object} accordion The accordion just created.
406 */
407function create_accordion(overlay_id, content_node) {
408 var accordion = new Y.Accordion({
409 useAnimation: true,
410 collapseOthersOnExpand: true,
411 visible: false
412 });
413
414 accordion.render(overlay_id);
415
416 var statuses_ai,
417 importances_ai,
418 tags_ai;
419
420 // Build tags pane.
421 tags_ai = new Y.AccordionItem( {
422 label: "Tags",
423 expanded: false,
424 alwaysVisible: false,
425 id: "tags_ai",
426 contentHeight: {method: "auto"}
427 } );
428
429 tags_ai.set("bodyContent",
430 '<div>\n' +
431 '<div>\n' +
432 ' <input type="radio" name="tag_match" value="' +
433 MATCH_ALL + '" checked> Match all tags\n' +
434 ' <input type="radio" name="tag_match" value="' +
435 MATCH_ANY + '"> Match any tags\n' +
436 '</div>\n' +
437 '<div style="padding-bottom:10px;">\n' +
438 ' <input type="text" name="tags" size="60"/>\n' +
439 ' <a target="help"'+
440 ' href="/+help/structural-subscription-tags.html" ' +
441 ' class="sprite maybe">&nbsp;'+
442 '<span class="invisible-link">Structural subscription tags '+
443 ' help</span></a>\n ' +
444 '</div>\n' +
445 '</div>\n');
446
447 accordion.addItem(tags_ai);
448
449 // Build importances pane.
450 importances_ai = new Y.AccordionItem( {
451 label: "Importances",
452 expanded: false,
453 alwaysVisible: false,
454 id: "importances_ai",
455 contentHeight: {method: "auto"}
456 } );
457 var importances = LP.cache.importances;
458 var selectors = make_selector_controls('importances');
459 var importances_html = '<div id="importances-wrapper">' +
460 selectors.html +
461 make_table(importances, 'importances', 4) +
462 '</div>';
463 importances_ai.set("bodyContent", importances_html);
464 accordion.addItem(importances_ai);
465 // Wire up the 'all' and 'none' selectors.
466 var all_link = content_node.one('#' + selectors.all_name);
467 var none_link = Y.one('#' + selectors.none_name);
468 var node = content_node.one('#importances-wrapper');
469 var select_all_handler = make_select_handler(node, importances, true);
470 var select_none_handler = make_select_handler(node, importances, false);
471 all_link.on('click', select_all_handler);
472 none_link.on('click', select_none_handler);
473
474 // Build statuses pane.
475 statuses_ai = new Y.AccordionItem( {
476 label: "Statuses",
477 expanded: false,
478 alwaysVisible: false,
479 id: "statuses_ai",
480 contentHeight: {method: "auto"}
481 } );
482 var statuses = LP.cache.statuses;
483 selectors = make_selector_controls('statuses');
484 var status_html = '<div id="statuses-wrapper">' +
485 selectors.html + make_table(statuses, 'statuses', 3)+
486 '</div>';
487 statuses_ai.set("bodyContent", status_html);
488 accordion.addItem(statuses_ai);
489 all_link = content_node.one('#' + selectors.all_name);
490 none_link = Y.one('#' + selectors.none_name);
491 node = content_node.one('#statuses-wrapper');
492 select_all_handler = make_select_handler(node, statuses, true);
493 select_none_handler = make_select_handler(node, statuses, false);
494 all_link.on('click', select_all_handler);
495 none_link.on('click', select_none_handler);
496
497 return accordion;
498}
499
500/**
501 * Collapse the node and set its arrow to 'collapsed'
502 */
503function collapse_node(node, user_cfg) {
504 if (user_cfg && user_cfg.duration === 0) {
505 node.setStyles({
506 height: 0,
507 visibility: 'hidden',
508 overflow: 'hidden'
509 // Don't set display: none because then the node won't be taken
510 // into account and the rendering will sometimes jiggle
511 // horizontally when the node is opened.
512 });
513 node.addClass('lazr-closed').removeClass('lazr-opened');
514 return;
515 }
516 var anim = Y.lazr.effects.slide_in(node, user_cfg);
517 // XXX: BradCrittenden 2011-03-03 bug=728457 : This fix for
518 // resizing needs to be incorporated into lazr.effects. When that
519 // is done it should be removed from here.
520 anim.on("start", function() {
521 node.setStyles({
522 visibility: 'visible'
523 });
524 });
525 anim.on("end", function() {
526 node.setStyles({
527 height: 0,
528 visibility: 'hidden',
529 display: null
530 // Don't set display: none because then the node won't be taken
531 // into account and the rendering will sometimes jiggle
532 // horizontally when the node is opened.
533 });
534 });
535 anim.run();
536}
537
538/**
539 * Expand the node and set its arrow to 'collapsed'
540 */
541function expand_node(node, user_cfg) {
542 if (user_cfg && user_cfg.duration === 0) {
543 node.setStyles({
544 height: 'auto',
545 visibility: 'visible',
546 overflow: null, // Inherit.
547 display: null // Inherit.
548 });
549 node.addClass('lazr-opened').removeClass('lazr-closed');
550 return;
551 }
552 // Set the node to 'hidden' so that the proper size can be found.
553 node.setStyles({
554 visibility: 'hidden'
555 });
556 var anim = Y.lazr.effects.slide_out(node, user_cfg);
557 // XXX: BradCrittenden 2011-03-03 bug=728457 : This fix for
558 // resizing needs to be incorporated into lazr.effects. When that
559 // is done it should be removed from here.
560 anim.on("start", function() {
561 // Set the node to 'visible' for the beginning of the animation.
562 node.setStyles({
563 visibility: 'visible'
564 });
565 });
566 anim.on("end", function() {
567 // Change the height to auto when the animation completes.
568 node.setStyles({
569 height: 'auto'
570 });
571 });
572 anim.run();
573}
574
575/**
576 * Construct the overlay and populate it with the add/edit form.
577 */
578function setup_overlay(content_box_id, hide_recipient_picker) {
579 var content_node = Y.one(content_box_id);
580 var container = Y.Node.create('<div id="overlay-container"></div>');
581 var accordion_overlay_id = 'accordion-overlay';
582 var teams = LP.cache.administratedTeams;
583 var no_recipient_picker =
584 ' <input type="hidden" name="recipient" value="user">\n' +
585 ' <span>Yourself</span>\n',
586 recipient_picker =
587 ' <input type="radio" name="recipient" value="user"\n'+
588 ' id="structural-subscription-recipient-user" checked>\n'+
589 ' <label for="structural-subscription-recipient-user">\n'+
590 ' Yourself</label><br>\n' +
591 ' <input type="radio" name="recipient"\n'+
592 ' id="structural-subscription-recipient-team"\n'+
593 ' value="team">\n'+
594 ' <label for="structural-subscription-recipient-team">One of\n'+
595 ' the teams you administer</label><br>\n' +
596 ' <dl style="margin-left:25px;">\n' +
597 ' <dt></dt>\n' +
598 ' <dd>\n' +
599 ' <select name="team" id="structural-subscription-teams">\n'+
600 ' </select>\n' +
601 ' </dd>\n' +
602 ' </dl>\n',
603 control_code =
604 '<dl>\n' +
605 ' <dt>Bug mail recipient</dt>\n' +
606 ' <dd>\n' +
607 ((!hide_recipient_picker && teams.length > 0) ?
608 recipient_picker : no_recipient_picker) +
609 ' </dd>\n' +
610 ' <dt>Subscription name</dt>\n' +
611 ' <dd>\n' +
612 ' <input type="text" name="name">\n' +
613 ' <a target="help" class="sprite maybe"\n' +
614 ' href="/+help/structural-subscription-name.html">&nbsp;\n' +
615 ' <span class="invisible-link">Structural subscription\n'+
616 ' description help</span></a>\n ' +
617 ' </dd>\n' +
618 ' <dt>Receive mail for bugs affecting\n'+
619 ' <span id="structural-subscription-context-title">\n'+
620 ' '+LP.cache.context.title+'</span> that</dt>\n' +
621 ' <dd>\n' +
622 ' <div id="events">\n' +
623 ' <input type="radio" name="events"\n' +
624 ' value="' + ADDED_OR_CLOSED + '"\n'+
625 ' id="' + ADDED_OR_CLOSED + '" checked>\n'+
626 ' <label for="'+ADDED_OR_CLOSED+'">are added or '+
627 ' closed</label>\n'+
628 ' <br>\n' +
629 ' <input type="radio" name="events"\n'+
630 ' value="' + ADDED_OR_CHANGED + '"\n' +
631 ' id="' + ADDED_OR_CHANGED + '">\n'+
632 ' <label for="'+ADDED_OR_CHANGED+'">are added or changed in\n'+
633 ' any way\n'+
634 ' <em id="'+ADDED_OR_CHANGED+'-more">(more options...)</em>\n'+
635 ' </label>\n' +
636 ' </div>\n' +
637 ' <div id="' + FILTER_WRAPPER + '" class="ss-collapsible">\n' +
638 ' <dl style="margin-left:25px;">\n' +
639 ' <dt></dt>\n' +
640 ' <dd>\n' +
641 ' <input type="checkbox" name="filters"\n' +
642 ' value="' + FILTER_COMMENTS + '"\n'+
643 ' id="'+FILTER_COMMENTS+'">\n' +
644 ' <label for="'+FILTER_COMMENTS+'">Don\'t send mail about\n'+
645 ' comments</label><br>\n' +
646 ' <input type="checkbox" name="filters"\n' +
647 ' value="' + ADVANCED_FILTER + '"\n' +
648 ' id="' + ADVANCED_FILTER + '">\n' +
649 ' <label for="'+ADVANCED_FILTER+'">Bugs must match this\n'+
650 ' filter <em id="'+ADVANCED_FILTER+'-more">(...)</em>\n'+
651 ' </label><br>\n' +
652 ' <div id="' + ACCORDION_WRAPPER + '" \n' +
653 ' class="' + SS_COLLAPSIBLE + '">\n' +
654 ' <dl>\n' +
655 ' <dt></dt>\n' +
656 ' <dd style="margin-left:25px;">\n' +
657 ' <div id="' + accordion_overlay_id + '"\n' +
658 ' style="position:relative; '+
659 'overflow:hidden;"></div>\n' +
660 ' </dd>\n' +
661 ' </dl>\n' +
662 ' </div> \n' +
663 ' </dd>\n' +
664 ' </dl>\n' +
665 ' </div> \n' +
666 ' </dd>\n' +
667 ' <dt></dt>\n' +
668 '</dl>';
669
670 content_node.appendChild(container);
671 container.appendChild(Y.Node.create(control_code));
672
673 var accordion = create_accordion(
674 '#' + accordion_overlay_id, content_node);
675
676 // Set up click handlers for the events radio buttons.
677 var radio_group = Y.all('#events input');
678 radio_group.on(
679 'change',
680 function() {handle_change(ADDED_OR_CHANGED, FILTER_WRAPPER);});
681
682 // And a listener for advanced filter selection.
683 var advanced_filter = Y.one('#' + ADVANCED_FILTER);
684 advanced_filter.on(
685 'change',
686 function() {handle_change(ADVANCED_FILTER, ACCORDION_WRAPPER);});
687 // Populate the team drop down from LP.cache data, if appropriate.
688 if (!hide_recipient_picker && teams.length > 0) {
689 var select = Y.one('#structural-subscription-teams');
690 var i;
691 var team;
692 for (i=0; i<teams.length; i++) {
693 team = teams[i];
694 var option = Y.Node.create('<option></option>');
695 option.set(INNER_HTML, team.title);
696 option.set(VALUE, team.link);
697 select.appendChild(option);
698 }
699 select.on(
700 'focus',
701 function () {
702 Y.one('input[value="team"][name="recipient"]').set(
703 'checked', true);
704 }
705 );
706 }
707 return '#' + container._node.id;
708} // setup_overlay
709// Expose in the namespace for testing purposes.
710namespace._setup_overlay = setup_overlay;
711
712function handle_change(control_name, div_name, user_cfg) {
713 // Expand or collapse the node depending on the control.
714 // user_cfg is passed to expand_node or collapse_node, and is
715 // useful to set the duration.
716 var ctl = Y.one('#' + control_name);
717 var more = Y.one('#' + control_name + '-more');
718 var div = Y.one('#' + div_name);
719 var checked = ctl.get('checked');
720 if (checked) {
721 expand_node(div, user_cfg);
722 more.setStyle('display', 'none');
723 } else {
724 collapse_node(div, user_cfg);
725 more.setStyle('display', null);
726 }
727}
728
729/*
730 * Create the LP client.
731 *
732 * @method setup_client
733 */
734function setup_client() {
735 namespace.lp_client = new Y.lp.client.Launchpad();
736} // setup_client
737
738/*
739 * External entry point for configuring the structual subscription.
740 * @method setup_bug_subscriptions
741 * @param {Object} config Object literal of config name/value pairs.
742 * config.content_box is the name of an element on the page where
743 * the overlay will be anchored.
744 */
745namespace.setup_bug_subscriptions = function(config) {
746 validate_config(config);
747 Y.on('domready', function() {
748 if (Y.Lang.isValue(config.lp_client)) {
749 // Tests can specify an lp_client if they want to.
750 namespace.lp_client = config.lp_client;
751 } else {
752 // Setup the Launchpad client.
753 setup_client();
754 }
755
756 var overlay_id = setup_overlay(config.content_box, true);
757 var submit_button = Y.Node.create(
758 '<button type="submit" name="field.actions.create" ' +
759 'value="Save Changes" class="lazr-pos lazr-btn" '+
760 '>OK</button>');
761 // This is a bit of an odd approach, but it lets us retrofit code
762 // without a large refactoring. When edit_subscription_handler is
763 // called, context.filter_info will have the information about the
764 // filter that is being edited.
765 var context = {};
766 create_overlay(config.content_box, overlay_id, submit_button,
767 function (form_data) {
768 return edit_subscription_handler(context, form_data);});
769 fill_in_bug_subscriptions(config, context);
770 // We need to initialize the help links. They may have already been
771 // initialized except for the ones we added, so setupHelpTrigger
772 // is idempotent. Notice that this is old MochiKit code.
773 forEach(findHelpLinks(), setupHelpTrigger);
774 }, window);
775};
776
777function get_input_by_value(node, value) {
778 // XXX broken: this should also care about input name because some values
779 // repeat in other areas of the form
780 return node.one('input[value="'+value+'"]');
781}
782
783
784/**
785 * Set the value of a set of checkboxes to the provided values.
786 */
787function set_checkboxes(node, all, checked) {
788 // Clear all the checkboxes.
789 Y.each(all, function (value) {
790 get_input_by_value(node, value).set('checked', false);
791 });
792 // Check the checkboxes that are supposed to be checked.
793 Y.each(checked, function (value) {
794 get_input_by_value(node, value).set('checked', true);
795 });
796}
797
798/**
799 * Set the value of a select box to the provided value.
800 */
801function set_options(node, name, value) {
802 var select = node.one('select[name="team"]');
803 Y.each(select.get('options'), function (option) {
804 option.set('selected', option.get('value')===value);
805 });
806}
807
808/**
809 * Set the value of a set of radio buttons to the provided value.
810 */
811function set_radio_buttons(node, all, value) {
812 set_checkboxes(node, all, [value]);
813}
814
815/**
816 * Set the values of the recipient select box and radio buttons.
817 */
818function set_recipient(node, is_team, team_link) {
819 if (LP.cache.administratedTeams.length > 0) {
820 get_input_by_value(node, 'user').set('checked', !is_team);
821 get_input_by_value(node, 'team').set('checked', is_team);
822 set_options(node, 'teams',
823 team_link || LP.cache.administratedTeams[0].link);
824 }
825}
826
827/**
828 * Return an edit handler for the specified filter.
829 */
830function make_edit_handler(subscription, filter_info, filter_id,
831 config, context) {
832 // subscription is the filter's subscription.
833 // filter_info is the filter's information (from subscription.filters).
834 // filter_id is the numerical id for the filter, unique on the page.
835 // config is the configuration object used for the entire assembly of the
836 // page.
837 // context is a way to communicate to the shared edit handler what filter
838 // should be updated.
839 return function(e) {
840 // Only proceed if the form content is already available.
841 if (add_subscription_overlay) {
842 e.halt();
843 var content_node = Y.one(config.content_box),
844 teams = LP.cache.administratedTeams,
845 filter = filter_info.filter,
846 is_lifecycle = filter.bug_notification_level==='Lifecycle',
847 recipient_label = content_node.one(
848 'input[name="recipient"] + span'),
849 statuses = filter.statuses,
850 importances = filter.importances;
851 if (filter_info.subscriber_is_team) {
852 var i;
853 for (i=0; i<teams.length; i++) {
854 if (teams[i].link === filter_info.subscriber_link){
855 recipient_label.set(INNER_HTML, teams[i].title);
856 break;
857 }
858 }
859 } else {
860 recipient_label.set(INNER_HTML, 'Yourself');
861 }
862 content_node.one('[name="name"]').set('value',filter.description);
863 if (is_lifecycle) {
864 statuses = LP.cache.statuses;
865 importances = LP.cache.importances;
866 } else {
867 // An absence of values is equivalent to all values.
868 if (statuses.length === 0) {
869 statuses = LP.cache.statuses;
870 }
871 if (importances.length === 0) {
872 importances = LP.cache.importances;
873 }
874 }
875 set_checkboxes(content_node, LP.cache.statuses, statuses);
876 set_checkboxes(
877 content_node, LP.cache.importances, importances);
878 content_node.one('[name="tags"]').set(
879 'value', is_lifecycle ? '' : filter.tags.join(' '));
880 set_radio_buttons(
881 content_node, [MATCH_ALL, MATCH_ANY],
882 filter.find_all_tags ? MATCH_ALL : MATCH_ANY);
883 var has_advanced_filters = !is_lifecycle && (
884 filter.statuses.length ||
885 filter.importances.length ||
886 filter.tags.length) > 0,
887 filters = has_advanced_filters ? [ADVANCED_FILTER] : [],
888 event = ADDED_OR_CHANGED;
889 // Chattiness: Lifecycle < Details < Discussion.
890 switch (filter.bug_notification_level) {
891 case 'Lifecycle':
892 event = ADDED_OR_CLOSED;
893 filters = [];
894 break;
895 case 'Details':
896 filters.push(FILTER_COMMENTS);
897 break;
898 // case 'Discussion': This is already handled/the default.
899 // default: If we get here then it is a programmer error.
900 }
901 set_radio_buttons(
902 content_node, [ADDED_OR_CLOSED, ADDED_OR_CHANGED], event);
903 set_checkboxes(
904 content_node, [FILTER_COMMENTS, ADVANCED_FILTER], filters);
905 handle_change(ADDED_OR_CHANGED, FILTER_WRAPPER, {duration: 0});
906 handle_change(ADVANCED_FILTER, ACCORDION_WRAPPER, {duration: 0});
907 context.filter_info = filter_info;
908 context.filter_id = filter_id;
909 var title = subscription.target_title;
910 Y.one('#structural-subscription-context-title').set(
911 INNER_HTML, title);
912 Y.one('#subscription-overlay-title').set(
913 INNER_HTML, 'Edit subscription for '+title+' bugs');
914 add_subscription_overlay.show();
915 }
916 };
917}
918
919/**
920 * Construct a handler for an unsubscribe link.
921 */
922function make_delete_handler(filter, filter_id, subscriber_id) {
923 var error_handler = new Y.lp.client.ErrorHandler();
924 error_handler.showError = function(error_msg) {
925 var unsubscribe_node = Y.one('#unsubscribe-'+filter_id.toString());
926 Y.lp.app.errors.display_error(unsubscribe_node, error_msg);
927 };
928 return function() {
929 var y_config = {
930 method: "POST",
931 headers: {'X-HTTP-Method-Override': 'DELETE'},
932 on: {success: function(transactionid, response, args){
933 var subscriber = Y.one(
934 '#subscription-'+subscriber_id.toString()),
935 node = subscriber,
936 filters = subscriber.all('.subscription-filter');
937 if (!filters.isEmpty()) {
938 node = Y.one(
939 '#subscription-filter-'+filter_id.toString());
940 }
941 collapse_node(node);
942 },
943 failure: error_handler.getFailureHandler()
944 }
945 };
946 Y.io(filter.self_link, y_config);
947 };
948}
949
950/**
951 * Attach activation (click) handlers to all of the edit links on the page.
952 */
953function wire_up_edit_links(config, context) {
954 var listing = Y.one('#subscription-listing');
955 var subscription_info = LP.cache.subscription_info;
956 var filter_id = 0;
957 var i;
958 var j;
959 for (i=0; i<subscription_info.length; i++) {
960 var sub = subscription_info[i];
961 for (j=0; j<sub.filters.length; j++) {
962 var filter_info = sub.filters[j];
963 if (!filter_info.subscriber_is_team ||
964 filter_info.user_is_team_admin) {
965 var edit_link = Y.one('#edit-'+filter_id.toString());
966 var edit_handler = make_edit_handler(
967 sub, filter_info, filter_id, config, context);
968 edit_link.on('click', edit_handler);
969 var delete_link = Y.one('#unsubscribe-'+filter_id.toString());
970 var delete_handler = make_delete_handler(
971 filter_info.filter, filter_id, i);
972 delete_link.on('click', delete_handler);
973 }
974 filter_id += 1;
975 }
976 }
977}
978
979/**
980 * Populate the subscription list DOM element with subscription descriptions.
981 */
982function fill_in_bug_subscriptions(config, context) {
983 validate_config(config);
984 var listing = Y.one('#subscription-listing');
985 var subscription_info = LP.cache.subscription_info;
986 var html = '<div class="yui-g"><div id="structural-subscriptions">';
987 var filter_id = 0;
988 var i;
989 var j;
990 for (i=0; i<subscription_info.length; i++) {
991 var sub = subscription_info[i];
992 html +=
993 '<div style="margin-top: 2em; padding: 0 1em 1em 1em; '+
994 ' border: 1px solid #ddd;"'+
995 ' id="subscription-'+i.toString()+'">'+
996 ' <span style="float: left; margin-top: -0.6em; padding: 0 1ex;'+
997 ' background-color: #fff;">Subscriptions to'+
998 ' <a href="'+sub.target_url+'">'+sub.target_title+'</a>'+
999 ' </span>';
1000
1001 for (j=0; j<sub.filters.length; j++) {
1002 var filter = sub.filters[j].filter;
1003 // We put the filters in the cache so that the patch mechanism
1004 // can automatically find them and update them on a successful
1005 // edit. This makes it possible to open up a filter after an edit
1006 // and see the information you expect to see.
1007 LP.cache['structural-subscription-filter-'+filter_id.toString()] =
1008 filter;
1009 html +=
1010 '<div style="margin: 1em 0em 0em 1em"'+
1011 ' id="subscription-filter-'+filter_id.toString()+'"'+
1012 ' class="subscription-filter">'+
1013 ' <div style="margin-top: 1em">'+
1014 ' <strong id="filter-name-'+
1015 filter_id.toString()+'">'+
1016 render_filter_name(sub.filters[j], filter)+'</strong>';
1017 if (!sub.filters[j].subscriber_is_team ||
1018 sub.filters[j].user_is_team_admin) {
1019 // User can edit the subscription.
1020 html +=
1021 '<span style="float: right">'+
1022 '<a href="#" class="sprite modify edit js-action"'+
1023 ' id="edit-'+filter_id.toString()+'">'+
1024 ' Edit this subscription</a> or '+
1025 '<a href="#" class="sprite modify remove js-action"'+
1026 ' id="unsubscribe-'+filter_id.toString()+'">'+
1027 ' Unsubscribe</a></span>';
1028 } else {
1029 // User cannot edit the subscription, because this is a
1030 // team and the user does not have admin privileges.
1031 html +=
1032 '<span style="float: right"><em>'+
1033 'You do not have privileges to change this subscription'+
1034 '</em></span>';
1035 }
1036 html += '</div>';
1037 html +=
1038 '<div style="padding-left: 1em"'+
1039 ' id="filter-description-'+filter_id.toString()+'">'+
1040 render_filter_description(filter)+'</div>';
1041
1042 html += '</div>';
1043 filter_id += 1;
1044 }
1045
1046 // We can remove this once we enforce at least one filter per
1047 // subscription.
1048 if (subscription_info[i].filters.length === 0) {
1049 html += '<strong>All messages</strong>';
1050 }
1051 html += '</div>';
1052 }
1053 html += '</div></div>';
1054 listing.appendChild(Y.Node.create(html));
1055
1056 wire_up_edit_links(config, context);
1057}
1058
1059/**
1060 * Construct a one-line textual description of a filter's name.
1061 */
1062function render_filter_name(filter_info, filter) {
1063 var description;
1064 if (filter.description) {
1065 description = '"'+filter.description+'"';
1066 } else {
1067 description = '(unnamed)';
1068 }
1069 if (filter_info.subscriber_is_team) {
1070 return '<a href="'+filter_info.subscriber_url+'">'+
1071 filter_info.subscriber_title+"</a> subscription: "+description;
1072 } else {
1073 return 'Your subscription: '+description;
1074 }
1075}
1076
1077/**
1078 * Construct a textual description of all of filter's properties.
1079 */
1080function render_filter_description(filter) {
1081 var html = '';
1082 var filter_items = '';
1083 // Format status conditions.
1084 if (filter.statuses.length !== 0) {
1085 filter_items += '<li> have status ' +
1086 filter.statuses.join(', ');
1087 }
1088
1089 // Format importance conditions.
1090 if (filter.importances.length !== 0) {
1091 filter_items += '<li> are of importance ' +
1092 filter.importances.join(', ');
1093 }
1094
1095 // Format tag conditions.
1096 if (filter.tags.length !== 0) {
1097 filter_items += '<li> are tagged with ';
1098 if (filter.find_all_tags) {
1099 filter_items += '<strong>all</strong>';
1100 } else {
1101 filter_items += '<strong>any</strong>';
1102 }
1103 filter_items += ' of these tags: ' +
1104 filter.tags.join(', ');
1105 }
1106
1107 // If there were any conditions to list, stich them in with an
1108 // intro.
1109 if (filter_items !== '') {
1110 html += 'You are subscribed to bugs that'+
1111 '<ul class="bulleted">'+filter_items+'</ul>';
1112 }
1113
1114 // Format event details.
1115 if (filter.bug_notification_level === 'Discussion') {
1116 html += 'You will recieve an email when any change '+
1117 'is made or a comment is added.';
1118 } else if (filter.bug_notification_level === 'Details') {
1119 html += 'You will recieve an email when any changes '+
1120 'are made to the bug. Bug comments will not be sent.';
1121 } else if (filter.bug_notification_level === 'Lifecycle') {
1122 html += 'You will recieve an email when bugs are '+
1123 'opened or closed.';
1124 }
1125 return html;
1126}
1127
1128/**
1129 * Check the configuration for obvious faults.
1130 */
1131function validate_config(config) {
1132 if (!Y.Lang.isValue(config)) {
1133 throw new Error(
1134 'Missing config for structural_subscription.');
1135 }
1136 if (!Y.Lang.isValue(config.content_box)) {
1137 throw new Error(
1138 'Structural_subscription configuration has ' +
1139 'undefined properties.');
1140 }
1141}
1142
1143// Expose in the namespace for testing purposes.
1144namespace._validate_config = validate_config;
1145
1146/*
1147 * External entry point for configuring the structual subscription.
1148 * @method setup
1149 * @param {Object} config Object literal of config name/value pairs.
1150 * config.content_box is the name of an element on the page where
1151 * the overlay will be anchored.
1152 */
1153namespace.setup = function(config) {
1154 validate_config(config);
1155
1156 // If the user is not logged in, then we need to defer to the
1157 // default behaviour.
1158 if (LP.links.me === undefined) {
1159 return;
1160 }
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 // Create the overlay.
1169 var overlay_id = setup_overlay(config.content_box);
1170 // Create the subscription links on the page.
1171 setup_subscription_links(overlay_id, config.content_box);
1172
1173 var submit_button = Y.Node.create(
1174 '<button type="submit" name="field.actions.create" ' +
1175 'value="Create subscription" class="lazr-pos lazr-btn" '+
1176 '>OK</button>');
1177 create_overlay(config.content_box, overlay_id, submit_button,
1178 save_subscription);
1179 // We need to initialize the help links. They may have already been
1180 // initialized except for the ones we added, so setupHelpTrigger
1181 // is idempotent. Notice that this is old MochiKit code.
1182 forEach(findHelpLinks(), setupHelpTrigger);
1183
1184}; // setup
1185
1186}, '0.1', {requires: [
1187 'dom', 'node', 'lazr.anim', 'lazr.formoverlay', 'lazr.overlay',
1188 'lazr.effects', 'lp.app.errors', 'lp.client', 'gallery-accordion'
1189 ]});
01190
=== added file 'lib/lp/registry/javascript/tests/test_structural_subscription.html'
--- lib/lp/registry/javascript/tests/test_structural_subscription.html 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/test_structural_subscription.html 2011-03-28 19:31:27 +0000
@@ -0,0 +1,63 @@
1<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
2<html>
3 <head>
4 <title>Structural Subscription Overlay</title>
5
6 <!-- YUI 3.0 Setup -->
7 <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
8 <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
9 <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
10 <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
11 <link rel="stylesheet"
12 href="../../../../canonical/launchpad/icing/lazr/build/testing/assets/testlogger.css"/>
13
14 <!-- Dependency -->
15 <script type="text/javascript"
16 src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
17 <script type="text/javascript"
18 src="../../../../canonical/launchpad/icing/lazr/build/testing/testing.js"></script>
19 <script type="text/javascript"
20 src="../../../../canonical/launchpad/icing/lazr/build/testing/mockio.js"></script>
21 <script type="text/javascript"
22 src="../../../../canonical/launchpad/icing/lazr/build/overlay/overlay.js">
23 </script>
24 <script type="text/javascript"
25 src="../../../../canonical/launchpad/icing/lazr/build/formoverlay/formoverlay.js">
26 </script>
27 <script type="text/javascript"
28 src="../../../../canonical/launchpad/icing/lazr/build/choiceedit/choiceedit.js">
29 </script>
30 <script type="text/javascript"
31 src="../../../app/javascript/client.js"></script>
32 <script type="text/javascript"
33 src="../../../contrib/javascript/yui3-gallery/gallery-accordion/gallery-accordion.js">
34 </script>
35 <script type="text/javascript"
36 src="../../../../canonical/launchpad/icing/MochiKit.js"></script>
37 <script type="text/javascript"
38 src="../../../services/inlinehelp/javascript/inlinehelp.js"></script>
39
40
41 <!-- The module under test -->
42 <script type="text/javascript" src="../structural-subscription.js"></script>
43
44 <!-- The test suite -->
45 <script type="text/javascript" src="test_structural_subscription.js"></script>
46
47 <!-- Test layout -->
48 <link rel="stylesheet"
49 href="../../../../canonical/launchpad/javascript/test.css" />
50 <style type="text/css">
51 /* CSS classes specific to this test */
52 .unseen { display: none; }
53 </style>
54</head>
55<body class="yui3-skin-sam">
56 <div>
57 This exists to stop tests from breaking.
58 <a href="#" class="menu-link-subscribe_to_bug_mail"
59 >A link, a link, my kingdom for a link</a>
60 </div>
61 <div id="log"></div>
62</body>
63</html>
064
=== added file 'lib/lp/registry/javascript/tests/test_structural_subscription.js'
--- lib/lp/registry/javascript/tests/test_structural_subscription.js 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/test_structural_subscription.js 2011-03-28 19:31:27 +0000
@@ -0,0 +1,751 @@
1/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
2
3YUI({
4 base: '../../../../canonical/launchpad/icing/yui/',
5 filter: 'raw',
6 combine: false,
7 fetchCSS: false
8 }).use('test', 'console', 'node', 'lp.client',
9 'lp.registry.structural_subscription', function(Y) {
10
11 var suite = new Y.Test.Suite("Structural subscription overlay tests");
12
13 var context;
14 var test_case;
15
16 // Local aliases
17 var Assert = Y.Assert,
18 ArrayAssert = Y.ArrayAssert,
19 module = Y.lp.registry.structural_subscription;
20
21 // Expected content box.
22 var content_box_name = 'ss-content-box';
23 var content_box_id = '#' + content_box_name;
24
25 var target_link_class = '.menu-link-subscribe_to_bug_mail';
26
27 function array_compare(a,b) {
28 if (a.length != b.length)
29 return false;
30 a.sort();
31 b.sort();
32 for (i in a) {
33 if (a[i] != b[i])
34 return false;
35 }
36 return true;
37 }
38
39 function create_test_node() {
40 return Y.Node.create(
41 '<div id="test-content">' +
42 ' <div id="' + content_box_name + '"></div>' +
43 '</div>');
44 }
45
46 function remove_test_node() {
47 Y.one('body').removeChild(Y.one('#test-content'));
48 }
49
50 function test_checked(list, expected) {
51 var item, i;
52 var length = list.size();
53 for (i=0; i < length; i++) {
54 item = list.item(i);
55 if (item.get('checked') != expected)
56 return false;
57 }
58 return true;
59 }
60
61 test_case = new Y.Test.Case({
62 name: 'structural_subscription_overlay',
63
64 _should: {
65 error: {
66 test_setup_config_none: new Error(
67 'Missing config for structural_subscription.'),
68 test_setup_config_no_content_box: new Error(
69 'Structural_subscription configuration has undefined '+
70 'properties.')
71 }
72 },
73
74 setUp: function() {
75 // Monkeypatch LP to avoid network traffic and to allow
76 // insertion of test data.
77 window.LP = {
78 links: {},
79 cache: {}
80 };
81 LP.cache.context = {
82 title: 'Test Project',
83 self_link: 'https://launchpad.dev/api/test_project'
84 };
85 LP.cache.administratedTeams = [];
86 LP.cache.importances = [];
87 LP.cache.statuses = [];
88
89 this.configuration = {
90 content_box: content_box_id,
91 };
92 this.content_node = create_test_node();
93 Y.one('body').appendChild(this.content_node);
94 },
95
96 tearDown: function() {
97 //delete this.configuration;
98 remove_test_node();
99 delete this.content_node;
100 delete this.configuration.lp_client;
101 delete this.content_node;
102 },
103
104 test_setup_config_none: function() {
105 // The config passed to setup may not be null.
106 module.setup();
107 },
108
109 test_setup_config_no_content_box: function() {
110 // The config passed to setup must contain a content_box.
111 module.setup({});
112 },
113
114 test_anonymous: function() {
115 // The link should not be shown to anonymous users so
116 // 'setup' should not do anything in that case. If it
117 // were successful, the lp_client would be defined after
118 // setup is called.
119 LP.links.me = undefined;
120 Assert.isUndefined(module.lp_client);
121 module.setup(this.configuration);
122 Assert.isUndefined(module.lp_client);
123 },
124
125 test_logged_in_user: function() {
126 // If there is a logged-in user, setup is successful
127 LP.links.me = 'https://launchpad.dev/api/~someone';
128 Assert.isUndefined(module.lp_client);
129 module.setup(this.configuration);
130 Assert.isNotUndefined(module.lp_client);
131 },
132
133 test_list_contains: function() {
134 // Validate that the list_contains function actually reports
135 // whether or not an element is in a list.
136 var list = ['a', 'b', 'c'];
137 Assert.isTrue(module._list_contains(list, 'b'));
138 Assert.isFalse(module._list_contains(list, 'd'));
139 Assert.isFalse(module._list_contains([], 'a'));
140 Assert.isTrue(module._list_contains(['a', 'a'], 'a'));
141 Assert.isFalse(module._list_contains([], ''));
142 Assert.isFalse(module._list_contains([], null));
143 Assert.isFalse(module._list_contains(['a'], null));
144 Assert.isFalse(module._list_contains([]));
145 },
146
147 test_make_selector_controls: function() {
148 // Verify the creation of select all/none controls.
149 var selectors = module.make_selector_controls('sharona');
150 Assert.areEqual('sharona-select-all', selectors['all_name']);
151 Assert.areEqual('sharona-select-none', selectors['none_name']);
152 Assert.areEqual(
153 '<div id="sharona-selectors"',
154 selectors['html'].slice(0, 27));
155 }
156 });
157 suite.add(test_case);
158
159 test_case = new Y.Test.Case({
160 name: 'Structural Subscription Overlay save_subscription',
161
162 _should: {
163 error: {}
164 },
165
166 setUp: function() {
167 // Monkeypatch LP to avoid network traffic and to allow
168 // insertion of test data.
169 window.LP = {
170 links: {},
171 cache: {}
172 };
173 Y.lp.client.Launchpad = function() {};
174 Y.lp.client.Launchpad.prototype.named_post =
175 function(url, func, config) {
176 context.url = url;
177 context.func = func;
178 context.config = config;
179 // No need to call the on.success handler.
180 };
181 LP.cache.context = {
182 title: 'Test Project',
183 self_link: 'https://launchpad.dev/api/test_project'
184 };
185 LP.links.me = 'https://launchpad.dev/api/~someone';
186 LP.cache.administratedTeams = [];
187 LP.cache.importances = [];
188 LP.cache.statuses = [];
189
190 this.configuration = {
191 content_box: content_box_id
192 };
193 this.content_node = create_test_node();
194 Y.one('body').appendChild(this.content_node);
195
196 this.bug_filter = {
197 lp_original_uri:
198 '/api/devel/firefox/+subscription/mark/+filter/28'
199 };
200 this.form_data = {
201 recipient: ['user']
202 };
203 context = {};
204 },
205
206 tearDown: function() {
207 delete this.configuration;
208 remove_test_node();
209 delete this.content_node;
210 },
211
212 test_user_recipient: function() {
213 // When the user selects themselves as the recipient, the current
214 // user's URI is used as the recipient value.
215 module.setup(this.configuration);
216 this.form_data.recipient = ['user'];
217 module.save_subscription(this.form_data);
218 Assert.areEqual(
219 LP.links.me,
220 context.config.parameters.subscriber);
221 },
222
223 test_team_recipient: function() {
224 // When the user selects a team as the recipient, the selected
225 // team's URI is used as the recipient value.
226 module.setup(this.configuration);
227 this.form_data.recipient = ['team'];
228 this.form_data.team = ['https://launchpad.dev/api/~super-team'];
229 module.save_subscription(this.form_data);
230 Assert.areEqual(
231 this.form_data.team[0],
232 context.config.parameters.subscriber);
233 }
234 });
235 suite.add(test_case);
236
237 test_case = new Y.Test.Case({
238 name: 'Structural Subscription interaction tests',
239
240 _should: {
241 error: {
242 }
243 },
244
245 setUp: function() {
246 // Monkeypatch LP to avoid network traffic and to allow
247 // insertion of test data.
248 window.LP = {
249 links: {},
250 cache: {}
251 };
252
253 LP.cache.context = {
254 title: 'Test Project',
255 self_link: 'https://launchpad.dev/api/test_project'
256 };
257 LP.cache.administratedTeams = [];
258 LP.cache.importances = [];
259 LP.cache.statuses = [];
260 LP.links.me = 'https://launchpad.dev/api/~someone';
261
262 var lp_client = function() {};
263 this.configuration = {
264 content_box: content_box_id,
265 lp_client: lp_client
266 };
267
268 this.content_node = create_test_node();
269 Y.one('body').appendChild(this.content_node);
270 },
271
272 tearDown: function() {
273 remove_test_node();
274 delete this.content_node;
275 },
276
277 test_setup_overlay: function() {
278 // At the outset there should be no overlay.
279 var overlay = Y.one('#accordion-overlay');
280 Assert.isNull(overlay);
281 module.setup(this.configuration);
282 // After the setup the overlay should be in the DOM.
283 overlay = Y.one('#accordion-overlay');
284 Assert.isNotNull(overlay);
285 var header = Y.one(content_box_id).one('h2');
286 Assert.areEqual(
287 'Add a mail subscription for Test Project bugs',
288 header.get('text'));
289 },
290
291 test_initial_state: function() {
292 // When initialized the <div> elements for the filter
293 // wrapper and the accordion wrapper should be collapsed.
294 module.setup(this.configuration);
295 // Simulate a click on the link to open the overlay.
296 var link = Y.one('.menu-link-subscribe_to_bug_mail');
297 Y.Event.simulate(
298 Y.Node.getDOMNode(link), 'click');
299 var filter_wrapper = Y.one('#filter-wrapper');
300 var accordion_wrapper = Y.one('#accordion-wrapper');
301 Assert.isTrue(filter_wrapper.hasClass('lazr-closed'));
302 Assert.isTrue(accordion_wrapper.hasClass('lazr-closed'));
303 },
304
305 test_added_or_changed_toggles: function() {
306 // Test that the filter wrapper opens and closes in
307 // response to the added_or_changed radio button.
308 module.setup(this.configuration);
309 // Simulate a click on the link to open the overlay.
310 var link = Y.one('.menu-link-subscribe_to_bug_mail');
311 Y.Event.simulate(
312 Y.Node.getDOMNode(link), 'click');
313 var added_changed = Y.one('#added-or-changed');
314 Assert.isFalse(added_changed.get('checked'));
315 var filter_wrapper = Y.one('#filter-wrapper');
316 // Initially closed.
317 Assert.isTrue(filter_wrapper.hasClass('lazr-closed'));
318 // Opens when selected.
319 Y.Event.simulate(Y.Node.getDOMNode(added_changed), 'click');
320 this.wait(function() {
321 Assert.isTrue(filter_wrapper.hasClass('lazr-opened'));
322 }, 500);
323 // Closes when deselected.
324 Y.Event.simulate(
325 Y.Node.getDOMNode(Y.one('#added-or-closed')), 'click');
326 this.wait(function() {
327 Assert.isTrue(filter_wrapper.hasClass('lazr-closed'));
328 }, 500);
329 },
330
331 test_advanced_filter_toggles: function() {
332 // Test that the accordion wrapper opens and closes in
333 // response to the advanced filter check box.
334 module.setup(this.configuration);
335 // Simulate a click on the link to open the overlay.
336 var link = Y.one('.menu-link-subscribe_to_bug_mail');
337 Y.Event.simulate(
338 Y.Node.getDOMNode(link), 'click');
339 var added_changed = Y.one('#added-or-changed');
340 added_changed.set('checked', true);
341
342 // Initially closed.
343 var advanced_filter = Y.one('#advanced-filter');
344 Assert.isFalse(advanced_filter.get('checked'));
345 var accordion_wrapper = Y.one('#accordion-wrapper');
346 this.wait(function() {
347 Assert.isTrue(accordion_wrapper.hasClass('lazr-closed'));
348 }, 500);
349 // Opens when selected.
350 advanced_filter.set('checked') = true;
351 this.wait(function() {
352 Assert.isTrue(accordion_wrapper.hasClass('lazr-opened'));
353 }, 500);
354 // Closes when deselected.
355 advanced_filter.set('checked') = false;
356 this.wait(function() {
357 Assert.isTrue(accordion_wrapper.hasClass('lazr-closed'));
358 }, 500);
359 },
360
361 test_importances_select_all_none: function() {
362 // Test the select all/none functionality for the importances
363 // accordion pane.
364 module.setup(this.configuration);
365 var checkboxes = Y.all('input[name="importances"]');
366 var select_all = Y.one('#importances-select-all');
367 var select_none = Y.one('#importances-select-none');
368 Assert.isTrue(test_checked(checkboxes, true));
369 // Simulate a click on the select_none control.
370 Y.Event.simulate(Y.Node.getDOMNode(select_none), 'click');
371 Assert.isTrue(test_checked(checkboxes, false));
372 // Simulate a click on the select_all control.
373 Y.Event.simulate(Y.Node.getDOMNode(select_all), 'click');
374 Assert.isTrue(test_checked(checkboxes, true));
375 },
376
377 test_statuses_select_all_none: function() {
378 // Test the select all/none functionality for the statuses
379 // accordion pane.
380 module.setup(this.configuration);
381 var checkboxes = Y.all('input[name="statuses"]');
382 var select_all = Y.one('#statuses-select-all');
383 var select_none = Y.one('#statuses-select-none');
384 Assert.isTrue(test_checked(checkboxes, true));
385 // Simulate a click on the select_none control.
386 Y.Event.simulate(Y.Node.getDOMNode(select_none), 'click');
387 Assert.isTrue(test_checked(checkboxes, false));
388 // Simulate a click on the select_all control.
389 Y.Event.simulate(Y.Node.getDOMNode(select_all), 'click');
390 Assert.isTrue(test_checked(checkboxes, true));
391 }
392
393 });
394 suite.add(test_case);
395
396 test_case = new Y.Test.Case({
397 // Test the setup method.
398 name: 'Structural Subscription error handling',
399
400 _should: {
401 error: {
402 }
403 },
404
405 setUp: function() {
406 // Monkeypatch LP to avoid network traffic and to allow
407 // insertion of test data.
408 window.LP = {
409 links: {},
410 cache: {}
411 };
412
413 LP.cache.context = {
414 title: 'Test Project',
415 self_link: 'https://launchpad.dev/api/test_project'
416 };
417 LP.cache.administratedTeams = [];
418 LP.cache.importances = ['Unknown', 'Critical', 'High', 'Medium',
419 'Low', 'Wishlist', 'Undecided'];
420 LP.cache.statuses = ['New', 'Incomplete', 'Opinion',
421 'Invalid', 'Won\'t Fix', 'Expired',
422 'Confirmed', 'Triaged', 'In Progress',
423 'Fix Committed', 'Fix Released', 'Unknown'];
424 LP.links.me = 'https://launchpad.dev/api/~someone';
425
426 var lp_client = function() {};
427 this.configuration = {
428 content_box: content_box_id,
429 lp_client: lp_client
430 };
431
432 this.content_node = create_test_node();
433 Y.one('body').appendChild(this.content_node);
434 },
435
436 tearDown: function() {
437 remove_test_node();
438 delete this.content_node;
439 },
440
441 test_overlay_error_handling_adding: function() {
442 // Verify that errors generated during adding of a filter are
443 // displayed to the user.
444 this.configuration.lp_client.named_post =
445 function(url, func, config) {
446 config.on.failure(true, true);
447 };
448 module.setup(this.configuration);
449 // After the setup the overlay should be in the DOM.
450 overlay = Y.one('#accordion-overlay');
451 Assert.isNotNull(overlay);
452 submit_button = Y.one('.yui3-lazr-formoverlay-actions button');
453 Y.Event.simulate(Y.Node.getDOMNode(submit_button), 'click');
454
455 var error_box = Y.one('.yui3-lazr-formoverlay-errors');
456 Assert.areEqual(
457 'The following errors were encountered: ',
458 error_box.get('text'));
459 },
460
461 test_overlay_error_handling_patching: function() {
462 // Verify that errors generated during patching of a filter are
463 // displayed to the user.
464 var original_delete_filter = module._delete_filter;
465 module._delete_filter = function() {};
466 this.configuration.lp_client.patch =
467 function(bug_filter, data, config) {
468 config.on.failure(true, true);
469 };
470 var bug_filter = {
471 'getAttrs': function() { return {}; }
472 };
473 this.configuration.lp_client.named_post =
474 function(url, func, config) {
475 config.on.success(bug_filter);
476 };
477 module.setup(this.configuration);
478 // After the setup the overlay should be in the DOM.
479 overlay = Y.one('#accordion-overlay');
480 Assert.isNotNull(overlay);
481 submit_button = Y.one('.yui3-lazr-formoverlay-actions button');
482 Y.Event.simulate(Y.Node.getDOMNode(submit_button), 'click');
483
484 // Put this stubbed function back.
485 module._delete_filter = original_delete_filter;
486
487 var error_box = Y.one('.yui3-lazr-formoverlay-errors');
488 Assert.areEqual(
489 'The following errors were encountered: ',
490 error_box.get('text'));
491 }
492
493 });
494 suite.add(test_case);
495
496 suite.add(new Y.Test.Case({
497 name: 'Structural Subscription: deleting failed filters',
498
499 _should: {error: {}},
500
501 setUp: function() {
502 // Monkeypatch LP to avoid network traffic and to allow
503 // insertion of test data.
504 this.original_lp = window.LP;
505 window.LP = {
506 links: {},
507 cache: {}
508 };
509 LP.cache.context = {
510 self_link: 'https://launchpad.dev/api/test_project'
511 };
512 LP.links.me = 'https://launchpad.dev/api/~someone';
513 LP.cache.administratedTeams = [];
514 },
515
516 tearDown: function() {
517 window.LP = this.original_lp;
518 },
519
520 test_delete_on_patch_failure: function() {
521 // Creating a filter is a two step process. First it is created
522 // and then patched. If the PATCH fails, then we should DELETE
523 // the undifferentiated filter.
524
525 // First we inject our own delete_filter implementation that just
526 // tells us that it was called.
527 var original_delete_filter = module._delete_filter;
528 var delete_called = false;
529 module._delete_filter = function() {
530 delete_called = true;
531 };
532 var patch_failed = false;
533
534 var TestBugFilter = function() {};
535 TestBugFilter.prototype = {
536 'getAttrs': function () {
537 return {};
538 },
539 };
540
541 // Now we need an lp_client that will appear to succesfully create
542 // the filter but then fail to patch it.
543 var TestClient = function() {};
544 TestClient.prototype = {
545 'named_post': function (uri, operation_name, config) {
546 if (operation_name === 'addBugSubscriptionFilter') {
547 config.on.success(new TestBugFilter());
548 } else {
549 throw new Error('unexpected operation');
550 }
551 },
552 'patch': function(uri, representation, config, headers) {
553 config.on.failure(true, {'status':400});
554 patch_failed = true;
555 },
556 };
557 module.lp_client = new TestClient();
558
559 // OK, we're ready to add the bug filter and let the various
560 // handlers be called.
561 module._add_bug_filter(LP.links.me, 'this is a test');
562 // Put some functions back.
563 module._delete_filter = original_delete_filter;
564
565 // Delete should have been called and the patch has failed.
566 Assert.isTrue(delete_called);
567 Assert.isTrue(patch_failed);
568 },
569
570 }));
571
572 suite.add(new Y.Test.Case({
573 name: 'Structural Subscription validate_config',
574
575 _should: {
576 error: {
577 test_setup_config_none: new Error(
578 'Missing config for structural_subscription.'),
579 test_setup_config_no_content_box: new Error(
580 'Structural_subscription configuration has undefined '+
581 'properties.')
582 }
583 },
584
585 // Included in _should/error above.
586 test_setup_config_none: function() {
587 // The config passed to setup may not be null.
588 module._validate_config();
589 },
590
591 // Included in _should/error above.
592 test_setup_config_no_content_box: function() {
593 // The config passed to setup must contain a content_box.
594 module._validate_config({});
595 }
596 }));
597
598 suite.add(new Y.Test.Case({
599 name: 'Structural Subscription extract_form_data',
600
601 // Verify that all the different values of the structural subscription
602 // add/edit form are correctly extracted by the extract_form_data
603 // function.
604
605 _should: {
606 error: {
607 }
608 },
609
610 test_extract_description: function() {
611 var form_data = {
612 name: ['filter description'],
613 events: [],
614 filters: [],
615 };
616 var patch_data = module._extract_form_data(form_data);
617 Assert.areEqual(patch_data.description, form_data.name[0]);
618 },
619
620 test_extract_description_trim: function() {
621 // Any leading or trailing whitespace is stripped from the
622 // description.
623 var form_data = {
624 name: [' filter description '],
625 events: [],
626 filters: [],
627 };
628 var patch_data = module._extract_form_data(form_data);
629 Assert.areEqual('filter description', patch_data.description);
630 },
631
632 test_extract_chattiness_lifecycle: function() {
633 var form_data = {
634 name: [],
635 events: ['added-or-closed'],
636 filters: [],
637 };
638 var patch_data = module._extract_form_data(form_data);
639 Assert.areEqual(
640 patch_data.bug_notification_level, 'Lifecycle');
641 },
642
643 test_extract_chattiness_discussion: function() {
644 var form_data = {
645 name: [],
646 events: [],
647 filters: ['filter-comments'],
648 };
649 var patch_data = module._extract_form_data(form_data);
650 Assert.areEqual(
651 patch_data.bug_notification_level, 'Details');
652 },
653
654 test_extract_chattiness_details: function() {
655 var form_data = {
656 name: [],
657 events: [],
658 filters: [],
659 };
660 var patch_data = module._extract_form_data(form_data);
661 Assert.areEqual(
662 patch_data.bug_notification_level, 'Discussion');
663 },
664
665 test_extract_tags: function() {
666 var form_data = {
667 name: [],
668 events: [],
669 filters: ['advanced-filter'],
670 tags: ['one two THREE'],
671 tag_match: [''],
672 importances: [],
673 statuses: [],
674 };
675 var patch_data = module._extract_form_data(form_data);
676 // Note that the tags are converted to lower case.
677 ArrayAssert.itemsAreEqual(
678 patch_data.tags, ['one', 'two', 'three']);
679 },
680
681 test_extract_find_all_tags_true: function() {
682 var form_data = {
683 name: [],
684 events: [],
685 filters: ['advanced-filter'],
686 tags: ['tag'],
687 tag_match: ['match-all'],
688 importances: [],
689 statuses: [],
690 };
691 var patch_data = module._extract_form_data(form_data);
692 Assert.isTrue(patch_data.find_all_tags);
693 },
694
695 test_extract_find_all_tags_false: function() {
696 var form_data = {
697 name: [],
698 events: [],
699 filters: ['advanced-filter'],
700 tags: ['tag'],
701 tag_match: [],
702 importances: [],
703 statuses: [],
704 };
705 var patch_data = module._extract_form_data(form_data);
706 Assert.isFalse(patch_data.find_all_tags);
707 },
708
709 test_all_values_set: function() {
710 // We need all the values to be set (even if empty) because
711 // PATCH expects a set of changes to make and any unspecified
712 // attributes will retain the previous value.
713 var form_data = {
714 name: [],
715 events: [],
716 filters: [],
717 tags: ['tag'],
718 tag_match: ['match-all'],
719 importances: ['importance1'],
720 statuses: ['status1'],
721 };
722 var patch_data = module._extract_form_data(form_data);
723 // Since advanced-filter isn't set, all the advanced values should
724 // be empty/false despite the form values.
725 Assert.isFalse(patch_data.find_all_tags);
726 ArrayAssert.isEmpty(patch_data.tags)
727 ArrayAssert.isEmpty(patch_data.importances)
728 ArrayAssert.isEmpty(patch_data.statuses)
729 },
730
731 }));
732
733 // Lock, stock, and two smoking barrels.
734 var handle_complete = function(data) {
735 var status_node = Y.Node.create(
736 '<p id="complete">Test status: complete</p>');
737 Y.one('body').appendChild(status_node);
738 };
739 Y.Test.Runner.on('complete', handle_complete);
740 Y.Test.Runner.add(suite);
741
742 // The following two lines may be commented out for debugging but
743 // must be restored before being checked in or the tests will fail
744 // in the test runner.
745 var console = new Y.Console({newestOnTop: false});
746 console.render('#log');
747
748 Y.on('domready', function() {
749 Y.Test.Runner.run();
750 });
751});
0752
=== modified file 'lib/lp/registry/templates/product-index.pt'
--- lib/lp/registry/templates/product-index.pt 2011-03-14 15:31:05 +0000
+++ lib/lp/registry/templates/product-index.pt 2011-03-28 19:31:27 +0000
@@ -32,6 +32,16 @@
32 </style>32 </style>
33 </noscript>33 </noscript>
3434
35 <script type="text/javascript"
36 tal:condition="
37 request/features/advanced-structural-subscriptions.enabled">
38 LPS.use('lp.registry.structural_subscription', function(Y) {
39 module = Y.lp.registry.structural_subscription;
40 Y.on('domready', function() {
41 module.setup({content_box: "#structural-subscription-content-box"});
42 });
43 });
44 </script>
35 </tal:head-epilogue>45 </tal:head-epilogue>
36</head>46</head>
3747
@@ -246,6 +256,10 @@
246256
247 <div tal:content="structure context/@@+portlet-coming-sprints" />257 <div tal:content="structure context/@@+portlet-coming-sprints" />
248 </div>258 </div>
259 <div class="yui-u">
260 <div id="structural-subscription-content-box"></div>
261 </div>
262
249 </div>263 </div>
250 </tal:main>264 </tal:main>
251265
252266
=== modified file 'lib/lp/registry/templates/product-portlet-license-missing.pt'
--- lib/lp/registry/templates/product-portlet-license-missing.pt 2009-08-11 12:43:24 +0000
+++ lib/lp/registry/templates/product-portlet-license-missing.pt 2011-03-28 19:31:27 +0000
@@ -13,7 +13,7 @@
13 src="/@@/expiration-large" />13 src="/@@/expiration-large" />
14 This project needs to have its licensing information entered.14 This project needs to have its licensing information entered.
15 <br />Select the appropriate license checkboxes from15 <br />Select the appropriate license checkboxes from
16 <a tal:content="structure context/menu:overview/edit/fmt:link-icon" />.16 <a tal:replace="structure context/menu:overview/edit/fmt:link-icon" />.
17 </div>17 </div>
18</div>18</div>
19</tal:root>19</tal:root>
2020
=== modified file 'lib/lp/services/inlinehelp/javascript/inlinehelp.js'
--- lib/lp/services/inlinehelp/javascript/inlinehelp.js 2010-05-10 22:21:41 +0000
+++ lib/lp/services/inlinehelp/javascript/inlinehelp.js 2011-03-28 19:31:27 +0000
@@ -50,8 +50,12 @@
50 'class="help"' attribute if it is missing, and connect the50 'class="help"' attribute if it is missing, and connect the
51 necessary event handlers.51 necessary event handlers.
52 */52 */
53 addElementClass(elem, 'help');53 // We want this to be idempotent, so we treat the 'help' class as a
54 connect(elem, 'onclick', handleClickOnHelp);54 // marker.
55 if (!hasElementClass(elem, 'help')) {
56 addElementClass(elem, 'help');
57 connect(elem, 'onclick', handleClickOnHelp);
58 }
55}59}
5660
5761