Merge lp:~thumper/launchpad/recipe-inline-edit-recipe-text into lp:launchpad

Proposed by Tim Penhey
Status: Merged
Approved by: Leonard Richardson
Approved revision: no longer in the source branch.
Merged at revision: 12402
Proposed branch: lp:~thumper/launchpad/recipe-inline-edit-recipe-text
Merge into: lp:launchpad
Prerequisite: lp:~thumper/launchpad/daily-ajax
Diff against target: 436 lines (+131/-56)
10 files modified
lib/canonical/launchpad/icing/style-3-0.css.in (+7/-7)
lib/lp/bugs/interfaces/bugsupervisor.py (+0/-3)
lib/lp/code/browser/sourcepackagerecipe.py (+7/-0)
lib/lp/code/errors.py (+16/-7)
lib/lp/code/interfaces/sourcepackagerecipe.py (+10/-2)
lib/lp/code/model/sourcepackagerecipe.py (+13/-2)
lib/lp/code/model/tests/test_sourcepackagerecipe.py (+46/-33)
lib/lp/code/templates/sourcepackagerecipe-index.pt (+5/-1)
lib/lp/code/windmill/tests/test_recipe_index.py (+26/-0)
versions.cfg (+1/-1)
To merge this branch: bzr merge lp:~thumper/launchpad/recipe-inline-edit-recipe-text
Reviewer Review Type Date Requested Status
Leonard Richardson (community) Approve
Review via email: mp+49585@code.launchpad.net

Commit message

[r=leonardr][bug=673530,708265] Allow inline editing of the recipe text.

Description of the change

This branch adds the ability to edit the recipe text on the
main recipe page.

As part of this I've tweaked the styles so we don't get the
horrible overlap at the top of the multiline editors. I
believe this was missed during the yui 3.1 -> 3.2 transition
as the yui- prefix became yui3-.

lib/lp/code/errors.py
 - annotated the RecipeParseError to be a 400 Bad Request error.
 - also for the NoSuchBranch and PrivateBranchRecipe errors as
   they can also be raised when setting recipe text
 - pulled the webservice_error up to the base class instead of
   having it in each of the three derived classes.

lib/lp/code/interfaces/sourcepackagerecipe.py
 - change the setRecipeText from being exported as a writable
   operatioln to be a mutator for the recipe_text attribute.
   I talked with Leonard about the stack of adapters here.
   The operate from bottom to top.

lib/lp/code/model/sourcepackagerecipe.py
 - in order to get the full error returned to the client, we
   need to call the "expose" method from lazr.restful. I
   chatted with Leonard about this one too as it seemed a
   little strange. There was some reason at some stage, but
   he couldn't recall the underlying reason at the time I
   was asking. We may perhaps want to re-evaluate the need
   for this method at some time.

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

I don't understand why you got rid of the CSS styles for edit-description and edit-commit-message. Did you already generalize those widgets, and you're just cleaning up the CSS?

Your annotations create a situation where setRecipeText is published in version 1.0 of the web service but absent in version devel, and in which you can PATCH ISourcePackageRecipe['recipe_text'] in devel but not in 1.0. I'd like to see some tests of that using launchpadlib. Your windmill test only tests that you can PATCH ISourcePackageRecipe['recipe_test'] in devel.

I don't think this is that big a deal, but if you care enough to distinguish between 1.0 and devel in the first place, you should test the different versions. Let me know if you need help with the tests. I'll start you out by saying that you can us launchpadlib_for("test", person, version="1.0") to get a launchpadlib that operates against 1.0.

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

As discussed over mumble and IRC, the CSS styles were changed to
be based more on the classes rather than the IDs.

Tests have been added, as you're aware.

I've also bumped the lazr.restful version here.

Revision history for this message
Leonard Richardson (leonardr) wrote :

