Merge lp:~wallyworld/launchpad/assign-non-contributor into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 12819
Proposed branch: lp:~wallyworld/launchpad/assign-non-contributor
Merge into: lp:launchpad
Prerequisite: lp:~wallyworld/launchpad/windmill-setup-improvement
Diff against target: 907 lines (+579/-71)
12 files modified
lib/canonical/launchpad/icing/style-3-0.css (+13/-0)
lib/lp/app/javascript/client.js (+1/-1)
lib/lp/app/javascript/multicheckbox.js (+1/-3)
lib/lp/app/javascript/picker.js (+156/-52)
lib/lp/app/javascript/tests/picker.html (+48/-0)
lib/lp/app/javascript/tests/picker.js (+180/-0)
lib/lp/bugs/configure.zcml (+2/-1)
lib/lp/bugs/interfaces/bugtask.py (+23/-0)
lib/lp/bugs/javascript/bugtask_index.js (+60/-9)
lib/lp/bugs/model/bugtask.py (+9/-0)
lib/lp/bugs/model/tests/test_bugtask.py (+29/-5)
lib/lp/bugs/windmill/tests/test_bug_inline_assignment.py (+57/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/assign-non-contributor
Reviewer Review Type Date Requested Status
Huw Wilkins (community) ui Approve
Robert Collins (community) Approve
William Grant code* Approve
Registry Administrators ui Pending
Review via email: mp+55869@code.launchpad.net

Commit message

[r=lifeless,wgrant][ui=huwshimi][bug=698138] When a bug task assignee is changed inline and the new assignee is a non-contributer, prompt the user to confirm their choice of assignee.

Description of the change

When a bug task assignee is changed inline and the new assignee is a non-contributer, prompt the user to confirm their choice of assignee. Currently the warning is only display if Javascript is disabled and the assignee changed using a form submission.

== Implementation ==

This new functionality is delivered on top of new validation infrastructure built into Launchpad's picker widget (which itself extends Y.lazr.Picker).

When a picker is created, one can optionally pass in a validation callback. This will be invoked when the user selects a picker item. Without the validation callback, the picker save function is invoked immediately. However, with the validation callback, the callback is invoked and returns either true/false. If true, save is invoked, if false, the picker stays open at the current selection.

For this bug fix, a yesno validation callback is implemented in the picker. This is the first example of using the validation callback infrastructure. The picker widget provides the boiler plate to wire it all together. The bug task Javascript provides the actual callback business logic. When the user makes a selection, an xhr call is made to see if that person is a contributor. If they aren't, a confirmation prompt appears allowing the user to answer "no" and choose another assignee. The conformation prompt replaces the picker content and animation is used to make it look good.

== Demo ==

Here's a screencast: http://people.canonical.com/~ianb/assignee_validation.ogv

== Tests ==

Tests were added for the new infrastructure and a Windmill test. The picker implementation was modified slightly to allow a list of vocabulary terms to be passed in as well as a vocabulary name. This was required to allow a test case to create a picker.

There were no existing Javascript tests for Launchpad's picker so a new test suite was added: lp/app/javascript/tests/picker.js
This test suite specifically tests the new yes/no validation callback added to the picker:
  test_picker_can_be_instantiated()
  test_no_confirmation_required()
  test_confirmation_yes()
  test_confirmation_no()

A unit test was added for the BugContributorView:
  TestBugContributorView
    test_non_contributor()
    test_contributor()

A windmill test was added for the specific use case of in-line bug assignment:
  lp/bugs/windmill/tests/test_bug_inline_assignment.py

= Launchpad lint =

I fixed some existing Javascript lint issues but was not comfortable about changing the suggested !== and === items.

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/javascript/picker.js
  lib/lp/app/javascript/tests/picker.html
  lib/lp/app/javascript/tests/picker.js
  lib/lp/bugs/browser/bugtask.py
  lib/lp/bugs/browser/configure.zcml
  lib/lp/bugs/browser/tests/test_bugtask.py
  lib/lp/bugs/javascript/bugtask_index.js
  lib/lp/bugs/windmill/tests/test_bug_inline_assignment.py

./lib/lp/app/javascript/picker.js
      92: Expected '!==' and instead saw '!='.
     222: Expected '!==' and instead saw '!='.
     286: Expected '!==' and instead saw '!='.
     302: Expected '!==' and instead saw '!='.
     307: Expected '!==' and instead saw '!='.
     355: Expected '!==' and instead saw '!='.
     355: Expected '!==' and instead saw '!='.
     422: Move 'var' declarations to the top of the function.
     422: Stopping. (86% scanned).
       0: JSLINT had a fatal error.
     193: Line exceeds 78 characters.
./lib/lp/bugs/javascript/bugtask_index.js
     269: Expected '===' and instead saw '=='.
     726: Expected '===' and instead saw '=='.
     759: Expected '===' and instead saw '=='.
     815: Expected '===' and instead saw '=='.
     817: Expected '===' and instead saw '=='.
     886: Expected '===' and instead saw '=='.
    1008: Unexpected 'in'. Compare with undefined, or use the hasOwnProperty method instead.
    1022: Expected '===' and instead saw '=='.
    1035: Expected '===' and instead saw '=='.
     387: Line exceeds 78 characters.
     388: Line exceeds 78 characters.
     389: Line exceeds 78 characters.
     443: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

As discussed on IRC, I have a couple of concerns.

Firstly, BugContributorView should probably be an API named operation. While lazr.restful doesn't in general like dicts very much, this is just a dict of JSON primitive types. Declare the method without a return type, and just return the dict.

Secondly, synchronous XHR is never a good idea -- it hangs the page while it's running. In older browsers (eg. Firefox) it actually hangs the whole browser! The picker is already thoroughly asynchronous; it seems like it shouldn't be all that difficult to make this bit async too.

review: Needs Fixing (code*)
Revision history for this message
Robert Collins (lifeless) wrote :

I concur

Revision history for this message
Ian Booth (wallyworld) wrote :

> Firstly, BugContributorView should probably be an API named operation. While
> lazr.restful doesn't in general like dicts very much, this is just a dict of
> JSON primitive types. Declare the method without a return type, and just
> return the dict.
>

I originally did it as a named_get but went with the view because there seemed to be issues with returning a dict. But reverting to the original api call and making a few tweaks and it all works.

> Secondly, synchronous XHR is never a good idea -- it hangs the page while it's
> running. In older browsers (eg. Firefox) it actually hangs the whole browser!
> The picker is already thoroughly asynchronous; it seems like it shouldn't be
> all that difficult to make this bit async too.

Luckily, it turned out that the main issue with the original async implementation was subsequently fixed to solve another issue along the way and it was easy to revert to the async approach.

Revision history for this message
William Grant (wgrant) wrote :

yesno_save_confirmation is a bit messy and unobvious. It may be clearer to create the whole structure from a single string, then fill the text in using deeper selectors. This would also encourage you to fix the yes_label/no_label string concatenation. The function could also probably just be called save_confirmation or confirm_save. Perhaps confirm/cancel is better terminology than yes/no, too. And I'm not a fan of the inline styles on .yesyno_buttons, nor of the typo in that class name. Is content_node.insert(validation_content_container, 'before') going to treat unsafe content properly? We probably want the behaviour where it's treated as text unless it's a Node, given the XSS that's here already (see below).

animate_validation_content could be a little shorter if you dropped content_node and called .hide() directly. The four lines of steps_node could also be replaced with a single .all('.steps').hide() line.

"Restore a picker to it's" s/it's/its/

reset_form has another instance of steps_node which could be replaced with a single line. It also has some duplicated content_node references which could be replaced with chained calls.

getContributorInfo is explicitly exposed in beta, but we're not exposing things outside devel except as necessary. Please restrict it to devel.

XSS here:
702 + "<p>{person_name} did not previously have any " +
703 + "assigned bugs in {pillar}.</p>" +
704 + "<p>Do you really want to assign them to this bug?</p>";
705 + var yesno_content = Y.Lang.substitute(
706 + yesno_content_template,
707 + {person_name: person, pillar: pillar});

test_inline_assignment_non_contributer should probably take an s/contributer/contributor/. The XPath with td[5] seems a bit fragile, is there a class you could use instead? I understand there might not be.

review: Needs Fixing (code*)
Revision history for this message
Robert Collins (lifeless) wrote :

I thought the plan was 'assign' / 'select again' rather than Y/N ?

Revision history for this message
Ian Booth (wallyworld) wrote :

> yesno_save_confirmation is a bit messy and unobvious. It may be clearer to

I don't think it's that bad. It's basically some node construction and assembly.

> create the whole structure from a single string, then fill the text in using
> deeper selectors. This would also encourage you to fix the yes_label/no_label

This isn't viable since the buttons need to be constructed separately so that onclick handlers can be added. The concatenation is in line with the coding guidelines which say that up to 3 lines of concatenation for a given string is permissible.
I've restructured the javascript to hopefully make it a little clearer.

> string concatenation. The function could also probably just be called
> save_confirmation or confirm_save. Perhaps confirm/cancel is better

I've kept the name as is because there may be other validator types in the future and the validator type name needs to be included in the function name for disambiguation.

> terminology than yes/no, too. And I'm not a fan of the inline styles on
> .yesyno_buttons, nor of the typo in that class name. Is

Typo fixed. The inline styles were copied from elsewhere. I've created a new css class called extra_form_buttons and used that instead.

> content_node.insert(validation_content_container, 'before') going to treat
> unsafe content properly? We probably want the behaviour where it's treated as
> text unless it's a Node, given the XSS that's here already (see below).
>

The code as written is ok - it's just assembling the nodes created in the callback.

> animate_validation_content could be a little shorter if you dropped
> content_node and called .hide() directly. The four lines of steps_node could
> also be replaced with a single .all('.steps').hide() line.
>
> "Restore a picker to it's" s/it's/its/
>
> reset_form has another instance of steps_node which could be replaced with a
> single line. It also has some duplicated content_node references which could
> be replaced with chained calls.
>

Done

> getContributorInfo is explicitly exposed in beta, but we're not exposing
> things outside devel except as necessary. Please restrict it to devel.
>

Done

> XSS here:
> 702 + "<p>{person_name} did not previously have any
> " +
> 703 + "assigned bugs in {pillar}.</p>" +
> 704 + "<p>Do you really want to assign them to this
> bug?</p>";
> 705 + var yesno_content = Y.Lang.substitute(
> 706 + yesno_content_template,
> 707 + {person_name: person, pillar: pillar});
>

Fixed - variable values to be substituted are escaped.

> test_inline_assignment_non_contributer should probably take an
> s/contributer/contributor/. The XPath with td[5] seems a bit fragile, is there
> a class you could use instead? I understand there might not be.

No class sadly. jquery is preferable but doesn't seem to "do the right thing" when used with Windmill. xpath works fine every time.

Revision history for this message
William Grant (wgrant) wrote :

> > yesno_save_confirmation is a bit messy and unobvious. It may be clearer to
>
> I don't think it's that bad. It's basically some node construction and
> assembly.
>
> > create the whole structure from a single string, then fill the text in using
> > deeper selectors. This would also encourage you to fix the
> yes_label/no_label
>
> This isn't viable since the buttons need to be constructed separately so that
> onclick handlers can be added. The concatenation is in line with the coding
> guidelines which say that up to 3 lines of concatenation for a given string is
> permissible.
> I've restructured the javascript to hopefully make it a little clearer.

I still think it could be improved. See lp:~wgrant/launchpad/assign-non-contributor for some suggestions.

> > string concatenation. The function could also probably just be called
> > save_confirmation or confirm_save. Perhaps confirm/cancel is better
>
> I've kept the name as is because there may be other validator types in the
> future and the validator type name needs to be included in the function name
> for disambiguation.

OK.

> > terminology than yes/no, too. And I'm not a fan of the inline styles on
> > .yesyno_buttons, nor of the typo in that class name. Is
>
> Typo fixed. The inline styles were copied from elsewhere. I've created a new
> css class called extra_form_buttons and used that instead.

Thanks!

> > content_node.insert(validation_content_container, 'before') going to treat
> > unsafe content properly? We probably want the behaviour where it's treated
> as
> > text unless it's a Node, given the XSS that's here already (see below).
> >
>
> The code as written is ok - it's just assembling the nodes created in the
> callback.

In my experimentation I've found that this is fine. insert handles nodes/text properly.

> [snip remaining fixed points; thanks]

Revision history for this message
Ian Booth (wallyworld) wrote :

>> This isn't viable since the buttons need to be constructed separately so that
>> onclick handlers can be added. The concatenation is in line with the coding
>> guidelines which say that up to 3 lines of concatenation for a given string is
>> permissible.
>> I've restructured the javascript to hopefully make it a little clearer.
>
> I still think it could be improved. See lp:~wgrant/launchpad/assign-non-contributor for some suggestions.
>

Thanks for the suggestion. I guess it's a matter of personal taste what
you're used to. I prefer the programmatic approach of constructing nodes
and wiring together :-)

