doc/book/devweb/edition/form.rst
changeset 10491 c67bcee93248
parent 8190 2a3c1b787688
child 12556 d1c659d70368
equal deleted inserted replaced
10490:76ab3c71aff2 10491:c67bcee93248
       
     1 .. _webform:
       
     2 
       
     3 HTML form construction
       
     4 ----------------------
       
     5 
       
     6 CubicWeb provides the somewhat usual form / field / widget / renderer abstraction
       
     7 to provide generic building blocks which will greatly help you in building forms
       
     8 properly integrated with CubicWeb (coherent display, error handling, etc...),
       
     9 while keeping things as flexible as possible.
       
    10 
       
    11 A ``form`` basically only holds a set of ``fields``, and has te be bound to a
       
    12 ``renderer`` which is responsible to layout them. Each field is bound to a
       
    13 ``widget`` that will be used to fill in value(s) for that field (at form
       
    14 generation time) and 'decode' (fetch and give a proper Python type to) values
       
    15 sent back by the browser.
       
    16 
       
    17 The ``field`` should be used according to the type of what you want to edit.
       
    18 E.g. if you want to edit some date, you'll have to use the
       
    19 :class:`cubicweb.web.formfields.DateField`. Then you can choose among multiple
       
    20 widgets to edit it, for instance :class:`cubicweb.web.formwidgets.TextInput` (a
       
    21 bare text field), :class:`~cubicweb.web.formwidgets.DateTimePicker` (a simple
       
    22 calendar) or even :class:`~cubicweb.web.formwidgets.JQueryDatePicker` (the JQuery
       
    23 calendar).  You can of course also write your own widget.
       
    24 
       
    25 Exploring the available forms
       
    26 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
       
    27 
       
    28 A small excursion into a |cubicweb| shell is the quickest way to
       
    29 discover available forms (or application objects in general).
       
    30 
       
    31 .. sourcecode:: python
       
    32 
       
    33  >>> from pprint import pprint
       
    34  >>> pprint( session.vreg['forms'] )
       
    35  {'base': [<class 'cubicweb.web.views.forms.FieldsForm'>,
       
    36            <class 'cubicweb.web.views.forms.EntityFieldsForm'>],
       
    37   'changestate': [<class 'cubicweb.web.views.workflow.ChangeStateForm'>,
       
    38                   <class 'cubes.tracker.views.forms.VersionChangeStateForm'>],
       
    39   'composite': [<class 'cubicweb.web.views.forms.CompositeForm'>,
       
    40                 <class 'cubicweb.web.views.forms.CompositeEntityForm'>],
       
    41   'deleteconf': [<class 'cubicweb.web.views.editforms.DeleteConfForm'>],
       
    42   'edition': [<class 'cubicweb.web.views.autoform.AutomaticEntityForm'>,
       
    43               <class 'cubicweb.web.views.workflow.TransitionEditionForm'>,
       
    44               <class 'cubicweb.web.views.workflow.StateEditionForm'>],
       
    45   'logform': [<class 'cubicweb.web.views.basetemplates.LogForm'>],
       
    46   'massmailing': [<class 'cubicweb.web.views.massmailing.MassMailingForm'>],
       
    47   'muledit': [<class 'cubicweb.web.views.editforms.TableEditForm'>],
       
    48   'sparql': [<class 'cubicweb.web.views.sparql.SparqlForm'>]}
       
    49 
       
    50 
       
    51 The two most important form families here (for all practical purposes) are `base`
       
    52 and `edition`. Most of the time one wants alterations of the
       
    53 :class:`AutomaticEntityForm` to generate custom forms to handle edition of an
       
    54 entity.
       
    55 
       
    56 The Automatic Entity Form
       
    57 ~~~~~~~~~~~~~~~~~~~~~~~~~
       
    58 
       
    59 .. automodule:: cubicweb.web.views.autoform
       
    60 
       
    61 Anatomy of a choices function
       
    62 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
       
    63 
       
    64 Let's have a look at the `ticket_done_in_choices` function given to
       
    65 the `choices` parameter of the relation tag that is applied to the
       
    66 ('Ticket', 'done_in', '*') relation definition, as it is both typical
       
    67 and sophisticated enough. This is a code snippet from the `tracker`_
       
    68 cube.
       
    69 
       
    70 .. _`tracker`: http://www.cubicweb.org/project/cubicweb-tracker
       
    71 
       
    72 The ``Ticket`` entity type can be related to a ``Project`` and a
       
    73 ``Version``, respectively through the ``concerns`` and ``done_in``
       
    74 relations. When a user is about to edit a ticket, we want to fill the
       
    75 combo box for the ``done_in`` relation with values pertinent with
       
    76 respect to the context. The important context here is:
       
    77 
       
    78 * creation or modification (we cannot fetch values the same way in
       
    79   either case)
       
    80 
       
    81 * ``__linkto`` url parameter given in a creation context
       
    82 
       
    83 .. sourcecode:: python
       
    84 
       
    85     from cubicweb.web import formfields
       
    86 
       
    87     def ticket_done_in_choices(form, field):
       
    88         entity = form.edited_entity
       
    89         # first see if its specified by __linkto form parameters
       
    90         linkedto = form.linked_to[('done_in', 'subject')]
       
    91         if linkedto:
       
    92             return linkedto
       
    93         # it isn't, get initial values
       
    94         vocab = field.relvoc_init(form)
       
    95         veid = None
       
    96         # try to fetch the (already or pending) related version and project
       
    97         if not entity.has_eid():
       
    98             peids = form.linked_to[('concerns', 'subject')]
       
    99             peid = peids and peids[0]
       
   100         else:
       
   101             peid = entity.project.eid
       
   102             veid = entity.done_in and entity.done_in[0].eid
       
   103         if peid:
       
   104             # we can complete the vocabulary with relevant values
       
   105             rschema = form._cw.vreg.schema['done_in'].rdef('Ticket', 'Version')
       
   106             rset = form._cw.execute(
       
   107                 'Any V, VN ORDERBY version_sort_value(VN) '
       
   108                 'WHERE V version_of P, P eid %(p)s, V num VN, '
       
   109                 'V in_state ST, NOT ST name "published"', {'p': peid}, 'p')
       
   110             vocab += [(v.view('combobox'), v.eid) for v in rset.entities()
       
   111                       if rschema.has_perm(form._cw, 'add', toeid=v.eid)
       
   112                       and v.eid != veid]
       
   113         return vocab
       
   114 
       
   115 The first thing we have to do is fetch potential values from the ``__linkto`` url
       
   116 parameter that is often found in entity creation contexts (the creation action
       
   117 provides such a parameter with a predetermined value; for instance in this case,
       
   118 ticket creation could occur in the context of a `Version` entity). The
       
   119 :class:`~cubicweb.web.formfields.RelationField` field class provides a
       
   120 :meth:`~cubicweb.web.formfields.RelationField.relvoc_linkedto` method that gets a
       
   121 list suitably filled with vocabulary values.
       
   122 
       
   123 .. sourcecode:: python
       
   124 
       
   125         linkedto = field.relvoc_linkedto(form)
       
   126         if linkedto:
       
   127             return linkedto
       
   128 
       
   129 Then, if no ``__linkto`` argument was given, we must prepare the vocabulary with
       
   130 an initial empty value (because `done_in` is not mandatory, we must allow the
       
   131 user to not select a verson) and already linked values. This is done with the
       
   132 :meth:`~cubicweb.web.formfields.RelationField.relvoc_init` method.
       
   133 
       
   134 .. sourcecode:: python
       
   135 
       
   136         vocab = field.relvoc_init(form)
       
   137 
       
   138 But then, we have to give more: if the ticket is related to a project,
       
   139 we should provide all the non published versions of this project
       
   140 (`Version` and `Project` can be related through the `version_of`
       
   141 relation). Conversely, if we do not know yet the project, it would not
       
   142 make sense to propose all existing versions as it could potentially
       
   143 lead to incoherences. Even if these will be caught by some
       
   144 RQLConstraint, it is wise not to tempt the user with error-inducing
       
   145 candidate values.
       
   146 
       
   147 The "ticket is related to a project" part must be decomposed as:
       
   148 
       
   149 * this is a new ticket which is created is the context of a project
       
   150 
       
   151 * this is an already existing ticket, linked to a project (through the
       
   152   `concerns` relation)
       
   153 
       
   154 * there is no related project (quite unlikely given the cardinality of
       
   155   the `concerns` relation, so it can only mean that we are creating a
       
   156   new ticket, and a project is about to be selected but there is no
       
   157   ``__linkto`` argument)
       
   158 
       
   159 .. note::
       
   160 
       
   161    the last situation could happen in several ways, but of course in a
       
   162    polished application, the paths to ticket creation should be
       
   163    controlled so as to avoid a suboptimal end-user experience
       
   164 
       
   165 Hence, we try to fetch the related project.
       
   166 
       
   167 .. sourcecode:: python
       
   168 
       
   169         veid = None
       
   170         if not entity.has_eid():
       
   171             peids = form.linked_to[('concerns', 'subject')]
       
   172             peid = peids and peids[0]
       
   173         else:
       
   174             peid = entity.project.eid
       
   175             veid = entity.done_in and entity.done_in[0].eid
       
   176 
       
   177 We distinguish between entity creation and entity modification using
       
   178 the ``Entity.has_eid()`` method, which returns `False` on creation. At
       
   179 creation time the only way to get a project is through the
       
   180 ``__linkto`` parameter. Notice that we fetch the version in which the
       
   181 ticket is `done_in` if any, for later.
       
   182 
       
   183 .. note::
       
   184 
       
   185   the implementation above assumes that if there is a ``__linkto``
       
   186   parameter, it is only about a project. While it makes sense most of
       
   187   the time, it is not an absolute. Depending on how an entity creation
       
   188   action action url is built, several outcomes could be possible
       
   189   there
       
   190 
       
   191 If the ticket is already linked to a project, fetching it is
       
   192 trivial. Then we add the relevant version to the initial vocabulary.
       
   193 
       
   194 .. sourcecode:: python
       
   195 
       
   196         if peid:
       
   197             rschema = form._cw.vreg.schema['done_in'].rdef('Ticket', 'Version')
       
   198             rset = form._cw.execute(
       
   199                 'Any V, VN ORDERBY version_sort_value(VN) '
       
   200                 'WHERE V version_of P, P eid %(p)s, V num VN, '
       
   201                 'V in_state ST, NOT ST name "published"', {'p': peid})
       
   202             vocab += [(v.view('combobox'), v.eid) for v in rset.entities()
       
   203                       if rschema.has_perm(form._cw, 'add', toeid=v.eid)
       
   204                       and v.eid != veid]
       
   205 
       
   206 .. warning::
       
   207 
       
   208    we have to defend ourselves against lack of a project eid. Given
       
   209    the cardinality of the `concerns` relation, there *must* be a
       
   210    project, but this rule can only be enforced at validation time,
       
   211    which will happen of course only after form subsmission
       
   212 
       
   213 Here, given a project eid, we complete the vocabulary with all
       
   214 unpublished versions defined in the project (sorted by number) for
       
   215 which the current user is allowed to establish the relation.
       
   216 
       
   217 
       
   218 Building self-posted form with custom fields/widgets
       
   219 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
       
   220 
       
   221 Sometimes you want a form that is not related to entity edition. For those,
       
   222 you'll have to handle form posting by yourself. Here is a complete example on how
       
   223 to achieve this (and more).
       
   224 
       
   225 Imagine you want a form that selects a month period. There are no proper
       
   226 field/widget to handle this in CubicWeb, so let's start by defining them:
       
   227 
       
   228 .. sourcecode:: python
       
   229 
       
   230     # let's have the whole import list at the beginning, even those necessary for
       
   231     # subsequent snippets
       
   232     from logilab.common import date
       
   233     from logilab.mtconverter import xml_escape
       
   234     from cubicweb.view import View
       
   235     from cubicweb.predicates import match_kwargs
       
   236     from cubicweb.web import RequestError, ProcessFormError
       
   237     from cubicweb.web import formfields as fields, formwidgets as wdgs
       
   238     from cubicweb.web.views import forms, calendar
       
   239 
       
   240     class MonthSelect(wdgs.Select):
       
   241         """Custom widget to display month and year. Expect value to be given as a
       
   242         date instance.
       
   243         """
       
   244 
       
   245         def format_value(self, form, field, value):
       
   246             return u'%s/%s' % (value.year, value.month)
       
   247 
       
   248         def process_field_data(self, form, field):
       
   249             val = super(MonthSelect, self).process_field_data(form, field)
       
   250             try:
       
   251                 year, month = val.split('/')
       
   252                 year = int(year)
       
   253                 month = int(month)
       
   254                 return date.date(year, month, 1)
       
   255             except ValueError:
       
   256                 raise ProcessFormError(
       
   257                     form._cw._('badly formated date string %s') % val)
       
   258 
       
   259 
       
   260     class MonthPeriodField(fields.CompoundField):
       
   261         """custom field composed of two subfields, 'begin_month' and 'end_month'.
       
   262 
       
   263         It expects to be used on form that has 'mindate' and 'maxdate' in its
       
   264         extra arguments, telling the range of month to display.
       
   265         """
       
   266 
       
   267         def __init__(self, *args, **kwargs):
       
   268             kwargs.setdefault('widget', wdgs.IntervalWidget())
       
   269             super(MonthPeriodField, self).__init__(
       
   270                 [fields.StringField(name='begin_month',
       
   271                                     choices=self.get_range, sort=False,
       
   272                                     value=self.get_mindate,
       
   273                                     widget=MonthSelect()),
       
   274                  fields.StringField(name='end_month',
       
   275                                     choices=self.get_range, sort=False,
       
   276                                     value=self.get_maxdate,
       
   277                                     widget=MonthSelect())], *args, **kwargs)
       
   278 
       
   279         @staticmethod
       
   280         def get_range(form, field):
       
   281             mindate = date.todate(form.cw_extra_kwargs['mindate'])
       
   282             maxdate = date.todate(form.cw_extra_kwargs['maxdate'])
       
   283             assert mindate <= maxdate
       
   284             _ = form._cw._
       
   285             months = []
       
   286             while mindate <= maxdate:
       
   287                 label = '%s %s' % (_(calendar.MONTHNAMES[mindate.month - 1]),
       
   288                                    mindate.year)
       
   289                 value = field.widget.format_value(form, field, mindate)
       
   290                 months.append( (label, value) )
       
   291                 mindate = date.next_month(mindate)
       
   292             return months
       
   293 
       
   294         @staticmethod
       
   295         def get_mindate(form, field):
       
   296             return form.cw_extra_kwargs['mindate']
       
   297 
       
   298         @staticmethod
       
   299         def get_maxdate(form, field):
       
   300             return form.cw_extra_kwargs['maxdate']
       
   301 
       
   302         def process_posted(self, form):
       
   303             for field, value in super(MonthPeriodField, self).process_posted(form):
       
   304                 if field.name == 'end_month':
       
   305                     value = date.last_day(value)
       
   306                 yield field, value
       
   307 
       
   308 
       
   309 Here we first define a widget that will be used to select the beginning and the
       
   310 end of the period, displaying months like '<month> YYYY' but using 'YYYY/mm' as
       
   311 actual value.
       
   312 
       
   313 We then define a field that will actually hold two fields, one for the beginning
       
   314 and another for the end of the period. Each subfield uses the widget we defined
       
   315 earlier, and the outer field itself uses the standard
       
   316 :class:`IntervalWidget`. The field adds some logic:
       
   317 
       
   318 * a vocabulary generation function `get_range`, used to populate each sub-field
       
   319 
       
   320 * two 'value' functions `get_mindate` and `get_maxdate`, used to tell to
       
   321   subfields which value they should consider on form initialization
       
   322 
       
   323 * overriding of `process_posted`, called when the form is being posted, so that
       
   324   the end of the period is properly set to the last day of the month.
       
   325 
       
   326 Now, we can define a very simple form:
       
   327 
       
   328 .. sourcecode:: python
       
   329 
       
   330     class MonthPeriodSelectorForm(forms.FieldsForm):
       
   331         __regid__ = 'myform'
       
   332         __select__ = match_kwargs('mindate', 'maxdate')
       
   333 
       
   334         form_buttons = [wdgs.SubmitButton()]
       
   335         form_renderer_id = 'onerowtable'
       
   336         period = MonthPeriodField()
       
   337 
       
   338 
       
   339 where we simply add our field, set a submit button and use a very simple renderer
       
   340 (try others!). Also we specify a selector that ensures form will have arguments
       
   341 necessary to our field.
       
   342 
       
   343 Now, we need a view that will wrap the form and handle post when it occurs,
       
   344 simply displaying posted values in the page:
       
   345 
       
   346 .. sourcecode:: python
       
   347 
       
   348     class SelfPostingForm(View):
       
   349         __regid__ = 'myformview'
       
   350 
       
   351         def call(self):
       
   352             mindate, maxdate = date.date(2010, 1, 1), date.date(2012, 1, 1)
       
   353             form = self._cw.vreg['forms'].select(
       
   354                 'myform', self._cw, mindate=mindate, maxdate=maxdate, action='')
       
   355             try:
       
   356                 posted = form.process_posted()
       
   357                 self.w(u'<p>posted values %s</p>' % xml_escape(repr(posted)))
       
   358             except RequestError: # no specified period asked
       
   359                 pass
       
   360             form.render(w=self.w, formvalues=self._cw.form)
       
   361 
       
   362 
       
   363 Notice usage of the :meth:`process_posted` method, that will return a dictionary
       
   364 of typed values (because they have been processed by the field). In our case, when
       
   365 the form is posted you should see a dictionary with 'begin_month' and 'end_month'
       
   366 as keys with the selected dates as value (as a python `date` object).
       
   367 
       
   368 
       
   369 APIs
       
   370 ~~~~
       
   371 
       
   372 .. automodule:: cubicweb.web.formfields
       
   373 .. automodule:: cubicweb.web.formwidgets
       
   374 .. automodule:: cubicweb.web.views.forms
       
   375 .. automodule:: cubicweb.web.views.formrenderers
       
   376 
       
   377