~launchpad-pqm/launchpad/devel

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