In any case, I've made the suggested changes. It does mean less lines of
code. The coding guidelines say not to concatenate strings over more
than 3 lines so I've used the approach of ['foo', 'bar'].join('') as per
the guidelines.

Revision history for this message
William Grant (wgrant) wrote :

> >> This isn't viable since the buttons need to be constructed separately so
> that
> >> onclick handlers can be added. The concatenation is in line with the coding
> >> guidelines which say that up to 3 lines of concatenation for a given string
> is
> >> permissible.
> >> I've restructured the javascript to hopefully make it a little clearer.
> >
> > I still think it could be improved. See lp:~wgrant/launchpad/assign-non-
> contributor for some suggestions.
> >
>
> Thanks for the suggestion. I guess it's a matter of personal taste what
> you're used to. I prefer the programmatic approach of constructing nodes
> and wiring together :-)

Indeed. While your method seems nicer in some ways, I think this is far easier to understand. You can clearly see the structure and exactly what we're hooking up to it, and the two parts are clearly separated.

> In any case, I've made the suggested changes. It does mean less lines of
> code. The coding guidelines say not to concatenate strings over more
> than 3 lines so I've used the approach of ['foo', 'bar'].join('') as per
> the guidelines.

It was a single string literal, not a series of concatenations, but OK. Neither is pretty.

