Merge lp:~thumper/launchpad/daily-ajax into lp:launchpad

Proposed by Tim Penhey
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 12377
Proposed branch: lp:~thumper/launchpad/daily-ajax
Merge into: lp:launchpad
Diff against target: 680 lines (+368/-43)
15 files modified
lib/canonical/launchpad/icing/style-3-0.css.in (+7/-1)
lib/lp/app/browser/lazrjs.py (+88/-15)
lib/lp/app/doc/lazr-js-widgets.txt (+58/-0)
lib/lp/app/javascript/choice.js (+37/-0)
lib/lp/app/templates/boolean-choice-widget.pt (+18/-0)
lib/lp/app/templates/launchpad-form.pt (+14/-1)
lib/lp/code/browser/sourcepackagerecipe.py (+13/-1)
lib/lp/code/browser/tests/test_sourcepackagerecipe.py (+8/-8)
lib/lp/code/help/recipe-build-frequency.html (+41/-0)
lib/lp/code/interfaces/sourcepackagerecipe.py (+2/-2)
lib/lp/code/templates/sourcepackagerecipe-index.pt (+6/-7)
lib/lp/code/templates/sourcepackagerecipe-new.pt (+2/-1)
lib/lp/code/windmill/tests/test_recipe_index.py (+46/-0)
lib/lp/testing/__init__.py (+18/-1)
lib/lp/testing/windmill/lpuser.py (+10/-6)
To merge this branch: bzr merge lp:~thumper/launchpad/daily-ajax
Reviewer Review Type Date Requested Status
Deryck Hodge (community) Approve
Robert Collins (community) Approve
William Grant code* Approve
Review via email: mp+49295@code.launchpad.net

Commit message

[r=deryck,lifeless,wgrant][bug=673530][incr] Add a popup choice widget for the build frequency.

Description of the change

The primary motivation of this branch is to add a simple popup
chooser for the build frequency. I did this by creating a
BooleanChoiceWidget.

lib/canonical/launchpad/icing/style-3-0.css.in
 - Since the value string of the choice is clickable, we should
   make the cursor change, and underline it as the user hovers

The lazr-js-widgets documentation is updated to cover the new
widget.

I also added an in-page help link about the build schedule.

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

This looks good. Just a few comments:

 - In style-3-0.css.in, there is a space missing after "cursor:".
 - In the help page: "before the 24 period is up" needs a s/24 period/24 hour period/. It also seems slightly repetitive, but it may be needed for clarity.
 - You've changed "Built daily" to "Build daily". Was that deliberate? I slightly prefer the former, but am not really fussed.
 - This UI is very different from the "Automatically build each day, if the source has changed" checkbox when creating a new recipe. Can we use the same option buttons and help there too?

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

@wgrant, @thumper - Theres an odd structure here:

The mixin looks like it could just depend on the Editablewidgetbase protocol; I wouldn't use a mixin at this point, just pull it up to the base class (and pull the self.tag setting up too).

Have a think about this, but don't feel you need to tell me what you choose to do.

review: Approve
Revision history for this message
Deryck Hodge (deryck) wrote :

Hi, all.

There are some problems with this widget and Windmill test. The widget doesn't show a spinner while it's doing XHR work, and the Windmill test shouldn't be using sleep or xpath for this simple of a test. I'm having a poke at this branch briefly to see if I can just provide a diff to show how this should work (in the hopes that that is helpful and not intrusive ;)) and will reply back when I come up with something.

Cheers,
deryck

review: Needs Fixing (javascript code)
Revision history for this message
Deryck Hodge (deryck) wrote :

Here's a patch that fixes this up some. It:

* ensures there is a spinner during the XHR work
* avoids sleep during the test
* avoids xpath
* always use a timeout on waits.forElement
* doesn't rely on a page reload to confirm data

This should be a pretty stable test now, and it's clearer to read.
It's also a little bit faster than the earlier version on my system
(around 3-4 seconds).

If you have questions ping me about it.

Cheers,
deryck

