4177.4.9
by James Henstridge
some changes from Tim's review |
1 |
= LaunchpadFormView = |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
2 |
|
3 |
LaunchpadFormView is a base class for form views in Launchpad. It is |
|
4 |
intended as a replacement for GeneralFormView. |
|
5 |
||
6 |
There are a number of things that need to be customised when creating |
|
7 |
a LaunchpadFormView based view class: |
|
8 |
||
9 |
* the "schema" attribute should be set to a schema defining the |
|
10 |
fields used in the form. |
|
11 |
||
12 |
* if only a subset of the fields are to be displayed in the form, the |
|
13 |
"field_names" attribute should be set. |
|
14 |
||
15 |
* if any fields require custom widgets, the "custom_widgets" |
|
16 |
attribute should be set to a dictionary mapping field names to |
|
17 |
widget factories. |
|
18 |
||
19 |
* one or more actions must be provided by the form if it is to |
|
20 |
support submission. |
|
21 |
||
22 |
* if form wide validation is needed, the validate() method should be |
|
3691.110.74
by Francis J. Lacoste
Allow action handlers to return the rendered content |
23 |
overriden. Errors are reported using the addError() and |
3691.110.75
by Francis J. Lacoste
Fix validate() documentation. |
24 |
setFieldError() methods. |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
25 |
|
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
26 |
* if a form contains multiple actions, the validate_widgets method |
27 |
can be used to validate that action's subset of the form's widgets. |
|
28 |
||
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
29 |
* the "next_url" attribute should be set to the URL to redirect to on |
30 |
successful form submission. If this is a computed value, you might |
|
31 |
want to use a property. |
|
32 |
||
3691.111.1
by James Henstridge
move "focus first widget with error" behaviour server side |
33 |
* the "initial_focus_widget" attribute specifies which element should be |
3691.68.30
by James Henstridge
add tests for initial form focus code |
34 |
focused by default. |
35 |
||
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
36 |
Views using LaunchpadFormView can be registered with the standard |
37 |
<browser:page> ZCML directive. |
|
38 |
||
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
39 |
There is also a LaunchpadEditFormView class to make it easier to write |
40 |
edit views. |
|
41 |
||
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
42 |
|
4177.4.9
by James Henstridge
some changes from Tim's review |
43 |
== The Schema == |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
44 |
|
45 |
The schema can be an interface implemented by your content object, or |
|
46 |
an interface specifically tailored for data entry. Below is an |
|
47 |
example schema: |
|
48 |
||
49 |
>>> from zope.interface import Interface, implements |
|
50 |
>>> from zope.schema import TextLine |
|
51 |
||
52 |
>>> class IFormTest(Interface): |
|
53 |
... name = TextLine(title=u"Name") |
|
54 |
... displayname = TextLine(title=u"Title") |
|
55 |
... password = TextLine(title=u"Password") |
|
56 |
||
57 |
>>> class FormTest: |
|
58 |
... implements(IFormTest) |
|
59 |
... name = 'fred' |
|
60 |
... displayname = 'Fred' |
|
61 |
... password = 'password' |
|
62 |
||
63 |
||
64 |
A form that handles all fields in the schema needs only set the |
|
65 |
"schema" attribute: |
|
66 |
||
11929.9.1
by Tim Penhey
Move launchpadform into lp.app.browser. |
67 |
>>> from lp.app.browser.launchpadform import LaunchpadFormView |
14600.2.2
by Curtis Hovey
Moved webapp to lp.services. |
68 |
>>> from lp.services.webapp.servers import LaunchpadTestRequest |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
69 |
|
70 |
>>> class FormTestView1(LaunchpadFormView): |
|
71 |
... schema = IFormTest |
|
72 |
||
73 |
>>> context = FormTest() |
|
74 |
>>> request = LaunchpadTestRequest() |
|
75 |
>>> view = FormTestView1(context, request) |
|
76 |
>>> view.setUpFields() |
|
77 |
>>> [field.__name__ for field in view.form_fields] |
|
78 |
['name', 'displayname', 'password'] |
|
79 |
||
80 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
81 |
== Restricting Displayed Fields == |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
82 |
|
83 |
The list of fields can be restricted with the "field_names" attribute: |
|
84 |
||
85 |
>>> class FormTestView2(LaunchpadFormView): |
|
86 |
... schema = IFormTest |
|
87 |
... field_names = ['name', 'displayname'] |
|
88 |
||
89 |
>>> context = FormTest() |
|
90 |
>>> request = LaunchpadTestRequest() |
|
91 |
>>> view = FormTestView2(context, request) |
|
92 |
>>> view.setUpFields() |
|
93 |
>>> [field.__name__ for field in view.form_fields] |
|
94 |
['name', 'displayname'] |
|
95 |
||
96 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
97 |
== Custom Adapters == |
4177.4.1
by James Henstridge
some small updates to the LaunchpadFormView API to simplify things |
98 |
|
99 |
Sometimes a schema is used for a form that is not actually implemented |
|
100 |
by the context widget. This can be handled by providing some custom |
|
101 |
adapters for the form. |
|
102 |
||
103 |
>>> class IFormTest2(Interface): |
|
104 |
... name = TextLine(title=u"Name") |
|
105 |
>>> class FormAdaptersTestView(LaunchpadFormView): |
|
106 |
... schema = IFormTest2 |
|
107 |
... @property |
|
108 |
... def adapters(self): |
|
109 |
... return {IFormTest2: self.context} |
|
110 |
||
111 |
>>> context = FormTest() |
|
112 |
>>> request = LaunchpadTestRequest() |
|
113 |
>>> IFormTest2.providedBy(context) |
|
114 |
False |
|
115 |
>>> view = FormAdaptersTestView(context, request) |
|
116 |
>>> view.setUpFields() |
|
117 |
>>> view.setUpWidgets() |
|
4177.4.9
by James Henstridge
some changes from Tim's review |
118 |
|
119 |
We now check to see that the widget is bound to our FormTest |
|
120 |
instance. The context for the widget is a bound field object, who |
|
121 |
should in turn have the FormTest instance as a context: |
|
122 |
||
4177.4.1
by James Henstridge
some small updates to the LaunchpadFormView API to simplify things |
123 |
>>> view.widgets['name'].context.context is context |
124 |
True |
|
125 |
||
126 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
127 |
== Custom Widgets == |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
128 |
|
129 |
In some cases we will want to use a custom widget for a particular |
|
130 |
field. These can be installed easily with the "custom_widgets" |
|
131 |
attribute: |
|
132 |
||
133 |
>>> from zope.app.form.browser import TextWidget |
|
14642.1.3
by Curtis Hovey
Removed custom_widget from webapp glob. |
134 |
>>> from lp.app.browser.launchpadform import custom_widget |
12293.1.9
by Curtis Hovey
Deglobbed widget imports. |
135 |
>>> from lp.app.widgets.password import PasswordChangeWidget |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
136 |
|
137 |
>>> class FormTestView3(LaunchpadFormView): |
|
138 |
... schema = IFormTest |
|
3691.68.10
by James Henstridge
provide custom_widget class advisor |
139 |
... custom_widget('password', PasswordChangeWidget) |
140 |
... custom_widget('displayname', TextWidget, displayWidth=50) |
|
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
141 |
|
142 |
>>> context = FormTest() |
|
143 |
>>> request = LaunchpadTestRequest() |
|
144 |
>>> view = FormTestView3(context, request) |
|
145 |
>>> view.setUpFields() |
|
146 |
>>> view.setUpWidgets() |
|
147 |
>>> view.widgets['displayname'] |
|
148 |
<...TextWidget object at ...> |
|
149 |
>>> view.widgets['displayname'].displayWidth |
|
150 |
50 |
|
151 |
>>> view.widgets['password'] |
|
152 |
<...PasswordChangeWidget object at ...> |
|
153 |
||
154 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
155 |
== Using Another Context == |
3851.6.7
by Bjorn Tillenius
review comments. |
156 |
|
157 |
setUpWidgets() uses the view's context by default when setting up the |
|
158 |
widgets, but it's also possible to specify the context explicitly. |
|
159 |
||
160 |
>>> view_context = FormTest() |
|
161 |
>>> another_context = FormTest() |
|
162 |
>>> request = LaunchpadTestRequest() |
|
163 |
>>> view = FormTestView3(view_context, request) |
|
164 |
>>> view.setUpFields() |
|
165 |
>>> view.setUpWidgets(context=another_context) |
|
166 |
>>> view.widgets['displayname'].context.context is view_context |
|
167 |
False |
|
168 |
>>> view.widgets['displayname'].context.context is another_context |
|
169 |
True |
|
170 |
||
171 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
172 |
== Actions == |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
173 |
|
174 |
In order for a form to accept submissions, it will need one or more |
|
175 |
submit actions. These are added to the view class using the "action" |
|
176 |
decorator: |
|
177 |
||
14642.1.2
by Curtis Hovey
Removed action from webapp globs. |
178 |
>>> from lp.app.browser.launchpadform import action |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
179 |
>>> class FormTestView4(LaunchpadFormView): |
180 |
... schema = IFormTest |
|
181 |
... field_names = ['displayname'] |
|
182 |
... |
|
183 |
... @action(u"Change Name", name="change") |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
184 |
... def change_action(self, action, data): |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
185 |
... self.context.displayname = data['displayname'] |
186 |
||
187 |
This will create a submit button at the bottom of the form labeled |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
188 |
"Change Name", and cause change_action() to be called when the form is |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
189 |
submitted with that button. |
190 |
||
191 |
>>> context = FormTest() |
|
192 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
193 |
... method='POST', |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
194 |
... form={'field.displayname': 'bob', |
195 |
... 'field.actions.change': 'Change Name'}) |
|
196 |
>>> view = FormTestView4(context, request) |
|
197 |
>>> view.initialize() |
|
198 |
>>> print context.displayname |
|
199 |
bob |
|
200 |
||
3691.68.27
by James Henstridge
support for per-widget errors |
201 |
Note that input validation should not be performed inside the action |
202 |
method. Instead, it should be performed in the validate() method, or |
|
203 |
in per-field validators. |
|
204 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
205 |
|
206 |
== Form Wide Validation == |
|
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
207 |
|
208 |
While constraints on individual fields and schema invariants can catch |
|
209 |
the majority of input errors, in some cases it is necessary to |
|
210 |
implement some custom validators for the form. |
|
211 |
||
212 |
This can be done by overriding the validate() method of |
|
3691.68.27
by James Henstridge
support for per-widget errors |
213 |
LaunchpadFormView. If validity errors are detected, they should be |
214 |
reported using the addError() method (for form wide errors) or the |
|
215 |
setFieldError() method (for errors specific to a field): |
|
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
216 |
|
217 |
>>> class FormTestView5(LaunchpadFormView): |
|
218 |
... schema = IFormTest |
|
219 |
... field_names = ['name', 'password'] |
|
220 |
... |
|
221 |
... def validate(self, data): |
|
3691.68.30
by James Henstridge
add tests for initial form focus code |
222 |
... if data.get('name') == data.get('password'): |
3691.68.8
by James Henstridge
changes suggested by SteveA |
223 |
... self.addError('your password may not be the same ' |
224 |
... 'as your name') |
|
3691.68.30
by James Henstridge
add tests for initial form focus code |
225 |
... if data.get('password') == 'password': |
3691.68.27
by James Henstridge
support for per-widget errors |
226 |
... self.setFieldError('password', |
227 |
... 'your password must not be "password"') |
|
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
228 |
|
229 |
>>> context = FormTest() |
|
230 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
231 |
... method='POST', |
3691.68.27
by James Henstridge
support for per-widget errors |
232 |
... form={'field.name': 'fred', 'field.password': '12345'}) |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
233 |
>>> view = FormTestView5(context, request) |
234 |
>>> view.setUpFields() |
|
235 |
>>> view.setUpWidgets() |
|
236 |
>>> data = {} |
|
237 |
>>> view._validate(None, data) |
|
238 |
[] |
|
239 |
||
3691.68.27
by James Henstridge
support for per-widget errors |
240 |
|
241 |
Check that form wide errors can be reported: |
|
242 |
||
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
243 |
>>> request = LaunchpadTestRequest( |
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
244 |
... method='POST', |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
245 |
... form={'field.name': 'fred', 'field.password': 'fred'}) |
246 |
>>> view = FormTestView5(context, request) |
|
247 |
>>> view.setUpFields() |
|
248 |
>>> view.setUpWidgets() |
|
249 |
>>> data = {} |
|
250 |
>>> view._validate(None, data) |
|
5653.2.6
by Maris Fogels
Fixed a number of broken pagetests. |
251 |
[u'your password may not be the same as your name'] |
3691.68.26
by James Henstridge
fix test |
252 |
>>> view.form_wide_errors |
5653.2.6
by Maris Fogels
Fixed a number of broken pagetests. |
253 |
[u'your password may not be the same as your name'] |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
254 |
|
3691.68.27
by James Henstridge
support for per-widget errors |
255 |
Check that widget specific errors can be reported: |
256 |
||
257 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
258 |
... method='POST', |
3691.68.27
by James Henstridge
support for per-widget errors |
259 |
... form={'field.name': 'fred', 'field.password': 'password'}) |
260 |
>>> view = FormTestView5(context, request) |
|
261 |
>>> view.setUpFields() |
|
262 |
>>> view.setUpWidgets() |
|
263 |
>>> data = {} |
|
264 |
>>> view._validate(None, data) |
|
5653.2.6
by Maris Fogels
Fixed a number of broken pagetests. |
265 |
[u'your password must not be "password"'] |
3691.68.27
by James Henstridge
support for per-widget errors |
266 |
>>> view.widget_errors |
5653.2.6
by Maris Fogels
Fixed a number of broken pagetests. |
267 |
{'password': u'your password must not be "password"'} |
3691.68.27
by James Henstridge
support for per-widget errors |
268 |
|
269 |
The base template used for LaunchpadFormView classes takes care of |
|
270 |
displaying these errors in the appropriate locations. |
|
271 |
||
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
272 |
|
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
273 |
== Widget Validation === |
274 |
||
275 |
A form may contain multiple actions, and a widget used for action A |
|
276 |
might not be used for action B. The validate_widgets() method makes it |
|
277 |
easy for an action to validate its widgets, while ignoring widgets |
|
278 |
that belong to other actions. Here, we'll define a form with two |
|
279 |
required fields, and show how to validate one field at a time. |
|
280 |
||
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
281 |
>>> class INameAndPasswordForm(Interface): |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
282 |
... name = TextLine(title=u"Name", required=True) |
283 |
... password = TextLine(title=u"Password", required=True) |
|
284 |
||
285 |
>>> class FormViewForWidgetValidation(LaunchpadFormView): |
|
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
286 |
... schema = INameAndPasswordForm |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
287 |
|
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
288 |
>>> def print_widget_validation(names): |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
289 |
... data = {'field.name': '', 'field.password': ''} |
290 |
... context = FormTest() |
|
291 |
... request = LaunchpadTestRequest(method='POST', form=data) |
|
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
292 |
... view = FormViewForWidgetValidation(context, request) |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
293 |
... view.setUpFields() |
294 |
... view.setUpWidgets() |
|
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
295 |
... for error in view.validate_widgets(data, names=names): |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
296 |
... if isinstance(error, str): |
297 |
... print error |
|
298 |
... else: |
|
299 |
... print "%s: %s" % (error.widget_title, error.doc()) |
|
300 |
||
301 |
Only the fields we specify will be validated: |
|
302 |
||
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
303 |
>>> print_widget_validation(['name']) |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
304 |
Name: Required input is missing. |
305 |
||
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
306 |
>>> print_widget_validation(['password']) |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
307 |
Password: Required input is missing. |
308 |
||
5178.3.3
by Leonard Richardson
Minor changes in response to feedback. |
309 |
>>> print_widget_validation(['name', 'password']) |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
310 |
Name: Required input is missing. |
311 |
Password: Required input is missing. |
|
312 |
||
313 |
The default behavior is to validate all widgets. |
|
314 |
||
315 |
>>> print_widget_validation(None) |
|
316 |
Name: Required input is missing. |
|
317 |
Password: Required input is missing. |
|
318 |
||
319 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
320 |
== Redirect URL == |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
321 |
|
322 |
If the form is successfully posted, then LaunchpadFormView will |
|
323 |
redirect the user to another URL. The URL is specified by the |
|
324 |
"next_url" attribute: |
|
325 |
||
326 |
>>> from zope.formlib.form import action |
|
327 |
>>> class FormTestView6(LaunchpadFormView): |
|
328 |
... schema = IFormTest |
|
329 |
... field_names = ['displayname'] |
|
330 |
... next_url = 'http://www.ubuntu.com/' |
|
331 |
... |
|
332 |
... @action(u"Change Name", name="change") |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
333 |
... def change_action(self, action, data): |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
334 |
... self.context.displayname = data['displayname'] |
335 |
||
336 |
>>> context = FormTest() |
|
337 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
338 |
... method='POST', |
3691.68.6
by James Henstridge
some documentation tests for LaunchpadFormView |
339 |
... form={'field.displayname': 'bob', |
340 |
... 'field.actions.change': 'Change Name'}) |
|
341 |
>>> view = FormTestView6(context, request) |
|
342 |
>>> view.initialize() |
|
343 |
>>> request.response.getStatus() |
|
344 |
302 |
|
345 |
>>> print request.response.getHeader('location') |
|
346 |
http://www.ubuntu.com/ |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
347 |
|
4177.4.9
by James Henstridge
some changes from Tim's review |
348 |
|
349 |
== Form Rendering == |
|
3691.110.74
by Francis J. Lacoste
Allow action handlers to return the rendered content |
350 |
|
351 |
(Let's define the view for the rendering tests.) |
|
352 |
>>> class RenderFormTest(LaunchpadFormView): |
|
353 |
... schema = IFormTest |
|
354 |
... field_names = ['displayname'] |
|
355 |
... |
|
356 |
... def template(self): |
|
357 |
... return u'Content that comes from a ZCML registered template.' |
|
358 |
... |
|
359 |
... @action(u'Redirect', name='redirect') |
|
360 |
... def redirect_action(self, action, data): |
|
361 |
... self.next_url = 'http://launchpad.dev/' |
|
362 |
... |
|
363 |
... def handleUpdateFailure(self, action, data, errors): |
|
364 |
... return u'Some errors occured.' |
|
365 |
... |
|
366 |
... @action(u'Update', name='update', failure=handleUpdateFailure) |
|
367 |
... def update_action(self, action, data): |
|
368 |
... return u'Display name changed to: %s.' % data['displayname'] |
|
369 |
||
370 |
Like with LaunchpadView, the view content will usually be rendered by |
|
371 |
executing the template attribute (which can be set from ZCML): |
|
372 |
||
373 |
>>> context = FormTest() |
|
374 |
>>> view = RenderFormTest(context, LaunchpadTestRequest(form={})) |
|
375 |
>>> view() |
|
376 |
u'Content that comes from a ZCML registered template.' |
|
377 |
||
378 |
When a redirection is done (either by calling |
|
379 |
self.request.response.redirect() or setting the next_url attribute), the |
|
380 |
rendered content is always the empty string. |
|
381 |
||
382 |
>>> context = FormTest() |
|
383 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
384 |
... method='POST', |
3691.110.74
by Francis J. Lacoste
Allow action handlers to return the rendered content |
385 |
... form={'field.displayname': 'bob', |
386 |
... 'field.actions.redirect': 'Redirect'}) |
|
387 |
>>> view = RenderFormTest(context, request) |
|
388 |
>>> view() |
|
389 |
u'' |
|
390 |
||
391 |
As an alternative to executing the template attribute, an action handler |
|
392 |
can directly return the rendered content: |
|
393 |
||
394 |
>>> context = FormTest() |
|
395 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
396 |
... method='POST', |
3691.110.74
by Francis J. Lacoste
Allow action handlers to return the rendered content |
397 |
... form={'field.displayname': 'bob', |
398 |
... 'field.actions.update': 'Update'}) |
|
399 |
>>> view = RenderFormTest(context, request) |
|
400 |
>>> view() |
|
401 |
u'Display name changed to: bob.' |
|
402 |
||
403 |
This is also true of failure handlers: |
|
404 |
||
405 |
>>> context = FormTest() |
|
406 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
407 |
... method='POST', |
3691.110.74
by Francis J. Lacoste
Allow action handlers to return the rendered content |
408 |
... form={'field.displayname': '', |
409 |
... 'field.actions.update': 'Update'}) |
|
410 |
>>> view = RenderFormTest(context, request) |
|
411 |
>>> view() |
|
412 |
u'Some errors occured.' |
|
413 |
||
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
414 |
|
4177.4.9
by James Henstridge
some changes from Tim's review |
415 |
== Initial Focused Widget == |
3691.68.30
by James Henstridge
add tests for initial form focus code |
416 |
|
417 |
The standard template for LaunchpadFormView can set the initial focus |
|
418 |
on a form element. This is achieved by some javascript that gets run |
|
419 |
on page load. By default, the first form widget will be focused: |
|
420 |
||
421 |
>>> context = FormTest() |
|
422 |
>>> request = LaunchpadTestRequest() |
|
423 |
>>> view = FormTestView5(context, request) |
|
424 |
>>> view.initialize() |
|
425 |
>>> print view.focusedElementScript() |
|
426 |
<!-- |
|
3691.111.6
by James Henstridge
fixes from BjornT's review |
427 |
setFocusByName('field.name'); |
3691.68.30
by James Henstridge
add tests for initial form focus code |
428 |
// --> |
429 |
||
3691.111.1
by James Henstridge
move "focus first widget with error" behaviour server side |
430 |
The focus can also be set explicitly by overriding initial_focus_widget: |
3691.68.30
by James Henstridge
add tests for initial form focus code |
431 |
|
432 |
>>> class FormTestView7(LaunchpadFormView): |
|
433 |
... schema = IFormTest |
|
434 |
... field_names = ['name', 'password'] |
|
3691.111.1
by James Henstridge
move "focus first widget with error" behaviour server side |
435 |
... initial_focus_widget = 'password' |
3691.68.30
by James Henstridge
add tests for initial form focus code |
436 |
>>> context = FormTest() |
437 |
>>> request = LaunchpadTestRequest() |
|
438 |
>>> view = FormTestView7(context, request) |
|
439 |
>>> view.initialize() |
|
440 |
>>> print view.focusedElementScript() |
|
441 |
<!-- |
|
3691.111.6
by James Henstridge
fixes from BjornT's review |
442 |
setFocusByName('field.password'); |
3691.68.30
by James Henstridge
add tests for initial form focus code |
443 |
// --> |
5178.3.2
by Leonard Richardson
Fixes and refactoring in response to review. |
444 |
|
3691.111.2
by James Henstridge
fix typo |
445 |
If initial_focus_widget is set to None, then no element will be focused |
3691.111.1
by James Henstridge
move "focus first widget with error" behaviour server side |
446 |
initially: |
447 |
||
448 |
>>> view.initial_focus_widget = None |
|
449 |
>>> view.focusedElementScript() |
|
450 |
'' |
|
451 |
||
452 |
Note that if the form is being redisplayed because of a validation |
|
453 |
error, the generated script will focus the first widget with an error: |
|
454 |
||
455 |
>>> view.setFieldError('password', 'Bad password') |
|
3691.68.30
by James Henstridge
add tests for initial form focus code |
456 |
>>> print view.focusedElementScript() |
457 |
<!-- |
|
3691.111.6
by James Henstridge
fixes from BjornT's review |
458 |
setFocusByName('field.password'); |
3691.68.30
by James Henstridge
add tests for initial form focus code |
459 |
// --> |
460 |
||
461 |
||
4108.4.10
by Guilherme Salgado
Make it possible to choose a team's renewal policy when creating/editing a team |
462 |
== Hidden widgets == |
463 |
||
464 |
Any widget can be hidden in a LaunchpadFormView while still having its |
|
465 |
value POSTed with the values of the other (visible) ones. The widget's |
|
466 |
visibility is controlled by its 'visible' attribute, which can be set |
|
467 |
through a custom_widget() call. |
|
468 |
||
469 |
First we'll create a fake pagetemplate which doesn't use Launchpad's main |
|
470 |
template and thus is way simpler. |
|
471 |
||
472 |
>>> from StringIO import StringIO |
|
473 |
>>> from tempfile import mkstemp |
|
11716.1.12
by Curtis Hovey
Sorted imports in doctests. |
474 |
>>> from z3c.ptcompat import ViewPageTemplateFile |
4108.4.10
by Guilherme Salgado
Make it possible to choose a team's renewal policy when creating/editing a team |
475 |
>>> file, filename = mkstemp() |
476 |
>>> f = open(filename, 'w') |
|
477 |
>>> f.write(u'<div metal:use-macro="context/@@launchpad_form/form" />') |
|
478 |
>>> f.close() |
|
479 |
||
480 |
By default, all widgets are visible. |
|
481 |
||
482 |
>>> class TestWidgetVisibility(LaunchpadFormView): |
|
483 |
... schema = IFormTest |
|
484 |
... field_names = ['displayname'] |
|
485 |
... template = ViewPageTemplateFile(filename) |
|
486 |
||
487 |
>>> context = FormTest() |
|
488 |
>>> request = LaunchpadTestRequest() |
|
489 |
>>> view = TestWidgetVisibility(context, request) |
|
490 |
||
491 |
>>> from BeautifulSoup import BeautifulSoup |
|
492 |
>>> soup = BeautifulSoup(view()) |
|
493 |
>>> for input in soup.findAll('input'): |
|
494 |
... print input |
|
495 |
<input ... name="field.displayname" ... type="text" ... |
|
496 |
||
497 |
If we change a widget's 'visible' flag to False, that widget is rendered |
|
498 |
using its hidden() method, which should return a hidden <input> tag. |
|
499 |
||
500 |
>>> class TestWidgetVisibility2(TestWidgetVisibility): |
|
501 |
... custom_widget('displayname', TextWidget, visible=False) |
|
502 |
||
503 |
>>> view = TestWidgetVisibility2(context, request) |
|
504 |
||
505 |
>>> soup = BeautifulSoup(view()) |
|
506 |
>>> for input in soup.findAll('input'): |
|
507 |
... print input |
|
508 |
<input ... name="field.displayname" type="hidden" ... |
|
509 |
||
510 |
>>> import os |
|
511 |
>>> os.remove(filename) |
|
512 |
||
513 |
||
4177.4.9
by James Henstridge
some changes from Tim's review |
514 |
== Safe Actions == |
4177.4.3
by James Henstridge
add safe_action descriptor and tests for it |
515 |
|
516 |
By default, LaunchpadFormView requires that form submissions be done |
|
517 |
via POST requests. There are a number of reasons for this: |
|
518 |
||
519 |
* Form submissions usually classed as "unsafe" (according to the HTTP |
|
520 |
definition), so should not be performed with a GET. |
|
521 |
* If we keep all GET requests readonly, we can potentially run them |
|
522 |
on read-only database transactions in the future. |
|
523 |
* We do not want remote sites posting our forms, as it leaves |
|
524 |
Launchpad open to Cross-site Request Forgery (XSRF) attacks. We |
|
525 |
perform additional checks on POST requests, and don't want them |
|
526 |
skipped by submitting the form with GET. |
|
527 |
||
528 |
However, there are cases where a form action is safe (e.g. a "search" |
|
529 |
action). Those actions can be marked as such: |
|
530 |
||
14642.1.4
by Curtis Hovey
Removed safe_action from webapp globs. |
531 |
>>> from lp.app.browser.launchpadform import safe_action |
4177.4.3
by James Henstridge
add safe_action descriptor and tests for it |
532 |
>>> class UnsafeActionTestView(LaunchpadFormView): |
533 |
... schema = IFormTest |
|
534 |
... field_names = ['name'] |
|
535 |
... |
|
536 |
... @action(u'Change', name='change') |
|
537 |
... def redirect_action(self, action, data): |
|
538 |
... print 'Change' |
|
539 |
... |
|
540 |
... @safe_action |
|
541 |
... @action(u'Search', name='search') |
|
542 |
... def search_action(self, action, data): |
|
543 |
... print 'Search' |
|
544 |
>>> context = FormTest() |
|
545 |
||
546 |
With this form, the "change" action can only be submitted with a POST |
|
547 |
request: |
|
548 |
||
549 |
>>> request = LaunchpadTestRequest( |
|
550 |
... environ={'REQUEST_METHOD': 'GET'}, |
|
551 |
... form={'field.name': 'foo', |
|
552 |
... 'field.actions.change': 'Change'}) |
|
553 |
>>> view = UnsafeActionTestView(context, request) |
|
554 |
>>> view.initialize() |
|
555 |
Traceback (most recent call last): |
|
556 |
... |
|
557 |
UnsafeFormGetSubmissionError: field.actions.change |
|
558 |
||
559 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
560 |
... method='POST', |
4177.4.3
by James Henstridge
add safe_action descriptor and tests for it |
561 |
... form={'field.name': 'foo', |
562 |
... 'field.actions.change': 'Change'}) |
|
563 |
>>> view = UnsafeActionTestView(context, request) |
|
564 |
>>> view.initialize() |
|
565 |
Change |
|
566 |
||
567 |
||
568 |
In contrast, the "search" action can be submitted with a GET request: |
|
569 |
||
570 |
>>> request = LaunchpadTestRequest( |
|
571 |
... environ={'REQUEST_METHOD': 'GET'}, |
|
572 |
... form={'field.name': 'foo', |
|
573 |
... 'field.actions.search': 'Search'}) |
|
574 |
>>> view = UnsafeActionTestView(context, request) |
|
575 |
>>> view.initialize() |
|
576 |
Search |
|
577 |
||
578 |
||
579 |
||
4177.4.10
by James Henstridge
fix one more heading |
580 |
== LaunchpadEditFormView == |
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
581 |
|
582 |
The LaunchpadEditFormView differs from LaunchpadFormView in the |
|
583 |
following ways: |
|
584 |
||
585 |
* fields take their default values from the context object. |
|
3691.68.34
by James Henstridge
rename update_context_from_data() to updateContextFromData() to match naming convention |
586 |
* a updateContextFromData() method is provided to apply the changes |
587 |
in the action method. |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
588 |
|
589 |
In other respects, it is used the same way as LaunchpadFormView: |
|
590 |
||
14642.1.5
by Curtis Hovey
remooved LaunchpadEditFormView from webapp globs. |
591 |
>>> from lp.app.browser.launchpadform import LaunchpadEditFormView |
3691.68.30
by James Henstridge
add tests for initial form focus code |
592 |
>>> class FormTestView8(LaunchpadEditFormView): |
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
593 |
... schema = IFormTest |
594 |
... field_names = ['displayname'] |
|
595 |
... next_url = 'http://www.ubuntu.com/' |
|
596 |
... |
|
597 |
... @action(u"Change Name", name="change") |
|
598 |
... def change_action(self, action, data): |
|
4177.4.1
by James Henstridge
some small updates to the LaunchpadFormView API to simplify things |
599 |
... if self.updateContextFromData(data): |
600 |
... print 'Context was updated' |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
601 |
|
602 |
>>> context = FormTest() |
|
603 |
>>> request = LaunchpadTestRequest() |
|
3691.68.30
by James Henstridge
add tests for initial form focus code |
604 |
>>> view = FormTestView8(context, request) |
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
605 |
>>> view.initialize() |
606 |
||
607 |
||
608 |
The field values take their defaults from the context object: |
|
609 |
||
610 |
>>> print view.widgets['displayname']() |
|
611 |
<input...value="Fred"... |
|
612 |
||
3691.68.34
by James Henstridge
rename update_context_from_data() to updateContextFromData() to match naming convention |
613 |
The updateContextFromData() method takes care of updating the context |
614 |
object for us too: |
|
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
615 |
|
616 |
>>> context = FormTest() |
|
617 |
>>> request = LaunchpadTestRequest( |
|
4177.4.13
by James Henstridge
small fix for launchpadform.txt |
618 |
... method='POST', |
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
619 |
... form={'field.displayname': 'James Henstridge', |
620 |
... 'field.actions.change': 'Change Name'}) |
|
3691.68.30
by James Henstridge
add tests for initial form focus code |
621 |
>>> view = FormTestView8(context, request) |
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
622 |
>>> view.initialize() |
4177.4.1
by James Henstridge
some small updates to the LaunchpadFormView API to simplify things |
623 |
Context was updated |
3691.68.17
by James Henstridge
add docs for LaunchpadEditFormView |
624 |
|
625 |
>>> request.response.getStatus() |
|
626 |
302 |
|
627 |
||
628 |
>>> context.displayname |
|
629 |
u'James Henstridge' |
|
630 |
||
3851.6.2
by Bjorn Tillenius
make LaunchpadFormView accept a custom context to use. |
631 |
By default updateContextFromData() uses the view's context, but it's |
632 |
possible to pass in a specific context to use instead: |
|
633 |
||
634 |
>>> custom_context = FormTest() |
|
635 |
>>> view.updateContextFromData({'displayname': u'New name'}, custom_context) |
|
4177.4.1
by James Henstridge
some small updates to the LaunchpadFormView API to simplify things |
636 |
True |
3851.6.2
by Bjorn Tillenius
make LaunchpadFormView accept a custom context to use. |
637 |
>>> custom_context.displayname |
638 |
u'New name' |
|
639 |