One remaining stylistic niggle: extra_form_buttons and important-notice-popup have styles added, but one uses hyphens and the other underscores. I guess we probably have a standard for this.

review: Approve (code*)
Revision history for this message
Ian Booth (wallyworld) wrote :

>
>> In any case, I've made the suggested changes. It does mean less lines of
>> code. The coding guidelines say not to concatenate strings over more
>> than 3 lines so I've used the approach of ['foo', 'bar'].join('') as per
>> the guidelines.
>
> It was a single string literal, not a series of concatenations, but OK. Neither is pretty.
>

Ah, it was changed to a single literal from a series of concatenations,
my mistake in the wording above sorry. But I also thought that using "\"
for line continuation was forbidden too :-)

> One remaining stylistic niggle: extra_form_buttons and important-notice-popup have styles added, but one uses hyphens and the other underscores. I guess we probably have a standard for this.

Yes, you are right. I missed that inconsistency. I will change it, thanks.

Revision history for this message
Robert Collins (lifeless) wrote :

Ok this looks fine. Its a shame that the vocabs we have are so coupled to our batchnav implementation - it means we'll need to change them as we rollout the constraint based batching.

review: Approve
Revision history for this message
Huw Wilkins (huwshimi) wrote :

This looks good to me. Thanks for implementing the things that we talked about previously.

I know you've had a lot of back and forward with this but one thing that has been niggling me that I didn't pick up when I looked at previously is that it doesn't look like there is any space between the Assign/Choose Again buttons.

It would be really nice to fix that, but I don't want to hold you back from landing this for one space so I'm approving and you can fix it if you haven't been defeated by this review already.

review: Approve (ui)
Revision history for this message
Ian Booth (wallyworld) wrote :

> This looks good to me. Thanks for implementing the things that we talked about
> previously.
>
> I know you've had a lot of back and forward with this but one thing that has
> been niggling me that I didn't pick up when I looked at previously is that it
> doesn't look like there is any space between the Assign/Choose Again buttons.
>
> It would be really nice to fix that, but I don't want to hold you back from
> landing this for one space so I'm approving and you can fix it if you haven't
> been defeated by this review already.

I used a css style:

.extra-form-buttons button {
    margin-right: 0.7em;
    }

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/style-3-0.css'
2--- lib/canonical/launchpad/icing/style-3-0.css 2011-03-30 10:47:41 +0000
3+++ lib/canonical/launchpad/icing/style-3-0.css 2011-04-13 07:06:21 +0000
4@@ -828,6 +828,14 @@
5 border-width: 0 0 1px 0;
6 display: block;
7 }
8+.extra-form-buttons {
9+ text-align: center;
10+ height: 3em;
11+ white-space: nowrap;
12+ }
13+.extra-form-buttons button {
14+ margin-right: 0.7em;
15+ }
16 .actions * {
17 /*
18 Action links are those that begin the process of doing something.
19@@ -1054,6 +1062,11 @@
20 .sml-informational::before {
21 content: url('/@@/info');
22 }
23+.important-notice-popup {
24+ padding: 1em 1em 0 1em;
25+ width: auto;
26+ overflow: hidden;
27+ }
28 .important-notice-container {
29 text-align: center;
30 width: 100%;
31
32=== modified file 'lib/lp/app/javascript/client.js'
33--- lib/lp/app/javascript/client.js 2011-04-11 14:20:53 +0000
34+++ lib/lp/app/javascript/client.js 2011-04-13 07:06:21 +0000
35@@ -589,7 +589,7 @@
36 'headers': headers,
37 data: data
38 };
39- Y.io(uri, y_config);
40+ return Y.io(uri, y_config);
41 },
42
43 'named_get' : function(uri, operation_name, config) {
44
45=== modified file 'lib/lp/app/javascript/multicheckbox.js'
46--- lib/lp/app/javascript/multicheckbox.js 2011-03-31 04:52:03 +0000
47+++ lib/lp/app/javascript/multicheckbox.js 2011-04-13 07:06:21 +0000
48@@ -73,9 +73,7 @@
49 config.content_box_id = content_box_id;
50 var editform = namespace.create(attribute_name, items, help_text, config);
51 editform._resource_uri = resource_uri;
52- var extra_buttons = Y.Node.create(
53- '<div style="text-align: center; height: 3em; ' +
54- 'white-space: nowrap"/>');
55+ var extra_buttons = Y.Node.create('<div class="extra-form-buttons"/>');
56 activator.subscribe('act', function (e) {
57 editform.show();
58 });
59
60=== modified file 'lib/lp/app/javascript/picker.js'
61--- lib/lp/app/javascript/picker.js 2011-04-06 19:13:23 +0000
62+++ lib/lp/app/javascript/picker.js 2011-04-13 07:06:21 +0000
63@@ -39,7 +39,7 @@
64 var remove_button_text = 'Remove';
65 var null_display_value = 'None';
66 var show_search_box = true;
67- resource_uri = Y.lp.client.normalize_uri(resource_uri)
68+ resource_uri = Y.lp.client.normalize_uri(resource_uri);
69
70 if (config !== undefined) {
71 if (config.remove_button_text !== undefined) {
72@@ -101,7 +101,6 @@
73 var success_handler = function (entry) {
74 activator.renderSuccess(entry.getHTML(attribute_name));
75 show_hide_buttons();
76- return;
77 };
78
79 var patch_payload = {};
80@@ -151,11 +150,9 @@
81 };
82
83 config.save = save;
84- var picker = namespace.create(vocabulary, config);
85+ var picker = namespace.create(vocabulary, config, activator);
86 picker._resource_uri = resource_uri;
87- var extra_buttons = Y.Node.create(
88- '<div style="text-align: center; height: 3em; ' +
89- 'white-space: nowrap"/>');
90+ var extra_buttons = Y.Node.create('<div class="extra-form-buttons"/>');
91 var remove_button, assign_me_button;
92 if (show_remove_button) {
93 remove_button = Y.Node.create(
94@@ -219,8 +216,96 @@
95 * Remove the Loading.... spinner (if it exists).
96 */
97 function hide_temporary_spinner(temp_spinner) {
98- if( temp_spinner != null )
99+ if( temp_spinner != null ) {
100 temp_spinner.addClass('unseen');
101+ }
102+}
103+
104+/*
105+ * After the user selects an item using the picker, this function can be used
106+ * to present the user with a yes/no prompt to ensure they really want to use
107+ * the selection. If they answer yes, the selection is processed as normal. If
108+ * they answer no, they can make another selection.
109+ *
110+ * @param {Picker} picker The picker displaying the yes/no content.
111+ * @param {String} content The html content to display.
112+ * @param {String} yes_label The label for the "yes" button.
113+ * @param {String} no_label The label for the "no" button.
114+ * @param {Object} yes_fn The function to call if the user answers yes.
115+ * @param {Object} no_fn The function to call if the user answers no.
116+ */
117+namespace.yesno_save_confirmation = function(
118+ picker, content, yes_label, no_label, yes_fn, no_fn) {
119+
120+ var node = Y.Node.create(
121+ ['<div class="validation-node">',
122+ '<div class="step-on" style="width: 100%;"></div>',
123+ '<div class="transparent important-notice-popup">',
124+ '<div class="validation-content-placeholder"></div>',
125+ '<div class="extra-form-buttons">',
126+ '<button class="yes_button" type="button"/>',
127+ '<button class="no_button" type="button"/>',
128+ '</div>',
129+ '</div>',
130+ '</div>'].join(''));
131+
132+ var button_callback = function(e, callback_fn) {
133+ e.halt();
134+ if (Y.Lang.isFunction(callback_fn) ) {
135+ callback_fn();
136+ }
137+ reset_form(picker);
138+ };
139+ node.one(".yes_button")
140+ .set('text', yes_label)
141+ .on('click', function(e) { button_callback(e, yes_fn); });
142+
143+ node.one(".no_button")
144+ .set('text', no_label)
145+ .on('click', function(e) { button_callback(e, no_fn); });
146+
147+ node.one(".validation-content-placeholder").replace(content);
148+ picker.get('contentBox').one('.yui3-widget-bd').insert(node, 'before');
149+ animate_validation_content(picker, node.one(".important-notice-popup"));
150+};
151+
152+/*
153+ * Insert the validation content into the form and animate its appearance.
154+ */
155+function animate_validation_content(picker, validation_content) {
156+ picker.get('contentBox').one('.yui3-widget-bd').hide();
157+ picker.get('contentBox').all('.steps').hide();
158+ var validation_fade_in = new Y.Anim({
159+ node: validation_content,
160+ to: {opacity: 1},
161+ duration: 0.9
162+ });
163+ validation_fade_in.run();
164+}
165+
166+/*
167+ * Restore a picker to its functional state after a validation operation.
168+ */
169+function reset_form(picker) {
170+ picker.get('contentBox').all('.steps').show();
171+ var validation_node = picker.get('contentBox').one('.validation-node');
172+ var content_node = picker.get('contentBox').one('.yui3-widget-bd');
173+ if (validation_node != null) {
174+ validation_node.get('parentNode').removeChild(validation_node);
175+ content_node.addClass('transparent');
176+ content_node.setStyle('opacity', 0);
177+ content_node.show();
178+ var content_fade_in = new Y.Anim({
179+ node: content_node,
180+ to: {opacity: 1},
181+ duration: 0.6
182+ });
183+ content_fade_in.run();
184+ } else {
185+ content_node.removeClass('transparent');
186+ content_node.setStyle('opacity', 1);
187+ content_node.show();
188+ }
189 }
190
191 /**
192@@ -236,27 +321,27 @@
193 * config.save is a Function (optional) which takes
194 * a single string argument.
195 */
196-namespace.create = function (vocabulary, config) {
197+namespace.create = function (vocabulary, config, activator) {
198 if (Y.UA.ie) {
199 return;
200 }
201
202+ var header = 'Choose an item.';
203+ var step_title = "Enter search terms";
204 if (config !== undefined) {
205- var header = 'Choose an item.';
206 if (config.header !== undefined) {
207 header = config.header;
208 }
209
210- var step_title = "Enter search terms";
211 if (config.step_title !== undefined) {
212 step_title = config.step_title;
213 }
214 }
215
216- if (typeof vocabulary != 'string') {
217+ if (typeof vocabulary != 'string' && typeof vocabulary != 'object') {
218 throw new TypeError(
219 "vocabulary argument for Y.lp.picker.create() must be a " +
220- "string: " + vocabulary);
221+ "string or associative array: " + vocabulary);
222 }
223
224 var new_config = Y.merge(config, {
225@@ -275,13 +360,26 @@
226
227 picker.subscribe('save', function (e) {
228 Y.log('Got save event.');
229- if (Y.Lang.isFunction(config.save)) {
230- config.save(e.details[Y.lazr.Picker.SAVE_RESULT]);
231+ e.preventDefault();
232+ var picker_result = e.details[Y.lazr.Picker.SAVE_RESULT];
233+ var do_save = function() {
234+ if (Y.Lang.isFunction(config.save)) {
235+ config.save(picker_result);
236+ }
237+ picker._defaultSave(e);
238+ };
239+ var validate_callback = config.validate_callback;
240+ if (Y.Lang.isFunction(validate_callback)) {
241+ validate_callback(
242+ picker, picker_result, do_save, undefined);
243+ } else {
244+ do_save();
245 }
246 });
247
248 picker.subscribe('cancel', function (e) {
249 Y.log('Got cancel event.');
250+ reset_form(picker);
251 });
252
253 // Search for results, create batches and update the display.
254@@ -291,15 +389,7 @@
255 var search_text = e.details[0];
256 var selected_batch = e.details[1] || 0;
257 var start = BATCH_SIZE * selected_batch;
258- var client = new Y.lp.client.Launchpad();
259-
260- var success_handler = function (ignore, response, args) {
261- var entry = Y.JSON.parse(response.responseText);
262- var total_size = entry.total_size;
263- var start = entry.start;
264- var results = entry.entries;
265-
266- hide_temporary_spinner(config.temp_spinner);
267+ var display_vocabulary = function(results, total_size, start) {
268 if (total_size > (MAX_BATCHES * BATCH_SIZE)) {
269 picker.set('error',
270 'Too many matches. Please try to narrow your search.');
271@@ -322,41 +412,55 @@
272 });
273 }
274 }
275-
276 picker.set('batches', batches);
277 }
278 }
279 };
280
281- var qs = '';
282- qs = Y.lp.client.append_qs(qs, 'name', vocabulary);
283- qs = Y.lp.client.append_qs(qs, 'search_text', search_text);
284- qs = Y.lp.client.append_qs(qs, 'batch', BATCH_SIZE);
285- qs = Y.lp.client.append_qs(qs, 'start', start);
286-
287- // The uri needs to be relative, so that the vocabulary
288- // has the same context as the form. Some vocabularies
289- // use the context to limit the results to the same project.
290-
291- var uri = '';
292- if (Y.Lang.isValue(config['context'])){
293- uri += Y.lp.get_url_path(config['context'].get('web_link')) + '/';
294+ // We can pass in a vocabulary name
295+ if (typeof vocabulary == 'string') {
296+ var success_handler = function (ignore, response, args) {
297+ var entry = Y.JSON.parse(response.responseText);
298+ var total_size = entry.total_size;
299+ var start = entry.start;
300+ var results = entry.entries;
301+ hide_temporary_spinner(config.temp_spinner);
302+ display_vocabulary(results, total_size, start);
303+ };
304+
305+ var qs = '';
306+ qs = Y.lp.client.append_qs(qs, 'name', vocabulary);
307+ qs = Y.lp.client.append_qs(qs, 'search_text', search_text);
308+ qs = Y.lp.client.append_qs(qs, 'batch', BATCH_SIZE);
309+ qs = Y.lp.client.append_qs(qs, 'start', start);
310+
311+ // The uri needs to be relative, so that the vocabulary
312+ // has the same context as the form. Some vocabularies
313+ // use the context to limit the results to the same project.
314+
315+ var uri = '';
316+ if (Y.Lang.isValue(config['context'])){
317+ uri += Y.lp.get_url_path(config['context'].get('web_link')) + '/';
318+ }
319+ uri += '@@+huge-vocabulary?' + qs;
320+
321+ Y.io(uri, {
322+ headers: {'Accept': 'application/json'},
323+ timeout: 20000,
324+ on: {
325+ success: success_handler,
326+ failure: function (arg) {
327+ hide_temporary_spinner(config.temp_spinner);
328+ picker.set('error', 'Loading results failed.');
329+ picker.set('search_mode', false);
330+ Y.log("Loading " + uri + " failed.");
331+ }
332+ }
333+ });
334+ // Or we can pass in a vocabulary directly.
335+ } else {
336+ display_vocabulary(vocabulary, Y.Object.size(vocabulary), 1);
337 }
338- uri += '@@+huge-vocabulary?' + qs;
339-
340- Y.io(uri, {
341- headers: {'Accept': 'application/json'},
342- timeout: 20000,
343- on: {
344- success: success_handler,
345- failure: function (arg) {
346- hide_temporary_spinner(config.temp_spinner);
347- picker.set('error', 'Loading results failed.');
348- picker.set('search_mode', false);
349- Y.log("Loading " + uri + " failed.");
350- }
351- }
352- });
353 };
354
355 picker.after('search', search_handler);
356
357=== added file 'lib/lp/app/javascript/tests/picker.html'
358--- lib/lp/app/javascript/tests/picker.html 1970-01-01 00:00:00 +0000
359+++ lib/lp/app/javascript/tests/picker.html 2011-04-13 07:06:21 +0000
360@@ -0,0 +1,48 @@
361+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
362+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
363+<html>
364+ <head>
365+ <title>Launchpad Picker</title>
366+
367+ <!-- YUI 3.0 Setup -->
368+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
369+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
370+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
371+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
372+ <link rel="stylesheet" href="../../../../canonical/launchpad/javascript/test.css" />
373+
374+ <!-- Some required dependencies -->
375+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/testing/config.js"></script>
376+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/activator/activator.js"></script>
377+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/overlay/overlay.js"></script>
378+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/picker/picker.js"></script>
379+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/anim/anim.js"></script>
380+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/lazr/lazr.js"></script>
381+ <script type="text/javascript" src="../client.js"></script>
382+
383+ <!-- The module under test -->
384+ <script type="text/javascript" src="../picker.js"></script>
385+
386+ <!-- The test suite -->
387+ <script type="text/javascript" src="picker.js"></script>
388+</head>
389+<body class="yui3-skin-sam">
390+ <div class="yui3-widget yui3-activator yui3-activator-focused">
391+ <span id="picker_id" class="yui3-activator-content yui3-activator-success">
392+ <span id="pickertest">
393+ <span>
394+ A picker widget test
395+ <button id="edit-pickertest-btn"
396+ class="lazr-btn yui3-activator-act yui3-activator-hidden">
397+ Edit
398+ </button>
399+ </span>
400+ <span class="yui3-activator-data-box">
401+ </span>
402+ <div class="yui3-activator-message-box yui3-activator-hidden"/>
403+ </span>
404+ </span>
405+ </div>
406+ <div id="log"></div>
407+</body>
408+</html>
409
410=== added file 'lib/lp/app/javascript/tests/picker.js'
411--- lib/lp/app/javascript/tests/picker.js 1970-01-01 00:00:00 +0000
412+++ lib/lp/app/javascript/tests/picker.js 2011-04-13 07:06:21 +0000
413@@ -0,0 +1,180 @@
414+/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
415+
416+YUI({
417+ base: '../../../../canonical/launchpad/icing/yui/',
418+ filter: 'raw',
419+ combine: false,
420+ fetchCSS: false
421+ }).use('test', 'console', 'node', 'lp', 'lp.client', 'escape', 'event',
422+ 'event-focus', 'event-simulate', 'lazr.picker','lp.app.picker',
423+ function(Y) {
424+
425+var Assert = Y.Assert;
426+
427+/*
428+ * A wrapper for the Y.Event.simulate() function. The wrapper accepts
429+ * CSS selectors and Node instances instead of raw nodes.
430+ */
431+function simulate(widget, selector, evtype, options) {
432+ var rawnode = Y.Node.getDOMNode(widget.one(selector));
433+ Y.Event.simulate(rawnode, evtype, options);
434+}
435+
436+/* Helper function to clean up a dynamically added widget instance. */
437+function cleanup_widget(widget) {
438+ // Nuke the boundingBox, but only if we've touched the DOM.
439+ if (widget.get('rendered')) {
440+ var bb = widget.get('boundingBox');
441+ bb.get('parentNode').removeChild(bb);
442+ }
443+ // Kill the widget itself.
444+ widget.destroy();
445+}
446+
447+var suite = new Y.Test.Suite("Launchpad Picker Tests");
448+
449+/*
450+ * Test cases for a picker with a yesno validation callback.
451+ */
452+suite.add(new Y.Test.Case({
453+
454+ name: 'picker_yesyno_validation',
455+
456+ setUp: function() {
457+ this.vocabulary = [
458+ {"value": "fred", "title": "Fred", "css": "sprite-person",
459+ "description": "fred@example.com", "api_uri": "~/fred"},
460+ {"value": "frieda", "title": "Frieda", "css": "sprite-person",
461+ "description": "frieda@example.com", "api_uri": "~/frieda"}
462+ ];
463+ },
464+
465+ tearDown: function() {
466+ cleanup_widget(this.picker);
467+ },
468+
469+ create_picker: function(validate_callback) {
470+ this.picker = Y.lp.app.picker.addPickerPatcher(
471+ this.vocabulary,
472+ "foo/bar",
473+ "test_link",
474+ "picker_id",
475+ {"step_title": "Choose someone",
476+ "header": "Pick Someone",
477+ "remove_button_text": "Remove someone",
478+ "null_display_value": "Noone",
479+ "show_remove_button": true,
480+ "show_assign_me_button": true,
481+ "validate_callback": validate_callback});
482+ },
483+
484+ test_picker_can_be_instantiated: function() {
485+ // Ensure the picker can be instantiated.
486+ this.create_picker();
487+ Assert.isInstanceOf(
488+ Y.lazr.Picker, this.picker, "Picker failed to be instantiated");
489+ },
490+
491+ // Called when the picker saves it's data. Sets a flag for checking.
492+ picker_save_callback: function(save_flag) {
493+ return function(e) {
494+ save_flag.event_has_fired = true;
495+ };
496+ },
497+
498+ // A validation callback stub. Instead of going to the server to see if
499+ // a picker value requires confirmation, we compare it to a known value.
500+ yesno_validate_callback: function(save_flag, expected_value) {
501+ var save_fn = this.picker_save_callback(save_flag);
502+ return function(picker, value, ignore, cancel_fn) {
503+ Assert.areEqual(
504+ expected_value, value.api_uri, "unexpected picker value");
505+ if (value === null) {
506+ return true;
507+ }
508+ var requires_confirmation = value.api_uri !== "~/fred";
509+ if (requires_confirmation) {
510+ var yesno_content = "<p>Confirm selection</p>";
511+ Y.lp.app.picker.yesno_save_confirmation(
512+ picker, yesno_content, "Yes", "No",
513+ save_fn, cancel_fn);
514+ } else {
515+ save_fn();
516+ }
517+ };
518+ },
519+
520+ test_no_confirmation_required: function() {
521+ // Test that the picker saves the selected value if no confirmation
522+ // is required.
523+ var save_flag = {};
524+ this.create_picker(this.yesno_validate_callback(save_flag, "~/fred"));
525+ this.picker.set('results', this.vocabulary);
526+ this.picker.render();
527+
528+ simulate(
529+ this.picker.get('boundingBox').one('.yui3-picker-results'),
530+ 'li:nth-child(1)', 'click');
531+ Assert.isTrue(save_flag.event_has_fired, "save event wasn't fired.");
532+ },
533+
534+ test_confirmation_yes: function() {
535+ // Test that the picker saves the selected value if the user answers
536+ // "Yes" to a confirmation request.
537+ var save_flag = {};
538+ this.create_picker(
539+ this.yesno_validate_callback(save_flag, "~/frieda"));
540+ this.picker.set('results', this.vocabulary);
541+ this.picker.render();
542+
543+ simulate(
544+ this.picker.get('boundingBox').one('.yui3-picker-results'),
545+ 'li:nth-child(2)', 'click');
546+ var yesno = this.picker.get('contentBox').one('.extra-form-buttons');
547+
548+ simulate(
549+ yesno, 'button:nth-child(1)', 'click');
550+ Assert.isTrue(
551+ save_flag.event_has_fired, "save event wasn't fired.");
552+ },
553+
554+ test_confirmation_no: function() {
555+ // Test that the picker doesn't save the selected value if the answers
556+ // "No" to a confirmation request.
557+ var save_flag = {};
558+ this.create_picker(
559+ this.yesno_validate_callback(save_flag, "~/frieda"));
560+ this.picker.set('results', this.vocabulary);
561+ this.picker.render();
562+
563+ simulate(
564+ this.picker.get('boundingBox').one('.yui3-picker-results'),
565+ 'li:nth-child(2)', 'click');
566+ var yesno = this.picker.get('contentBox').one('.extra-form-buttons');
567+ simulate(
568+ yesno, 'button:nth-child(2)', 'click');
569+ Assert.areEqual(
570+ undefined, save_flag.event_has_fired,
571+ "save event wasn't fired.");
572+ }
573+}));
574+
575+// Lock, stock, and two smoking barrels.
576+var handle_complete = function(data) {
577+ status_node = Y.Node.create(
578+ '<p id="complete">Test status: complete</p>');
579+ Y.one('body').appendChild(status_node);
580+ };
581+Y.Test.Runner.on('complete', handle_complete);
582+Y.Test.Runner.add(suite);
583+
584+var yui_console = new Y.Console({
585+ newestOnTop: false
586+});
587+yui_console.render('#log');
588+
589+Y.on('domready', function() {
590+ Y.Test.Runner.run();
591+});
592+
593+});
594
595=== modified file 'lib/lp/bugs/configure.zcml'
596--- lib/lp/bugs/configure.zcml 2011-04-07 18:55:36 +0000
597+++ lib/lp/bugs/configure.zcml 2011-04-13 07:06:21 +0000
598@@ -241,7 +241,8 @@
599 conjoined_slave
600 subscribe
601 getConjoinedMaster
602- findSimilarBugs"/>
603+ findSimilarBugs
604+ getContributorInfo"/>
605 <require
606 permission="launchpad.Edit"
607 attributes="
608
609=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
610--- lib/lp/bugs/interfaces/bugtask.py 2011-03-30 19:04:42 +0000
611+++ lib/lp/bugs/interfaces/bugtask.py 2011-04-13 07:06:21 +0000
612@@ -62,6 +62,7 @@
613 export_write_operation,
614 exported,
615 mutator_for,
616+ operation_for_version,
617 operation_parameters,
618 operation_returns_collection_of,
619 rename_parameters_as,
620@@ -654,6 +655,28 @@
621 def findSimilarBugs(user, limit=10):
622 """Return the list of possible duplicates for this BugTask."""
623
624+ @call_with(user=REQUEST_USER)
625+ @operation_parameters(person=copy_field(assignee))
626+ @export_read_operation()
627+ @operation_for_version("devel")
628+ def getContributorInfo(user, person):
629+ """Is the person a contributor to bugs in this task's pillar?
630+
631+ :param user: The user doing the search. Private bugs that this
632+ user doesn't have access to won't be included in the search.
633+ :param person: The person to check to see if they are a contributor.
634+
635+ Return a dict with the following values:
636+ is_contributor: True if the user has any bugs assigned to him in the
637+ context of this bug task's pillar, either directly or by team
638+ participation.
639+ person_name: the displayname of the person
640+ pillar_name: the displayname of the bug task's pillar
641+
642+ This API call is provided for use by the client Javascript where the
643+ calling context does not have access to the person or pillar names.
644+ """
645+
646 def getConjoinedMaster(bugtasks, bugtasks_by_package=None):
647 """Return the conjoined master in the given bugtasks, if any.
648
649
650=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
651--- lib/lp/bugs/javascript/bugtask_index.js 2011-03-22 05:56:34 +0000
652+++ lib/lp/bugs/javascript/bugtask_index.js 2011-04-13 07:06:21 +0000
653@@ -50,7 +50,7 @@
654 display_privacy_notification();
655 });
656 }
657-
658+
659 /*
660 * Check the page for links related to overlay forms and request the HTML
661 * for these forms.
662@@ -443,7 +443,7 @@
663 Y.one('body').addClass('feature-flag-bugs-private-notification-enabled');
664 /* Set the visible flag so that the content moves down. */
665 Y.one('body').addClass('global-notification-visible');
666-
667+
668 if (Y.one('.global-notification').hasClass('hidden')) {
669 Y.one('.global-notification').addClass('transparent');
670 Y.one('.global-notification').removeClass('hidden');
671@@ -464,12 +464,12 @@
672 duration: 0.2,
673 easing: Y.Easing.easeOut
674 });
675-
676+
677 fade_in.run();
678 body_space.run();
679 login_space.run();
680 }
681-
682+
683 Y.on('click', function(e) {
684 hide_privacy_notification(true);
685 e.halt();
686@@ -509,11 +509,11 @@
687 body_space.on('end', function() {
688 Y.one('body').removeClass('global-notification-visible');
689 });
690-
691+
692 fade_out.run();
693 body_space.run();
694 login_space.run();
695-
696+
697 if (highlight_portlet) {
698 var portlet_colour = new Y.Anim({
699 node: '.portlet.private',
700@@ -521,7 +521,7 @@
701 color: '#fff',
702 backgroundColor:'#8d1f1f'
703 },
704- duration: 0.4,
705+ duration: 0.4
706 });
707 portlet_colour.run();
708 }
709@@ -830,6 +830,56 @@
710 milestone_choice_edit.render();
711 }
712 if (Y.Lang.isValue(assignee_content)) {
713+ // A validation callback called by the picker when the user selects
714+ // an assignee. We check to see if an assignee is a contributor and if
715+ // they are not, the user is asked to confirm their selection.
716+ var validate_assignee = function(picker, value, save_fn, cancel_fn) {
717+ if (value === null) {
718+ return true;
719+ }
720+ var assignee_uri = Y.lp.client.normalize_uri(value.api_uri);
721+ assignee_uri = Y.lp.client.get_absolute_uri(assignee_uri);
722+ var error_handler = new Y.lp.client.ErrorHandler();
723+ error_handler.showError = function(error_msg) {
724+ Y.lp.app.errors.display_error(null, error_msg);
725+ };
726+
727+ var process_contributor_result = function(contributor_info) {
728+ var is_contributor = contributor_info.is_contributor;
729+ if (!is_contributor) {
730+ // Handle assignment to non contributor
731+ var person = Y.Escape.html(contributor_info.person_name);
732+ var pillar = Y.Escape.html(contributor_info.pillar_name);
733+ var yesno_content_template =
734+ "<p>{person_name} did not previously have any " +
735+ "assigned bugs in {pillar}.</p>" +
736+ "<p>Do you really want to assign them to this bug?</p>";
737+ var yesno_content = Y.Lang.substitute(
738+ yesno_content_template,
739+ {person_name: person, pillar: pillar});
740+ Y.lp.app.picker.yesno_save_confirmation(
741+ picker, yesno_content, "Assign", "Choose Again",
742+ save_fn, cancel_fn);
743+ } else {
744+ if (Y.Lang.isFunction(save_fn)) {
745+ save_fn();
746+ }
747+ }
748+ };
749+
750+ var y_config = {
751+ on: {
752+ success: process_contributor_result,
753+ failure: error_handler.getFailureHandler()
754+ },
755+ parameters: {
756+ person: assignee_uri
757+ }
758+ };
759+ lp_client.named_get(
760+ conf.bugtask_path, "getContributorInfo", y_config);
761+ };
762+
763 var step_title =
764 (conf.assignee_vocabulary == 'ValidAssignee') ?
765 "Search for people or teams" :
766@@ -844,7 +894,8 @@
767 "remove_button_text": "Remove assignee",
768 "null_display_value": "Unassigned",
769 "show_remove_button": conf.user_can_unassign,
770- "show_assign_me_button": true});
771+ "show_assign_me_button": true,
772+ "validate_callback": validate_assignee});
773 // Ordinary users can select only themselves and their teams.
774 // Do not show the team selection, if a user is not a member
775 // of any team,
776@@ -1058,6 +1109,6 @@
777 "json-parse", "substitute", "widget-position-ext",
778 "lazr.formoverlay", "lazr.anim", "lazr.base",
779 "lazr.overlay", "lazr.choiceedit", "lp.app.picker",
780- "lp.client",
781+ "lp.client", "escape",
782 "lp.client.plugins", "lp.bugs.bugtask_index.portlets",
783 "lp.bugs.subscriber", "lp.app.errors"]});
784
785=== modified file 'lib/lp/bugs/model/bugtask.py'
786--- lib/lp/bugs/model/bugtask.py 2011-04-01 14:46:21 +0000
787+++ lib/lp/bugs/model/bugtask.py 2011-04-13 07:06:21 +0000
788@@ -658,6 +658,15 @@
789 bugtask.sourcepackagenameID == self.sourcepackagenameID):
790 bugtask.sourcepackagenameID = PassthroughValue(new_spnid)
791
792+ def getContributorInfo(self, user, person):
793+ """See `IBugTask`."""
794+ result = {}
795+ result['is_contributor'] = person.isBugContributorInTarget(
796+ user, self.pillar)
797+ result['person_name'] = person.displayname
798+ result['pillar_name'] = self.pillar.displayname
799+ return result
800+
801 def getConjoinedMaster(self, bugtasks, bugtasks_by_package=None):
802 """See `IBugTask`."""
803 conjoined_master = None
804
805=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
806--- lib/lp/bugs/model/tests/test_bugtask.py 2011-03-30 13:33:35 +0000
807+++ lib/lp/bugs/model/tests/test_bugtask.py 2011-04-13 07:06:21 +0000
808@@ -1355,8 +1355,32 @@
809 self.assertNotIn(BugTaskStatus.UNKNOWN, UNRESOLVED_BUGTASK_STATUSES)
810
811
812-def test_suite():
813- suite = unittest.TestSuite()
814- suite.addTest(unittest.TestLoader().loadTestsFromName(__name__))
815- suite.addTest(DocTestSuite('lp.bugs.model.bugtask'))
816- return suite
817+class TestBugTaskContributor(TestCaseWithFactory):
818+
819+ layer = DatabaseFunctionalLayer
820+
821+ def test_non_contributor(self):
822+ owner = self.factory.makePerson()
823+ bug = self.factory.makeBug(owner=owner)
824+ # Create a person who has not contributed
825+ person = self.factory.makePerson()
826+ result = bug.default_bugtask.getContributorInfo(owner, person)
827+ self.assertFalse(result['is_contributor'])
828+ self.assertEqual(person.displayname, result['person_name'])
829+ self.assertEqual(
830+ bug.default_bugtask.pillar.displayname, result['pillar_name'])
831+
832+ def test_contributor(self):
833+ owner = self.factory.makePerson()
834+ product = self.factory.makeProduct()
835+ bug = self.factory.makeBug(product=product, owner=owner)
836+ bug1 = self.factory.makeBug(product=product, owner=owner)
837+ # Create a person who has contributed
838+ person = self.factory.makePerson()
839+ login('foo.bar@canonical.com')
840+ bug1.default_bugtask.transitionToAssignee(person)
841+ result = bug.default_bugtask.getContributorInfo(owner, person)
842+ self.assertTrue(result['is_contributor'])
843+ self.assertEqual(person.displayname, result['person_name'])
844+ self.assertEqual(
845+ bug.default_bugtask.pillar.displayname, result['pillar_name'])
846
847=== added file 'lib/lp/bugs/windmill/tests/test_bug_inline_assignment.py'
848--- lib/lp/bugs/windmill/tests/test_bug_inline_assignment.py 1970-01-01 00:00:00 +0000
849+++ lib/lp/bugs/windmill/tests/test_bug_inline_assignment.py 2011-04-13 07:06:21 +0000
850@@ -0,0 +1,57 @@
851+# Copyright 2011 Canonical Ltd. This software is licensed under the
852+# GNU Affero General Public License version 3 (see the file LICENSE).
853+
854+from lp.bugs.windmill.testing import BugsWindmillLayer
855+from lp.testing import WindmillTestCase
856+from lp.testing.windmill import lpuser
857+from lp.testing.windmill.constants import (
858+ FOR_ELEMENT,
859+ )
860+
861+
862+class TestInlineAssignment(WindmillTestCase):
863+
864+ layer = BugsWindmillLayer
865+
866+ def test_inline_assignment_non_contributor(self):
867+ """Test assigning bug to a non contributor displays a confirmation."""
868+
869+ import transaction
870+ # Create a person who has not contributed
871+ self.factory.makePerson(name="fred")
872+ transaction.commit()
873+
874+ client, start_url = self.getClientFor(
875+ "/firefox/+bug/1", lpuser.SAMPLE_PERSON)
876+
877+ ASSIGN_BUTTON = (u'//*[@id="affected-software"]//tr//td[5]' +
878+ '//button[contains(@class, "yui3-activator-act")]')
879+ client.waits.forElement(xpath=ASSIGN_BUTTON, timeout=FOR_ELEMENT)
880+ client.click(xpath=ASSIGN_BUTTON)
881+
882+ VISIBLE_PICKER_OVERLAY = (
883+ u'//div[contains(@class, "yui3-picker ") and '
884+ 'not(contains(@class, "yui3-picker-hidden"))]')
885+
886+ client.waits.forElement(
887+ xpath=VISIBLE_PICKER_OVERLAY, timeout=FOR_ELEMENT)
888+
889+ def full_picker_element_xpath(element_path):
890+ return VISIBLE_PICKER_OVERLAY + element_path
891+
892+ client.type(xpath=full_picker_element_xpath(
893+ "//input[@class='yui3-picker-search']"), text='fred')
894+ client.click(xpath=full_picker_element_xpath(
895+ "//div[@class='yui3-picker-search-box']/button"))
896+
897+ PICKER_RESULT = full_picker_element_xpath(
898+ "//ul[@class='yui3-picker-results']/li[1]/span")
899+
900+ client.waits.forElement(xpath=PICKER_RESULT, timeout=FOR_ELEMENT)
901+ client.click(xpath=PICKER_RESULT)
902+
903+ CONFIRMATION = ("//div[@class='important-notice-balloon']")
904+ client.waits.forElement(xpath=CONFIRMATION, timeout=FOR_ELEMENT)
905+ self.client.asserts.assertTextIn(
906+ xpath=CONFIRMATION,
907+ validator="Fred did not previously have any assigned bugs")