Merge lp:~wallyworld/launchpad/request-build-popup into lp:launchpad

Proposed by Ian Booth
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
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][ui=sinzui][bug=673519] When requesting recipe builds, use a popup form if Javascript is enabled instead of an html form which requires extra page loads.

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 SourcePackageRecipeRequestBuildsView was used as a base class and new subclasses introduced:
- SourcePackageRecipeRequestBuildsHtmlView
- SourcePackageRecipeRequestBuildsAjaxView

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 PackagePublishingPocket.RELEASE

== Demo ==

See screenshot: http://people.canonical.com/~ianb/request-build-error.png

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_request_build

== Lint ==

Linting changed files:
  lib/lp/code/browser/configure.zcml
  lib/lp/code/browser/sourcepackagerecipe.py
  lib/lp/code/javascript/requestbuild_overlay.js
  lib/lp/code/model/sourcepackagerecipe.py
  lib/lp/code/templates/sourcepackagerecipe-builds.pt
  lib/lp/code/templates/sourcepackagerecipe-index.pt
  lib/lp/code/windmill/tests/test_recipe_request_build.py

./lib/lp/code/templates/sourcepackagerecipe-builds.pt
      13: unbound prefix

To post a comment you must log in.
Curtis Hovey (sinzui) 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.

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

review: Needs Fixing (ui)
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://people.canonical.com/~ianb/request-build-error1.png

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.

