Merge lp:~thumper/launchpad/daily-ajax into lp:launchpad
- daily-ajax
- Merge into devel
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 | ||||
Related bugs: |
|
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,
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
BooleanChoiceWi
lib/canonical/
- 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.
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.
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
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) |
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.
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.
Deryck Hodge (deryck) : | # |
Preview Diff
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 | + |
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 | + |
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 | + <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 | + <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 | + <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 |
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?