<leonardr> thumper, i don't think test_recipe_text tests anything? to test that you can edit the recipe text, you would have to call lp_save()
<thumper> do we have to call lp_save for mutators?
<leonardr> thumper: you don't have to call lp_save() when you invoke a named operation
<leonardr> but the purpose of a mutator is to hide a named operation so that it looks like normal field access
<thumper> leonardr: but you do for mutators?
<leonardr> and in that case you do need to call lp_save()
* thumper adds a lp_save()

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-14 00:58:22 +0000
3+++ lib/canonical/launchpad/icing/style-3-0.css.in 2011-02-17 22:48:58 +0000
4@@ -947,18 +947,18 @@
5 top: -3px;
6 margin-left: 0.5em;
7 }
8-.yui-ieditor {
9+.yui3-ieditor {
10 padding-right: 288px;
11 }
12-.lazr-multiline-edit .yui-ieditor {
13+h1 .yui3-ieditor-errors {
14+ font-size: 0.5em;
15+ }
16+.lazr-multiline-edit .yui3-ieditor {
17 padding-right: 0;
18 }
19 .lazr-multiline-edit textarea {
20 max-width: inherit;
21 }
22-.lazr-multiline-edit .yui-ieditor-input {
23- padding-right: 5px !important;
24- }
25 .widget-hd.js-action {
26 /* The js-action class is also used for non-links, for example, with
27 expand/collapse sections. */
28@@ -1825,8 +1825,8 @@
29 font-size: 93%;
30 margin: 1em 0;
31 }
32-div#edit-description .yui-ieditor-input,
33-div#edit-commit_message .yui-ieditor-input {
34+
35+.yui3-ieditor-multiline .yui3-ieditor-input {
36 top: 0;
37 }
38
39
40=== modified file 'lib/lp/bugs/interfaces/bugsupervisor.py'
41--- lib/lp/bugs/interfaces/bugsupervisor.py 2010-08-20 20:31:18 +0000
42+++ lib/lp/bugs/interfaces/bugsupervisor.py 2011-02-17 22:48:58 +0000
43@@ -36,9 +36,6 @@
44 "all bugs and will receive email about all activity on all bugs "
45 "for this project, so that should be a factor in your decision. "
46 "The bug supervisor will also have access to all private bugs."),
47-
48-
49-
50 required=False, vocabulary='ValidPersonOrTeam', readonly=True))
51
52 @mutator_for(bug_supervisor)
53
54=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
55--- lib/lp/code/browser/sourcepackagerecipe.py 2011-02-15 11:31:56 +0000
56+++ lib/lp/code/browser/sourcepackagerecipe.py 2011-02-17 22:48:58 +0000
57@@ -72,6 +72,7 @@
58 from lp.app.browser.lazrjs import (
59 BooleanChoiceWidget,
60 InlineEditPickerWidget,
61+ TextAreaEditorWidget,
62 )
63 from lp.app.browser.tales import format_link
64 from lp.app.widgets.itemswidgets import (
65@@ -264,6 +265,12 @@
66 step_title='Select a PPA')
67
68 @property
69+ def recipe_text_widget(self):
70+ """The recipe text as widget HTML."""
71+ recipe_text = ISourcePackageRecipe['recipe_text']
72+ return TextAreaEditorWidget(self.context, recipe_text, title="")
73+
74+ @property
75 def daily_build_widget(self):
76 return BooleanChoiceWidget(
77 self.context, ISourcePackageRecipe['build_daily'],
78
79=== modified file 'lib/lp/code/errors.py'
80--- lib/lp/code/errors.py 2011-01-21 21:12:58 +0000
81+++ lib/lp/code/errors.py 2011-02-17 22:48:58 +0000
82@@ -42,10 +42,17 @@
83
84 import httplib
85
86-from lazr.restful.declarations import webservice_error
87+from bzrlib.plugins.builder.recipe import RecipeParseError
88+from lazr.restful.declarations import (
89+ error_status,
90+ webservice_error,
91+ )
92
93 from lp.app.errors import NameLookupFailed
94
95+# Annotate the RecipeParseError's with a 400 webservice status.
96+error_status(400)(RecipeParseError)
97+
98
99 class BadBranchMergeProposalSearchContext(Exception):
100 """The context is not valid for a branch merge proposal search."""
101@@ -204,11 +211,15 @@
102 class NoSuchBranch(NameLookupFailed):
103 """Raised when we try to load a branch that does not exist."""
104
105+ webservice_error(400)
106+
107 _message_prefix = "No such branch"
108
109
110 class PrivateBranchRecipe(Exception):
111
112+ webservice_error(400)
113+
114 def __init__(self, branch):
115 message = (
116 'Recipe may not refer to private branch: %s' %
117@@ -265,6 +276,8 @@
118 class TooNewRecipeFormat(Exception):
119 """The format of the recipe supplied was too new."""
120
121+ webservice_error(400)
122+
123 def __init__(self, supplied_format, newest_supported):
124 super(TooNewRecipeFormat, self).__init__()
125 self.supplied_format = supplied_format
126@@ -273,6 +286,8 @@
127
128 class RecipeBuildException(Exception):
129
130+ webservice_error(400)
131+
132 def __init__(self, recipe, distroseries, template):
133 self.recipe = recipe
134 self.distroseries = distroseries
135@@ -283,8 +298,6 @@
136 class TooManyBuilds(RecipeBuildException):
137 """A build was requested that exceeded the quota."""
138
139- webservice_error(400)
140-
141 def __init__(self, recipe, distroseries):
142 RecipeBuildException.__init__(
143 self, recipe, distroseries,
144@@ -295,8 +308,6 @@
145 class BuildAlreadyPending(RecipeBuildException):
146 """A build was requested when an identical build was already pending."""
147
148- webservice_error(400)
149-
150 def __init__(self, recipe, distroseries):
151 RecipeBuildException.__init__(
152 self, recipe, distroseries,
153@@ -306,8 +317,6 @@
154 class BuildNotAllowedForDistro(RecipeBuildException):
155 """A build was requested against an unsupported distroseries."""
156
157- webservice_error(400)
158-
159 def __init__(self, recipe, distroseries):
160 RecipeBuildException.__init__(
161 self, recipe, distroseries,
162
163=== modified file 'lib/lp/code/interfaces/sourcepackagerecipe.py'
164--- lib/lp/code/interfaces/sourcepackagerecipe.py 2011-02-14 01:48:57 +0000
165+++ lib/lp/code/interfaces/sourcepackagerecipe.py 2011-02-17 22:48:58 +0000
166@@ -25,13 +25,17 @@
167 export_as_webservice_entry,
168 export_write_operation,
169 exported,
170+ mutator_for,
171+ operation_for_version,
172 operation_parameters,
173+ operation_removed_in_version,
174 REQUEST_USER,
175 )
176 from lazr.restful.fields import (
177 CollectionField,
178 Reference,
179 )
180+from lazr.restful.interface import copy_field
181 from zope.interface import (
182 Attribute,
183 Interface,
184@@ -97,7 +101,7 @@
185 required=True, readonly=True,
186 vocabulary='ValidPersonOrTeam'))
187
188- recipe_text = exported(Text())
189+ recipe_text = exported(Text(readonly=True))
190
191 def isOverQuota(requester, distroseries):
192 """True if the recipe/requester/distroseries combo is >= quota.
193@@ -135,7 +139,11 @@
194 class ISourcePackageRecipeEdit(Interface):
195 """ISourcePackageRecipe methods that require launchpad.Edit permission."""
196
197- @operation_parameters(recipe_text=Text())
198+ @mutator_for(ISourcePackageRecipeView['recipe_text'])
199+ @operation_for_version("devel")
200+ @operation_parameters(
201+ recipe_text=copy_field(
202+ ISourcePackageRecipeView['recipe_text']))
203 @export_write_operation()
204 def setRecipeText(recipe_text):
205 """Set the text of the recipe."""
206
207=== modified file 'lib/lp/code/model/sourcepackagerecipe.py'
208--- lib/lp/code/model/sourcepackagerecipe.py 2011-02-17 04:58:44 +0000
209+++ lib/lp/code/model/sourcepackagerecipe.py 2011-02-17 22:48:58 +0000
210@@ -15,7 +15,10 @@
211 datetime,
212 timedelta,
213 )
214+
215+from bzrlib.plugins.builder.recipe import RecipeParseError
216 from lazr.delegates import delegates
217+from lazr.restful.error import expose
218 from pytz import utc
219 from storm.expr import (
220 And,
221@@ -50,7 +53,10 @@
222 from lp.code.errors import (
223 BuildAlreadyPending,
224 BuildNotAllowedForDistro,
225+ NoSuchBranch,
226+ PrivateBranchRecipe,
227 TooManyBuilds,
228+ TooNewRecipeFormat,
229 )
230 from lp.code.interfaces.sourcepackagerecipe import (
231 ISourcePackageRecipe,
232@@ -160,8 +166,13 @@
233 return self._recipe_data.base_branch
234
235 def setRecipeText(self, recipe_text):
236- parsed = SourcePackageRecipeData.getParsedRecipe(recipe_text)
237- self._recipe_data.setRecipe(parsed)
238+ try:
239+ parsed = SourcePackageRecipeData.getParsedRecipe(recipe_text)
240+ self._recipe_data.setRecipe(parsed)
241+ except (RecipeParseError, NoSuchBranch, PrivateBranchRecipe,
242+ TooNewRecipeFormat) as e:
243+ expose(e)
244+ raise
245
246 @property
247 def recipe_text(self):
248
249=== modified file 'lib/lp/code/model/tests/test_sourcepackagerecipe.py'
250--- lib/lp/code/model/tests/test_sourcepackagerecipe.py 2011-02-17 10:10:29 +0000
251+++ lib/lp/code/model/tests/test_sourcepackagerecipe.py 2011-02-17 22:48:58 +0000
252@@ -616,11 +616,40 @@
253 self.assertEqual([build], list(recipe.getBuilds()))
254 self.assertEqual([], list(recipe.getPendingBuilds()))
255
256+ def test_setRecipeText_private_base_branch(self):
257+ source_package_recipe = self.factory.makeSourcePackageRecipe()
258+ with person_logged_in(source_package_recipe.owner):
259+ branch = self.factory.makeAnyBranch(
260+ private=True, owner=source_package_recipe.owner)
261+ recipe_text = self.factory.makeRecipeText(branch)
262+ e = self.assertRaises(
263+ PrivateBranchRecipe, source_package_recipe.setRecipeText,
264+ recipe_text)
265+ self.assertEqual(
266+ 'Recipe may not refer to private branch: %s' %
267+ branch.bzr_identity, str(e))
268+
269+ def test_setRecipeText_private_referenced_branch(self):
270+ source_package_recipe = self.factory.makeSourcePackageRecipe()
271+ with person_logged_in(source_package_recipe.owner):
272+ base_branch = self.factory.makeAnyBranch(
273+ owner=source_package_recipe.owner)
274+ referenced_branch = self.factory.makeAnyBranch(
275+ private=True, owner=source_package_recipe.owner)
276+ recipe_text = self.factory.makeRecipeText(
277+ base_branch, referenced_branch)
278+ e = self.assertRaises(
279+ PrivateBranchRecipe, source_package_recipe.setRecipeText,
280+ recipe_text)
281+ self.assertEqual(
282+ 'Recipe may not refer to private branch: %s' %
283+ referenced_branch.bzr_identity, str(e))
284+
285 def test_getBuilds_ignores_disabled_archive(self):
286 # Builds into a disabled archive aren't returned.
287 archive = self.factory.makeArchive()
288 recipe = self.factory.makeSourcePackageRecipe()
289- build = self.factory.makeSourcePackageRecipeBuild(
290+ self.factory.makeSourcePackageRecipeBuild(
291 recipe=recipe, archive=archive)
292 with person_logged_in(archive.owner):
293 archive.disable()
294@@ -825,7 +854,7 @@
295 branch = self.factory.makeBranch()
296 return MINIMAL_RECIPE_TEXT % branch.bzr_identity
297
298- def makeRecipe(self, user=None, owner=None, recipe_text=None):
299+ def makeRecipe(self, user=None, owner=None, recipe_text=None, version='devel'):
300 # rockstar 21 Jul 2010 - This function does more commits than I'd
301 # like, but it's the result of the fact that the webservice runs in a
302 # separate thread so doesn't get the database updates without those
303@@ -839,8 +868,9 @@
304 recipe_text = self.makeRecipeText()
305 db_archive = self.factory.makeArchive(owner=owner, name="recipe-ppa")
306 transaction.commit()
307- launchpad = launchpadlib_for('test', user,
308- service_root=self.layer.appserver_root_url('api'))
309+ launchpad = launchpadlib_for(
310+ 'test', user, version=version,
311+ service_root=self.layer.appserver_root_url('api'))
312 login(ANONYMOUS)
313 distroseries = ws_object(launchpad, db_distroseries)
314 ws_owner = ws_object(launchpad, owner)
315@@ -871,38 +901,21 @@
316 def test_recipe_text(self):
317 recipe_text2 = self.makeRecipeText()
318 recipe = self.makeRecipe()[0]
319+ recipe.recipe_text = recipe_text2
320+ recipe.lp_save()
321+ self.assertEqual(recipe_text2, recipe.recipe_text)
322+
323+ def test_recipe_text_setRecipeText_not_in_devel(self):
324+ recipe = self.makeRecipe()[0]
325+ method = getattr(recipe, 'setRecipeText', None)
326+ self.assertIs(None, method)
327+
328+ def test_recipe_text_setRecipeText_in_one_zero(self):
329+ recipe_text2 = self.makeRecipeText()
330+ recipe = self.makeRecipe(version='1.0')[0]
331 recipe.setRecipeText(recipe_text=recipe_text2)
332 self.assertEqual(recipe_text2, recipe.recipe_text)
333
334- def test_setRecipeText_private_base_branch(self):
335- source_package_recipe = self.factory.makeSourcePackageRecipe()
336- with person_logged_in(source_package_recipe.owner):
337- branch = self.factory.makeAnyBranch(
338- private=True, owner=source_package_recipe.owner)
339- recipe_text = self.factory.makeRecipeText(branch)
340- e = self.assertRaises(
341- PrivateBranchRecipe, source_package_recipe.setRecipeText,
342- recipe_text)
343- self.assertEqual(
344- 'Recipe may not refer to private branch: %s' %
345- branch.bzr_identity, str(e))
346-
347- def test_setRecipeText_private_referenced_branch(self):
348- source_package_recipe = self.factory.makeSourcePackageRecipe()
349- with person_logged_in(source_package_recipe.owner):
350- base_branch = self.factory.makeAnyBranch(
351- owner=source_package_recipe.owner)
352- referenced_branch = self.factory.makeAnyBranch(
353- private=True, owner=source_package_recipe.owner)
354- recipe_text = self.factory.makeRecipeText(
355- base_branch, referenced_branch)
356- e = self.assertRaises(
357- PrivateBranchRecipe, source_package_recipe.setRecipeText,
358- recipe_text)
359- self.assertEqual(
360- 'Recipe may not refer to private branch: %s' %
361- referenced_branch.bzr_identity, str(e))
362-
363 def test_getRecipe(self):
364 """Person.getRecipe returns the named recipe."""
365 recipe, user = self.makeRecipe()[:-1]
366
367=== modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt'
368--- lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-11 01:50:20 +0000
369+++ lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-17 22:48:58 +0000
370@@ -12,6 +12,10 @@
371 .binary-build .indent {
372 padding-left: 2em;
373 }
374+ #edit-recipe_text {
375+ font-family: "UbuntuBeta Mono","Ubuntu Mono",monospace;
376+ margin-top: -15px;
377+ }
378 </style>
379 </metal:block>
380
381@@ -164,7 +168,7 @@
382 </div>
383 <div class='portlet'>
384 <h2>Recipe contents</h2>
385- <pre tal:content="context/recipe_text" />
386+ <tal:widget replace="structure view/recipe_text_widget"/>
387 </div>
388 </div>
389 </div>
390
391=== modified file 'lib/lp/code/windmill/tests/test_recipe_index.py'
392--- lib/lp/code/windmill/tests/test_recipe_index.py 2011-02-14 00:58:45 +0000
393+++ lib/lp/code/windmill/tests/test_recipe_index.py 2011-02-17 22:48:58 +0000
394@@ -44,3 +44,29 @@
395 freshly_fetched_recipe = Store.of(recipe).find(
396 SourcePackageRecipe, SourcePackageRecipe.id == recipe.id).one()
397 self.assertTrue(freshly_fetched_recipe.build_daily)
398+
399+ def test_inline_recipe_text_errors(self):
400+ eric = self.factory.makePerson(
401+ name="eric", displayname="Eric the Viking", password="test",
402+ email="eric@example.com")
403+ recipe = self.factory.makeSourcePackageRecipe(owner=eric)
404+ recipe_text = recipe.recipe_text + 'merge WTF?'
405+ transaction.commit()
406+
407+ client, start_url = self.getClientFor(recipe, user=eric)
408+ client.click(
409+ jquery=u'("div#edit-recipe_text a.yui3-editable_text-trigger")[0]')
410+ client.waits.forElement(
411+ jquery=u'("div#edit-recipe_text textarea.yui3-ieditor-input")',
412+ timeout=FOR_ELEMENT)
413+ client.type(
414+ text=recipe_text,
415+ jquery=u'("div#edit-recipe_text textarea.yui3-ieditor-input")[0]')
416+ client.click(
417+ jquery=u'("div#edit-recipe_text button.yui3-ieditor-submit_button")[0]')
418+ client.waits.forElement(
419+ jquery=u'("div#edit-recipe_text textarea.yui3-ieditor-errors")',
420+ timeout=FOR_ELEMENT)
421+ client.asserts.assertTextIn(
422+ jquery=u'("div#edit-recipe_text textarea.yui3-ieditor-errors")[0]',
423+ validator=u'End of line while looking for the branch url.')
424
425=== modified file 'versions.cfg'
426--- versions.cfg 2011-02-11 04:25:27 +0000
427+++ versions.cfg 2011-02-17 22:48:58 +0000
428@@ -33,7 +33,7 @@
429 lazr.delegates = 1.2.0
430 lazr.enum = 1.1.2
431 lazr.lifecycle = 1.1
432-lazr.restful = 0.16.0-r171
433+lazr.restful = 0.16.1
434 lazr.restfulclient = 0.11.2
435 lazr.smtptest = 1.1
436 lazr.testing = 0.1.1