review: Approve (ui)
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.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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 <browser:page
6 for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"
7 layer="lp.code.publisher.CodeLayer"
8- class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsView"
9+ class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsHtmlView"
10 name="+request-builds"
11 template="../templates/sourcepackagerecipe-request-builds.pt"
12 permission="launchpad.AnyPerson"/>
13+ <browser:page
14+ for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"
15+ layer="lp.code.publisher.CodeLayer"
16+ class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsAjaxView"
17+ name="+builds"
18+ template="../templates/sourcepackagerecipe-builds.pt"
19+ permission="launchpad.View"/>
20 </facet>
21 <facet facet="branches">
22 <browser:defaultView
23
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 'SourcePackageRecipeView',
29 ]
30
31+import simplejson
32+
33 from bzrlib.plugins.builder.recipe import (
34 ForbiddenInstructionError,
35 RecipeParseError,
36@@ -93,7 +95,6 @@
37 MINIMAL_RECIPE_TEXT,
38 RECIPE_BETA_FLAG,
39 )
40-from lp.registry.interfaces.pocket import PackagePublishingPocket
41 from lp.services.features import getFeatureFlag
42 from lp.services.propertycache import cachedproperty
43 from lp.soyuz.model.archive import Archive
44@@ -216,21 +217,7 @@
45
46 @property
47 def builds(self):
48- """A list of interesting builds.
49-
50- All pending builds are shown, as well as 1-5 recent builds.
51- Recent builds are ordered by date finished (if completed) or
52- date_started (if date finished is not set due to an error building or
53- other circumstance which resulted in the build not being completed).
54- This allows started but unfinished builds to show up in the view but
55- be discarded as more recent builds become available.
56- """
57- builds = list(self.context.getPendingBuilds())
58- for build in self.context.getBuilds():
59- builds.append(build)
60- if len(builds) >= 5:
61- break
62- return builds
63+ return builds_for_recipe(self.context)
64
65 def dailyBuildWithoutUploadPermission(self):
66 """Returns true if there are upload permissions to the daily archive.
67@@ -288,6 +275,24 @@
68 self.context, description, title="")
69
70
71+def builds_for_recipe(recipe):
72+ """A list of interesting builds.
73+
74+ All pending builds are shown, as well as 1-5 recent builds.
75+ Recent builds are ordered by date finished (if completed) or
76+ date_started (if date finished is not set due to an error building or
77+ other circumstance which resulted in the build not being completed).
78+ This allows started but unfinished builds to show up in the view but
79+ be discarded as more recent builds become available.
80+ """
81+ builds = list(recipe.getPendingBuilds())
82+ for build in recipe.getBuilds():
83+ builds.append(build)
84+ if len(builds) >= 5:
85+ break
86+ return builds
87+
88+
89 class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):
90 """A view for requesting builds of a SourcePackageRecipe."""
91
92@@ -305,24 +310,20 @@
93
94 class schema(Interface):
95 """Schema for requesting a build."""
96+ archive = Choice(vocabulary='TargetPPAs', title=u'Archive')
97 distros = List(
98 Choice(vocabulary='BuildableDistroSeries'),
99 title=u'Distribution series')
100- archive = Choice(vocabulary='TargetPPAs', title=u'Archive')
101
102 custom_widget('distros', LabeledMultiCheckBoxWidget)
103
104- @property
105- def title(self):
106- return 'Request builds for %s' % self.context.name
107-
108- label = title
109-
110- @property
111- def cancel_url(self):
112- return canonical_url(self.context)
113-
114 def validate(self, data):
115+ distros = data.get('distros', [])
116+ if not len(distros):
117+ self.setFieldError('distros',
118+ "You need to specify at least one distro series for which "
119+ "to build.")
120+ return
121 over_quota_distroseries = []
122 for distroseries in data['distros']:
123 if self.context.isOverQuota(self.user, distroseries):
124@@ -333,23 +334,89 @@
125 "You have exceeded today's quota for %s." %
126 ', '.join(over_quota_distroseries))
127
128- @action('Request builds', name='request')
129- def request_action(self, action, data):
130- """User action for requesting a number of builds."""
131+ def requestBuild(self, data):
132+ """User action for requesting a number of builds.
133+
134+ We raise exceptions for most errors but if there's already a pending
135+ build for a particular distroseries, we simply record that so that
136+ other builds can ne queued and a message be displayed to the caller.
137+ """
138+ errors = {}
139 for distroseries in data['distros']:
140 try:
141 self.context.requestBuild(
142- data['archive'], self.user, distroseries,
143- PackagePublishingPocket.RELEASE, manual=True)
144+ data['archive'], self.user, distroseries, manual=True)
145 except BuildAlreadyPending, e:
146- self.setFieldError(
147- 'distros',
148- 'An identical build is already pending for %s.' %
149- e.distroseries)
150- return
151+ errors['distros'] = ("An identical build is already pending "
152+ "for %s." % e.distroseries)
153+ return errors
154+
155+
156+class SourcePackageRecipeRequestBuildsHtmlView(
157+ SourcePackageRecipeRequestBuildsView):
158+ """Supports HTML form recipe build requests."""
159+
160+ @property
161+ def title(self):
162+ return 'Request builds for %s' % self.context.name
163+
164+ label = title
165+
166+ @property
167+ def cancel_url(self):
168+ return canonical_url(self.context)
169+
170+ @action('Request builds', name='request')
171+ def request_action(self, action, data):
172+ errors = self.requestBuild(data)
173+ if errors:
174+ [self.setFieldError(field, message)
175+ for (field, message) in errors.items()]
176+ return
177 self.next_url = self.cancel_url
178
179
180+class SourcePackageRecipeRequestBuildsAjaxView(
181+ SourcePackageRecipeRequestBuildsView):
182+ """Supports AJAX form recipe build requests."""
183+
184+ def _process_error(self, data, errors, reason):
185+ """Set up the response and json data to return to the caller."""
186+ self.request.response.setStatus(400, reason)
187+ self.request.response.setHeader('Content-type', 'application/json')
188+ return simplejson.dumps(errors)
189+
190+ def failure(self, action, data, errors):
191+ """Called by the form if validate() finds any errors.
192+
193+ We simply convert the errors to json and return that data to the
194+ caller for display to the user.
195+ """
196+ return self._process_error(data, self.widget_errors, "Validation")
197+
198+ @action('Request builds', name='request', failure=failure)
199+ def request_action(self, action, data):
200+ """User action for requesting a number of builds.
201+
202+ The failure handler will handle any validation errors. We still need
203+ to handle errors which may occur when invoking the business logic.
204+ These "expected" errors are ones which result in a predefined message
205+ being displayed to the user. If the business method raises an
206+ unexpected exception, that will be handled using the form's standard
207+ exception processing mechanism (using response code 500).
208+ """
209+ errors = self.requestBuild(data)
210+ # If there are errors we return a json data snippet containing the
211+ # errors instead of rendering the form. These errors are processed
212+ # by the caller's response handler and displayed to the user.
213+ if errors:
214+ return self._process_error(data, errors, "Request Build")
215+
216+ @property
217+ def builds(self):
218+ return builds_for_recipe(self.context)
219+
220+
221 class ISourcePackageEditSchema(Interface):
222 """Schema for adding or editing a recipe."""
223
224
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+/* Copyright 2011 Canonical Ltd. This software is licensed under the
230+ * GNU Affero General Public License version 3 (see the file LICENSE).
231+ *
232+ * A form overlay that can request builds for a recipe..
233+ *
234+ * @namespace Y.lp.code.recipebuild_overlay
235+ * @requires dom, node, io-base, lazr.anim, lazr.formoverlay
236+ */
237+YUI.add('lp.code.requestbuild_overlay', function(Y) {
238+
239+var namespace = Y.namespace('lp.code.requestbuild_overlay');
240+
241+var lp_client;
242+var request_build_overlay = null;
243+var response_handler;
244+
245+function set_up_lp_client() {
246+ if (lp_client === undefined) {
247+ lp_client = new LP.client.Launchpad();
248+ }
249+}
250+
251+// This handler is used to process the results of form submission or other
252+// such operation (eg get, post). It provides some boiler plate and allows the
253+// developer to specify onSuccess and onError hooks. It is quite generic and
254+// perhaps could be moved to an infrastructure class.
255+
256+RequestResponseHandler = function () {};
257+RequestResponseHandler.prototype = {
258+ clearProgressUI: function () {},
259+ showError: function (error_msg) {},
260+ getErrorHandler: function (errorCallback) {
261+ var self = this;
262+ return function (id, response) {
263+ self.clearProgressUI();
264+ // If it was a timeout...
265+ if (response.status == 503) {
266+ self.showError(
267+ 'Timeout error, please try again in a few minutes.');
268+ } else {
269+ if (errorCallback != null) {
270+ errorCallback(self, id, response);
271+ } else {
272+ self.showError(response.responseText);
273+ }
274+ }
275+ };
276+ },
277+ getSuccessHandler: function (successCallback) {
278+ var self = this;
279+ return function (id, response) {
280+ self.clearProgressUI();
281+ successCallback(self, id, response);
282+ };
283+ }
284+};
285+
286+namespace.connect_requestbuild = function() {
287+
288+ var request_build_handle = Y.one('#request-builds');
289+ request_build_handle.addClass('js-action');
290+ request_build_handle.on('click', function(e) {
291+ e.preventDefault();
292+ if (request_build_overlay == null) {
293+ // Render the form and load the widgets to display
294+ var recipe_name = LP.client.cache.context['name'];
295+ request_build_overlay = new Y.lazr.FormOverlay({
296+ headerContent: '<h2>Request builds for '
297+ + recipe_name + ' </h2>',
298+ form_submit_button: Y.Node.create(
299+ '<button type="submit" name="field.actions.request" ' +
300+ 'value="Request builds">Request Builds</button>'),
301+ form_cancel_button: Y.Node.create(
302+ '<button type="button" name="field.actions.cancel" ' +
303+ '>Cancel</button>'),
304+ centered: true,
305+ form_submit_callback: do_request_builds,
306+ visible: false
307+ });
308+ request_build_overlay.render();
309+ }
310+ request_build_overlay.clearError();
311+ var temp_spinner = [
312+ '<div id="temp-spinner">',
313+ '<img src="/@@/spinner"/>Loading...',
314+ '</div>'].join('');
315+ request_build_overlay.form_node.set("innerHTML", temp_spinner);
316+ request_build_overlay.loadFormContentAndRender('+builds/++form++');
317+ request_build_overlay.show();
318+ });
319+
320+ // Wire up the processing hooks
321+ response_handler = new RequestResponseHandler();
322+ response_handler.clearProgressUI = function() {
323+ destroy_temporary_spinner();
324+ };
325+ response_handler.showError = function(error) {
326+ request_build_overlay.showError(error);
327+ Y.log(error);
328+ };
329+};
330+
331+/*
332+ * A function to return the current build records as displayed on the page
333+ */
334+function harvest_current_build_records() {
335+ var row_classes = ['package-build', 'binary-build'];
336+ var builds = new Array();
337+ Y.Array.each(row_classes, function(row_class) {
338+ Y.all('.'+row_class).each(function(row) {
339+ var row_id = row.getAttribute('id');
340+ if (builds.indexOf(row_id)<0) {
341+ builds.push(row_id);
342+ }
343+ });
344+ });
345+ return builds;
346+}
347+
348+/*
349+ * Perform any client side validation
350+ * Return: true if data is valid
351+ */
352+function validate(data) {
353+ var distros = data['field.distros']
354+ if (Y.Object.size(distros) == 0) {
355+ response_handler.showError("You need to specify at least one " +
356+ "distro series for which to build.");
357+ return false;
358+ }
359+ return true;
360+}
361+
362+/*
363+ * The form submit function
364+ */
365+function do_request_builds(data) {
366+ if (!validate(data))
367+ return;
368+ create_temporary_spinner();
369+ var base_url = LP.client.cache.context.web_link;
370+ var submit_url = base_url+"/+builds";
371+ var current_builds = harvest_current_build_records();
372+ var y_config = {
373+ method: "POST",
374+ headers: {'Accept': 'application/json; application/xhtml'},
375+ on: {
376+ failure: response_handler.getErrorHandler(
377+ function(handler, id, response) {
378+ if( response.status >= 500 ) {
379+ // There's some error content we need to display.
380+ request_build_overlay.set(
381+ 'form_content', response.responseText);
382+ request_build_overlay.get("form_submit_button")
383+ .setStyle('display', 'none');
384+ request_build_overlay.renderUI();
385+ //We want to force the form to be re-created
386+ request_build_overlay = null;
387+ return;
388+ }
389+ var error_info = Y.JSON.parse(response.responseText)
390+ var errors = [];
391+ for (var field_name in error_info)
392+ errors.push(error_info[field_name]);
393+ handler.showError(errors);
394+ }),
395+ success: response_handler.getSuccessHandler(
396+ function(handler, id, response) {
397+ request_build_overlay.hide();
398+ var target = Y.one('#builds-target');
399+ target.set('innerHTML', response.responseText);
400+ var new_builds = harvest_current_build_records();
401+ Y.Array.each(new_builds, function(row_id) {
402+ if (current_builds.indexOf(row_id)>=0)
403+ return;
404+ var row = Y.one('#'+row_id);
405+ var anim = Y.lazr.anim.green_flash({node: row});
406+ anim.run();
407+ });
408+ })
409+ },
410+ form: {
411+ id: request_build_overlay.form_node,
412+ useDisabled: true
413+ }
414+ };
415+ Y.io(submit_url, y_config);
416+}
417+
418+/*
419+ * Show the temporary "Requesting..." text
420+ */
421+function create_temporary_spinner() {
422+ // Add the temp "Requesting build..." text
423+ var temp_spinner = Y.Node.create([
424+ '<div id="temp-spinner">',
425+ '<img src="/@@/spinner"/>Requesting build...',
426+ '</div>'].join(''));
427+ var request_build_handle = Y.one('.yui3-lazr-formoverlay-actions');
428+ request_build_handle.insert(temp_spinner, request_build_handle);
429+}
430+
431+/*
432+ * Destroy the temporary "Requesting..." text
433+ */
434+function destroy_temporary_spinner() {
435+ var temp_spinner = Y.one('#temp-spinner');
436+ var spinner_parent = temp_spinner.get('parentNode');
437+ spinner_parent.removeChild(temp_spinner);
438+}
439+
440+}, "0.1", {"requires": [
441+ "dom", "node", "io-base", "lazr.anim", "lazr.formoverlay"
442+ ]});
443
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 from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData
449 from lp.registry.interfaces.distroseries import IDistroSeriesSet
450 from lp.registry.model.distroseries import DistroSeries
451+from lp.registry.interfaces.pocket import PackagePublishingPocket
452 from lp.services.database.stormexpr import Greatest
453 from lp.soyuz.interfaces.archive import IArchiveSet
454 from lp.soyuz.model.archive import Archive
455@@ -244,7 +245,8 @@
456 return SourcePackageRecipeBuild.getRecentBuilds(
457 requester, self, distroseries).count() >= 5
458
459- def requestBuild(self, archive, requester, distroseries, pocket,
460+ def requestBuild(self, archive, requester, distroseries,
461+ pocket=PackagePublishingPocket.RELEASE,
462 manual=False):
463 """See `ISourcePackageRecipe`."""
464 if not archive.is_ppa:
465
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+<div id="latest-builds" class="portlet">
471+<h2>Latest builds</h2>
472+<table id="latest-builds-listing" class="listing" style='margin-bottom: 1em;'>
473+ <thead>
474+ <tr>
475+ <th>Status</th>
476+ <th>When complete</th>
477+ <th>Distribution series</th>
478+ <th>Archive</th>
479+ </tr>
480+ </thead>
481+ <tbody>
482+ <tal:recipe-builds repeat="build view/builds">
483+ <tal:build-view define="buildview nocall:build/@@+index">
484+ <tr class="package-build" tal:attributes="id string:build-${build/url_id}">
485+ <td>
486+ <span tal:replace="structure build/image:icon" />
487+ <a tal:content="buildview/status"
488+ tal:attributes="href build/fmt:url"></a>
489+ </td>
490+ <td>
491+ <tal:date replace="buildview/date/fmt:displaydate" />
492+ <tal:estimate condition="buildview/estimate">
493+ (estimated)
494+ </tal:estimate>
495+
496+ <tal:build-log define="file build/log"
497+ tal:condition="file">
498+ <a class="sprite download"
499+ tal:attributes="href build/log_url">buildlog</a>
500+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
501+ </tal:build-log>
502+ </td>
503+ <td>
504+ <tal:distro
505+ replace="structure build/distroseries/fmt:link:mainsite" />
506+ </td>
507+ <td>
508+ <tal:archive replace="structure build/archive/fmt:link"/>
509+ </td>
510+ </tr>
511+ <tal:binary-builds repeat="binary buildview/binary_builds">
512+ <tr tal:define="binaryview nocall:binary/@@+index"
513+ class="binary-build" tal:attributes="id string:build-${binary/url_id}">
514+ <td class="indent">
515+ <span tal:replace="structure binary/image:icon"/>
516+ <a tal:content="binary/source_package_release/title"
517+ tal:attributes="href binary/fmt:url">package - version</a>
518+ </td>
519+ <td>
520+ <tal:date replace="binaryview/date/fmt:displaydate" />
521+ <tal:estimate condition="binaryview/estimate">
522+ (estimated)
523+ </tal:estimate>
524+
525+ <tal:build-log define="file binary/log"
526+ tal:condition="file">
527+ <a class="sprite download"
528+ tal:attributes="href binary/log_url">buildlog</a>
529+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
530+ </tal:build-log>
531+ </td>
532+ <td class="indent">
533+ <a class="sprite distribution"
534+ tal:define="archseries binary/distro_arch_series"
535+ tal:attributes="href archseries/fmt:url"
536+ tal:content="archseries/architecturetag">i386</a>
537+ </td>
538+ </tr>
539+ </tal:binary-builds>
540+ </tal:build-view>
541+ </tal:recipe-builds>
542+ </tbody>
543+</table>
544+<p tal:condition="not: view/builds">
545+ This recipe has not been built yet.
546+</p>
547+</div>
548+
549
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 </div>
555 </div>
556 </div>
557+
558 <div class="yui-g">
559- <div class="portlet">
560- <h2>Latest builds</h2>
561- <table class="listing" style='margin-bottom: 1em;'>
562- <thead>
563- <tr>
564- <th>Status</th>
565- <th>When complete</th>
566- <th>Distribution series</th>
567- <th>Archive</th>
568- </tr>
569- </thead>
570- <tbody>
571- <tal:recipe-builds repeat="build view/builds">
572- <tal:build-view define="buildview nocall:build/@@+index">
573- <tr>
574- <td>
575- <span tal:replace="structure build/image:icon" />
576- <a tal:content="buildview/status"
577- tal:attributes="href build/fmt:url"></a>
578- </td>
579- <td>
580- <tal:date replace="buildview/date/fmt:displaydate" />
581- <tal:estimate condition="buildview/estimate">
582- (estimated)
583- </tal:estimate>
584-
585- <tal:build-log define="file build/log"
586- tal:condition="file">
587- <a class="sprite download"
588- tal:attributes="href build/log_url">buildlog</a>
589- (<span tal:replace="file/content/filesize/fmt:bytes" />)
590- </tal:build-log>
591- </td>
592- <td>
593- <tal:distro
594- replace="structure build/distroseries/fmt:link:mainsite" />
595- </td>
596- <td>
597- <tal:archive replace="structure build/archive/fmt:link"/>
598- </td>
599- </tr>
600- <tal:binary-builds repeat="binary buildview/binary_builds">
601- <tr tal:define="binaryview nocall:binary/@@+index"
602- class="binary-build">
603- <td class="indent">
604- <span tal:replace="structure binary/image:icon"/>
605- <a tal:content="binary/source_package_release/title"
606- tal:attributes="href binary/fmt:url">package - version</a>
607- </td>
608- <td>
609- <tal:date replace="binaryview/date/fmt:displaydate" />
610- <tal:estimate condition="binaryview/estimate">
611- (estimated)
612- </tal:estimate>
613-
614- <tal:build-log define="file binary/log"
615- tal:condition="file">
616- <a class="sprite download"
617- tal:attributes="href binary/log_url">buildlog</a>
618- (<span tal:replace="file/content/filesize/fmt:bytes" />)
619- </tal:build-log>
620- </td>
621- <td class="indent">
622- <a class="sprite distribution"
623- tal:define="archseries binary/distro_arch_series"
624- tal:attributes="href archseries/fmt:url"
625- tal:content="archseries/architecturetag">i386</a>
626- </td>
627- </tr>
628- </tal:binary-builds>
629- </tal:build-view>
630- </tal:recipe-builds>
631- </tbody>
632- </table>
633- <p tal:condition="not: view/builds">
634- This recipe has not been built yet.
635- </p>
636- <tal:request replace="structure context/menu:context/request_builds/fmt:link" />
637- </div>
638- <div class='portlet'>
639- <h2>Recipe contents</h2>
640+ <div id="builds-target" tal:content="structure context/@@+builds" />
641+ </div>
642+ <div
643+ tal:define="link context/menu:context/request_builds"
644+ tal:condition="link/enabled"
645+ >
646+ <a id="request-builds"
647+ class="sprite add"
648+ tal:attributes="href link/url"
649+ tal:content="link/text" />
650+ </div>
651+ <div class='portlet'>
652+ <h2>Recipe contents</h2>
653 <tal:widget replace="structure view/recipe_text_widget"/>
654- </div>
655- </div>
656 </div>
657+
658+ <tal:script>
659+ <script id='requestbuild-script' type="text/javascript" tal:content="string:
660+ LPS.use('io-base', 'lp.code.requestbuild_overlay', function(Y) {
661+ if(Y.UA.ie) {
662+ return;
663+ }
664+
665+ Y.on('load', function() {
666+ var logged_in = LP.client.links['me'] !== undefined;
667+ if (logged_in) {
668+ Y.lp.code.requestbuild_overlay.connect_requestbuild();
669+ }
670+ }, window);
671+ });"
672+ >
673+ </script>
674+ </tal:script>
675+</div>
676 </body>
677 </html>
678
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+# Copyright 2011 Canonical Ltd. This software is licensed under the
684+# GNU Affero General Public License version 3 (see the file LICENSE).
685+
686+"""Tests for requesting recipe builds."""
687+
688+__metaclass__ = type
689+__all__ = []
690+
691+import transaction
692+from zope.security.proxy import removeSecurityProxy
693+
694+from canonical.launchpad.webapp.publisher import canonical_url
695+from lp.testing.windmill.constants import (
696+ FOR_ELEMENT,
697+ PAGE_LOAD,
698+ SLEEP,
699+ )
700+from lp.testing.windmill.lpuser import login_person
701+from lp.app.browser.tales import PPAFormatterAPI
702+from lp.code.windmill.testing import CodeWindmillLayer
703+from lp.soyuz.model.processor import ProcessorFamily
704+from lp.testing import WindmillTestCase
705+
706+
707+class TestRecipeBuild(WindmillTestCase):
708+ """Test setting branch status."""
709+
710+ layer = CodeWindmillLayer
711+ suite_name = "Request recipe build"
712+
713+ def setUp(self):
714+ super(TestRecipeBuild, self).setUp()
715+ self.chef = self.factory.makePerson(
716+ displayname='Master Chef', name='chef', password='test',
717+ email="chef@example.com")
718+ self.user = self.chef
719+ self.ppa = self.factory.makeArchive(
720+ displayname='Secret PPA', owner=self.chef, name='ppa')
721+ self.squirrel = self.factory.makeDistroSeries(
722+ displayname='Secret Squirrel', name='secret', version='100.04',
723+ distribution=self.ppa.distribution)
724+ naked_squirrel = removeSecurityProxy(self.squirrel)
725+ naked_squirrel.nominatedarchindep = self.squirrel.newArch(
726+ 'i386', ProcessorFamily.get(1), False, self.chef,
727+ supports_virtualized=True)
728+ chocolate = self.factory.makeProduct(name='chocolate')
729+ cake_branch = self.factory.makeProductBranch(
730+ owner=self.chef, name='cake', product=chocolate)
731+ self.recipe = self.factory.makeSourcePackageRecipe(
732+ owner=self.chef, distroseries=self.squirrel, name=u'cake_recipe',
733+ description=u'This recipe builds a foo for disto bar, with my'
734+ ' Secret Squirrel changes.', branches=[cake_branch],
735+ daily_build_archive=self.ppa)
736+ transaction.commit()
737+ login_person(self.chef, "chef@example.com", "test", self.client)
738+
739+ def makeRecipeBuild(self):
740+ """Create and return a specific recipe."""
741+ build = self.factory.makeSourcePackageRecipeBuild(recipe=self.recipe)
742+ return build
743+
744+ def test_recipe_build_request(self):
745+ """Request a recipe build."""
746+
747+ client = self.client
748+ client.open(url=canonical_url(self.recipe))
749+ client.waits.forElement(
750+ id=u'request-builds', timeout=PAGE_LOAD)
751+
752+ # Request a new build.
753+ client.click(id=u'request-builds')
754+ client.waits.forElement(id=u'field.archive')
755+ client.click(name=u'field.actions.request')
756+
757+ # Ensure it shows up.
758+ client.waits.forElement(
759+ xpath = (u'//tr[contains(@class, "package-build")]/td[4]'
760+ '/a[@href="%s"]') % PPAFormatterAPI(self.ppa).url(),
761+ timeout=FOR_ELEMENT)
762+
763+ # And try the same one again.
764+ client.click(id=u'request-builds')
765+ client.waits.forElement(id=u'field.archive')
766+ client.click(name=u'field.actions.request')
767+
768+ # And check that there's an error.
769+ client.waits.forElement(
770+ xpath = (
771+ u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
772+ '/ul/li'), timeout=FOR_ELEMENT)
773+ client.asserts.assertText(
774+ xpath = (
775+ u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
776+ '/ul/li'),
777+ validator=u'An identical build is already pending for %s %s.'
778+ % (self.ppa.distribution.name, self.squirrel.name))
779
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 # Avoiding circular imports.
785 from lp.soyuz.model.archivepermission import ArchivePermission
786
787+ # If there's no user logged in, then there's no archives.
788+ if user is None:
789+ return []
790 store = Store.of(user)
791 direct_membership = store.find(
792 Archive,