Merge lp:~wallyworld/launchpad/assign-non-contributor into lp:launchpad
- assign-non-contributor
- Merge into devel
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 | ||||
Related bugs: |
|
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,
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://
== 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/
This test suite specifically tests the new yes/no validation callback added to the picker:
test_
test_
test_
test_
A unit test was added for the BugContributorView:
TestBugContri
test_
test_
A windmill test was added for the specific use case of in-line bug assignment:
lp/bugs/
= 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/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
./lib/lp/
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/
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.
Robert Collins (lifeless) wrote : | # |
I concur
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.
William Grant (wgrant) wrote : | # |
yesno_save_
animate_
"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_
707 + {person_name: person, pillar: pillar});
test_inline_
Robert Collins (lifeless) wrote : | # |
I thought the plan was 'assign' / 'select again' rather than Y/N ?
Ian Booth (wallyworld) wrote : | # |
> yesno_save_
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_
> 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_
> content_node and called .hide() directly. The four lines of steps_node could
> also be replaced with a single .all('.
>
> "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_
> 707 + {person_name: person, pillar: pillar});
>
Fixed - variable values to be substituted are escaped.
> test_inline_
> s/contributer/
> 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.
William Grant (wgrant) wrote : | # |
> > yesno_save_
>
> 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_
> > 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]
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.
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-
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-
Yes, you are right. I missed that inconsistency. I will change it, thanks.
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.
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.
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
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") |
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.