1=== modified file 'lib/lp/app/javascript/choice.js'
2--- lib/lp/app/javascript/choice.js 2011-02-10 01:57:55 +0000
3+++ lib/lp/app/javascript/choice.js 2011-02-11 16:46:13 +0000
4@@ -14,6 +14,22 @@
5 cfg: {
6 patch: attribute,
7 resource: resource_uri}});
8+ // ChoiceSource makes assumptions about HTML in lazr-js
9+ // that don't hold true here, so we need to do our own
10+ // spinner icon and clear it when finished.
11+ Y.after(function() {
12+ var icon = this.get('editicon');
13+ icon.removeClass('edit');
14+ icon.addClass('update-in-progress-message');
15+ icon.setStyle('position', 'relative');
16+ icon.setStyle('bottom', '2px');
17+ }, widget, '_uiSetWaiting');
18+ Y.after(function() {
19+ var icon = this.get('editicon');
20+ icon.removeClass('update-in-progress-message');
21+ icon.addClass('edit');
22+ icon.setStyle('bottom', '0px');
23+ }, widget, '_uiClearWaiting');
24 widget.render();
25 };
26
27
28=== modified file 'lib/lp/code/windmill/tests/test_recipe_index.py'
29--- lib/lp/code/windmill/tests/test_recipe_index.py 2011-02-11 01:49:55 +0000
30+++ lib/lp/code/windmill/tests/test_recipe_index.py 2011-02-11 18:08:58 +0000
31@@ -1,20 +1,19 @@
32 # Copyright 2011 Canonical Ltd. This software is licensed under the
33 # GNU Affero General Public License version 3 (see the file LICENSE).
34
35-"""Tests for branch statuses."""
36+"""Tests for recipe index pages."""
37
38 __metaclass__ = type
39 __all__ = []
40
41 import transaction
42-import unittest
43-
44+
45+from storm.store import Store
46+
47+from lp.code.model.sourcepackagerecipe import SourcePackageRecipe
48 from lp.code.windmill.testing import CodeWindmillLayer
49 from lp.testing import WindmillTestCase
50-from lp.testing.windmill.constants import (
51- PAGE_LOAD,
52- SLEEP,
53- )
54+from lp.testing.windmill.constants import FOR_ELEMENT
55
56
57 class TestRecipeSetDaily(WindmillTestCase):
58@@ -23,9 +22,6 @@
59 layer = CodeWindmillLayer
60 suite_name = "Recipe daily build flag setting"
61
62- BUILD_DAILY_TEXT = u'//span[@id="edit-build_daily"]/span[@class="value"]'
63- BUILD_DAILY_POPUP = u'//div[contains(@class, "yui3-ichoicelist-content")]'
64-
65 def test_inline_recipe_daily_build(self):
66 eric = self.factory.makePerson(
67 name="eric", displayname="Eric the Viking", password="test",
68@@ -34,15 +30,17 @@
69 transaction.commit()
70
71 client, start_url = self.getClientFor(recipe, user=eric)
72- client.click(xpath=self.BUILD_DAILY_TEXT)
73- client.waits.forElement(xpath=self.BUILD_DAILY_POPUP)
74+ client.click(id=u'edit-build_daily')
75+ client.waits.forElement(
76+ classname=u'yui3-ichoicelist-content', timeout=FOR_ELEMENT)
77 client.click(link=u'Build daily')
78- client.waits.sleep(milliseconds=SLEEP)
79- client.asserts.assertText(
80- xpath=self.BUILD_DAILY_TEXT, validator=u'Build daily')
81+ client.waits.forElement(
82+ jquery=u'("div#edit-build_daily a.editicon.sprite.edit")',
83+ timeout=FOR_ELEMENT)
84+ client.asserts.assertTextIn(
85+ id=u'edit-build_daily', validator=u'Build daily')
86
87- # Reload the page and make sure the change has stuck.
88- client.open(url=start_url)
89- client.waits.forPageLoad(timeout=PAGE_LOAD)
90- client.asserts.assertText(
91- xpath=self.BUILD_DAILY_TEXT, validator=u'Build daily')
92+ transaction.commit()
93+ freshly_fetched_recipe = Store.of(recipe).find(
94+ SourcePackageRecipe, SourcePackageRecipe.id == recipe.id).one()
95+ self.assertTrue(freshly_fetched_recipe.build_daily)
Revision history for this message
Tim Penhey (thumper) wrote :

On Fri, 11 Feb 2011 19:17:53 you wrote:
> Review: Approve
> @wgrant, @thumper - Theres an odd structure here:
>
> The mixin looks like it could just depend on the Editablewidgetbase
> protocol; I wouldn't use a mixin at this point, just pull it up to the
> base class (and pull the self.tag setting up too).
>
> Have a think about this, but don't feel you need to tell me what you choose
> to do.

I did think about this, but the multiline editor does not need it, so I
decided not to put it into the EditableWidgetBase.

Revision history for this message
Tim Penhey (thumper) wrote :

On Sat, 12 Feb 2011 07:55:32 you wrote:
> Here's a patch that fixes this up some. It:
>
> * ensures there is a spinner during the XHR work
> * avoids sleep during the test
> * avoids xpath
> * always use a timeout on waits.forElement
> * doesn't rely on a page reload to confirm data

Thanks for this Deryck. I've included your patch. We need to make sure that
more Javascript reviewers understand the best way to write the windmill tests.

Revision history for this message
Deryck Hodge (deryck) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
2--- lib/canonical/launchpad/icing/style-3-0.css.in 2011-02-10 23:30:53 +0000
3+++ lib/canonical/launchpad/icing/style-3-0.css.in 2011-02-14 07:50:33 +0000
4@@ -963,11 +963,17 @@
5 /* The js-action class is also used for non-links, for example, with
6 expand/collapse sections. */
7 color: #093;
8- cursor:pointer;
9+ cursor: pointer;
10 }
11 .widget-hd.js-action:hover {
12 text-decoration: underline;
13 }
14+
15+.yui3-ichoicesource-content .value:hover {
16+ text-decoration: underline;
17+ cursor: pointer;
18+ }
19+
20 .error.message, .warning.message, .informational.message {
21 border: solid #666;
22 border-width: 1px 2px 2px 1px;
23
24=== modified file 'lib/lp/app/browser/lazrjs.py'
25--- lib/lp/app/browser/lazrjs.py 2011-01-28 01:09:35 +0000
26+++ lib/lp/app/browser/lazrjs.py 2011-02-14 07:50:33 +0000
27@@ -5,6 +5,7 @@
28
29 __metaclass__ = type
30 __all__ = [
31+ 'BooleanChoiceWidget',
32 'InlineEditPickerWidget',
33 'standard_text_html_representation',
34 'TextAreaEditorWidget',
35@@ -61,6 +62,7 @@
36 if mutator_info is not None:
37 mutator_method, mutator_extra = mutator_info
38 self.mutator_method_name = mutator_method.__name__
39+ self.json_attribute = simplejson.dumps(self.api_attribute)
40
41 @property
42 def resource_uri(self):
43@@ -90,19 +92,27 @@
44 return False
45
46
47-class TextWidgetBase(WidgetBase):
48+class EditableWidgetBase(WidgetBase):
49+ """Adds an edit_url property to WidgetBase."""
50+
51+ def __init__(self, context, exported_field, content_box_id,
52+ edit_view, edit_url):
53+ super(EditableWidgetBase, self).__init__(
54+ context, exported_field, content_box_id)
55+ if edit_url is None:
56+ edit_url = canonical_url(self.context, view_name=edit_view)
57+ self.edit_url = edit_url
58+
59+
60+class TextWidgetBase(EditableWidgetBase):
61 """Abstract base for the single and multiline text editor widgets."""
62
63 def __init__(self, context, exported_field, title, content_box_id,
64 edit_view, edit_url):
65 super(TextWidgetBase, self).__init__(
66- context, exported_field, content_box_id)
67- if edit_url is None:
68- edit_url = canonical_url(self.context, view_name=edit_view)
69- self.edit_url = edit_url
70+ context, exported_field, content_box_id, edit_view, edit_url)
71 self.accept_empty = simplejson.dumps(self.optional_field)
72 self.title = title
73- self.json_attribute = simplejson.dumps(self.api_attribute)
74 self.widget_css_selector = simplejson.dumps('#' + self.content_box_id)
75
76 @property
77@@ -110,7 +120,19 @@
78 return simplejson.dumps(self.resource_uri + '/' + self.api_attribute)
79
80
81-class TextLineEditorWidget(TextWidgetBase):
82+class DefinedTagMixin:
83+ """Mixin class to define open and closing tags."""
84+
85+ @property
86+ def open_tag(self):
87+ return '<%s id="%s">' % (self.tag, self.content_box_id)
88+
89+ @property
90+ def close_tag(self):
91+ return '</%s>' % self.tag
92+
93+
94+class TextLineEditorWidget(TextWidgetBase, DefinedTagMixin):
95 """Wrapper for the lazr-js inlineedit/editor.js widget."""
96
97 __call__ = ViewPageTemplateFile('../templates/text-line-editor.pt')
98@@ -146,14 +168,6 @@
99 self.width = simplejson.dumps(width)
100
101 @property
102- def open_tag(self):
103- return '<%s id="%s">' % (self.tag, self.content_box_id)
104-
105- @property
106- def close_tag(self):
107- return '</%s>' % self.tag
108-
109- @property
110 def value(self):
111 text = getattr(self.context, self.attribute_name, self.default_text)
112 if text is None:
113@@ -333,3 +347,62 @@
114 return ''
115 nomail = FormattersAPI(value).obfuscate_email()
116 return FormattersAPI(nomail).text_to_html(linkify_text=linkify_text)
117+
118+
119+class BooleanChoiceWidget(EditableWidgetBase, DefinedTagMixin):
120+ """A ChoiceEdit for a boolean field."""
121+
122+ __call__ = ViewPageTemplateFile('../templates/boolean-choice-widget.pt')
123+
124+ def __init__(self, context, exported_field,
125+ tag, false_text, true_text, prefix=None,
126+ edit_view="+edit", edit_url=None,
127+ content_box_id=None, header='Select an item'):
128+ """Create a widget wrapper.
129+
130+ :param context: The object that is being edited.
131+ :param exported_field: The attribute being edited. This should be
132+ a field from an interface of the form ISomeInterface['fieldname']
133+ :param tag: The HTML tag to use.
134+ :param false_text: The string to show for a false value.
135+ :param true_text: The string to show for a true value.
136+ :param prefix: Optional text to show before the value.
137+ :param edit_view: The view name to use to generate the edit_url if
138+ one is not specified.
139+ :param edit_url: The URL to use for editing when the user isn't logged
140+ in and when JS is off. Defaults to the edit_view on the context.
141+ :param content_box_id: The HTML id to use for this widget. Automatically
142+ generated if this is not provided.
143+ :param header: The large text at the top of the choice popup.
144+ """
145+ super(BooleanChoiceWidget, self).__init__(
146+ context, exported_field, content_box_id, edit_view, edit_url)
147+ self.header = header
148+ self.tag = tag
149+ self.prefix = prefix
150+ self.true_text = true_text
151+ self.false_text = false_text
152+ self.current_value = getattr(self.context, self.attribute_name)
153+
154+ @property
155+ def value(self):
156+ if self.current_value:
157+ return self.true_text
158+ else:
159+ return self.false_text
160+
161+ @property
162+ def config(self):
163+ return dict(
164+ contentBox='#'+self.content_box_id,
165+ value=self.current_value,
166+ title=self.header,
167+ items=[
168+ dict(name=self.true_text, value=True, style='', help='',
169+ disabled=False),
170+ dict(name=self.false_text, value=False, style='', help='',
171+ disabled=False)])
172+
173+ @property
174+ def json_config(self):
175+ return simplejson.dumps(self.config)
176
177=== modified file 'lib/lp/app/doc/lazr-js-widgets.txt'
178--- lib/lp/app/doc/lazr-js-widgets.txt 2011-01-28 00:54:11 +0000
179+++ lib/lp/app/doc/lazr-js-widgets.txt 2011-02-14 07:50:33 +0000
180@@ -268,3 +268,61 @@
181
182 If the field is optional, a "Remove" link is shown. The "Remove" text is
183 customizable thought the "remove_button_text" parameter.
184+
185+
186+BooleanChoiceWidget
187+-------------------
188+
189+This widget provides a simple popup with two options for the user to choose
190+from.
191+
192+ >>> from lp.app.browser.lazrjs import BooleanChoiceWidget
193+
194+As with the other widgets, this one requires a context object and a Bool type
195+field. The rendering of the widget hooks up to the lazr ChoiceSource with the
196+standard patch plugin.
197+
198+The surrounding tag is customisable, and a prefix may be given. The prefix is
199+passed through to the ChoiceSource and is rendered as part of the widget, but
200+isn't updated when the value changes.
201+
202+If the user does not have edit rights, the widget just renders the text based
203+on the current value of the field on the object:
204+
205+ >>> login(ANONYMOUS)
206+ >>> from lp.registry.interfaces.person import IPerson
207+ >>> hide_email = IPerson['hide_email_addresses']
208+ >>> widget = BooleanChoiceWidget(
209+ ... eric, hide_email, 'span',
210+ ... false_text="Don't hide it",
211+ ... true_text="Keep it secret",
212+ ... prefix="My email: ")
213+ >>> print widget()
214+ <span id="edit-hide_email_addresses">
215+ My email: <span class="value">Don't hide it</span>
216+ </span>
217+
218+If the user has edit rights, an edit icon is rendered and some javascript is
219+rendered to hook up the widget.
220+
221+ >>> login_person(eric)
222+ >>> print widget()
223+ <span id="edit-hide_email_addresses">
224+ My email: <span class="value">Don't hide it</span>
225+ <span>
226+ &nbsp;
227+ <a class="editicon sprite edit"
228+ href="http://launchpad.dev/~eric/+edit"></a>
229+ </span>
230+ </span>
231+ <script>
232+ LPS.use('lp.app.choice', function(Y) {
233+ ...
234+ </script>
235+
236+
237+Changing the edit link
238+**********************
239+
240+The edit link can be changed in exactly the same way as for the
241+TextLineEditorWidget above.
242
243=== added file 'lib/lp/app/javascript/choice.js'
244--- lib/lp/app/javascript/choice.js 1970-01-01 00:00:00 +0000
245+++ lib/lp/app/javascript/choice.js 2011-02-14 07:50:33 +0000
246@@ -0,0 +1,37 @@
247+YUI.add('lp.app.choice', function(Y) {
248+
249+var namespace = Y.namespace('lp.app.choice');
250+
251+namespace.addBinaryChoice = function(config, resource_uri, attribute) {
252+
253+ if (Y.UA.ie) {
254+ return;
255+ }
256+
257+ var widget = new Y.ChoiceSource(config);
258+ widget.plug({
259+ fn: Y.lp.client.plugins.PATCHPlugin,
260+ cfg: {
261+ patch: attribute,
262+ resource: resource_uri}});
263+ // ChoiceSource makes assumptions about HTML in lazr-js
264+ // that don't hold true here, so we need to do our own
265+ // spinner icon and clear it when finished.
266+ Y.after(function() {
267+ var icon = this.get('editicon');
268+ icon.removeClass('edit');
269+ icon.addClass('update-in-progress-message');
270+ icon.setStyle('position', 'relative');
271+ icon.setStyle('bottom', '2px');
272+ }, widget, '_uiSetWaiting');
273+ Y.after(function() {
274+ var icon = this.get('editicon');
275+ icon.removeClass('update-in-progress-message');
276+ icon.addClass('edit');
277+ icon.setStyle('bottom', '0px');
278+ }, widget, '_uiClearWaiting');
279+ widget.render();
280+};
281+
282+
283+}, "0.1", {"requires": ["lazr.choiceedit", "lp.client.plugins"]});
284
285=== added file 'lib/lp/app/templates/boolean-choice-widget.pt'
286--- lib/lp/app/templates/boolean-choice-widget.pt 1970-01-01 00:00:00 +0000
287+++ lib/lp/app/templates/boolean-choice-widget.pt 2011-02-14 07:50:33 +0000
288@@ -0,0 +1,18 @@
289+<tal:open-tag replace="structure view/open_tag"/>
290+<tal:prefix replace="view/prefix"/><span class="value" tal:content="view/value">Unset</span>
291+ <span tal:condition="view/can_write">
292+ &nbsp;
293+ <a tal:attributes="href view/edit_url"
294+ class="editicon sprite edit"></a>
295+ </span>
296+<tal:close-tag replace="structure view/close_tag"/>
297+
298+<script tal:condition="view/can_write"
299+ tal:content="structure string:
300+LPS.use('lp.app.choice', function(Y) {
301+ Y.lp.app.choice.addBinaryChoice(
302+ ${view/json_config},
303+ ${view/json_resource_uri},
304+ ${view/json_attribute});
305+});
306+"/>
307
308=== modified file 'lib/lp/app/templates/launchpad-form.pt'
309--- lib/lp/app/templates/launchpad-form.pt 2010-11-26 02:01:47 +0000
310+++ lib/lp/app/templates/launchpad-form.pt 2011-02-14 07:50:33 +0000
311@@ -104,7 +104,8 @@
312 error python:view.getFieldError(field_name);
313 error_class python:error and 'error' or None;
314 show_optional python:view.showOptionalMarker(field_name);
315- widget_class widget/widget_class|nothing">
316+ widget_class widget/widget_class|nothing;
317+ widget_help_link widget_help_link|nothing">
318 <tal:is-visible condition="widget/visible">
319 <tr
320 tal:condition="python: view.isSingleLineLayout(field_name)"
321@@ -120,6 +121,12 @@
322 </tal:block>
323 <div>
324 <input tal:replace="structure widget" />
325+ <tal:help-link condition="widget_help_link">
326+ <a tal:attributes="href widget_help_link"
327+ target="help" class="sprite maybe">
328+ &nbsp;<span class="invisible-link">Tag help</span>
329+ </a>
330+ </tal:help-link>
331 </div>
332 <div class="message" tal:condition="error"
333 tal:content="structure error">Error message</div>
334@@ -160,6 +167,12 @@
335 <input type="checkbox" tal:replace="structure widget" />
336 <label tal:attributes="for widget/name"
337 tal:content="widget/label">Label</label>
338+ <tal:help-link condition="widget_help_link">
339+ <a tal:attributes="href widget_help_link"
340+ target="help" class="sprite maybe">
341+ &nbsp;<span class="invisible-link">Tag help</span>
342+ </a>
343+ </tal:help-link>
344 <div
345 tal:condition="error"
346 tal:content="structure error"
347
348=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
349--- lib/lp/code/browser/sourcepackagerecipe.py 2011-02-03 05:23:55 +0000
350+++ lib/lp/code/browser/sourcepackagerecipe.py 2011-02-14 07:50:33 +0000
351@@ -69,7 +69,10 @@
352 LaunchpadFormView,
353 render_radio_widget_part,
354 )
355-from lp.app.browser.lazrjs import InlineEditPickerWidget
356+from lp.app.browser.lazrjs import (
357+ BooleanChoiceWidget,
358+ InlineEditPickerWidget,
359+ )
360 from lp.app.browser.tales import format_link
361 from lp.app.widgets.itemswidgets import (
362 LabeledMultiCheckBoxWidget,
363@@ -260,6 +263,15 @@
364 header='Change daily build archive',
365 step_title='Select a PPA')
366
367+ @property
368+ def daily_build_widget(self):
369+ return BooleanChoiceWidget(
370+ self.context, ISourcePackageRecipe['build_daily'],
371+ tag='span',
372+ false_text='Built on request',
373+ true_text='Built daily',
374+ header='Change build schedule')
375+
376
377 class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):
378 """A view for requesting builds of a SourcePackageRecipe."""
379
380=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipe.py'
381--- lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2011-02-03 05:23:55 +0000
382+++ lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2011-02-14 07:50:33 +0000
383@@ -218,7 +218,7 @@
384 browser.getControl(name='field.name').value = 'daily'
385 browser.getControl('Description').value = 'Make some food!'
386 browser.getControl('Secret Squirrel').click()
387- browser.getControl('Automatically build each day').click()
388+ browser.getControl('Built daily').click()
389 browser.getControl('Create Recipe').click()
390
391 pattern = """\
392@@ -229,7 +229,7 @@
393 Make some food!
394
395 Recipe information
396- Build schedule: Built daily
397+ Build schedule: Tag help Built daily
398 Owner: Master Chef Edit
399 Base branch: lp://dev/~chef/ratatouille/veggies
400 Debian version: {debupstream}-0~{revno}
401@@ -281,7 +281,7 @@
402 browser.getControl(name='field.name').value = 'daily'
403 browser.getControl('Description').value = 'Make some food!'
404 browser.getControl('Secret Squirrel').click()
405- browser.getControl('Automatically build each day').click()
406+ browser.getControl('Built daily').click()
407 browser.getControl('Other').click()
408 browser.getControl(name='field.owner.owner').displayValue = [
409 'Good Chefs']
410@@ -427,7 +427,7 @@
411 browser.getControl(name='field.name').value = 'daily'
412 browser.getControl('Description').value = 'Make some food!'
413
414- browser.getControl('Automatically build each day').click()
415+ browser.getControl('Built daily').click()
416 browser.getControl('Create Recipe').click()
417 self.assertEqual(
418 'You must specify at least one series for daily builds.',
419@@ -755,7 +755,7 @@
420 This is stuff
421
422 Recipe information
423- Build schedule: Built on request
424+ Build schedule: Tag help Built on request
425 Owner: Master Chef Edit
426 Base branch: lp://dev/~chef/ratatouille/meat
427 Debian version: {debupstream}-0~{revno}
428@@ -815,7 +815,7 @@
429 This is stuff
430
431 Recipe information
432- Build schedule: Built on request
433+ Build schedule: Tag help Built on request
434 Owner: Master Chef Edit
435 Base branch: lp://dev/~chef/ratatouille/meat
436 Debian version: {debupstream}-0~{revno}
437@@ -955,7 +955,7 @@
438 This is stuff
439
440 Recipe information
441- Build schedule: Built on request
442+ Build schedule: Tag help Built on request
443 Owner: Master Chef Edit
444 Base branch: lp://dev/~chef/ratatouille/meat
445 Debian version: {debupstream}-0~{revno}
446@@ -1098,7 +1098,7 @@
447 This recipe .*changes.
448
449 Recipe information
450- Build schedule: Built on request
451+ Build schedule: Tag help Built on request
452 Owner: Master Chef Edit
453 Base branch: lp://dev/~chef/chocolate/cake
454 Debian version: {debupstream}-0~{revno}
455
456=== added file 'lib/lp/code/help/recipe-build-frequency.html'
457--- lib/lp/code/help/recipe-build-frequency.html 1970-01-01 00:00:00 +0000
458+++ lib/lp/code/help/recipe-build-frequency.html 2011-02-14 07:50:33 +0000
459@@ -0,0 +1,41 @@
460+<html>
461+ <head>
462+ <title>Source package recipe build schedule</title>
463+ <link rel="stylesheet" type="text/css"
464+ href="/+icing/yui/cssreset/reset.css" />
465+ <link rel="stylesheet" type="text/css"
466+ href="/+icing/yui/cssfonts/fonts.css" />
467+ <link rel="stylesheet" type="text/css"
468+ href="/+icing/yui/cssbase/base.css" />
469+ <style type="text/css">
470+ dt { font-weight: bold }
471+ dd p { margin-bottom: 0.5em }
472+ </style>
473+ </head>
474+ <body>
475+ <h1>Source package recipe build schedule</h1>
476+
477+ <p>There are two options for when recipes get built:</p>
478+ <dl>
479+ <dt>Built daily</dt>
480+ <dd>
481+ <p>A build will be scheduled automatically once a change in any
482+ of the branches used in the recipe is detected.</p>
483+ <p>If there has been a build of the recipe within the previous
484+ 24 hours into the daily build PPA, the build will not be scheduled
485+ until 24 hours since the last build into the daily build PPA.</p>
486+ <p>If the recipe had been built within the last 24 hours into a
487+ different PPA using the "Request build" action, this will not
488+ delay the daily build.</p>
489+ <p>If you really want the build to happen before the 24 hour period is up,
490+ you can use the "Request build" action.</p>
491+ </dd>
492+ <dt>Built on request</dt>
493+ <dd>
494+ <p>Builds of the recipe have to be manually requested using the
495+ "Request build" action.</p>
496+ </dd>
497+ </dl>
498+
499+ </body>
500+</html>
501
502=== modified file 'lib/lp/code/interfaces/sourcepackagerecipe.py'
503--- lib/lp/code/interfaces/sourcepackagerecipe.py 2011-01-13 03:53:53 +0000
504+++ lib/lp/code/interfaces/sourcepackagerecipe.py 2011-02-14 07:50:33 +0000
505@@ -171,8 +171,8 @@
506 " build a source package for"),
507 readonly=False)
508 build_daily = exported(Bool(
509- title=_("Automatically build each day, if the source has changed"),
510- description=_("You can manually request a build at any time.")))
511+ title=_("Built daily"),
512+ description=_("Automatically build each day, if the source has changed.")))
513
514 name = exported(TextLine(
515 title=_("Name"), required=True,
516
517=== modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt'
518--- lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-01-20 20:35:32 +0000
519+++ lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-14 07:50:33 +0000
520@@ -44,13 +44,12 @@
521 <h2>Recipe information</h2>
522 <div class="two-column-list">
523 <dl id="build_daily">
524- <dt>Build schedule:</dt>
525- <dd tal:condition="context/build_daily">
526- Built daily
527- </dd>
528- <dd tal:condition="not:context/build_daily">
529- Built on request
530- </dd>
531+ <dt>Build schedule:
532+ <a href="/+help/recipe-build-frequency.html" target="help" class="sprite maybe">
533+ &nbsp;<span class="invisible-link">Tag help</span>
534+ </a>
535+ </dt>
536+ <dd tal:content="structure view/daily_build_widget"/>
537 </dl>
538
539 <dl id="owner">
540
541=== modified file 'lib/lp/code/templates/sourcepackagerecipe-new.pt'
542--- lib/lp/code/templates/sourcepackagerecipe-new.pt 2011-01-25 04:39:16 +0000
543+++ lib/lp/code/templates/sourcepackagerecipe-new.pt 2011-02-14 07:50:33 +0000
544@@ -53,7 +53,8 @@
545 <tal:widget define="widget nocall:view/widgets/owner">
546 <metal:block use-macro="context/@@launchpad_form/widget_row" />
547 </tal:widget>
548- <tal:widget define="widget nocall:view/widgets/build_daily">
549+ <tal:widget define="widget nocall:view/widgets/build_daily;
550+ widget_help_link string:/+help/recipe-build-frequency.html">
551 <metal:block use-macro="context/@@launchpad_form/widget_row" />
552 </tal:widget>
553
554
555=== added file 'lib/lp/code/windmill/tests/test_recipe_index.py'
556--- lib/lp/code/windmill/tests/test_recipe_index.py 1970-01-01 00:00:00 +0000
557+++ lib/lp/code/windmill/tests/test_recipe_index.py 2011-02-14 07:50:33 +0000
558@@ -0,0 +1,46 @@
559+# Copyright 2011 Canonical Ltd. This software is licensed under the
560+# GNU Affero General Public License version 3 (see the file LICENSE).
561+
562+"""Tests for recipe index pages."""
563+
564+__metaclass__ = type
565+__all__ = []
566+
567+import transaction
568+
569+from storm.store import Store
570+
571+from lp.code.model.sourcepackagerecipe import SourcePackageRecipe
572+from lp.code.windmill.testing import CodeWindmillLayer
573+from lp.testing import WindmillTestCase
574+from lp.testing.windmill.constants import FOR_ELEMENT
575+
576+
577+class TestRecipeSetDaily(WindmillTestCase):
578+ """Test setting the daily build flag."""
579+
580+ layer = CodeWindmillLayer
581+ suite_name = "Recipe daily build flag setting"
582+
583+ def test_inline_recipe_daily_build(self):
584+ eric = self.factory.makePerson(
585+ name="eric", displayname="Eric the Viking", password="test",
586+ email="eric@example.com")
587+ recipe = self.factory.makeSourcePackageRecipe(owner=eric)
588+ transaction.commit()
589+
590+ client, start_url = self.getClientFor(recipe, user=eric)
591+ client.click(id=u'edit-build_daily')
592+ client.waits.forElement(
593+ classname=u'yui3-ichoicelist-content', timeout=FOR_ELEMENT)
594+ client.click(link=u'Built daily')
595+ client.waits.forElement(
596+ jquery=u'("div#edit-build_daily a.editicon.sprite.edit")',
597+ timeout=FOR_ELEMENT)
598+ client.asserts.assertTextIn(
599+ id=u'edit-build_daily', validator=u'Built daily')
600+
601+ transaction.commit()
602+ freshly_fetched_recipe = Store.of(recipe).find(
603+ SourcePackageRecipe, SourcePackageRecipe.id == recipe.id).one()
604+ self.assertTrue(freshly_fetched_recipe.build_daily)
605
606=== modified file 'lib/lp/testing/__init__.py'
607--- lib/lp/testing/__init__.py 2011-02-10 08:38:19 +0000
608+++ lib/lp/testing/__init__.py 2011-02-14 07:50:33 +0000
609@@ -158,7 +158,7 @@
610 from lp.testing.fixture import ZopeEventHandlerFixture
611 from lp.testing.karma import KarmaRecorder
612 from lp.testing.matchers import Provides
613-from lp.testing.windmill import constants
614+from lp.testing.windmill import constants, lpuser
615
616
617 class FakeTime:
618@@ -772,6 +772,23 @@
619 # of things like https://launchpad.net/bugs/515494)
620 self.client.open(url=self.layer.appserver_root_url())
621
622+ def getClientFor(self, obj, user=None, password='test', view_name=None):
623+ """Return a new client, and the url that it has loaded."""
624+ client = WindmillTestClient(self.suite_name)
625+ if user is not None:
626+ email = removeSecurityProxy(user).preferredemail.email
627+ client.open(url=lpuser.get_basic_login_url(email, password))
628+ client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
629+ if isinstance(obj, basestring):
630+ url = obj
631+ else:
632+ url = canonical_url(
633+ obj, view_name=view_name, force_local_path=True)
634+ obj_url = self.layer.base_url + url
635+ client.open(url=obj_url)
636+ client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
637+ return client, obj_url
638+
639
640 class YUIUnitTestCase(WindmillTestCase):
641
642
643=== modified file 'lib/lp/testing/windmill/lpuser.py'
644--- lib/lp/testing/windmill/lpuser.py 2011-02-10 04:00:00 +0000
645+++ lib/lp/testing/windmill/lpuser.py 2011-02-14 07:50:33 +0000
646@@ -11,6 +11,14 @@
647 from lp.testing.windmill import constants
648
649
650+def get_basic_login_url(email, password):
651+ """Return the constructed url to login a user."""
652+ base_url = windmill.settings['TEST_URL']
653+ basic_auth_url = base_url.replace('http://', 'http://%s:%s@')
654+ basic_auth_url = basic_auth_url + '+basiclogin'
655+ return basic_auth_url % (email, password)
656+
657+
658 class LaunchpadUser:
659 """Object representing well-known user on Launchpad."""
660
661@@ -32,10 +40,7 @@
662
663 current_url = client.commands.execJS(
664 code='windmill.testWin().location;')['result']['href']
665- base_url = windmill.settings['TEST_URL']
666- basic_auth_url = base_url.replace('http://', 'http://%s:%s@')
667- basic_auth_url = basic_auth_url + '+basiclogin'
668- client.open(url=basic_auth_url % (self.email, self.password))
669+ client.open(url=get_basic_login_url(self.email, self.password))
670 client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
671 client.open(url=current_url)
672 client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
673@@ -63,8 +68,7 @@
674
675 def login_person(person, email, password, client):
676 """Create a LaunchpadUser for a person and password."""
677- user = LaunchpadUser(
678- person.displayname, email, password)
679+ user = LaunchpadUser(person.displayname, email, password)
680 user.ensure_login(client)
681
682