Merge lp:~wallyworld/launchpad/request-build-popup into lp:launchpad
- request-build-popup
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Ian Booth | ||||
Approved revision: | no longer in the source branch. | ||||
Merged at revision: | 12422 | ||||
Proposed branch: | lp:~wallyworld/launchpad/request-build-popup | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
792 lines (+540/-122) 8 files modified
lib/lp/code/browser/configure.zcml (+8/-1) lib/lp/code/browser/sourcepackagerecipe.py (+104/-37) lib/lp/code/javascript/requestbuild_overlay.js (+214/-0) lib/lp/code/model/sourcepackagerecipe.py (+3/-1) lib/lp/code/templates/sourcepackagerecipe-builds.pt (+79/-0) lib/lp/code/templates/sourcepackagerecipe-index.pt (+33/-83) lib/lp/code/windmill/tests/test_recipe_request_build.py (+96/-0) lib/lp/soyuz/model/archive.py (+3/-0) |
||||
To merge this branch: | bzr merge lp:~wallyworld/launchpad/request-build-popup | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tim Penhey (community) | Approve | ||
Curtis Hovey (community) | ui | Approve | |
Review via email: mp+48864@code.launchpad.net |
Commit message
[r=thumper]
Description of the change
This branch introduces a popup form to allow the user to initiate recipe builds. Previously, a separate page load was used to display a standard html form.
== Implementation ==
The existing SourcePackageRe
- SourcePackageRe
- SourcePackageRe
The base class defines the form schema and provides the validation functionality. The individual subclasses provide slightly different implementations of the submit action, error handling and data rendering.
The html form behaves as previously. It renders the form page and relies on the underlying Launchpad form error handling to display field errors etc.
The ajax form works slightly differently. It renders just the recipe builds table (via a new +builds Zope URL). So when build(s) are requested and everything goes ok, a new version of the builds table is returned and the Javascrip ajax handler re-renders that part of the recipe page, flashing the rows of new builds. If there are errors, the ajax form returns a json data object containing the errors and sets the request status code to 400. This allows the client side Javascript to parse the json data and display the errors on the popup form. At the moment, only a single form wide error message is used, even though the json data contains field level errors. This is a limitation of the lazr.js FormOverlay implementation.
On the client side, the popup form acts modally. Client side validation is done and errors displayed on the form without a back end request - this is used to check that at least on distro series has been selected. Once the submit button is presses, the "please wait" spinner appears on the popup form. If the request went ok, the form disappears the the recipe page updated. If there are errors, the form stays visible and the errors are displayed on the form. This allows the user to correct any issues and try again. The most common case would be a user requesting an identical build to one already pending. They just need to untick that distro series and try again.
A driveby change was to make the pocket parameter of the requestBuild() method on SourcePackageRecipe default to PackagePublishi
== Demo ==
See screenshot: http://
The screenshot shows how an error is displayed, but also gives a good indication of what the form looks like. The form is standard lazr.js functionality.
== QA ==
Simply use the form to initiate some recipe builds and ensure the overall functionality is equivalent to that of the html form.
== Tests ==
A new Windmill test was written to check the form can initiate a build and display errors as required.
bin/test -vvt test_recipe_
== Lint ==
Linting changed files:
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
./lib/lp/
13: unbound prefix
Ian Booth (wallyworld) wrote : | # |
> The check and cross button look wrong. I do not know of any other "popups" or
> "overlays" that use them. I only see them used when editing inline content. I
> do not think they mean submit or cancel. I think users believe they mean done
> editing or cancel editing.
>
They are used on a few other popup forms, mainly in the bugs area. eg Mark a Bug Report as Duplicate etc
> I expect to see a button [Request Build] to submit the form. Overlay forms use
> a [Cancel] button, but I think that contradicts the _Cancel_ rule for page
> forms. Most popups (gosh, maybe all) place the cancel (x) icon in the top
> right corner. There is a rule (maybe in code) that when the (x) cancel icon is
> in the right corner the popup will be dismissed when the user clicks outside
> the popup. Overlays remain on visible when he user clicks outside. I think the
> unfocus rule will help us decide the buttons.
>
> If the popup is dismissed when it loses focus, place the (x) cancel icon in
> the right corner. If it should remain, we use a [Cancel] button. The submit
> button should be [Request Build].
>
> /me is very aware that the NE cancel icon contradicts his unity desktop
> conventions
There's 2 lazr widgets at play here - PrettyOverlay which provides the behaviour where a popup has the close button in the top right corner and is dismissed if the user clicks outside. Then there's FormOverlay which extends PrettyOverlay and wires in form submission behaviour and doesn't wire in the "dismiss when click outside" behaviour since it allows for a proper cancel button as such (ie as used by the bug forms mentioned earlier).
I've stuck with using FormOverlay since it really is a modal form that is required here. The user clicks Request Build and they see a spinner and when the submission has been processed, the form disappears. On the other hand, if there's an error processing the form stays and an error is shown. They can then choose to correct the error and try again or click cancel to dismiss the form. What I did do though is replace the check/cross buttons with standard "Request Builds" and "Cancel" buttons.
http://
What do you think?
Curtis Hovey (sinzui) wrote : | # |
Hi Ian.
Thank you for taking the time to resolve the UI issues. I think this looks good to land.
Tim Penhey (thumper) wrote : | # |
Remove Y.log when loading the module.
Add some comments in the form processing as discussed.
Change SLEEP to FOR_ELEMENT.
Good with these changes.
Preview Diff
1 | === modified file 'lib/lp/code/browser/configure.zcml' | |||
2 | --- lib/lp/code/browser/configure.zcml 2011-01-18 21:46:21 +0000 | |||
3 | +++ lib/lp/code/browser/configure.zcml 2011-02-21 23:04:01 +0000 | |||
4 | @@ -1228,10 +1228,17 @@ | |||
5 | 1228 | <browser:page | 1228 | <browser:page |
6 | 1229 | for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe" | 1229 | for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe" |
7 | 1230 | layer="lp.code.publisher.CodeLayer" | 1230 | layer="lp.code.publisher.CodeLayer" |
9 | 1231 | class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsView" | 1231 | class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsHtmlView" |
10 | 1232 | name="+request-builds" | 1232 | name="+request-builds" |
11 | 1233 | template="../templates/sourcepackagerecipe-request-builds.pt" | 1233 | template="../templates/sourcepackagerecipe-request-builds.pt" |
12 | 1234 | permission="launchpad.AnyPerson"/> | 1234 | permission="launchpad.AnyPerson"/> |
13 | 1235 | <browser:page | ||
14 | 1236 | for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe" | ||
15 | 1237 | layer="lp.code.publisher.CodeLayer" | ||
16 | 1238 | class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsAjaxView" | ||
17 | 1239 | name="+builds" | ||
18 | 1240 | template="../templates/sourcepackagerecipe-builds.pt" | ||
19 | 1241 | permission="launchpad.View"/> | ||
20 | 1235 | </facet> | 1242 | </facet> |
21 | 1236 | <facet facet="branches"> | 1243 | <facet facet="branches"> |
22 | 1237 | <browser:defaultView | 1244 | <browser:defaultView |
23 | 1238 | 1245 | ||
24 | === modified file 'lib/lp/code/browser/sourcepackagerecipe.py' | |||
25 | --- lib/lp/code/browser/sourcepackagerecipe.py 2011-02-18 02:39:47 +0000 | |||
26 | +++ lib/lp/code/browser/sourcepackagerecipe.py 2011-02-21 23:04:01 +0000 | |||
27 | @@ -14,6 +14,8 @@ | |||
28 | 14 | 'SourcePackageRecipeView', | 14 | 'SourcePackageRecipeView', |
29 | 15 | ] | 15 | ] |
30 | 16 | 16 | ||
31 | 17 | import simplejson | ||
32 | 18 | |||
33 | 17 | from bzrlib.plugins.builder.recipe import ( | 19 | from bzrlib.plugins.builder.recipe import ( |
34 | 18 | ForbiddenInstructionError, | 20 | ForbiddenInstructionError, |
35 | 19 | RecipeParseError, | 21 | RecipeParseError, |
36 | @@ -93,7 +95,6 @@ | |||
37 | 93 | MINIMAL_RECIPE_TEXT, | 95 | MINIMAL_RECIPE_TEXT, |
38 | 94 | RECIPE_BETA_FLAG, | 96 | RECIPE_BETA_FLAG, |
39 | 95 | ) | 97 | ) |
40 | 96 | from lp.registry.interfaces.pocket import PackagePublishingPocket | ||
41 | 97 | from lp.services.features import getFeatureFlag | 98 | from lp.services.features import getFeatureFlag |
42 | 98 | from lp.services.propertycache import cachedproperty | 99 | from lp.services.propertycache import cachedproperty |
43 | 99 | from lp.soyuz.model.archive import Archive | 100 | from lp.soyuz.model.archive import Archive |
44 | @@ -216,21 +217,7 @@ | |||
45 | 216 | 217 | ||
46 | 217 | @property | 218 | @property |
47 | 218 | def builds(self): | 219 | def builds(self): |
63 | 219 | """A list of interesting builds. | 220 | return builds_for_recipe(self.context) |
49 | 220 | |||
50 | 221 | All pending builds are shown, as well as 1-5 recent builds. | ||
51 | 222 | Recent builds are ordered by date finished (if completed) or | ||
52 | 223 | date_started (if date finished is not set due to an error building or | ||
53 | 224 | other circumstance which resulted in the build not being completed). | ||
54 | 225 | This allows started but unfinished builds to show up in the view but | ||
55 | 226 | be discarded as more recent builds become available. | ||
56 | 227 | """ | ||
57 | 228 | builds = list(self.context.getPendingBuilds()) | ||
58 | 229 | for build in self.context.getBuilds(): | ||
59 | 230 | builds.append(build) | ||
60 | 231 | if len(builds) >= 5: | ||
61 | 232 | break | ||
62 | 233 | return builds | ||
64 | 234 | 221 | ||
65 | 235 | def dailyBuildWithoutUploadPermission(self): | 222 | def dailyBuildWithoutUploadPermission(self): |
66 | 236 | """Returns true if there are upload permissions to the daily archive. | 223 | """Returns true if there are upload permissions to the daily archive. |
67 | @@ -288,6 +275,24 @@ | |||
68 | 288 | self.context, description, title="") | 275 | self.context, description, title="") |
69 | 289 | 276 | ||
70 | 290 | 277 | ||
71 | 278 | def builds_for_recipe(recipe): | ||
72 | 279 | """A list of interesting builds. | ||
73 | 280 | |||
74 | 281 | All pending builds are shown, as well as 1-5 recent builds. | ||
75 | 282 | Recent builds are ordered by date finished (if completed) or | ||
76 | 283 | date_started (if date finished is not set due to an error building or | ||
77 | 284 | other circumstance which resulted in the build not being completed). | ||
78 | 285 | This allows started but unfinished builds to show up in the view but | ||
79 | 286 | be discarded as more recent builds become available. | ||
80 | 287 | """ | ||
81 | 288 | builds = list(recipe.getPendingBuilds()) | ||
82 | 289 | for build in recipe.getBuilds(): | ||
83 | 290 | builds.append(build) | ||
84 | 291 | if len(builds) >= 5: | ||
85 | 292 | break | ||
86 | 293 | return builds | ||
87 | 294 | |||
88 | 295 | |||
89 | 291 | class SourcePackageRecipeRequestBuildsView(LaunchpadFormView): | 296 | class SourcePackageRecipeRequestBuildsView(LaunchpadFormView): |
90 | 292 | """A view for requesting builds of a SourcePackageRecipe.""" | 297 | """A view for requesting builds of a SourcePackageRecipe.""" |
91 | 293 | 298 | ||
92 | @@ -305,24 +310,20 @@ | |||
93 | 305 | 310 | ||
94 | 306 | class schema(Interface): | 311 | class schema(Interface): |
95 | 307 | """Schema for requesting a build.""" | 312 | """Schema for requesting a build.""" |
96 | 313 | archive = Choice(vocabulary='TargetPPAs', title=u'Archive') | ||
97 | 308 | distros = List( | 314 | distros = List( |
98 | 309 | Choice(vocabulary='BuildableDistroSeries'), | 315 | Choice(vocabulary='BuildableDistroSeries'), |
99 | 310 | title=u'Distribution series') | 316 | title=u'Distribution series') |
100 | 311 | archive = Choice(vocabulary='TargetPPAs', title=u'Archive') | ||
101 | 312 | 317 | ||
102 | 313 | custom_widget('distros', LabeledMultiCheckBoxWidget) | 318 | custom_widget('distros', LabeledMultiCheckBoxWidget) |
103 | 314 | 319 | ||
104 | 315 | @property | ||
105 | 316 | def title(self): | ||
106 | 317 | return 'Request builds for %s' % self.context.name | ||
107 | 318 | |||
108 | 319 | label = title | ||
109 | 320 | |||
110 | 321 | @property | ||
111 | 322 | def cancel_url(self): | ||
112 | 323 | return canonical_url(self.context) | ||
113 | 324 | |||
114 | 325 | def validate(self, data): | 320 | def validate(self, data): |
115 | 321 | distros = data.get('distros', []) | ||
116 | 322 | if not len(distros): | ||
117 | 323 | self.setFieldError('distros', | ||
118 | 324 | "You need to specify at least one distro series for which " | ||
119 | 325 | "to build.") | ||
120 | 326 | return | ||
121 | 326 | over_quota_distroseries = [] | 327 | over_quota_distroseries = [] |
122 | 327 | for distroseries in data['distros']: | 328 | for distroseries in data['distros']: |
123 | 328 | if self.context.isOverQuota(self.user, distroseries): | 329 | if self.context.isOverQuota(self.user, distroseries): |
124 | @@ -333,23 +334,89 @@ | |||
125 | 333 | "You have exceeded today's quota for %s." % | 334 | "You have exceeded today's quota for %s." % |
126 | 334 | ', '.join(over_quota_distroseries)) | 335 | ', '.join(over_quota_distroseries)) |
127 | 335 | 336 | ||
131 | 336 | @action('Request builds', name='request') | 337 | def requestBuild(self, data): |
132 | 337 | def request_action(self, action, data): | 338 | """User action for requesting a number of builds. |
133 | 338 | """User action for requesting a number of builds.""" | 339 | |
134 | 340 | We raise exceptions for most errors but if there's already a pending | ||
135 | 341 | build for a particular distroseries, we simply record that so that | ||
136 | 342 | other builds can ne queued and a message be displayed to the caller. | ||
137 | 343 | """ | ||
138 | 344 | errors = {} | ||
139 | 339 | for distroseries in data['distros']: | 345 | for distroseries in data['distros']: |
140 | 340 | try: | 346 | try: |
141 | 341 | self.context.requestBuild( | 347 | self.context.requestBuild( |
144 | 342 | data['archive'], self.user, distroseries, | 348 | data['archive'], self.user, distroseries, manual=True) |
143 | 343 | PackagePublishingPocket.RELEASE, manual=True) | ||
145 | 344 | except BuildAlreadyPending, e: | 349 | except BuildAlreadyPending, e: |
151 | 345 | self.setFieldError( | 350 | errors['distros'] = ("An identical build is already pending " |
152 | 346 | 'distros', | 351 | "for %s." % e.distroseries) |
153 | 347 | 'An identical build is already pending for %s.' % | 352 | return errors |
154 | 348 | e.distroseries) | 353 | |
155 | 349 | return | 354 | |
156 | 355 | class SourcePackageRecipeRequestBuildsHtmlView( | ||
157 | 356 | SourcePackageRecipeRequestBuildsView): | ||
158 | 357 | """Supports HTML form recipe build requests.""" | ||
159 | 358 | |||
160 | 359 | @property | ||
161 | 360 | def title(self): | ||
162 | 361 | return 'Request builds for %s' % self.context.name | ||
163 | 362 | |||
164 | 363 | label = title | ||
165 | 364 | |||
166 | 365 | @property | ||
167 | 366 | def cancel_url(self): | ||
168 | 367 | return canonical_url(self.context) | ||
169 | 368 | |||
170 | 369 | @action('Request builds', name='request') | ||
171 | 370 | def request_action(self, action, data): | ||
172 | 371 | errors = self.requestBuild(data) | ||
173 | 372 | if errors: | ||
174 | 373 | [self.setFieldError(field, message) | ||
175 | 374 | for (field, message) in errors.items()] | ||
176 | 375 | return | ||
177 | 350 | self.next_url = self.cancel_url | 376 | self.next_url = self.cancel_url |
178 | 351 | 377 | ||
179 | 352 | 378 | ||
180 | 379 | class SourcePackageRecipeRequestBuildsAjaxView( | ||
181 | 380 | SourcePackageRecipeRequestBuildsView): | ||
182 | 381 | """Supports AJAX form recipe build requests.""" | ||
183 | 382 | |||
184 | 383 | def _process_error(self, data, errors, reason): | ||
185 | 384 | """Set up the response and json data to return to the caller.""" | ||
186 | 385 | self.request.response.setStatus(400, reason) | ||
187 | 386 | self.request.response.setHeader('Content-type', 'application/json') | ||
188 | 387 | return simplejson.dumps(errors) | ||
189 | 388 | |||
190 | 389 | def failure(self, action, data, errors): | ||
191 | 390 | """Called by the form if validate() finds any errors. | ||
192 | 391 | |||
193 | 392 | We simply convert the errors to json and return that data to the | ||
194 | 393 | caller for display to the user. | ||
195 | 394 | """ | ||
196 | 395 | return self._process_error(data, self.widget_errors, "Validation") | ||
197 | 396 | |||
198 | 397 | @action('Request builds', name='request', failure=failure) | ||
199 | 398 | def request_action(self, action, data): | ||
200 | 399 | """User action for requesting a number of builds. | ||
201 | 400 | |||
202 | 401 | The failure handler will handle any validation errors. We still need | ||
203 | 402 | to handle errors which may occur when invoking the business logic. | ||
204 | 403 | These "expected" errors are ones which result in a predefined message | ||
205 | 404 | being displayed to the user. If the business method raises an | ||
206 | 405 | unexpected exception, that will be handled using the form's standard | ||
207 | 406 | exception processing mechanism (using response code 500). | ||
208 | 407 | """ | ||
209 | 408 | errors = self.requestBuild(data) | ||
210 | 409 | # If there are errors we return a json data snippet containing the | ||
211 | 410 | # errors instead of rendering the form. These errors are processed | ||
212 | 411 | # by the caller's response handler and displayed to the user. | ||
213 | 412 | if errors: | ||
214 | 413 | return self._process_error(data, errors, "Request Build") | ||
215 | 414 | |||
216 | 415 | @property | ||
217 | 416 | def builds(self): | ||
218 | 417 | return builds_for_recipe(self.context) | ||
219 | 418 | |||
220 | 419 | |||
221 | 353 | class ISourcePackageEditSchema(Interface): | 420 | class ISourcePackageEditSchema(Interface): |
222 | 354 | """Schema for adding or editing a recipe.""" | 421 | """Schema for adding or editing a recipe.""" |
223 | 355 | 422 | ||
224 | 356 | 423 | ||
225 | === added file 'lib/lp/code/javascript/requestbuild_overlay.js' | |||
226 | --- lib/lp/code/javascript/requestbuild_overlay.js 1970-01-01 00:00:00 +0000 | |||
227 | +++ lib/lp/code/javascript/requestbuild_overlay.js 2011-02-21 23:04:01 +0000 | |||
228 | @@ -0,0 +1,214 @@ | |||
229 | 1 | /* Copyright 2011 Canonical Ltd. This software is licensed under the | ||
230 | 2 | * GNU Affero General Public License version 3 (see the file LICENSE). | ||
231 | 3 | * | ||
232 | 4 | * A form overlay that can request builds for a recipe.. | ||
233 | 5 | * | ||
234 | 6 | * @namespace Y.lp.code.recipebuild_overlay | ||
235 | 7 | * @requires dom, node, io-base, lazr.anim, lazr.formoverlay | ||
236 | 8 | */ | ||
237 | 9 | YUI.add('lp.code.requestbuild_overlay', function(Y) { | ||
238 | 10 | |||
239 | 11 | var namespace = Y.namespace('lp.code.requestbuild_overlay'); | ||
240 | 12 | |||
241 | 13 | var lp_client; | ||
242 | 14 | var request_build_overlay = null; | ||
243 | 15 | var response_handler; | ||
244 | 16 | |||
245 | 17 | function set_up_lp_client() { | ||
246 | 18 | if (lp_client === undefined) { | ||
247 | 19 | lp_client = new LP.client.Launchpad(); | ||
248 | 20 | } | ||
249 | 21 | } | ||
250 | 22 | |||
251 | 23 | // This handler is used to process the results of form submission or other | ||
252 | 24 | // such operation (eg get, post). It provides some boiler plate and allows the | ||
253 | 25 | // developer to specify onSuccess and onError hooks. It is quite generic and | ||
254 | 26 | // perhaps could be moved to an infrastructure class. | ||
255 | 27 | |||
256 | 28 | RequestResponseHandler = function () {}; | ||
257 | 29 | RequestResponseHandler.prototype = { | ||
258 | 30 | clearProgressUI: function () {}, | ||
259 | 31 | showError: function (error_msg) {}, | ||
260 | 32 | getErrorHandler: function (errorCallback) { | ||
261 | 33 | var self = this; | ||
262 | 34 | return function (id, response) { | ||
263 | 35 | self.clearProgressUI(); | ||
264 | 36 | // If it was a timeout... | ||
265 | 37 | if (response.status == 503) { | ||
266 | 38 | self.showError( | ||
267 | 39 | 'Timeout error, please try again in a few minutes.'); | ||
268 | 40 | } else { | ||
269 | 41 | if (errorCallback != null) { | ||
270 | 42 | errorCallback(self, id, response); | ||
271 | 43 | } else { | ||
272 | 44 | self.showError(response.responseText); | ||
273 | 45 | } | ||
274 | 46 | } | ||
275 | 47 | }; | ||
276 | 48 | }, | ||
277 | 49 | getSuccessHandler: function (successCallback) { | ||
278 | 50 | var self = this; | ||
279 | 51 | return function (id, response) { | ||
280 | 52 | self.clearProgressUI(); | ||
281 | 53 | successCallback(self, id, response); | ||
282 | 54 | }; | ||
283 | 55 | } | ||
284 | 56 | }; | ||
285 | 57 | |||
286 | 58 | namespace.connect_requestbuild = function() { | ||
287 | 59 | |||
288 | 60 | var request_build_handle = Y.one('#request-builds'); | ||
289 | 61 | request_build_handle.addClass('js-action'); | ||
290 | 62 | request_build_handle.on('click', function(e) { | ||
291 | 63 | e.preventDefault(); | ||
292 | 64 | if (request_build_overlay == null) { | ||
293 | 65 | // Render the form and load the widgets to display | ||
294 | 66 | var recipe_name = LP.client.cache.context['name']; | ||
295 | 67 | request_build_overlay = new Y.lazr.FormOverlay({ | ||
296 | 68 | headerContent: '<h2>Request builds for ' | ||
297 | 69 | + recipe_name + ' </h2>', | ||
298 | 70 | form_submit_button: Y.Node.create( | ||
299 | 71 | '<button type="submit" name="field.actions.request" ' + | ||
300 | 72 | 'value="Request builds">Request Builds</button>'), | ||
301 | 73 | form_cancel_button: Y.Node.create( | ||
302 | 74 | '<button type="button" name="field.actions.cancel" ' + | ||
303 | 75 | '>Cancel</button>'), | ||
304 | 76 | centered: true, | ||
305 | 77 | form_submit_callback: do_request_builds, | ||
306 | 78 | visible: false | ||
307 | 79 | }); | ||
308 | 80 | request_build_overlay.render(); | ||
309 | 81 | } | ||
310 | 82 | request_build_overlay.clearError(); | ||
311 | 83 | var temp_spinner = [ | ||
312 | 84 | '<div id="temp-spinner">', | ||
313 | 85 | '<img src="/@@/spinner"/>Loading...', | ||
314 | 86 | '</div>'].join(''); | ||
315 | 87 | request_build_overlay.form_node.set("innerHTML", temp_spinner); | ||
316 | 88 | request_build_overlay.loadFormContentAndRender('+builds/++form++'); | ||
317 | 89 | request_build_overlay.show(); | ||
318 | 90 | }); | ||
319 | 91 | |||
320 | 92 | // Wire up the processing hooks | ||
321 | 93 | response_handler = new RequestResponseHandler(); | ||
322 | 94 | response_handler.clearProgressUI = function() { | ||
323 | 95 | destroy_temporary_spinner(); | ||
324 | 96 | }; | ||
325 | 97 | response_handler.showError = function(error) { | ||
326 | 98 | request_build_overlay.showError(error); | ||
327 | 99 | Y.log(error); | ||
328 | 100 | }; | ||
329 | 101 | }; | ||
330 | 102 | |||
331 | 103 | /* | ||
332 | 104 | * A function to return the current build records as displayed on the page | ||
333 | 105 | */ | ||
334 | 106 | function harvest_current_build_records() { | ||
335 | 107 | var row_classes = ['package-build', 'binary-build']; | ||
336 | 108 | var builds = new Array(); | ||
337 | 109 | Y.Array.each(row_classes, function(row_class) { | ||
338 | 110 | Y.all('.'+row_class).each(function(row) { | ||
339 | 111 | var row_id = row.getAttribute('id'); | ||
340 | 112 | if (builds.indexOf(row_id)<0) { | ||
341 | 113 | builds.push(row_id); | ||
342 | 114 | } | ||
343 | 115 | }); | ||
344 | 116 | }); | ||
345 | 117 | return builds; | ||
346 | 118 | } | ||
347 | 119 | |||
348 | 120 | /* | ||
349 | 121 | * Perform any client side validation | ||
350 | 122 | * Return: true if data is valid | ||
351 | 123 | */ | ||
352 | 124 | function validate(data) { | ||
353 | 125 | var distros = data['field.distros'] | ||
354 | 126 | if (Y.Object.size(distros) == 0) { | ||
355 | 127 | response_handler.showError("You need to specify at least one " + | ||
356 | 128 | "distro series for which to build."); | ||
357 | 129 | return false; | ||
358 | 130 | } | ||
359 | 131 | return true; | ||
360 | 132 | } | ||
361 | 133 | |||
362 | 134 | /* | ||
363 | 135 | * The form submit function | ||
364 | 136 | */ | ||
365 | 137 | function do_request_builds(data) { | ||
366 | 138 | if (!validate(data)) | ||
367 | 139 | return; | ||
368 | 140 | create_temporary_spinner(); | ||
369 | 141 | var base_url = LP.client.cache.context.web_link; | ||
370 | 142 | var submit_url = base_url+"/+builds"; | ||
371 | 143 | var current_builds = harvest_current_build_records(); | ||
372 | 144 | var y_config = { | ||
373 | 145 | method: "POST", | ||
374 | 146 | headers: {'Accept': 'application/json; application/xhtml'}, | ||
375 | 147 | on: { | ||
376 | 148 | failure: response_handler.getErrorHandler( | ||
377 | 149 | function(handler, id, response) { | ||
378 | 150 | if( response.status >= 500 ) { | ||
379 | 151 | // There's some error content we need to display. | ||
380 | 152 | request_build_overlay.set( | ||
381 | 153 | 'form_content', response.responseText); | ||
382 | 154 | request_build_overlay.get("form_submit_button") | ||
383 | 155 | .setStyle('display', 'none'); | ||
384 | 156 | request_build_overlay.renderUI(); | ||
385 | 157 | //We want to force the form to be re-created | ||
386 | 158 | request_build_overlay = null; | ||
387 | 159 | return; | ||
388 | 160 | } | ||
389 | 161 | var error_info = Y.JSON.parse(response.responseText) | ||
390 | 162 | var errors = []; | ||
391 | 163 | for (var field_name in error_info) | ||
392 | 164 | errors.push(error_info[field_name]); | ||
393 | 165 | handler.showError(errors); | ||
394 | 166 | }), | ||
395 | 167 | success: response_handler.getSuccessHandler( | ||
396 | 168 | function(handler, id, response) { | ||
397 | 169 | request_build_overlay.hide(); | ||
398 | 170 | var target = Y.one('#builds-target'); | ||
399 | 171 | target.set('innerHTML', response.responseText); | ||
400 | 172 | var new_builds = harvest_current_build_records(); | ||
401 | 173 | Y.Array.each(new_builds, function(row_id) { | ||
402 | 174 | if (current_builds.indexOf(row_id)>=0) | ||
403 | 175 | return; | ||
404 | 176 | var row = Y.one('#'+row_id); | ||
405 | 177 | var anim = Y.lazr.anim.green_flash({node: row}); | ||
406 | 178 | anim.run(); | ||
407 | 179 | }); | ||
408 | 180 | }) | ||
409 | 181 | }, | ||
410 | 182 | form: { | ||
411 | 183 | id: request_build_overlay.form_node, | ||
412 | 184 | useDisabled: true | ||
413 | 185 | } | ||
414 | 186 | }; | ||
415 | 187 | Y.io(submit_url, y_config); | ||
416 | 188 | } | ||
417 | 189 | |||
418 | 190 | /* | ||
419 | 191 | * Show the temporary "Requesting..." text | ||
420 | 192 | */ | ||
421 | 193 | function create_temporary_spinner() { | ||
422 | 194 | // Add the temp "Requesting build..." text | ||
423 | 195 | var temp_spinner = Y.Node.create([ | ||
424 | 196 | '<div id="temp-spinner">', | ||
425 | 197 | '<img src="/@@/spinner"/>Requesting build...', | ||
426 | 198 | '</div>'].join('')); | ||
427 | 199 | var request_build_handle = Y.one('.yui3-lazr-formoverlay-actions'); | ||
428 | 200 | request_build_handle.insert(temp_spinner, request_build_handle); | ||
429 | 201 | } | ||
430 | 202 | |||
431 | 203 | /* | ||
432 | 204 | * Destroy the temporary "Requesting..." text | ||
433 | 205 | */ | ||
434 | 206 | function destroy_temporary_spinner() { | ||
435 | 207 | var temp_spinner = Y.one('#temp-spinner'); | ||
436 | 208 | var spinner_parent = temp_spinner.get('parentNode'); | ||
437 | 209 | spinner_parent.removeChild(temp_spinner); | ||
438 | 210 | } | ||
439 | 211 | |||
440 | 212 | }, "0.1", {"requires": [ | ||
441 | 213 | "dom", "node", "io-base", "lazr.anim", "lazr.formoverlay" | ||
442 | 214 | ]}); | ||
443 | 0 | 215 | ||
444 | === modified file 'lib/lp/code/model/sourcepackagerecipe.py' | |||
445 | --- lib/lp/code/model/sourcepackagerecipe.py 2011-02-18 02:39:47 +0000 | |||
446 | +++ lib/lp/code/model/sourcepackagerecipe.py 2011-02-21 23:04:01 +0000 | |||
447 | @@ -70,6 +70,7 @@ | |||
448 | 70 | from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData | 70 | from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData |
449 | 71 | from lp.registry.interfaces.distroseries import IDistroSeriesSet | 71 | from lp.registry.interfaces.distroseries import IDistroSeriesSet |
450 | 72 | from lp.registry.model.distroseries import DistroSeries | 72 | from lp.registry.model.distroseries import DistroSeries |
451 | 73 | from lp.registry.interfaces.pocket import PackagePublishingPocket | ||
452 | 73 | from lp.services.database.stormexpr import Greatest | 74 | from lp.services.database.stormexpr import Greatest |
453 | 74 | from lp.soyuz.interfaces.archive import IArchiveSet | 75 | from lp.soyuz.interfaces.archive import IArchiveSet |
454 | 75 | from lp.soyuz.model.archive import Archive | 76 | from lp.soyuz.model.archive import Archive |
455 | @@ -244,7 +245,8 @@ | |||
456 | 244 | return SourcePackageRecipeBuild.getRecentBuilds( | 245 | return SourcePackageRecipeBuild.getRecentBuilds( |
457 | 245 | requester, self, distroseries).count() >= 5 | 246 | requester, self, distroseries).count() >= 5 |
458 | 246 | 247 | ||
460 | 247 | def requestBuild(self, archive, requester, distroseries, pocket, | 248 | def requestBuild(self, archive, requester, distroseries, |
461 | 249 | pocket=PackagePublishingPocket.RELEASE, | ||
462 | 248 | manual=False): | 250 | manual=False): |
463 | 249 | """See `ISourcePackageRecipe`.""" | 251 | """See `ISourcePackageRecipe`.""" |
464 | 250 | if not archive.is_ppa: | 252 | if not archive.is_ppa: |
465 | 251 | 253 | ||
466 | === added file 'lib/lp/code/templates/sourcepackagerecipe-builds.pt' | |||
467 | --- lib/lp/code/templates/sourcepackagerecipe-builds.pt 1970-01-01 00:00:00 +0000 | |||
468 | +++ lib/lp/code/templates/sourcepackagerecipe-builds.pt 2011-02-21 23:04:01 +0000 | |||
469 | @@ -0,0 +1,79 @@ | |||
470 | 1 | <div id="latest-builds" class="portlet"> | ||
471 | 2 | <h2>Latest builds</h2> | ||
472 | 3 | <table id="latest-builds-listing" class="listing" style='margin-bottom: 1em;'> | ||
473 | 4 | <thead> | ||
474 | 5 | <tr> | ||
475 | 6 | <th>Status</th> | ||
476 | 7 | <th>When complete</th> | ||
477 | 8 | <th>Distribution series</th> | ||
478 | 9 | <th>Archive</th> | ||
479 | 10 | </tr> | ||
480 | 11 | </thead> | ||
481 | 12 | <tbody> | ||
482 | 13 | <tal:recipe-builds repeat="build view/builds"> | ||
483 | 14 | <tal:build-view define="buildview nocall:build/@@+index"> | ||
484 | 15 | <tr class="package-build" tal:attributes="id string:build-${build/url_id}"> | ||
485 | 16 | <td> | ||
486 | 17 | <span tal:replace="structure build/image:icon" /> | ||
487 | 18 | <a tal:content="buildview/status" | ||
488 | 19 | tal:attributes="href build/fmt:url"></a> | ||
489 | 20 | </td> | ||
490 | 21 | <td> | ||
491 | 22 | <tal:date replace="buildview/date/fmt:displaydate" /> | ||
492 | 23 | <tal:estimate condition="buildview/estimate"> | ||
493 | 24 | (estimated) | ||
494 | 25 | </tal:estimate> | ||
495 | 26 | |||
496 | 27 | <tal:build-log define="file build/log" | ||
497 | 28 | tal:condition="file"> | ||
498 | 29 | <a class="sprite download" | ||
499 | 30 | tal:attributes="href build/log_url">buildlog</a> | ||
500 | 31 | (<span tal:replace="file/content/filesize/fmt:bytes" />) | ||
501 | 32 | </tal:build-log> | ||
502 | 33 | </td> | ||
503 | 34 | <td> | ||
504 | 35 | <tal:distro | ||
505 | 36 | replace="structure build/distroseries/fmt:link:mainsite" /> | ||
506 | 37 | </td> | ||
507 | 38 | <td> | ||
508 | 39 | <tal:archive replace="structure build/archive/fmt:link"/> | ||
509 | 40 | </td> | ||
510 | 41 | </tr> | ||
511 | 42 | <tal:binary-builds repeat="binary buildview/binary_builds"> | ||
512 | 43 | <tr tal:define="binaryview nocall:binary/@@+index" | ||
513 | 44 | class="binary-build" tal:attributes="id string:build-${binary/url_id}"> | ||
514 | 45 | <td class="indent"> | ||
515 | 46 | <span tal:replace="structure binary/image:icon"/> | ||
516 | 47 | <a tal:content="binary/source_package_release/title" | ||
517 | 48 | tal:attributes="href binary/fmt:url">package - version</a> | ||
518 | 49 | </td> | ||
519 | 50 | <td> | ||
520 | 51 | <tal:date replace="binaryview/date/fmt:displaydate" /> | ||
521 | 52 | <tal:estimate condition="binaryview/estimate"> | ||
522 | 53 | (estimated) | ||
523 | 54 | </tal:estimate> | ||
524 | 55 | |||
525 | 56 | <tal:build-log define="file binary/log" | ||
526 | 57 | tal:condition="file"> | ||
527 | 58 | <a class="sprite download" | ||
528 | 59 | tal:attributes="href binary/log_url">buildlog</a> | ||
529 | 60 | (<span tal:replace="file/content/filesize/fmt:bytes" />) | ||
530 | 61 | </tal:build-log> | ||
531 | 62 | </td> | ||
532 | 63 | <td class="indent"> | ||
533 | 64 | <a class="sprite distribution" | ||
534 | 65 | tal:define="archseries binary/distro_arch_series" | ||
535 | 66 | tal:attributes="href archseries/fmt:url" | ||
536 | 67 | tal:content="archseries/architecturetag">i386</a> | ||
537 | 68 | </td> | ||
538 | 69 | </tr> | ||
539 | 70 | </tal:binary-builds> | ||
540 | 71 | </tal:build-view> | ||
541 | 72 | </tal:recipe-builds> | ||
542 | 73 | </tbody> | ||
543 | 74 | </table> | ||
544 | 75 | <p tal:condition="not: view/builds"> | ||
545 | 76 | This recipe has not been built yet. | ||
546 | 77 | </p> | ||
547 | 78 | </div> | ||
548 | 79 | |||
549 | 0 | 80 | ||
550 | === modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt' | |||
551 | --- lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-15 02:33:13 +0000 | |||
552 | +++ lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-21 23:04:01 +0000 | |||
553 | @@ -86,91 +86,41 @@ | |||
554 | 86 | </div> | 86 | </div> |
555 | 87 | </div> | 87 | </div> |
556 | 88 | </div> | 88 | </div> |
557 | 89 | |||
558 | 89 | <div class="yui-g"> | 90 | <div class="yui-g"> |
640 | 90 | <div class="portlet"> | 91 | <div id="builds-target" tal:content="structure context/@@+builds" /> |
641 | 91 | <h2>Latest builds</h2> | 92 | </div> |
642 | 92 | <table class="listing" style='margin-bottom: 1em;'> | 93 | <div |
643 | 93 | <thead> | 94 | tal:define="link context/menu:context/request_builds" |
644 | 94 | <tr> | 95 | tal:condition="link/enabled" |
645 | 95 | <th>Status</th> | 96 | > |
646 | 96 | <th>When complete</th> | 97 | <a id="request-builds" |
647 | 97 | <th>Distribution series</th> | 98 | class="sprite add" |
648 | 98 | <th>Archive</th> | 99 | tal:attributes="href link/url" |
649 | 99 | </tr> | 100 | tal:content="link/text" /> |
650 | 100 | </thead> | 101 | </div> |
651 | 101 | <tbody> | 102 | <div class='portlet'> |
652 | 102 | <tal:recipe-builds repeat="build view/builds"> | 103 | <h2>Recipe contents</h2> |
572 | 103 | <tal:build-view define="buildview nocall:build/@@+index"> | ||
573 | 104 | <tr> | ||
574 | 105 | <td> | ||
575 | 106 | <span tal:replace="structure build/image:icon" /> | ||
576 | 107 | <a tal:content="buildview/status" | ||
577 | 108 | tal:attributes="href build/fmt:url"></a> | ||
578 | 109 | </td> | ||
579 | 110 | <td> | ||
580 | 111 | <tal:date replace="buildview/date/fmt:displaydate" /> | ||
581 | 112 | <tal:estimate condition="buildview/estimate"> | ||
582 | 113 | (estimated) | ||
583 | 114 | </tal:estimate> | ||
584 | 115 | |||
585 | 116 | <tal:build-log define="file build/log" | ||
586 | 117 | tal:condition="file"> | ||
587 | 118 | <a class="sprite download" | ||
588 | 119 | tal:attributes="href build/log_url">buildlog</a> | ||
589 | 120 | (<span tal:replace="file/content/filesize/fmt:bytes" />) | ||
590 | 121 | </tal:build-log> | ||
591 | 122 | </td> | ||
592 | 123 | <td> | ||
593 | 124 | <tal:distro | ||
594 | 125 | replace="structure build/distroseries/fmt:link:mainsite" /> | ||
595 | 126 | </td> | ||
596 | 127 | <td> | ||
597 | 128 | <tal:archive replace="structure build/archive/fmt:link"/> | ||
598 | 129 | </td> | ||
599 | 130 | </tr> | ||
600 | 131 | <tal:binary-builds repeat="binary buildview/binary_builds"> | ||
601 | 132 | <tr tal:define="binaryview nocall:binary/@@+index" | ||
602 | 133 | class="binary-build"> | ||
603 | 134 | <td class="indent"> | ||
604 | 135 | <span tal:replace="structure binary/image:icon"/> | ||
605 | 136 | <a tal:content="binary/source_package_release/title" | ||
606 | 137 | tal:attributes="href binary/fmt:url">package - version</a> | ||
607 | 138 | </td> | ||
608 | 139 | <td> | ||
609 | 140 | <tal:date replace="binaryview/date/fmt:displaydate" /> | ||
610 | 141 | <tal:estimate condition="binaryview/estimate"> | ||
611 | 142 | (estimated) | ||
612 | 143 | </tal:estimate> | ||
613 | 144 | |||
614 | 145 | <tal:build-log define="file binary/log" | ||
615 | 146 | tal:condition="file"> | ||
616 | 147 | <a class="sprite download" | ||
617 | 148 | tal:attributes="href binary/log_url">buildlog</a> | ||
618 | 149 | (<span tal:replace="file/content/filesize/fmt:bytes" />) | ||
619 | 150 | </tal:build-log> | ||
620 | 151 | </td> | ||
621 | 152 | <td class="indent"> | ||
622 | 153 | <a class="sprite distribution" | ||
623 | 154 | tal:define="archseries binary/distro_arch_series" | ||
624 | 155 | tal:attributes="href archseries/fmt:url" | ||
625 | 156 | tal:content="archseries/architecturetag">i386</a> | ||
626 | 157 | </td> | ||
627 | 158 | </tr> | ||
628 | 159 | </tal:binary-builds> | ||
629 | 160 | </tal:build-view> | ||
630 | 161 | </tal:recipe-builds> | ||
631 | 162 | </tbody> | ||
632 | 163 | </table> | ||
633 | 164 | <p tal:condition="not: view/builds"> | ||
634 | 165 | This recipe has not been built yet. | ||
635 | 166 | </p> | ||
636 | 167 | <tal:request replace="structure context/menu:context/request_builds/fmt:link" /> | ||
637 | 168 | </div> | ||
638 | 169 | <div class='portlet'> | ||
639 | 170 | <h2>Recipe contents</h2> | ||
653 | 171 | <tal:widget replace="structure view/recipe_text_widget"/> | 104 | <tal:widget replace="structure view/recipe_text_widget"/> |
654 | 172 | </div> | ||
655 | 173 | </div> | ||
656 | 174 | </div> | 105 | </div> |
657 | 106 | |||
658 | 107 | <tal:script> | ||
659 | 108 | <script id='requestbuild-script' type="text/javascript" tal:content="string: | ||
660 | 109 | LPS.use('io-base', 'lp.code.requestbuild_overlay', function(Y) { | ||
661 | 110 | if(Y.UA.ie) { | ||
662 | 111 | return; | ||
663 | 112 | } | ||
664 | 113 | |||
665 | 114 | Y.on('load', function() { | ||
666 | 115 | var logged_in = LP.client.links['me'] !== undefined; | ||
667 | 116 | if (logged_in) { | ||
668 | 117 | Y.lp.code.requestbuild_overlay.connect_requestbuild(); | ||
669 | 118 | } | ||
670 | 119 | }, window); | ||
671 | 120 | });" | ||
672 | 121 | > | ||
673 | 122 | </script> | ||
674 | 123 | </tal:script> | ||
675 | 124 | </div> | ||
676 | 175 | </body> | 125 | </body> |
677 | 176 | </html> | 126 | </html> |
678 | 177 | 127 | ||
679 | === added file 'lib/lp/code/windmill/tests/test_recipe_request_build.py' | |||
680 | --- lib/lp/code/windmill/tests/test_recipe_request_build.py 1970-01-01 00:00:00 +0000 | |||
681 | +++ lib/lp/code/windmill/tests/test_recipe_request_build.py 2011-02-21 23:04:01 +0000 | |||
682 | @@ -0,0 +1,96 @@ | |||
683 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
684 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
685 | 3 | |||
686 | 4 | """Tests for requesting recipe builds.""" | ||
687 | 5 | |||
688 | 6 | __metaclass__ = type | ||
689 | 7 | __all__ = [] | ||
690 | 8 | |||
691 | 9 | import transaction | ||
692 | 10 | from zope.security.proxy import removeSecurityProxy | ||
693 | 11 | |||
694 | 12 | from canonical.launchpad.webapp.publisher import canonical_url | ||
695 | 13 | from lp.testing.windmill.constants import ( | ||
696 | 14 | FOR_ELEMENT, | ||
697 | 15 | PAGE_LOAD, | ||
698 | 16 | SLEEP, | ||
699 | 17 | ) | ||
700 | 18 | from lp.testing.windmill.lpuser import login_person | ||
701 | 19 | from lp.app.browser.tales import PPAFormatterAPI | ||
702 | 20 | from lp.code.windmill.testing import CodeWindmillLayer | ||
703 | 21 | from lp.soyuz.model.processor import ProcessorFamily | ||
704 | 22 | from lp.testing import WindmillTestCase | ||
705 | 23 | |||
706 | 24 | |||
707 | 25 | class TestRecipeBuild(WindmillTestCase): | ||
708 | 26 | """Test setting branch status.""" | ||
709 | 27 | |||
710 | 28 | layer = CodeWindmillLayer | ||
711 | 29 | suite_name = "Request recipe build" | ||
712 | 30 | |||
713 | 31 | def setUp(self): | ||
714 | 32 | super(TestRecipeBuild, self).setUp() | ||
715 | 33 | self.chef = self.factory.makePerson( | ||
716 | 34 | displayname='Master Chef', name='chef', password='test', | ||
717 | 35 | email="chef@example.com") | ||
718 | 36 | self.user = self.chef | ||
719 | 37 | self.ppa = self.factory.makeArchive( | ||
720 | 38 | displayname='Secret PPA', owner=self.chef, name='ppa') | ||
721 | 39 | self.squirrel = self.factory.makeDistroSeries( | ||
722 | 40 | displayname='Secret Squirrel', name='secret', version='100.04', | ||
723 | 41 | distribution=self.ppa.distribution) | ||
724 | 42 | naked_squirrel = removeSecurityProxy(self.squirrel) | ||
725 | 43 | naked_squirrel.nominatedarchindep = self.squirrel.newArch( | ||
726 | 44 | 'i386', ProcessorFamily.get(1), False, self.chef, | ||
727 | 45 | supports_virtualized=True) | ||
728 | 46 | chocolate = self.factory.makeProduct(name='chocolate') | ||
729 | 47 | cake_branch = self.factory.makeProductBranch( | ||
730 | 48 | owner=self.chef, name='cake', product=chocolate) | ||
731 | 49 | self.recipe = self.factory.makeSourcePackageRecipe( | ||
732 | 50 | owner=self.chef, distroseries=self.squirrel, name=u'cake_recipe', | ||
733 | 51 | description=u'This recipe builds a foo for disto bar, with my' | ||
734 | 52 | ' Secret Squirrel changes.', branches=[cake_branch], | ||
735 | 53 | daily_build_archive=self.ppa) | ||
736 | 54 | transaction.commit() | ||
737 | 55 | login_person(self.chef, "chef@example.com", "test", self.client) | ||
738 | 56 | |||
739 | 57 | def makeRecipeBuild(self): | ||
740 | 58 | """Create and return a specific recipe.""" | ||
741 | 59 | build = self.factory.makeSourcePackageRecipeBuild(recipe=self.recipe) | ||
742 | 60 | return build | ||
743 | 61 | |||
744 | 62 | def test_recipe_build_request(self): | ||
745 | 63 | """Request a recipe build.""" | ||
746 | 64 | |||
747 | 65 | client = self.client | ||
748 | 66 | client.open(url=canonical_url(self.recipe)) | ||
749 | 67 | client.waits.forElement( | ||
750 | 68 | id=u'request-builds', timeout=PAGE_LOAD) | ||
751 | 69 | |||
752 | 70 | # Request a new build. | ||
753 | 71 | client.click(id=u'request-builds') | ||
754 | 72 | client.waits.forElement(id=u'field.archive') | ||
755 | 73 | client.click(name=u'field.actions.request') | ||
756 | 74 | |||
757 | 75 | # Ensure it shows up. | ||
758 | 76 | client.waits.forElement( | ||
759 | 77 | xpath = (u'//tr[contains(@class, "package-build")]/td[4]' | ||
760 | 78 | '/a[@href="%s"]') % PPAFormatterAPI(self.ppa).url(), | ||
761 | 79 | timeout=FOR_ELEMENT) | ||
762 | 80 | |||
763 | 81 | # And try the same one again. | ||
764 | 82 | client.click(id=u'request-builds') | ||
765 | 83 | client.waits.forElement(id=u'field.archive') | ||
766 | 84 | client.click(name=u'field.actions.request') | ||
767 | 85 | |||
768 | 86 | # And check that there's an error. | ||
769 | 87 | client.waits.forElement( | ||
770 | 88 | xpath = ( | ||
771 | 89 | u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]' | ||
772 | 90 | '/ul/li'), timeout=FOR_ELEMENT) | ||
773 | 91 | client.asserts.assertText( | ||
774 | 92 | xpath = ( | ||
775 | 93 | u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]' | ||
776 | 94 | '/ul/li'), | ||
777 | 95 | validator=u'An identical build is already pending for %s %s.' | ||
778 | 96 | % (self.ppa.distribution.name, self.squirrel.name)) | ||
779 | 0 | 97 | ||
780 | === modified file 'lib/lp/soyuz/model/archive.py' | |||
781 | --- lib/lp/soyuz/model/archive.py 2011-02-04 09:12:13 +0000 | |||
782 | +++ lib/lp/soyuz/model/archive.py 2011-02-21 23:04:01 +0000 | |||
783 | @@ -1969,6 +1969,9 @@ | |||
784 | 1969 | # Avoiding circular imports. | 1969 | # Avoiding circular imports. |
785 | 1970 | from lp.soyuz.model.archivepermission import ArchivePermission | 1970 | from lp.soyuz.model.archivepermission import ArchivePermission |
786 | 1971 | 1971 | ||
787 | 1972 | # If there's no user logged in, then there's no archives. | ||
788 | 1973 | if user is None: | ||
789 | 1974 | return [] | ||
790 | 1972 | store = Store.of(user) | 1975 | store = Store.of(user) |
791 | 1973 | direct_membership = store.find( | 1976 | direct_membership = store.find( |
792 | 1974 | Archive, | 1977 | Archive, |
The check and cross button look wrong. I do not know of any other "popups" or "overlays" that use them. I only see them used when editing inline content. I do not think they mean submit or cancel. I think users believe they mean done editing or cancel editing.
I expect to see a button [Request Build] to submit the form. Overlay forms use a [Cancel] button, but I think that contradicts the _Cancel_ rule for page forms. Most popups (gosh, maybe all) place the cancel (x) icon in the top right corner. There is a rule (maybe in code) that when the (x) cancel icon is in the right corner the popup will be dismissed when the user clicks outside the popup. Overlays remain on visible when he user clicks outside. I think the unfocus rule will help us decide the buttons.
If the popup is dismissed when it loses focus, place the (x) cancel icon in the right corner. If it should remain, we use a [Cancel] button. The submit button should be [Request Build].
/me is very aware that the NE cancel icon contradicts his unity desktop conventions