Merge lp:~cjwatson/launchpad/git-repository-ui-edit-target into lp:launchpad
- git-repository-ui-edit-target
- Merge into devel
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | 17574 |
Proposed branch: | lp:~cjwatson/launchpad/git-repository-ui-edit-target |
Merge into: | lp:launchpad |
Prerequisite: | lp:~cjwatson/launchpad/git-repository-ui-edit-owner |
Diff against target: |
1467 lines (+1306/-8) 10 files modified
lib/lp/code/browser/configure.zcml (+1/-1) lib/lp/code/browser/gitrepository.py (+113/-6) lib/lp/code/browser/tests/test_gitrepository.py (+212/-1) lib/lp/code/browser/widgets/gitrepositorytarget.py (+223/-0) lib/lp/code/browser/widgets/templates/gitrepository-target.pt (+50/-0) lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py (+391/-0) lib/lp/code/javascript/gitrepository.edit.js (+43/-0) lib/lp/code/javascript/tests/test_gitrepository.edit.html (+150/-0) lib/lp/code/javascript/tests/test_gitrepository.edit.js (+100/-0) lib/lp/code/templates/gitrepository-edit.pt (+23/-0) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-repository-ui-edit-target |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+261232@code.launchpad.net |
Commit message
Teach GitRepository:+edit how to change target, target_default, and owner_default.
Description of the change
Teach GitRepository:+edit how to change target, target_default, and owner_default. This involves some JavaScript to disable the *_default fields when the target is set to a personal repository, where they don't make sense.
To post a comment you must log in.
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 2015-06-12 08:08:00 +0000 |
3 | +++ lib/lp/code/browser/configure.zcml 2015-06-18 20:19:28 +0000 |
4 | @@ -796,7 +796,7 @@ |
5 | class="lp.code.browser.gitrepository.GitRepositoryEditView" |
6 | permission="launchpad.Edit" |
7 | name="+edit" |
8 | - template="../../app/templates/generic-edit.pt"/> |
9 | + template="../templates/gitrepository-edit.pt"/> |
10 | <browser:page |
11 | for="lp.code.interfaces.gitrepository.IGitRepository" |
12 | class="lp.code.browser.gitrepository.GitRepositoryDeletionView" |
13 | |
14 | === modified file 'lib/lp/code/browser/gitrepository.py' |
15 | --- lib/lp/code/browser/gitrepository.py 2015-06-13 01:45:19 +0000 |
16 | +++ lib/lp/code/browser/gitrepository.py 2015-06-18 20:19:28 +0000 |
17 | @@ -27,11 +27,17 @@ |
18 | ) |
19 | from storm.expr import Desc |
20 | from zope.event import notify |
21 | +from zope.formlib import form |
22 | from zope.interface import ( |
23 | implements, |
24 | Interface, |
25 | providedBy, |
26 | ) |
27 | +from zope.schema import Choice |
28 | +from zope.schema.vocabulary import ( |
29 | + SimpleTerm, |
30 | + SimpleVocabulary, |
31 | + ) |
32 | |
33 | from lp import _ |
34 | from lp.app.browser.informationtype import InformationTypePortletMixin |
35 | @@ -45,13 +51,21 @@ |
36 | from lp.app.vocabularies import InformationTypeVocabulary |
37 | from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription |
38 | from lp.code.browser.branch import CodeEditOwnerMixin |
39 | +from lp.code.browser.widgets.gitrepositorytarget import ( |
40 | + GitRepositoryTargetDisplayWidget, |
41 | + GitRepositoryTargetWidget, |
42 | + ) |
43 | from lp.code.errors import ( |
44 | + GitDefaultConflict, |
45 | GitRepositoryCreationForbidden, |
46 | GitRepositoryExists, |
47 | + GitTargetError, |
48 | ) |
49 | from lp.code.interfaces.gitnamespace import get_git_namespace |
50 | from lp.code.interfaces.gitref import IGitRefBatchNavigator |
51 | from lp.code.interfaces.gitrepository import IGitRepository |
52 | +from lp.registry.interfaces.person import IPerson |
53 | +from lp.registry.vocabularies import UserTeamsParticipationPlusSelfVocabulary |
54 | from lp.services.config import config |
55 | from lp.services.database.constants import UTC_NOW |
56 | from lp.services.propertycache import cachedproperty |
57 | @@ -275,7 +289,10 @@ |
58 | vocabulary=InformationTypeVocabulary(types=info_types)) |
59 | name = copy_field(IGitRepository["name"], readonly=False) |
60 | owner = copy_field(IGitRepository["owner"], readonly=False) |
61 | + owner_default = copy_field( |
62 | + IGitRepository["owner_default"], readonly=False) |
63 | reviewer = copy_field(IGitRepository["reviewer"], required=True) |
64 | + target = copy_field(IGitRepository["target"], readonly=False) |
65 | |
66 | return GitRepositoryEditSchema |
67 | |
68 | @@ -320,6 +337,25 @@ |
69 | information_type = data.pop("information_type") |
70 | self.context.transitionToInformationType( |
71 | information_type, self.user) |
72 | + if "target" in data: |
73 | + target = data.pop("target") |
74 | + if target is None: |
75 | + target = self.context.owner |
76 | + if target != self.context.target: |
77 | + try: |
78 | + self.context.setTarget(target, self.user) |
79 | + except GitTargetError as e: |
80 | + self.setFieldError("target", e.message) |
81 | + return |
82 | + changed = True |
83 | + if IPerson.providedBy(target): |
84 | + self.request.response.addNotification( |
85 | + "This repository is now a personal repository for %s " |
86 | + "(%s)" % (target.displayname, target.name)) |
87 | + else: |
88 | + self.request.response.addNotification( |
89 | + "The repository target has been changed to %s (%s)" % |
90 | + (target.displayname, target.name)) |
91 | if "reviewer" in data: |
92 | reviewer = data.pop("reviewer") |
93 | if reviewer != self.context.code_reviewer: |
94 | @@ -329,6 +365,11 @@ |
95 | else: |
96 | self.context.reviewer = reviewer |
97 | changed = True |
98 | + if "owner_default" in data: |
99 | + owner_default = data.pop("owner_default") |
100 | + if (self.context.namespace.has_defaults and |
101 | + owner_default != self.context.owner_default): |
102 | + self.context.setOwnerDefault(owner_default) |
103 | |
104 | if self.updateContextFromData(data, notify_modified=False): |
105 | changed = True |
106 | @@ -381,7 +422,9 @@ |
107 | field_names = [ |
108 | "owner", |
109 | "name", |
110 | + "target", |
111 | "information_type", |
112 | + "owner_default", |
113 | "default_branch", |
114 | ] |
115 | |
116 | @@ -391,6 +434,61 @@ |
117 | "As an administrator you are able to assign this repository to any " |
118 | "person or team.") |
119 | |
120 | + def setUpFields(self): |
121 | + super(GitRepositoryEditView, self).setUpFields() |
122 | + repository = self.context |
123 | + # If the user can administer repositories, then they should be able |
124 | + # to assign the ownership of the repository to any valid person or |
125 | + # team. |
126 | + if check_permission("launchpad.Admin", repository): |
127 | + owner_field = self.schema["owner"] |
128 | + any_owner_choice = Choice( |
129 | + __name__="owner", title=owner_field.title, |
130 | + description=_( |
131 | + "As an administrator you are able to assign this " |
132 | + "repository to any person or team."), |
133 | + required=True, vocabulary="ValidPersonOrTeam") |
134 | + any_owner_field = form.Fields( |
135 | + any_owner_choice, render_context=self.render_context) |
136 | + # Replace the normal owner field with a more permissive vocab. |
137 | + self.form_fields = self.form_fields.omit("owner") |
138 | + self.form_fields = any_owner_field + self.form_fields |
139 | + else: |
140 | + # For normal users, there are some cases (e.g. package |
141 | + # repositories) where the editor may not be in the team of the |
142 | + # repository owner. In these cases we need to extend the |
143 | + # vocabulary connected to the owner field. |
144 | + if not self.user.inTeam(self.context.owner): |
145 | + vocab = UserTeamsParticipationPlusSelfVocabulary() |
146 | + owner = self.context.owner |
147 | + terms = [SimpleTerm( |
148 | + owner, owner.name, owner.unique_displayname)] |
149 | + terms.extend([term for term in vocab]) |
150 | + owner_field = self.schema["owner"] |
151 | + owner_choice = Choice( |
152 | + __name__="owner", title=owner_field.title, |
153 | + description=owner_field.description, |
154 | + required=True, vocabulary=SimpleVocabulary(terms)) |
155 | + new_owner_field = form.Fields( |
156 | + owner_choice, render_context=self.render_context) |
157 | + # Replace the normal owner field with a more permissive vocab. |
158 | + self.form_fields = self.form_fields.omit("owner") |
159 | + self.form_fields = new_owner_field + self.form_fields |
160 | + # If this is the target default, then the target is read-only. |
161 | + target_field = self.form_fields.get("target") |
162 | + if self.context.target_default: |
163 | + target_field.for_display = True |
164 | + target_field.custom_widget = GitRepositoryTargetDisplayWidget |
165 | + else: |
166 | + target_field.custom_widget = GitRepositoryTargetWidget |
167 | + |
168 | + def setUpWidgets(self, context=None): |
169 | + super(GitRepositoryEditView, self).setUpWidgets(context=context) |
170 | + if self.context.target_default: |
171 | + self.widgets["target"].hint = ( |
172 | + "This is the default repository for this target, so it " |
173 | + "cannot be moved to another target.") |
174 | + |
175 | def _setRepositoryExists(self, existing_repository, field_name="name"): |
176 | owner = existing_repository.owner |
177 | if owner == self.user: |
178 | @@ -404,14 +502,15 @@ |
179 | self.setFieldError(field_name, message) |
180 | |
181 | def validate(self, data): |
182 | - if "name" in data and "owner" in data: |
183 | + if "name" in data and "owner" in data and "target" in data: |
184 | name = data["name"] |
185 | owner = data["owner"] |
186 | - if name != self.context.name or owner != self.context.owner: |
187 | - if self.context.owner == self.context.target: |
188 | - target = owner |
189 | - else: |
190 | - target = self.context.target |
191 | + target = data["target"] |
192 | + if target is None: |
193 | + target = owner |
194 | + if (name != self.context.name or |
195 | + owner != self.context.owner or |
196 | + target != self.context.target): |
197 | namespace = get_git_namespace(target, owner) |
198 | try: |
199 | namespace.validateMove(self.context, self.user, name=name) |
200 | @@ -421,6 +520,14 @@ |
201 | (owner.displayname, target.displayname)) |
202 | except GitRepositoryExists as e: |
203 | self._setRepositoryExists(e.existing_repository) |
204 | + except GitDefaultConflict as e: |
205 | + self.addError(str(e)) |
206 | + if (self.context.target_default and "target" in data and |
207 | + data["target"] != self.context.target): |
208 | + self.setFieldError( |
209 | + "target", |
210 | + "The default repository for a target cannot be moved to " |
211 | + "another target.") |
212 | if "default_branch" in data: |
213 | default_branch = data["default_branch"] |
214 | if (default_branch is not None and |
215 | |
216 | === modified file 'lib/lp/code/browser/tests/test_gitrepository.py' |
217 | --- lib/lp/code/browser/tests/test_gitrepository.py 2015-06-13 01:45:19 +0000 |
218 | +++ lib/lp/code/browser/tests/test_gitrepository.py 2015-06-18 20:19:28 +0000 |
219 | @@ -17,6 +17,7 @@ |
220 | ) |
221 | import transaction |
222 | from zope.component import getUtility |
223 | +from zope.formlib.itemswidgets import ItemDisplayWidget |
224 | from zope.publisher.interfaces import NotFound |
225 | from zope.security.proxy import removeSecurityProxy |
226 | |
227 | @@ -42,7 +43,10 @@ |
228 | from lp.testing.fakemethod import FakeMethod |
229 | from lp.testing.fixture import ZopeUtilityFixture |
230 | from lp.testing.layers import DatabaseFunctionalLayer |
231 | -from lp.testing.matchers import HasQueryCount |
232 | +from lp.testing.matchers import ( |
233 | + Contains, |
234 | + HasQueryCount, |
235 | + ) |
236 | from lp.testing.pages import ( |
237 | get_feedback_messages, |
238 | setupBrowser, |
239 | @@ -259,6 +263,213 @@ |
240 | |
241 | layer = DatabaseFunctionalLayer |
242 | |
243 | + def test_repository_target_widget_read_only(self): |
244 | + # The repository target widget is read-only if the repository is the |
245 | + # default for its target. |
246 | + person = self.factory.makePerson() |
247 | + project = self.factory.makeProduct(owner=person) |
248 | + repository = self.factory.makeGitRepository( |
249 | + owner=person, target=project) |
250 | + login_person(person) |
251 | + repository.setTargetDefault(True) |
252 | + view = create_initialized_view(repository, name="+edit") |
253 | + self.assertEqual("project", view.widgets["target"].default_option) |
254 | + self.assertIsInstance( |
255 | + view.widgets["target"].project_widget, ItemDisplayWidget) |
256 | + self.assertEqual( |
257 | + project.title, view.widgets["target"].project_widget()) |
258 | + |
259 | + def test_repository_target_widget_renders_personal(self): |
260 | + # The repository target widget renders correctly for a personal |
261 | + # repository. |
262 | + person = self.factory.makePerson() |
263 | + repository = self.factory.makeGitRepository( |
264 | + owner=person, target=person) |
265 | + login_person(person) |
266 | + view = create_initialized_view(repository, name="+edit") |
267 | + self.assertEqual("personal", view.widgets["target"].default_option) |
268 | + |
269 | + def test_repository_target_widget_renders_product(self): |
270 | + # The repository target widget renders correctly for a product |
271 | + # repository. |
272 | + person = self.factory.makePerson() |
273 | + project = self.factory.makeProduct() |
274 | + repository = self.factory.makeGitRepository( |
275 | + owner=person, target=project) |
276 | + login_person(person) |
277 | + view = create_initialized_view(repository, name="+edit") |
278 | + self.assertEqual("project", view.widgets["target"].default_option) |
279 | + self.assertEqual( |
280 | + project.name, view.widgets["target"].project_widget.selected_value) |
281 | + |
282 | + def test_repository_target_widget_renders_package(self): |
283 | + # The repository target widget renders correctly for a package |
284 | + # repository. |
285 | + person = self.factory.makePerson() |
286 | + dsp = self.factory.makeDistributionSourcePackage() |
287 | + repository = self.factory.makeGitRepository(owner=person, target=dsp) |
288 | + login_person(person) |
289 | + view = create_initialized_view(repository, name="+edit") |
290 | + self.assertEqual("package", view.widgets["target"].default_option) |
291 | + self.assertEqual( |
292 | + dsp.distribution, |
293 | + view.widgets["target"].distribution_widget._getFormValue()) |
294 | + self.assertEqual( |
295 | + dsp.sourcepackagename.name, |
296 | + view.widgets["target"].package_widget.selected_value) |
297 | + |
298 | + def test_repository_target_widget_saves_personal(self): |
299 | + # The repository target widget can retarget to a personal |
300 | + # repository. |
301 | + person = self.factory.makePerson() |
302 | + repository = self.factory.makeGitRepository(owner=person) |
303 | + login_person(person) |
304 | + form = { |
305 | + "field.target": "personal", |
306 | + "field.actions.change": "Change Git Repository", |
307 | + } |
308 | + view = create_initialized_view(repository, name="+edit", form=form) |
309 | + self.assertEqual(person, repository.target) |
310 | + self.assertEqual(1, len(view.request.response.notifications)) |
311 | + self.assertEqual( |
312 | + "This repository is now a personal repository for %s (%s)" |
313 | + % (person.displayname, person.name), |
314 | + view.request.response.notifications[0].message) |
315 | + |
316 | + def test_repository_target_widget_saves_personal_different_owner(self): |
317 | + # The repository target widget can retarget to a personal repository |
318 | + # for a different owner. |
319 | + person = self.factory.makePerson() |
320 | + repository = self.factory.makeGitRepository( |
321 | + owner=person, target=person) |
322 | + new_owner = self.factory.makeTeam(name="newowner", members=[person]) |
323 | + login_person(person) |
324 | + form = { |
325 | + "field.target": "personal", |
326 | + "field.owner": "newowner", |
327 | + "field.actions.change": "Change Git Repository", |
328 | + } |
329 | + view = create_initialized_view(repository, name="+edit", form=form) |
330 | + self.assertEqual(new_owner, repository.target) |
331 | + self.assertEqual(1, len(view.request.response.notifications)) |
332 | + self.assertEqual( |
333 | + "The repository owner has been changed to Newowner (newowner)", |
334 | + view.request.response.notifications[0].message) |
335 | + |
336 | + def test_repository_target_widget_saves_personal_clears_default(self): |
337 | + # When retargeting to a personal repository, the owner-target |
338 | + # default flag is cleared. |
339 | + person = self.factory.makePerson() |
340 | + project = self.factory.makeProduct(owner=person) |
341 | + repository = self.factory.makeGitRepository( |
342 | + owner=person, target=project) |
343 | + login_person(person) |
344 | + repository.setOwnerDefault(True) |
345 | + form = { |
346 | + "field.target": "personal", |
347 | + "field.actions.change": "Change Git Repository", |
348 | + } |
349 | + view = create_initialized_view(repository, name="+edit", form=form) |
350 | + self.assertEqual([], view.errors) |
351 | + self.assertEqual(person, repository.target) |
352 | + self.assertFalse(repository.owner_default) |
353 | + self.assertEqual(1, len(view.request.response.notifications)) |
354 | + self.assertEqual( |
355 | + "This repository is now a personal repository for %s (%s)" |
356 | + % (person.displayname, person.name), |
357 | + view.request.response.notifications[0].message) |
358 | + |
359 | + def test_repository_target_widget_saves_project(self): |
360 | + # The repository target widget can retarget to a project repository. |
361 | + person = self.factory.makePerson() |
362 | + repository = self.factory.makeGitRepository( |
363 | + owner=person, target=person) |
364 | + project = self.factory.makeProduct() |
365 | + login_person(person) |
366 | + form = { |
367 | + "field.target": "project", |
368 | + "field.target.project": project.name, |
369 | + "field.actions.change": "Change Git Repository", |
370 | + } |
371 | + view = create_initialized_view(repository, name="+edit", form=form) |
372 | + self.assertEqual(project, repository.target) |
373 | + self.assertEqual( |
374 | + "The repository target has been changed to %s (%s)" |
375 | + % (project.displayname, project.name), |
376 | + view.request.response.notifications[0].message) |
377 | + |
378 | + def test_repository_target_widget_saves_package(self): |
379 | + # The repository target widget can retarget to a package repository. |
380 | + person = self.factory.makePerson() |
381 | + repository = self.factory.makeGitRepository( |
382 | + owner=person, target=person) |
383 | + dsp = self.factory.makeDistributionSourcePackage() |
384 | + self.factory.makeSourcePackagePublishingHistory( |
385 | + distroseries=dsp.distribution.currentseries, |
386 | + sourcepackagename=dsp.sourcepackagename, |
387 | + archive=dsp.distribution.main_archive) |
388 | + login_person(person) |
389 | + form = { |
390 | + "field.target": "package", |
391 | + "field.target.distribution": dsp.distribution.name, |
392 | + "field.target.package": dsp.sourcepackagename.name, |
393 | + "field.actions.change": "Change Git Repository", |
394 | + } |
395 | + view = create_initialized_view(repository, name="+edit", form=form) |
396 | + self.assertEqual(dsp, repository.target) |
397 | + self.assertEqual( |
398 | + "The repository target has been changed to %s (%s)" |
399 | + % (dsp.displayname, dsp.name), |
400 | + view.request.response.notifications[0].message) |
401 | + |
402 | + def test_forbidden_target_is_error(self): |
403 | + # An error is displayed if a repository is saved with a target that |
404 | + # is not allowed by the sharing policy. |
405 | + owner = self.factory.makePerson() |
406 | + initial_target = self.factory.makeProduct() |
407 | + self.factory.makeProduct( |
408 | + name="commercial", owner=owner, |
409 | + branch_sharing_policy=BranchSharingPolicy.PROPRIETARY) |
410 | + repository = self.factory.makeGitRepository( |
411 | + owner=owner, target=initial_target, |
412 | + information_type=InformationType.PUBLIC) |
413 | + browser = self.getUserBrowser( |
414 | + canonical_url(repository) + "/+edit", user=owner) |
415 | + browser.getControl(name="field.target.project").value = "commercial" |
416 | + browser.getControl("Change Git Repository").click() |
417 | + self.assertThat( |
418 | + browser.contents, |
419 | + Contains( |
420 | + "Public repositories are not allowed for target Commercial.")) |
421 | + with person_logged_in(owner): |
422 | + self.assertEqual(initial_target, repository.target) |
423 | + |
424 | + def test_default_conflict_is_error(self): |
425 | + # An error is displayed if an owner-default repository is saved with |
426 | + # a new target that already has an owner-default repository. |
427 | + owner = self.factory.makePerson() |
428 | + initial_target = self.factory.makeProduct() |
429 | + new_target = self.factory.makeProduct(name="new", displayname="New") |
430 | + repository = self.factory.makeGitRepository( |
431 | + owner=owner, target=initial_target) |
432 | + existing_default = self.factory.makeGitRepository( |
433 | + owner=owner, target=new_target) |
434 | + login_person(owner) |
435 | + repository.setOwnerDefault(True) |
436 | + existing_default.setOwnerDefault(True) |
437 | + browser = self.getUserBrowser( |
438 | + canonical_url(repository) + "/+edit", user=owner) |
439 | + browser.getControl(name="field.target.project").value = "new" |
440 | + browser.getControl("Change Git Repository").click() |
441 | + with person_logged_in(owner): |
442 | + self.assertThat( |
443 | + browser.contents, |
444 | + Contains( |
445 | + "%s's default repository for 'New' is " |
446 | + "already set to %s." % |
447 | + (owner.displayname, existing_default.unique_name))) |
448 | + self.assertEqual(initial_target, repository.target) |
449 | + |
450 | def test_rename(self): |
451 | # The name of a repository can be changed via the UI by an |
452 | # authorised user. |
453 | |
454 | === added file 'lib/lp/code/browser/widgets/gitrepositorytarget.py' |
455 | --- lib/lp/code/browser/widgets/gitrepositorytarget.py 1970-01-01 00:00:00 +0000 |
456 | +++ lib/lp/code/browser/widgets/gitrepositorytarget.py 2015-06-18 20:19:28 +0000 |
457 | @@ -0,0 +1,223 @@ |
458 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
459 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
460 | + |
461 | +__metaclass__ = type |
462 | + |
463 | +__all__ = [ |
464 | + 'GitRepositoryTargetDisplayWidget', |
465 | + 'GitRepositoryTargetWidget', |
466 | + ] |
467 | + |
468 | +from z3c.ptcompat import ViewPageTemplateFile |
469 | +from zope.component import getUtility |
470 | +from zope.formlib.interfaces import ( |
471 | + ConversionError, |
472 | + IDisplayWidget, |
473 | + IInputWidget, |
474 | + InputErrors, |
475 | + MissingInputError, |
476 | + WidgetInputError, |
477 | + ) |
478 | +from zope.formlib.utility import setUpWidget |
479 | +from zope.formlib.widget import ( |
480 | + BrowserWidget, |
481 | + CustomWidgetFactory, |
482 | + DisplayWidget, |
483 | + InputWidget, |
484 | + renderElement, |
485 | + ) |
486 | +from zope.interface import implements |
487 | +from zope.schema import Choice |
488 | + |
489 | +from lp.app.errors import ( |
490 | + NotFoundError, |
491 | + UnexpectedFormData, |
492 | + ) |
493 | +from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
494 | +from lp.app.validators import LaunchpadValidationError |
495 | +from lp.app.widgets.itemswidgets import LaunchpadDropdownWidget |
496 | +from lp.registry.interfaces.distributionsourcepackage import ( |
497 | + IDistributionSourcePackage, |
498 | + ) |
499 | +from lp.registry.interfaces.person import IPerson |
500 | +from lp.registry.interfaces.product import IProduct |
501 | +from lp.services.webapp.interfaces import ( |
502 | + IAlwaysSubmittedWidget, |
503 | + IMultiLineWidgetLayout, |
504 | + ) |
505 | + |
506 | + |
507 | +class GitRepositoryTargetWidgetBase(BrowserWidget): |
508 | + |
509 | + implements(IMultiLineWidgetLayout) |
510 | + |
511 | + template = ViewPageTemplateFile("templates/gitrepository-target.pt") |
512 | + default_option = "project" |
513 | + _widgets_set_up = False |
514 | + |
515 | + def setUpSubWidgets(self): |
516 | + if self._widgets_set_up: |
517 | + return |
518 | + fields = [ |
519 | + Choice( |
520 | + __name__="project", title=u"Project", |
521 | + required=True, vocabulary="Product"), |
522 | + Choice( |
523 | + __name__="distribution", title=u"Distribution", |
524 | + required=True, vocabulary="Distribution", |
525 | + default=getUtility(ILaunchpadCelebrities).ubuntu), |
526 | + Choice( |
527 | + __name__="package", title=u"Package", |
528 | + required=False, vocabulary="BinaryAndSourcePackageName"), |
529 | + ] |
530 | + if not self._read_only: |
531 | + self.distribution_widget = CustomWidgetFactory( |
532 | + LaunchpadDropdownWidget) |
533 | + for field in fields: |
534 | + setUpWidget( |
535 | + self, field.__name__, field, self._sub_widget_interface, |
536 | + prefix=self.name) |
537 | + self._widgets_set_up = True |
538 | + |
539 | + def setUpOptions(self): |
540 | + """Set up options to be rendered.""" |
541 | + self.options = {} |
542 | + for option in ["personal", "package", "project"]: |
543 | + attributes = dict( |
544 | + type="radio", name=self.name, value=option, |
545 | + id="%s.option.%s" % (self.name, option)) |
546 | + if self.request.form_ng.getOne( |
547 | + self.name, self.default_option) == option: |
548 | + attributes["checked"] = "checked" |
549 | + if self._read_only: |
550 | + attributes["disabled"] = "disabled" |
551 | + self.options[option] = renderElement("input", **attributes) |
552 | + |
553 | + @property |
554 | + def show_options(self): |
555 | + return { |
556 | + option: not self._read_only or self.default_option == option |
557 | + for option in ["personal", "package", "project"]} |
558 | + |
559 | + def setRenderedValue(self, value): |
560 | + """See `IWidget`.""" |
561 | + self.setUpSubWidgets() |
562 | + if value is None or IPerson.providedBy(value): |
563 | + self.default_option = "personal" |
564 | + return |
565 | + elif IProduct.providedBy(value): |
566 | + self.default_option = "project" |
567 | + self.project_widget.setRenderedValue(value) |
568 | + return |
569 | + elif IDistributionSourcePackage.providedBy(value): |
570 | + self.default_option = "package" |
571 | + self.distribution_widget.setRenderedValue(value.distribution) |
572 | + self.package_widget.setRenderedValue(value.sourcepackagename) |
573 | + else: |
574 | + raise AssertionError("Not a valid value: %r" % value) |
575 | + |
576 | + def __call__(self): |
577 | + """See `zope.formlib.interfaces.IBrowserWidget`.""" |
578 | + self.setUpSubWidgets() |
579 | + self.setUpOptions() |
580 | + return self.template() |
581 | + |
582 | + |
583 | +class GitRepositoryTargetDisplayWidget( |
584 | + GitRepositoryTargetWidgetBase, DisplayWidget): |
585 | + """Widget for displaying a Git repository target.""" |
586 | + |
587 | + implements(IDisplayWidget) |
588 | + |
589 | + _sub_widget_interface = IDisplayWidget |
590 | + _read_only = True |
591 | + |
592 | + |
593 | +class GitRepositoryTargetWidget(GitRepositoryTargetWidgetBase, InputWidget): |
594 | + """Widget for selecting a Git repository target.""" |
595 | + |
596 | + implements(IAlwaysSubmittedWidget, IInputWidget) |
597 | + |
598 | + _sub_widget_interface = IInputWidget |
599 | + _read_only = False |
600 | + _widgets_set_up = False |
601 | + |
602 | + def hasInput(self): |
603 | + return self.name in self.request.form |
604 | + |
605 | + def hasValidInput(self): |
606 | + """See `zope.formlib.interfaces.IInputWidget`.""" |
607 | + try: |
608 | + self.getInputValue() |
609 | + return True |
610 | + except (InputErrors, UnexpectedFormData): |
611 | + return False |
612 | + |
613 | + def getInputValue(self): |
614 | + """See `zope.formlib.interfaces.IInputWidget`.""" |
615 | + self.setUpSubWidgets() |
616 | + form_value = self.request.form_ng.getOne(self.name) |
617 | + if form_value == "project": |
618 | + try: |
619 | + return self.project_widget.getInputValue() |
620 | + except MissingInputError: |
621 | + raise WidgetInputError( |
622 | + self.name, self.label, |
623 | + LaunchpadValidationError("Please enter a project name")) |
624 | + except ConversionError: |
625 | + entered_name = self.request.form_ng.getOne( |
626 | + "%s.project" % self.name) |
627 | + raise WidgetInputError( |
628 | + self.name, self.label, |
629 | + LaunchpadValidationError( |
630 | + "There is no project named '%s' registered in " |
631 | + "Launchpad" % entered_name)) |
632 | + elif form_value == "package": |
633 | + try: |
634 | + distribution = self.distribution_widget.getInputValue() |
635 | + except ConversionError: |
636 | + entered_name = self.request.form_ng.getOne( |
637 | + "%s.distribution" % self.name) |
638 | + raise WidgetInputError( |
639 | + self.name, self.label, |
640 | + LaunchpadValidationError( |
641 | + "There is no distribution named '%s' registered in " |
642 | + "Launchpad" % entered_name)) |
643 | + try: |
644 | + if self.package_widget.hasInput(): |
645 | + package_name = self.package_widget.getInputValue() |
646 | + else: |
647 | + package_name = None |
648 | + if package_name is None: |
649 | + raise WidgetInputError( |
650 | + self.name, self.label, |
651 | + LaunchpadValidationError( |
652 | + "Please enter a package name")) |
653 | + if IDistributionSourcePackage.providedBy(package_name): |
654 | + dsp = package_name |
655 | + else: |
656 | + source_name = distribution.guessPublishedSourcePackageName( |
657 | + package_name.name) |
658 | + dsp = distribution.getSourcePackage(source_name) |
659 | + except (ConversionError, NotFoundError): |
660 | + entered_name = self.request.form_ng.getOne( |
661 | + "%s.package" % self.name) |
662 | + raise WidgetInputError( |
663 | + self.name, self.label, |
664 | + LaunchpadValidationError( |
665 | + "There is no package named '%s' published in %s." % |
666 | + (entered_name, distribution.displayname))) |
667 | + return dsp |
668 | + elif form_value == "personal": |
669 | + return None |
670 | + else: |
671 | + raise UnexpectedFormData("No valid option was selected.") |
672 | + |
673 | + def error(self): |
674 | + """See `zope.formlib.interfaces.IBrowserWidget`.""" |
675 | + try: |
676 | + if self.hasInput(): |
677 | + self.getInputValue() |
678 | + except InputErrors as error: |
679 | + self._error = error |
680 | + return super(GitRepositoryTargetWidget, self).error() |
681 | |
682 | === added file 'lib/lp/code/browser/widgets/templates/gitrepository-target.pt' |
683 | --- lib/lp/code/browser/widgets/templates/gitrepository-target.pt 1970-01-01 00:00:00 +0000 |
684 | +++ lib/lp/code/browser/widgets/templates/gitrepository-target.pt 2015-06-18 20:19:28 +0000 |
685 | @@ -0,0 +1,50 @@ |
686 | +<table> |
687 | + <tr tal:condition="view/show_options/personal"> |
688 | + <td colspan="2"> |
689 | + <label> |
690 | + <input |
691 | + type="radio" value="personal" |
692 | + tal:replace="structure view/options/personal" /> |
693 | + Personal |
694 | + </label> |
695 | + </td> |
696 | + </tr> |
697 | + |
698 | + <tr tal:condition="view/show_options/package"> |
699 | + <td> |
700 | + <label> |
701 | + <input |
702 | + type="radio" value="package" |
703 | + tal:replace="structure view/options/package" /> |
704 | + Distribution |
705 | + </label> |
706 | + </td> |
707 | + <td> |
708 | + <tal:distribution tal:replace="structure view/distribution_widget" /> |
709 | + </td> |
710 | + </tr> |
711 | + <tr tal:condition="view/show_options/package"> |
712 | + <td align="right"> |
713 | + <label tal:attributes="for string:${view/name}.option.package"> |
714 | + Package |
715 | + </label> |
716 | + </td> |
717 | + <td> |
718 | + <tal:package tal:replace="structure view/package_widget" /> |
719 | + </td> |
720 | + </tr> |
721 | + |
722 | + <tr tal:condition="view/show_options/project"> |
723 | + <td> |
724 | + <label> |
725 | + <input |
726 | + type="radio" value="project" |
727 | + tal:replace="structure view/options/project" /> |
728 | + Project |
729 | + </label> |
730 | + </td> |
731 | + <td> |
732 | + <tal:product tal:replace="structure view/project_widget" /> |
733 | + </td> |
734 | + </tr> |
735 | +</table> |
736 | |
737 | === added file 'lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py' |
738 | --- lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py 1970-01-01 00:00:00 +0000 |
739 | +++ lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py 2015-06-18 20:19:28 +0000 |
740 | @@ -0,0 +1,391 @@ |
741 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
742 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
743 | + |
744 | +__metaclass__ = type |
745 | + |
746 | +import re |
747 | + |
748 | +from BeautifulSoup import BeautifulSoup |
749 | +from lazr.restful.fields import Reference |
750 | +from zope.formlib.interfaces import ( |
751 | + IBrowserWidget, |
752 | + IDisplayWidget, |
753 | + IInputWidget, |
754 | + WidgetInputError, |
755 | + ) |
756 | +from zope.interface import ( |
757 | + implements, |
758 | + Interface, |
759 | + ) |
760 | + |
761 | +from lp.app.validators import LaunchpadValidationError |
762 | +from lp.code.browser.widgets.gitrepositorytarget import ( |
763 | + GitRepositoryTargetDisplayWidget, |
764 | + GitRepositoryTargetWidget, |
765 | + ) |
766 | +from lp.registry.vocabularies import ( |
767 | + DistributionVocabulary, |
768 | + ProductVocabulary, |
769 | + ) |
770 | +from lp.services.webapp.escaping import html_escape |
771 | +from lp.services.webapp.servers import LaunchpadTestRequest |
772 | +from lp.soyuz.model.binaryandsourcepackagename import ( |
773 | + BinaryAndSourcePackageNameVocabulary, |
774 | + ) |
775 | +from lp.testing import ( |
776 | + TestCaseWithFactory, |
777 | + verifyObject, |
778 | + ) |
779 | +from lp.testing.layers import DatabaseFunctionalLayer |
780 | + |
781 | + |
782 | +class IThing(Interface): |
783 | + owner = Reference(schema=Interface) |
784 | + target = Reference(schema=Interface) |
785 | + |
786 | + |
787 | +class Thing: |
788 | + implements(IThing) |
789 | + owner = None |
790 | + target = None |
791 | + |
792 | + |
793 | +class TestGitRepositoryTargetWidgetBase: |
794 | + |
795 | + layer = DatabaseFunctionalLayer |
796 | + |
797 | + def setUp(self): |
798 | + super(TestGitRepositoryTargetWidgetBase, self).setUp() |
799 | + self.distribution, self.package = self.factory.makeDSPCache( |
800 | + distro_name="fnord", package_name="snarf") |
801 | + self.project = self.factory.makeProduct("pting") |
802 | + field = Reference( |
803 | + __name__="target", schema=Interface, title=u"target") |
804 | + self.context = Thing() |
805 | + field = field.bind(self.context) |
806 | + request = LaunchpadTestRequest() |
807 | + self.widget = self.widget_factory(field, request) |
808 | + |
809 | + def test_implements(self): |
810 | + self.assertTrue(verifyObject(IBrowserWidget, self.widget)) |
811 | + self.assertTrue( |
812 | + verifyObject(self.expected_widget_interface, self.widget)) |
813 | + |
814 | + def test_template(self): |
815 | + # The render template is setup. |
816 | + self.assertTrue( |
817 | + self.widget.template.filename.endswith("gitrepository-target.pt"), |
818 | + "Template was not setup.") |
819 | + |
820 | + def test_default_option(self): |
821 | + # This project field is the default option. |
822 | + self.assertEqual("project", self.widget.default_option) |
823 | + |
824 | + def test_setUpSubWidgets_first_call(self): |
825 | + # The subwidgets are setup and a flag is set. |
826 | + self.widget.setUpSubWidgets() |
827 | + self.assertTrue(self.widget._widgets_set_up) |
828 | + self.assertIsInstance( |
829 | + self.widget.distribution_widget.context.vocabulary, |
830 | + DistributionVocabulary) |
831 | + self.assertIsInstance( |
832 | + self.widget.package_widget.context.vocabulary, |
833 | + BinaryAndSourcePackageNameVocabulary) |
834 | + self.assertIsInstance( |
835 | + self.widget.project_widget.context.vocabulary, |
836 | + ProductVocabulary) |
837 | + |
838 | + def test_setUpSubWidgets_second_call(self): |
839 | + # The setUpSubWidgets method exits early if a flag is set to |
840 | + # indicate that the widgets were setup. |
841 | + self.widget._widgets_set_up = True |
842 | + self.widget.setUpSubWidgets() |
843 | + self.assertIsNone(getattr(self.widget, "distribution_widget", None)) |
844 | + self.assertIsNone(getattr(self.widget, "package_widget", None)) |
845 | + self.assertIsNone(getattr(self.widget, "project_widget", None)) |
846 | + |
847 | + def test_setUpOptions_default_project_checked(self): |
848 | + # The radio button options are composed of the setup widgets with |
849 | + # the project widget set as the default. |
850 | + self.widget.setUpSubWidgets() |
851 | + self.widget.setUpOptions() |
852 | + self.assertEqual( |
853 | + '<input class="radioType" ' + self.expected_disabled_attr + |
854 | + 'id="field.target.option.personal" name="field.target" ' |
855 | + 'type="radio" value="personal" />', |
856 | + self.widget.options["personal"]) |
857 | + self.assertEqual( |
858 | + '<input class="radioType" ' + self.expected_disabled_attr + |
859 | + 'id="field.target.option.package" name="field.target" ' |
860 | + 'type="radio" value="package" />', |
861 | + self.widget.options["package"]) |
862 | + self.assertEqual( |
863 | + '<input class="radioType" checked="checked" ' + |
864 | + self.expected_disabled_attr + |
865 | + 'id="field.target.option.project" name="field.target" ' |
866 | + 'type="radio" value="project" />', |
867 | + self.widget.options["project"]) |
868 | + |
869 | + def test_setUpOptions_personal_checked(self): |
870 | + # The personal radio button is selected when the form is submitted |
871 | + # when the target field's value is 'personal'. |
872 | + form = { |
873 | + "field.target": "personal", |
874 | + } |
875 | + self.widget.request = LaunchpadTestRequest(form=form) |
876 | + self.widget.setUpSubWidgets() |
877 | + self.widget.setUpOptions() |
878 | + self.assertEqual( |
879 | + '<input class="radioType" checked="checked" ' + |
880 | + self.expected_disabled_attr + |
881 | + 'id="field.target.option.personal" name="field.target" ' |
882 | + 'type="radio" value="personal" />', |
883 | + self.widget.options["personal"]) |
884 | + self.assertEqual( |
885 | + '<input class="radioType" ' + self.expected_disabled_attr + |
886 | + 'id="field.target.option.package" name="field.target" ' |
887 | + 'type="radio" value="package" />', |
888 | + self.widget.options["package"]) |
889 | + self.assertEqual( |
890 | + '<input class="radioType" ' + self.expected_disabled_attr + |
891 | + 'id="field.target.option.project" name="field.target" ' |
892 | + 'type="radio" value="project" />', |
893 | + self.widget.options["project"]) |
894 | + |
895 | + def test_setUpOptions_package_checked(self): |
896 | + # The package radio button is selected when the form is submitted |
897 | + # when the target field's value is 'package'. |
898 | + form = { |
899 | + "field.target": "package", |
900 | + } |
901 | + self.widget.request = LaunchpadTestRequest(form=form) |
902 | + self.widget.setUpSubWidgets() |
903 | + self.widget.setUpOptions() |
904 | + self.assertEqual( |
905 | + '<input class="radioType" ' + self.expected_disabled_attr + |
906 | + 'id="field.target.option.personal" name="field.target" ' |
907 | + 'type="radio" value="personal" />', |
908 | + self.widget.options["personal"]) |
909 | + self.assertEqual( |
910 | + '<input class="radioType" checked="checked" ' + |
911 | + self.expected_disabled_attr + |
912 | + 'id="field.target.option.package" name="field.target" ' |
913 | + 'type="radio" value="package" />', |
914 | + self.widget.options["package"]) |
915 | + self.assertEqual( |
916 | + '<input class="radioType" ' + self.expected_disabled_attr + |
917 | + 'id="field.target.option.project" name="field.target" ' |
918 | + 'type="radio" value="project" />', |
919 | + self.widget.options["project"]) |
920 | + |
921 | + def test_setUpOptions_project_checked(self): |
922 | + # The project radio button is selected when the form is submitted |
923 | + # when the target field's value is 'project'. |
924 | + form = { |
925 | + "field.target": "project", |
926 | + } |
927 | + self.widget.request = LaunchpadTestRequest(form=form) |
928 | + self.widget.setUpSubWidgets() |
929 | + self.widget.setUpOptions() |
930 | + self.assertEqual( |
931 | + '<input class="radioType" ' + self.expected_disabled_attr + |
932 | + 'id="field.target.option.personal" name="field.target" ' |
933 | + 'type="radio" value="personal" />', |
934 | + self.widget.options["personal"]) |
935 | + self.assertEqual( |
936 | + '<input class="radioType" ' + self.expected_disabled_attr + |
937 | + 'id="field.target.option.package" name="field.target" ' |
938 | + 'type="radio" value="package" />', |
939 | + self.widget.options["package"]) |
940 | + self.assertEqual( |
941 | + '<input class="radioType" checked="checked" ' + |
942 | + self.expected_disabled_attr + |
943 | + 'id="field.target.option.project" name="field.target" ' |
944 | + 'type="radio" value="project" />', |
945 | + self.widget.options["project"]) |
946 | + |
947 | + def test_setRenderedValue_personal(self): |
948 | + # Passing a person will set the widget's render state to 'personal'. |
949 | + self.widget.setUpSubWidgets() |
950 | + self.widget.setRenderedValue(self.factory.makePerson()) |
951 | + self.assertEqual("personal", self.widget.default_option) |
952 | + |
953 | + def test_setRenderedValue_package(self): |
954 | + # Passing a package will set the widget's render state to 'package'. |
955 | + self.widget.setUpSubWidgets() |
956 | + self.widget.setRenderedValue(self.package) |
957 | + self.assertEqual("package", self.widget.default_option) |
958 | + self.assertEqual( |
959 | + self.distribution, |
960 | + self.widget.distribution_widget._getCurrentValue()) |
961 | + self.assertEqual( |
962 | + self.package.sourcepackagename, |
963 | + self.widget.package_widget._getCurrentValue()) |
964 | + |
965 | + def test_setRenderedValue_project(self): |
966 | + # Passing a project will set the widget's render state to 'project'. |
967 | + self.widget.setUpSubWidgets() |
968 | + self.widget.setRenderedValue(self.project) |
969 | + self.assertEqual("project", self.widget.default_option) |
970 | + self.assertEqual( |
971 | + self.project, self.widget.project_widget._getCurrentValue()) |
972 | + |
973 | + def test_call(self): |
974 | + # The __call__ method sets up the widgets and the options. |
975 | + markup = self.widget() |
976 | + self.assertIsNotNone(self.widget.project_widget) |
977 | + self.assertIn("personal", self.widget.options) |
978 | + self.assertIn("package", self.widget.options) |
979 | + soup = BeautifulSoup(markup) |
980 | + fields = soup.findAll(["input", "select"], {"id": re.compile(".*")}) |
981 | + ids = [field["id"] for field in fields] |
982 | + self.assertContentEqual(self.expected_ids, ids) |
983 | + |
984 | + |
985 | +class TestGitRepositoryTargetDisplayWidget( |
986 | + TestGitRepositoryTargetWidgetBase, TestCaseWithFactory): |
987 | + """Test the GitRepositoryTargetDisplayWidget class.""" |
988 | + |
989 | + widget_factory = GitRepositoryTargetDisplayWidget |
990 | + expected_widget_interface = IDisplayWidget |
991 | + expected_disabled_attr = 'disabled="disabled" ' |
992 | + expected_ids = [ |
993 | + "field.target.option.project", |
994 | + ] |
995 | + |
996 | + |
997 | +class TestGitRepositoryTargetWidget( |
998 | + TestGitRepositoryTargetWidgetBase, TestCaseWithFactory): |
999 | + """Test the GitRepositoryTargetWidget class.""" |
1000 | + |
1001 | + widget_factory = GitRepositoryTargetWidget |
1002 | + expected_widget_interface = IInputWidget |
1003 | + expected_disabled_attr = '' |
1004 | + expected_ids = [ |
1005 | + "field.target.distribution", |
1006 | + "field.target.option.personal", |
1007 | + "field.target.option.package", |
1008 | + "field.target.option.project", |
1009 | + "field.target.package", |
1010 | + "field.target.project", |
1011 | + ] |
1012 | + |
1013 | + @property |
1014 | + def form(self): |
1015 | + return { |
1016 | + "field.target": "project", |
1017 | + "field.target.distribution": "fnord", |
1018 | + "field.target.package": "snarf", |
1019 | + "field.target.project": "pting", |
1020 | + } |
1021 | + |
1022 | + def test_hasInput_false(self): |
1023 | + # hasInput is false when the widget's name is not in the form data. |
1024 | + self.widget.request = LaunchpadTestRequest(form={}) |
1025 | + self.assertEqual("field.target", self.widget.name) |
1026 | + self.assertFalse(self.widget.hasInput()) |
1027 | + |
1028 | + def test_hasInput_true(self): |
1029 | + # hasInput is true is the widget's name in the form data. |
1030 | + self.widget.request = LaunchpadTestRequest(form=self.form) |
1031 | + self.assertEqual("field.target", self.widget.name) |
1032 | + self.assertTrue(self.widget.hasInput()) |
1033 | + |
1034 | + def test_hasValidInput_true(self): |
1035 | + # The field input is valid when all submitted parts are valid. |
1036 | + self.widget.request = LaunchpadTestRequest(form=self.form) |
1037 | + self.assertTrue(self.widget.hasValidInput()) |
1038 | + |
1039 | + def test_hasValidInput_false(self): |
1040 | + # The field input is invalid if any of the submitted parts are invalid. |
1041 | + form = self.form |
1042 | + form["field.target.project"] = "non-existent" |
1043 | + self.widget.request = LaunchpadTestRequest(form=form) |
1044 | + self.assertFalse(self.widget.hasValidInput()) |
1045 | + |
1046 | + def test_getInputValue_personal(self): |
1047 | + # The field value is None when the personal radio button is |
1048 | + # selected. |
1049 | + form = self.form |
1050 | + form["field.target"] = "personal" |
1051 | + self.context.owner = self.factory.makePerson() |
1052 | + self.widget.request = LaunchpadTestRequest(form=form) |
1053 | + self.assertIsNone(self.widget.getInputValue()) |
1054 | + |
1055 | + def test_getInputValue_package_spn(self): |
1056 | + # The field value is the package when the package radio button |
1057 | + # is selected and the package sub field has official spn. |
1058 | + form = self.form |
1059 | + form["field.target"] = "package" |
1060 | + self.widget.request = LaunchpadTestRequest(form=form) |
1061 | + self.assertEqual(self.package, self.widget.getInputValue()) |
1062 | + |
1063 | + def test_getInputValue_package_invalid(self): |
1064 | + # An error is raised when the package is not published in the distro. |
1065 | + form = self.form |
1066 | + form["field.target"] = "package" |
1067 | + form["field.target.package"] = 'non-existent' |
1068 | + self.widget.request = LaunchpadTestRequest(form=form) |
1069 | + message = ( |
1070 | + "There is no package named 'non-existent' published in Fnord.") |
1071 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1072 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1073 | + self.assertEqual(html_escape(message), self.widget.error()) |
1074 | + |
1075 | + def test_getInputValue_distribution(self): |
1076 | + # An error is raised when the package radio button is selected and |
1077 | + # the package sub field is empty. |
1078 | + form = self.form |
1079 | + form["field.target"] = "package" |
1080 | + form["field.target.package"] = '' |
1081 | + self.widget.request = LaunchpadTestRequest(form=form) |
1082 | + message = "Please enter a package name" |
1083 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1084 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1085 | + self.assertEqual(message, self.widget.error()) |
1086 | + |
1087 | + def test_getInputValue_distribution_invalid(self): |
1088 | + # An error is raised when the distribution is invalid. |
1089 | + form = self.form |
1090 | + form["field.target"] = "package" |
1091 | + form["field.target.package"] = '' |
1092 | + form["field.target.distribution"] = 'non-existent' |
1093 | + self.widget.request = LaunchpadTestRequest(form=form) |
1094 | + message = ( |
1095 | + "There is no distribution named 'non-existent' registered in " |
1096 | + "Launchpad") |
1097 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1098 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1099 | + self.assertEqual(html_escape(message), self.widget.error()) |
1100 | + |
1101 | + def test_getInputValue_project(self): |
1102 | + # The field value is the project when the project radio button is |
1103 | + # selected and the project sub field is valid. |
1104 | + form = self.form |
1105 | + form["field.target"] = "project" |
1106 | + self.widget.request = LaunchpadTestRequest(form=form) |
1107 | + self.assertEqual(self.project, self.widget.getInputValue()) |
1108 | + |
1109 | + def test_getInputValue_project_missing(self): |
1110 | + # An error is raised when the project field is missing. |
1111 | + form = self.form |
1112 | + form["field.target"] = "project" |
1113 | + del form["field.target.project"] |
1114 | + self.widget.request = LaunchpadTestRequest(form=form) |
1115 | + message = "Please enter a project name" |
1116 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1117 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1118 | + self.assertEqual(message, self.widget.error()) |
1119 | + |
1120 | + def test_getInputValue_project_invalid(self): |
1121 | + # An error is raised when the project is not valid. |
1122 | + form = self.form |
1123 | + form["field.target"] = "project" |
1124 | + form["field.target.project"] = "non-existent" |
1125 | + self.widget.request = LaunchpadTestRequest(form=form) |
1126 | + message = ( |
1127 | + "There is no project named 'non-existent' registered in " |
1128 | + "Launchpad") |
1129 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1130 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1131 | + self.assertEqual(html_escape(message), self.widget.error()) |
1132 | |
1133 | === added file 'lib/lp/code/javascript/gitrepository.edit.js' |
1134 | --- lib/lp/code/javascript/gitrepository.edit.js 1970-01-01 00:00:00 +0000 |
1135 | +++ lib/lp/code/javascript/gitrepository.edit.js 2015-06-18 20:19:28 +0000 |
1136 | @@ -0,0 +1,43 @@ |
1137 | +/* Copyright 2015 Canonical Ltd. This software is licensed under the |
1138 | + * GNU Affero General Public License version 3 (see the file LICENSE). |
1139 | + * |
1140 | + * Control enabling/disabling form elements on the GitRepository:+edit page. |
1141 | + * |
1142 | + * @module Y.lp.code.gitrepository.edit |
1143 | + * @requires node, DOM |
1144 | + */ |
1145 | +YUI.add('lp.code.gitrepository.edit', function(Y) { |
1146 | + Y.log('loading lp.code.gitrepository.edit'); |
1147 | + var module = Y.namespace('lp.code.gitrepository.edit'); |
1148 | + |
1149 | + module.set_enabled = function(field_id, is_enabled) { |
1150 | + var field = Y.DOM.byId(field_id); |
1151 | + field.disabled = !is_enabled; |
1152 | + }; |
1153 | + |
1154 | + module.onclick_target = function(e) { |
1155 | + var value = false; |
1156 | + Y.all('input[name="field.target"]').each(function(node) { |
1157 | + if (node.get('checked')) |
1158 | + value = node.get('value'); |
1159 | + }); |
1160 | + module.set_enabled('field.owner_default', value !== 'personal'); |
1161 | + }; |
1162 | + |
1163 | + module.setup = function() { |
1164 | + Y.all('input[name="field.target"]').on('click', module.onclick_target); |
1165 | + Y.all('input[name="field.target.package"]').on( |
1166 | + 'keypress', function(e) { |
1167 | + Y.DOM.byId('field.target.option.package', e).checked = true; |
1168 | + module.set_enabled('field.owner_default', true); |
1169 | + }); |
1170 | + Y.all('input[name="field.target.project"]').on( |
1171 | + 'keypress', function(e) { |
1172 | + Y.DOM.byId('field.target.option.project', e).checked = true; |
1173 | + module.set_enabled('field.owner_default', true); |
1174 | + }); |
1175 | + |
1176 | + // Set the initial state. |
1177 | + module.onclick_target(); |
1178 | + }; |
1179 | +}, '0.1', {'requires': ['node', 'DOM']}); |
1180 | |
1181 | === added file 'lib/lp/code/javascript/tests/test_gitrepository.edit.html' |
1182 | --- lib/lp/code/javascript/tests/test_gitrepository.edit.html 1970-01-01 00:00:00 +0000 |
1183 | +++ lib/lp/code/javascript/tests/test_gitrepository.edit.html 2015-06-18 20:19:28 +0000 |
1184 | @@ -0,0 +1,150 @@ |
1185 | +<!DOCTYPE html> |
1186 | +<!-- |
1187 | +Copyright 2015 Canonical Ltd. This software is licensed under the |
1188 | +GNU Affero General Public License version 3 (see the file LICENSE). |
1189 | +--> |
1190 | + |
1191 | +<html> |
1192 | + <head> |
1193 | + <title>code.gitrepository.edit Tests</title> |
1194 | + |
1195 | + <!-- YUI and test setup --> |
1196 | + <script type="text/javascript" |
1197 | + src="../../../../../build/js/yui/yui/yui.js"> |
1198 | + </script> |
1199 | + <link rel="stylesheet" |
1200 | + href="../../../../../build/js/yui/console/assets/console-core.css" /> |
1201 | + <link rel="stylesheet" |
1202 | + href="../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" /> |
1203 | + <link rel="stylesheet" |
1204 | + href="../../../../../build/js/yui/test/assets/skins/sam/test.css" /> |
1205 | + |
1206 | + <script type="text/javascript" |
1207 | + src="../../../../../build/js/lp/app/testing/testrunner.js"></script> |
1208 | + |
1209 | + <link rel="stylesheet" href="../../../app/javascript/testing/test.css" /> |
1210 | + |
1211 | + <!-- Dependencies --> |
1212 | + <script type="text/javascript" |
1213 | + src="../../../../../build/js/lp/app/lp.js"></script> |
1214 | + |
1215 | + <!-- The module under test. --> |
1216 | + <script type="text/javascript" src="../gitrepository.edit.js"></script> |
1217 | + |
1218 | + <!-- The test suite --> |
1219 | + <script type="text/javascript" src="test_gitrepository.edit.js"></script> |
1220 | + |
1221 | + <script type="text/javascript"> |
1222 | + YUI().use('lp.code.gitrepository.edit', function(Y) { |
1223 | + Y.on('domready', Y.lp.code.gitrepository.edit.setup); |
1224 | + }); |
1225 | + </script> |
1226 | + |
1227 | + </head> |
1228 | + <body class="yui3-skin-sam"> |
1229 | + <ul id="suites"> |
1230 | + <li>lp.code.gitrepository.edit.test</li> |
1231 | + </ul> |
1232 | + <div id="gitrepository.edit"> |
1233 | + <form action="." name="launchpadform" method="post" |
1234 | + enctype="multipart/form-data" |
1235 | + accept-charset="UTF-8"> |
1236 | + |
1237 | + <table class="form"> |
1238 | + <tr> |
1239 | + <td colspan="2" style="text-align: left"> |
1240 | + <div> |
1241 | + <label for="field.target">Target:</label> |
1242 | + <div> |
1243 | + <table> |
1244 | + <tr> |
1245 | + <td colspan="2"> |
1246 | + <label> |
1247 | + <input class="radioType" |
1248 | + id="field.target.option.personal" |
1249 | + name="field.target" |
1250 | + type="radio" |
1251 | + value="personal" /> |
1252 | + Personal |
1253 | + </label> |
1254 | + </td> |
1255 | + </tr> |
1256 | + <tr> |
1257 | + <td> |
1258 | + <label> |
1259 | + <input class="radioType" |
1260 | + id="field.target.option.package" |
1261 | + name="field.target" |
1262 | + type="radio" value="package" /> |
1263 | + Distribution |
1264 | + </label> |
1265 | + </td> |
1266 | + <td> |
1267 | + <select id="field.target.distribution" |
1268 | + name="field.target.distribution" size="1"> |
1269 | + <option value="debian">Debian GNU/Linux</option> |
1270 | + <option value="ubuntu">Ubuntu</option> |
1271 | + </select> |
1272 | + </td> |
1273 | + </tr> |
1274 | + <tr> |
1275 | + <td align="right"> |
1276 | + <label for="field.target.option.package"> |
1277 | + Package |
1278 | + </label> |
1279 | + </td> |
1280 | + <td> |
1281 | + <input type="text" value="" id="field.target.package" |
1282 | + name="field.target.package" size="20" /> |
1283 | + </td> |
1284 | + </tr> |
1285 | + <tr> |
1286 | + <td> |
1287 | + <label> |
1288 | + <input class="radioType" |
1289 | + id="field.target.option.project" |
1290 | + name="field.target" |
1291 | + type="radio" |
1292 | + value="project" /> |
1293 | + Project |
1294 | + </label> |
1295 | + </td> |
1296 | + <td> |
1297 | + <input type="text" value="" id="field.target.project" |
1298 | + name="field.target.project" size="20" /> |
1299 | + </td> |
1300 | + </tr> |
1301 | + </table> |
1302 | + </div> |
1303 | + <p class="formHelp">The target of the repository.</p> |
1304 | + </div> |
1305 | + </td> |
1306 | + </tr> |
1307 | + |
1308 | + <tr> |
1309 | + <td colspan="2"> |
1310 | + <div> |
1311 | + <input class="hiddenType" id="field.owner_default.used" |
1312 | + name="field.owner_default.used" type="hidden" |
1313 | + value="" /> |
1314 | + <input class="checkboxType" checked="checked" |
1315 | + id="field.owner_default" name="field.owner_default" |
1316 | + type="checkbox" value="on" /> |
1317 | + <label for="field.owner_default">Owner default</label> |
1318 | + <p class="formHelp"> |
1319 | + Whether this repository is the default for its owner. |
1320 | + </p> |
1321 | + </div> |
1322 | + </td> |
1323 | + </tr> |
1324 | + </table> |
1325 | + |
1326 | + <input type="submit" id="field.actions.change" |
1327 | + name="field.actions.change" value="Change Git Repository" |
1328 | + class="button" /> |
1329 | + or |
1330 | + <a href="https://code.launchpad.dev/~me/p/+git/r">Cancel</a> |
1331 | + </form> |
1332 | + </div> |
1333 | + </body> |
1334 | +</html> |
1335 | |
1336 | === added file 'lib/lp/code/javascript/tests/test_gitrepository.edit.js' |
1337 | --- lib/lp/code/javascript/tests/test_gitrepository.edit.js 1970-01-01 00:00:00 +0000 |
1338 | +++ lib/lp/code/javascript/tests/test_gitrepository.edit.js 2015-06-18 20:19:28 +0000 |
1339 | @@ -0,0 +1,100 @@ |
1340 | +/* Copyright 2015 Canonical Ltd. This software is licensed under the |
1341 | + * GNU Affero General Public License version 3 (see the file LICENSE). |
1342 | + * |
1343 | + * Test driver for gitrepository.edit.js. |
1344 | + */ |
1345 | +YUI.add('lp.code.gitrepository.edit.test', function(Y) { |
1346 | + var tests = Y.namespace('lp.code.gitrepository.edit.test'); |
1347 | + var module = Y.lp.code.gitrepository.edit; |
1348 | + tests.suite = new Y.Test.Suite('code.gitrepository.edit Tests'); |
1349 | + |
1350 | + tests.suite.add(new Y.Test.Case({ |
1351 | + name: 'code.gitrepository.edit_tests', |
1352 | + |
1353 | + setUp: function() { |
1354 | + this.tbody = Y.one('#gitrepository.edit'); |
1355 | + |
1356 | + // Get the individual target type radio buttons. |
1357 | + this.target_personal = Y.DOM.byId('field.target.option.personal'); |
1358 | + this.target_package = Y.DOM.byId('field.target.option.package'); |
1359 | + this.target_project = Y.DOM.byId('field.target.option.project'); |
1360 | + |
1361 | + // Get the input widgets. |
1362 | + this.input_package = Y.one('input[name="field.target.package"]'); |
1363 | + this.input_project = Y.one('input[name="field.target.project"]'); |
1364 | + this.owner_default = Y.DOM.byId('field.owner_default'); |
1365 | + }, |
1366 | + |
1367 | + tearDown: function() { |
1368 | + delete this.tbody; |
1369 | + }, |
1370 | + |
1371 | + test_handlers_connected: function() { |
1372 | + // Manually invoke the setup function to ensure the handlers are |
1373 | + // set. |
1374 | + module.setup(); |
1375 | + |
1376 | + var check_handler = function(field, expected) { |
1377 | + var custom_events = Y.Event.getListeners(field, 'click'); |
1378 | + var click_event = custom_events[0]; |
1379 | + var subscribers = click_event.subscribers; |
1380 | + Y.each(subscribers, function(sub) { |
1381 | + Y.Assert.isTrue(sub.contains(expected), |
1382 | + 'handler not set up'); |
1383 | + }); |
1384 | + }; |
1385 | + |
1386 | + check_handler(this.target_personal, module.onclick_target); |
1387 | + check_handler(this.target_package, module.onclick_target); |
1388 | + check_handler(this.target_project, module.onclick_target); |
1389 | + }, |
1390 | + |
1391 | + test_select_target_personal: function() { |
1392 | + this.target_personal.checked = true; |
1393 | + module.onclick_target(); |
1394 | + // The owner_default checkbox is disabled. |
1395 | + Y.Assert.isTrue(this.owner_default.disabled, |
1396 | + 'owner_default not disabled'); |
1397 | + }, |
1398 | + |
1399 | + test_select_target_package: function() { |
1400 | + this.target_package.checked = true; |
1401 | + module.onclick_target(); |
1402 | + // The owner_default checkbox is enabled. |
1403 | + Y.Assert.isFalse(this.owner_default.disabled, |
1404 | + 'owner_default not disabled'); |
1405 | + }, |
1406 | + |
1407 | + test_select_target_project: function() { |
1408 | + this.target_project.checked = true; |
1409 | + module.onclick_target(); |
1410 | + // The owner_default checkbox is enabled. |
1411 | + Y.Assert.isFalse(this.owner_default.disabled, |
1412 | + 'owner_default not disabled'); |
1413 | + }, |
1414 | + |
1415 | + test_keypress_package: function() { |
1416 | + this.target_personal.checked = true; |
1417 | + this.owner_default.disabled = true; |
1418 | + this.input_package.simulate('keypress', { charCode: 97 }); |
1419 | + Y.Assert.isTrue(this.target_package.checked); |
1420 | + // The owner_default checkbox is enabled. |
1421 | + Y.Assert.isFalse(this.owner_default.disabled, |
1422 | + 'owner_default not disabled'); |
1423 | + }, |
1424 | + |
1425 | + test_keypress_project: function() { |
1426 | + this.target_personal.checked = true; |
1427 | + this.owner_default.disabled = true; |
1428 | + this.input_project.simulate('keypress', { charCode: 97 }); |
1429 | + Y.Assert.isTrue(this.target_project.checked); |
1430 | + // The owner_default checkbox is enabled. |
1431 | + Y.Assert.isFalse(this.owner_default.disabled, |
1432 | + 'owner_default not disabled'); |
1433 | + }, |
1434 | + })); |
1435 | +}, '0.1', { |
1436 | + requires: ['lp.testing.runner', 'test', 'test-console', |
1437 | + 'Event', 'node-event-simulate', |
1438 | + 'lp.code.gitrepository.edit'] |
1439 | +}); |
1440 | |
1441 | === added file 'lib/lp/code/templates/gitrepository-edit.pt' |
1442 | --- lib/lp/code/templates/gitrepository-edit.pt 1970-01-01 00:00:00 +0000 |
1443 | +++ lib/lp/code/templates/gitrepository-edit.pt 2015-06-18 20:19:28 +0000 |
1444 | @@ -0,0 +1,23 @@ |
1445 | +<html |
1446 | + xmlns="http://www.w3.org/1999/xhtml" |
1447 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
1448 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
1449 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
1450 | + metal:use-macro="view/macro:page/main_only" |
1451 | + i18n:domain="launchpad"> |
1452 | +<body> |
1453 | + |
1454 | +<div metal:fill-slot="main"> |
1455 | + <div metal:use-macro="context/@@launchpad_form/form" /> |
1456 | + |
1457 | + <script type="text/javascript"> |
1458 | + LPJS.use('lp.code.gitrepository.edit', function(Y) { |
1459 | + Y.on('domready', function(e) { |
1460 | + Y.lp.code.gitrepository.edit.setup(); |
1461 | + }, window); |
1462 | + }); |
1463 | + </script> |
1464 | +</div> |
1465 | + |
1466 | +</body> |
1467 | +</html> |
target_default and owner_default are a bit weird to show on a form like this. target_default can only be set by a few people, and there will be project-level UI for that. I don't know where the personal default flag should be set, but it makes a little more sense on this form as it can be used by anyone who can edit the repo.
There's also the issue that they only make sense for non-personal targets, though the JS makes that semi-clear by disabling them.
I'd consider replacing the ancient onKeyPress inline JS with proper stuff.