cubicweb/web/formfields.py
changeset 11057 0b59724cb3f2
parent 10903 da30851f9706
child 11129 97095348b3ee
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/formfields.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,1269 @@
+# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""
+The Field class and basic fields
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. Note::
+  Fields are used to control what's edited in forms. They makes the link between
+  something to edit and its display in the form. Actual display is handled by a
+  widget associated to the field.
+
+Let first see the base class for fields:
+
+.. autoclass:: cubicweb.web.formfields.Field
+
+Now, you usually don't use that class but one of the concrete field classes
+described below, according to what you want to edit.
+
+Basic fields
+''''''''''''
+
+.. autoclass:: cubicweb.web.formfields.StringField()
+.. autoclass:: cubicweb.web.formfields.PasswordField()
+.. autoclass:: cubicweb.web.formfields.IntField()
+.. autoclass:: cubicweb.web.formfields.BigIntField()
+.. autoclass:: cubicweb.web.formfields.FloatField()
+.. autoclass:: cubicweb.web.formfields.BooleanField()
+.. autoclass:: cubicweb.web.formfields.DateField()
+.. autoclass:: cubicweb.web.formfields.DateTimeField()
+.. autoclass:: cubicweb.web.formfields.TimeField()
+.. autoclass:: cubicweb.web.formfields.TimeIntervalField()
+
+Compound fields
+''''''''''''''''
+
+.. autoclass:: cubicweb.web.formfields.RichTextField()
+.. autoclass:: cubicweb.web.formfields.FileField()
+.. autoclass:: cubicweb.web.formfields.CompoundField()
+
+.. autoclass cubicweb.web.formfields.EditableFileField() XXX should be a widget
+
+Entity specific fields and function
+'''''''''''''''''''''''''''''''''''
+
+.. autoclass:: cubicweb.web.formfields.RelationField()
+.. autofunction:: cubicweb.web.formfields.guess_field
+
+"""
+__docformat__ = "restructuredtext en"
+
+from warnings import warn
+from datetime import datetime, timedelta
+
+from six import PY2, text_type, string_types
+
+from logilab.mtconverter import xml_escape
+from logilab.common import nullobject
+from logilab.common.date import ustrftime
+from logilab.common.configuration import format_time
+from logilab.common.textutils import apply_units, TIME_UNITS
+
+from yams.schema import KNOWN_METAATTRIBUTES, role_name
+from yams.constraints import (SizeConstraint, StaticVocabularyConstraint,
+                              FormatConstraint)
+
+from cubicweb import Binary, tags, uilib
+from cubicweb.utils import support_args
+from cubicweb.web import INTERNAL_FIELD_VALUE, ProcessFormError, eid_param, \
+     formwidgets as fw
+from cubicweb.web.views import uicfg
+
+class UnmodifiedField(Exception):
+    """raise this when a field has not actually been edited and you want to skip
+    it
+    """
+
+def normalize_filename(filename):
+    return filename.split('\\')[-1]
+
+def vocab_sort(vocab):
+    """sort vocabulary, considering option groups"""
+    result = []
+    partresult = []
+    for label, value in vocab:
+        if value is None: # opt group start
+            if partresult:
+                result += sorted(partresult)
+                partresult = []
+            result.append( (label, value) )
+        else:
+            partresult.append( (label, value) )
+    result += sorted(partresult)
+    return result
+
+_MARKER = nullobject()
+
+class Field(object):
+    """This class is the abstract base class for all fields. It hold a bunch
+    of attributes which may be used for fine control of the behaviour of a
+    concrete field.
+
+    **Attributes**
+
+    All the attributes described below have sensible default value which may be
+    overriden by named arguments given to field's constructor.
+
+    :attr:`name`
+       base name of the field (basestring). The actual input name is returned by
+       the :meth:`input_name` method and may differ from that name (for instance
+       if `eidparam` is true).
+    :attr:`id`
+       DOM identifier (default to the same value as `name`), should be unique in
+       a form.
+    :attr:`label`
+       label of the field (default to the same value as `name`).
+    :attr:`help`
+       help message about this field.
+    :attr:`widget`
+       widget associated to the field. Each field class has a default widget
+       class which may be overriden per instance.
+    :attr:`value`
+       field value. May be an actual value or a callable which should take the
+       form as argument and return a value.
+    :attr:`choices`
+       static vocabulary for this field. May be a list of values, a list of
+       (label, value) tuples or a callable which should take the form and field
+       as arguments and return a list of values or a list of (label, value).
+    :attr:`required`
+       bool flag telling if the field is required or not.
+    :attr:`sort`
+       bool flag telling if the vocabulary (either static vocabulary specified
+       in `choices` or dynamic vocabulary fetched from the form) should be
+       sorted on label.
+    :attr:`internationalizable`
+       bool flag telling if the vocabulary labels should be translated using the
+       current request language.
+    :attr:`eidparam`
+       bool flag telling if this field is linked to a specific entity
+    :attr:`role`
+       when the field is linked to an entity attribute or relation, tells the
+       role of the entity in the relation (eg 'subject' or 'object'). If this is
+       not an attribute or relation of the edited entity, `role` should be
+       `None`.
+    :attr:`fieldset`
+       optional fieldset to which this field belongs to
+    :attr:`order`
+       key used by automatic forms to sort fields
+    :attr:`ignore_req_params`
+       when true, this field won't consider value potentially specified using
+       request's form parameters (eg you won't be able to specify a value using for
+       instance url like http://mywebsite.com/form?field=value)
+
+    .. currentmodule:: cubicweb.web.formfields
+
+    **Generic methods**
+
+    .. automethod:: Field.input_name
+    .. automethod:: Field.dom_id
+    .. automethod:: Field.actual_fields
+
+    **Form generation methods**
+
+    .. automethod:: form_init
+    .. automethod:: typed_value
+
+    **Post handling methods**
+
+    .. automethod:: process_posted
+    .. automethod:: process_form_value
+
+    """
+    # default widget associated to this class of fields. May be overriden per
+    # instance
+    widget = fw.TextInput
+    # does this field requires a multipart form
+    needs_multipart = False
+    # class attribute used for ordering of fields in a form
+    __creation_rank = 0
+
+    eidparam = False
+    role = None
+    id = None
+    help = None
+    required = False
+    choices = None
+    sort = True
+    internationalizable = False
+    fieldset = None
+    order = None
+    value = _MARKER
+    fallback_on_none_attribute = False
+    ignore_req_params = False
+
+    def __init__(self, name=None, label=_MARKER, widget=None, **kwargs):
+        for key, val in kwargs.items():
+            assert hasattr(self.__class__, key) and not key[0] == '_', key
+            setattr(self, key, val)
+        self.name = name
+        if label is _MARKER:
+            label = name or _MARKER
+        self.label = label
+        # has to be done after other attributes initialization
+        self.init_widget(widget)
+        # ordering number for this field instance
+        self.creation_rank = Field.__creation_rank
+        Field.__creation_rank += 1
+
+    def as_string(self, repr=True):
+        l = [u'<%s' % self.__class__.__name__]
+        for attr in ('name', 'eidparam', 'role', 'id', 'value'):
+            value = getattr(self, attr)
+            if value is not None and value is not _MARKER:
+                l.append('%s=%r' % (attr, value))
+        if repr:
+            l.append('@%#x' % id(self))
+        return u'%s>' % ' '.join(l)
+
+    def __unicode__(self):
+        return self.as_string(False)
+
+    if PY2:
+        def __str__(self):
+            return self.as_string(False).encode('UTF8')
+    else:
+        __str__ = __unicode__
+
+    def __repr__(self):
+        return self.as_string(True)
+
+    def init_widget(self, widget):
+        if widget is not None:
+            self.widget = widget
+        elif self.choices and not self.widget.vocabulary_widget:
+            self.widget = fw.Select()
+        if isinstance(self.widget, type):
+            self.widget = self.widget()
+
+    def set_name(self, name):
+        """automatically set .label when name is set"""
+        assert name
+        self.name = name
+        if self.label is _MARKER:
+            self.label = name
+
+    def is_visible(self):
+        """return true if the field is not an hidden field"""
+        return not isinstance(self.widget, fw.HiddenInput)
+
+    def actual_fields(self, form):
+        """Fields may be composed of other fields. For instance the
+        :class:`~cubicweb.web.formfields.RichTextField` is containing a format
+        field to define the text format. This method returns actual fields that
+        should be considered for display / edition. It usually simply return
+        self.
+        """
+        yield self
+
+    def format_value(self, req, value):
+        """return value suitable for display where value may be a list or tuple
+        of values
+        """
+        if isinstance(value, (list, tuple)):
+            return [self.format_single_value(req, val) for val in value]
+        return self.format_single_value(req, value)
+
+    def format_single_value(self, req, value):
+        """return value suitable for display"""
+        if value is None or value is False:
+            return u''
+        if value is True:
+            return u'1'
+        return text_type(value)
+
+    def get_widget(self, form):
+        """return the widget instance associated to this field"""
+        return self.widget
+
+    def input_name(self, form, suffix=None):
+        """Return the 'qualified name' for this field, e.g. something suitable
+        to use as HTML input name. You can specify a suffix that will be
+        included in the name when widget needs several inputs.
+        """
+        # caching is necessary else we get some pb on entity creation :
+        # entity.eid is modified from creation mark (eg 'X') to its actual eid
+        # (eg 123), and then `field.input_name()` won't return the right key
+        # anymore if not cached (first call to input_name done *before* eventual
+        # eid affectation).
+        #
+        # note that you should NOT use @cached else it will create a memory leak
+        # on persistent fields (eg created once for all on a form class) because
+        # of the 'form' appobject argument: the cache will keep growing as new
+        # form are created...
+        try:
+            return form.formvalues[(self, 'input_name', suffix)]
+        except KeyError:
+            name = self.role_name()
+            if suffix is not None:
+                name += suffix
+            if self.eidparam:
+                name = eid_param(name, form.edited_entity.eid)
+            form.formvalues[(self, 'input_name', suffix)] = name
+            return name
+
+    def role_name(self):
+        """return <field.name>-<field.role> if role is specified, else field.name"""
+        assert self.name, 'field without a name (give it to constructor for explicitly built fields)'
+        if self.role is not None:
+            return role_name(self.name, self.role)
+        return self.name
+
+    def dom_id(self, form, suffix=None):
+        """Return the HTML DOM identifier for this field, e.g. something
+        suitable to use as HTML input id. You can specify a suffix that will be
+        included in the name when widget needs several inputs.
+        """
+        id = self.id or self.role_name()
+        if suffix is not None:
+            id += suffix
+        if self.eidparam:
+            return eid_param(id, form.edited_entity.eid)
+        return id
+
+    def typed_value(self, form, load_bytes=False):
+        """Return the correctly typed value for this field in the form context.
+        """
+        if self.eidparam and self.role is not None:
+            entity = form.edited_entity
+            if form._cw.vreg.schema.rschema(self.name).final:
+                if entity.has_eid() or self.name in entity.cw_attr_cache:
+                    value = getattr(entity, self.name)
+                    if value is not None or not self.fallback_on_none_attribute:
+                        return value
+            elif entity.has_eid() or entity.cw_relation_cached(self.name, self.role):
+                value = [r[0] for r in entity.related(self.name, self.role)]
+                if value or not self.fallback_on_none_attribute:
+                    return value
+        return self.initial_typed_value(form, load_bytes)
+
+    def initial_typed_value(self, form, load_bytes):
+        if self.value is not _MARKER:
+            if callable(self.value):
+                return self.value(form, self)
+            return self.value
+        formattr = '%s_%s_default' % (self.role, self.name)
+        if self.eidparam and self.role is not None:
+            if form._cw.vreg.schema.rschema(self.name).final:
+                return form.edited_entity.e_schema.default(self.name)
+            return form.linked_to.get((self.name, self.role), ())
+        return None
+
+    def example_format(self, req):
+        """return a sample string describing what can be given as input for this
+        field
+        """
+        return u''
+
+    def render(self, form, renderer):
+        """render this field, which is part of form, using the given form
+        renderer
+        """
+        widget = self.get_widget(form)
+        return widget.render(form, self, renderer)
+
+    def vocabulary(self, form, **kwargs):
+        """return vocabulary for this field. This method will be
+        called by widgets which requires a vocabulary.
+
+        It should return a list of tuple (label, value), where value
+        *must be a unicode string*, not a typed value.
+        """
+        assert self.choices is not None
+        if callable(self.choices):
+            # pylint: disable=E1102
+            if getattr(self.choices, '__self__', None) is self:
+                vocab = self.choices(form=form, **kwargs)
+            else:
+                vocab = self.choices(form=form, field=self, **kwargs)
+        else:
+            vocab = self.choices
+        if vocab and not isinstance(vocab[0], (list, tuple)):
+            vocab = [(x, x) for x in vocab]
+        if self.internationalizable:
+            # the short-cirtcuit 'and' boolean operator is used here
+            # to permit a valid empty string in vocabulary without
+            # attempting to translate it by gettext (which can lead to
+            # weird strings display)
+            vocab = [(label and form._cw._(label), value)
+                     for label, value in vocab]
+        if self.sort:
+            vocab = vocab_sort(vocab)
+        return vocab
+
+    # support field as argument to avoid warning when used as format field value
+    # callback
+    def format(self, form, field=None):
+        """return MIME type used for the given (text or bytes) field"""
+        if self.eidparam and self.role == 'subject':
+            entity = form.edited_entity
+            if entity.e_schema.has_metadata(self.name, 'format') and (
+                entity.has_eid() or '%s_format' % self.name in entity.cw_attr_cache):
+                return form.edited_entity.cw_attr_metadata(self.name, 'format')
+        return form._cw.property_value('ui.default-text-format')
+
+    def encoding(self, form):
+        """return encoding used for the given (text) field"""
+        if self.eidparam:
+            entity = form.edited_entity
+            if entity.e_schema.has_metadata(self.name, 'encoding') and (
+                entity.has_eid() or '%s_encoding' % self.name in entity):
+                return form.edited_entity.cw_attr_metadata(self.name, 'encoding')
+        return form._cw.encoding
+
+    def form_init(self, form):
+        """Method called at form initialization to trigger potential field
+        initialization requiring the form instance. Do nothing by default.
+        """
+        pass
+
+    def has_been_modified(self, form):
+        for field in self.actual_fields(form):
+            if field._has_been_modified(form):
+                return True # XXX
+        return False # not modified
+
+    def _has_been_modified(self, form):
+        # fields not corresponding to an entity attribute / relations
+        # are considered modified
+        if not self.eidparam or not self.role or not form.edited_entity.has_eid():
+            return True # XXX
+        try:
+            if self.role == 'subject':
+                previous_value = getattr(form.edited_entity, self.name)
+            else:
+                previous_value = getattr(form.edited_entity,
+                                         'reverse_%s' % self.name)
+        except AttributeError:
+            # fields with eidparam=True but not corresponding to an actual
+            # attribute or relation
+            return True
+        # if it's a non final relation, we need the eids
+        if isinstance(previous_value, (list, tuple)):
+            # widget should return a set of untyped eids
+            previous_value = set(e.eid for e in previous_value)
+        try:
+            new_value = self.process_form_value(form)
+        except ProcessFormError:
+            return True
+        except UnmodifiedField:
+            return False # not modified
+        if previous_value == new_value:
+            return False # not modified
+        return True
+
+    def process_form_value(self, form):
+        """Return the correctly typed value posted for this field."""
+        try:
+            return form.formvalues[(self, form)]
+        except KeyError:
+            value = form.formvalues[(self, form)] = self._process_form_value(form)
+            return value
+
+    def _process_form_value(self, form):
+        widget = self.get_widget(form)
+        value = widget.process_field_data(form, self)
+        return self._ensure_correctly_typed(form, value)
+
+    def _ensure_correctly_typed(self, form, value):
+        """widget might to return date as a correctly formatted string or as
+        correctly typed objects, but process_for_value must return a typed value.
+        Override this method to type the value if necessary
+        """
+        return value or None
+
+    def process_posted(self, form):
+        """Return an iterator on (field, value) that has been posted for
+        field returned by :meth:`~cubicweb.web.formfields.Field.actual_fields`.
+        """
+        for field in self.actual_fields(form):
+            if field is self:
+                try:
+                    value = field.process_form_value(form)
+                    if field.no_value(value) and field.required:
+                        raise ProcessFormError(form._cw._("required field"))
+                    yield field, value
+                except UnmodifiedField:
+                    continue
+            else:
+                # recursive function: we might have compound fields
+                # of compound fields (of compound fields of ...)
+                for field, value in field.process_posted(form):
+                    yield field, value
+
+    @staticmethod
+    def no_value(value):
+        """return True if the value can be considered as no value for the field"""
+        return value is None
+
+
+class StringField(Field):
+    """Use this field to edit unicode string (`String` yams type). This field
+    additionally support a `max_length` attribute that specify a maximum size for
+    the string (`None` meaning no limit).
+
+    Unless explicitly specified, the widget for this field will be:
+
+    * :class:`~cubicweb.web.formwidgets.Select` if some vocabulary is specified
+      using `choices` attribute
+
+    * :class:`~cubicweb.web.formwidgets.TextInput` if maximum size is specified
+      using `max_length` attribute and this length is inferior to 257.
+
+    * :class:`~cubicweb.web.formwidgets.TextArea` in all other cases
+    """
+    widget = fw.TextArea
+    size = 45
+    placeholder = None
+
+    def __init__(self, name=None, max_length=None, **kwargs):
+        self.max_length = max_length # must be set before super call
+        super(StringField, self).__init__(name=name, **kwargs)
+
+    def init_widget(self, widget):
+        if widget is None:
+            if self.choices:
+                widget = fw.Select()
+            elif self.max_length and self.max_length < 257:
+                widget = fw.TextInput()
+
+        super(StringField, self).init_widget(widget)
+        if isinstance(self.widget, fw.TextArea):
+            self.init_text_area(self.widget)
+        elif isinstance(self.widget, fw.TextInput):
+            self.init_text_input(self.widget)
+
+        if self.placeholder:
+            self.widget.attrs.setdefault('placeholder', self.placeholder)
+
+    def init_text_input(self, widget):
+        if self.max_length:
+            widget.attrs.setdefault('size', min(self.size, self.max_length))
+            widget.attrs.setdefault('maxlength', self.max_length)
+
+    def init_text_area(self, widget):
+        if self.max_length and self.max_length < 513:
+            widget.attrs.setdefault('cols', 60)
+            widget.attrs.setdefault('rows', 5)
+
+    def set_placeholder(self, placeholder):
+        self.placeholder = placeholder
+        if self.widget and self.placeholder:
+            self.widget.attrs.setdefault('placeholder', self.placeholder)
+
+
+class PasswordField(StringField):
+    """Use this field to edit password (`Password` yams type, encoded python
+    string).
+
+    Unless explicitly specified, the widget for this field will be
+    a :class:`~cubicweb.web.formwidgets.PasswordInput`.
+    """
+    widget = fw.PasswordInput
+    def form_init(self, form):
+        if self.eidparam and form.edited_entity.has_eid():
+            # see below: value is probably set but we can't retreive it. Ensure
+            # the field isn't show as a required field on modification
+            self.required = False
+
+    def typed_value(self, form, load_bytes=False):
+        if self.eidparam:
+            # no way to fetch actual password value with cw
+            if form.edited_entity.has_eid():
+                return ''
+            return self.initial_typed_value(form, load_bytes)
+        return super(PasswordField, self).typed_value(form, load_bytes)
+
+
+class RichTextField(StringField):
+    """This compound field allow edition of text (unicode string) in
+    a particular format. It has an inner field holding the text format,
+    that can be specified using `format_field` argument. If not specified
+    one will be automaticall generated.
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.FCKEditor` or a
+    :class:`~cubicweb.web.formwidgets.TextArea`. according to the field's
+    format and to user's preferences.
+    """
+
+    widget = None
+    def __init__(self, format_field=None, **kwargs):
+        super(RichTextField, self).__init__(**kwargs)
+        self.format_field = format_field
+
+    def init_text_area(self, widget):
+        pass
+
+    def get_widget(self, form):
+        if self.widget is None:
+            if self.use_fckeditor(form):
+                return fw.FCKEditor()
+            widget = fw.TextArea()
+            self.init_text_area(widget)
+            return widget
+        return self.widget
+
+    def get_format_field(self, form):
+        if self.format_field:
+            return self.format_field
+        # we have to cache generated field since it's use as key in the
+        # context dictionary
+        req = form._cw
+        try:
+            return req.data[self]
+        except KeyError:
+            fkwargs = {'eidparam': self.eidparam, 'role': self.role}
+            if self.use_fckeditor(form):
+                # if fckeditor is used and format field isn't explicitly
+                # deactivated, we want an hidden field for the format
+                fkwargs['widget'] = fw.HiddenInput()
+                fkwargs['value'] = 'text/html'
+            else:
+                # else we want a format selector
+                fkwargs['widget'] = fw.Select()
+                fcstr = FormatConstraint()
+                fkwargs['choices'] = fcstr.vocabulary(form=form)
+                fkwargs['internationalizable'] = True
+                fkwargs['value'] = self.format
+            fkwargs['eidparam'] = self.eidparam
+            field = StringField(name=self.name + '_format', **fkwargs)
+            req.data[self] = field
+            return field
+
+    def actual_fields(self, form):
+        yield self
+        format_field = self.get_format_field(form)
+        if format_field:
+            yield format_field
+
+    def use_fckeditor(self, form):
+        """return True if fckeditor should be used to edit entity's attribute named
+        `attr`, according to user preferences
+        """
+        if form._cw.use_fckeditor():
+            return self.format(form) == 'text/html'
+        return False
+
+    def render(self, form, renderer):
+        format_field = self.get_format_field(form)
+        if format_field:
+            # XXX we want both fields to remain vertically aligned
+            if format_field.is_visible():
+                format_field.widget.attrs['style'] = 'display: block'
+            result = format_field.render(form, renderer)
+        else:
+            result = u''
+        return result + self.get_widget(form).render(form, self, renderer)
+
+
+class FileField(StringField):
+    """This compound field allow edition of binary stream (`Bytes` yams
+    type). Three inner fields may be specified:
+
+    * `format_field`, holding the file's format.
+    * `encoding_field`, holding the file's content encoding.
+    * `name_field`, holding the file's name.
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.FileInput`. Inner fields, if any,
+    will be added to a drop down menu at the right of the file input.
+    """
+    widget = fw.FileInput
+    needs_multipart = True
+
+    def __init__(self, format_field=None, encoding_field=None, name_field=None,
+                 **kwargs):
+        super(FileField, self).__init__(**kwargs)
+        self.format_field = format_field
+        self.encoding_field = encoding_field
+        self.name_field = name_field
+
+    def actual_fields(self, form):
+        yield self
+        if self.format_field:
+            yield self.format_field
+        if self.encoding_field:
+            yield self.encoding_field
+        if self.name_field:
+            yield self.name_field
+
+    def typed_value(self, form, load_bytes=False):
+        if self.eidparam and self.role is not None:
+            if form.edited_entity.has_eid():
+                if load_bytes:
+                    return getattr(form.edited_entity, self.name)
+                # don't actually load data
+                # XXX value should reflect if some file is already attached
+                # * try to display name metadata
+                # * check length(data) / data != null
+                return True
+            return False
+        return super(FileField, self).typed_value(form, load_bytes)
+
+    def render(self, form, renderer):
+        wdgs = [self.get_widget(form).render(form, self, renderer)]
+        if self.format_field or self.encoding_field:
+            divid = '%s-advanced' % self.input_name(form)
+            wdgs.append(u'<a href="%s" title="%s"><img src="%s" alt="%s"/></a>' %
+                        (xml_escape(uilib.toggle_action(divid)),
+                         form._cw._('show advanced fields'),
+                         xml_escape(form._cw.data_url('puce_down.png')),
+                         form._cw._('show advanced fields')))
+            wdgs.append(u'<div id="%s" class="hidden">' % divid)
+            if self.name_field:
+                wdgs.append(self.render_subfield(form, self.name_field, renderer))
+            if self.format_field:
+                wdgs.append(self.render_subfield(form, self.format_field, renderer))
+            if self.encoding_field:
+                wdgs.append(self.render_subfield(form, self.encoding_field, renderer))
+            wdgs.append(u'</div>')
+        if not self.required and self.typed_value(form):
+            # trick to be able to delete an uploaded file
+            wdgs.append(u'<br/>')
+            wdgs.append(tags.input(name=self.input_name(form, u'__detach'),
+                                   type=u'checkbox'))
+            wdgs.append(form._cw._('detach attached file'))
+        return u'\n'.join(wdgs)
+
+    def render_subfield(self, form, field, renderer):
+        return (renderer.render_label(form, field)
+                + field.render(form, renderer)
+                + renderer.render_help(form, field)
+                + u'<br/>')
+
+    def _process_form_value(self, form):
+        posted = form._cw.form
+        if self.input_name(form, u'__detach') in posted:
+            # drop current file value on explictily asked to detach
+            return None
+        try:
+            value = posted[self.input_name(form)]
+        except KeyError:
+            # raise UnmodifiedField instead of returning None, since the later
+            # will try to remove already attached file if any
+            raise UnmodifiedField()
+        # value is a 2-uple (filename, stream) or a list of such
+        # tuples (multiple files)
+        try:
+            if isinstance(value, list):
+                value = value[0]
+                form.warning('mutiple files provided, however '
+                             'only the first will be picked')
+            filename, stream = value
+        except ValueError:
+            raise UnmodifiedField()
+        # XXX avoid in memory loading of posted files. Requires Binary handling changes...
+        value = Binary(stream.read())
+        if not value.getvalue(): # usually an unexistant file
+            value = None
+        else:
+            # set filename on the Binary instance, may be used later in hooks
+            value.filename = normalize_filename(filename)
+        return value
+
+
+# XXX turn into a widget
+class EditableFileField(FileField):
+    """This compound field allow edition of binary stream as
+    :class:`~cubicweb.web.formfields.FileField` but expect that stream to
+    actually contains some text.
+
+    If the stream format is one of text/plain, text/html, text/rest,
+    text/markdown
+    then a :class:`~cubicweb.web.formwidgets.TextArea` will be additionally
+    displayed, allowing to directly the file's content when desired, instead
+    of choosing a file from user's file system.
+    """
+    editable_formats = (
+        'text/plain', 'text/html', 'text/rest', 'text/markdown')
+
+    def render(self, form, renderer):
+        wdgs = [super(EditableFileField, self).render(form, renderer)]
+        if self.format(form) in self.editable_formats:
+            data = self.typed_value(form, load_bytes=True)
+            if data:
+                encoding = self.encoding(form)
+                try:
+                    form.formvalues[(self, form)] = data.getvalue().decode(encoding)
+                except UnicodeError:
+                    pass
+                else:
+                    if not self.required:
+                        msg = form._cw._(
+                            'You can either submit a new file using the browse button above'
+                            ', or choose to remove already uploaded file by checking the '
+                            '"detach attached file" check-box, or edit file content online '
+                            'with the widget below.')
+                    else:
+                        msg = form._cw._(
+                            'You can either submit a new file using the browse button above'
+                            ', or edit file content online with the widget below.')
+                    wdgs.append(u'<p><b>%s</b></p>' % msg)
+                    wdgs.append(fw.TextArea(setdomid=False).render(form, self, renderer))
+                    # XXX restore form context?
+        return '\n'.join(wdgs)
+
+    def _process_form_value(self, form):
+        value = form._cw.form.get(self.input_name(form))
+        if isinstance(value, text_type):
+            # file modified using a text widget
+            return Binary(value.encode(self.encoding(form)))
+        return super(EditableFileField, self)._process_form_value(form)
+
+
+class BigIntField(Field):
+    """Use this field to edit big integers (`BigInt` yams type). This field
+    additionally support `min` and `max` attributes that specify a minimum and/or
+    maximum value for the integer (`None` meaning no boundary).
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.TextInput`.
+    """
+    default_text_input_size = 10
+
+    def __init__(self, min=None, max=None, **kwargs):
+        super(BigIntField, self).__init__(**kwargs)
+        self.min = min
+        self.max = max
+
+    def init_widget(self, widget):
+        super(BigIntField, self).init_widget(widget)
+        if isinstance(self.widget, fw.TextInput):
+            self.widget.attrs.setdefault('size', self.default_text_input_size)
+
+    def _ensure_correctly_typed(self, form, value):
+        if isinstance(value, string_types):
+            value = value.strip()
+            if not value:
+                return None
+            try:
+                return int(value)
+            except ValueError:
+                raise ProcessFormError(form._cw._('an integer is expected'))
+        return value
+
+
+class IntField(BigIntField):
+    """Use this field to edit integers (`Int` yams type). Similar to
+    :class:`~cubicweb.web.formfields.BigIntField` but set max length when text
+    input widget is used (the default).
+    """
+    default_text_input_size = 5
+
+    def init_widget(self, widget):
+        super(IntField, self).init_widget(widget)
+        if isinstance(self.widget, fw.TextInput):
+            self.widget.attrs.setdefault('maxlength', 15)
+
+
+class BooleanField(Field):
+    """Use this field to edit booleans (`Boolean` yams type).
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.Radio` with yes/no values. You
+    can change that values by specifing `choices`.
+    """
+    widget = fw.Radio
+
+    def __init__(self, allow_none=False, **kwargs):
+        super(BooleanField, self).__init__(**kwargs)
+        self.allow_none = allow_none
+
+    def vocabulary(self, form):
+        if self.choices:
+            return super(BooleanField, self).vocabulary(form)
+        if self.allow_none:
+            return [(form._cw._('indifferent'), ''),
+                    (form._cw._('yes'), '1'),
+                    (form._cw._('no'), '0')]
+        # XXX empty string for 'no' in that case for bw compat
+        return [(form._cw._('yes'), '1'), (form._cw._('no'), '')]
+
+    def format_single_value(self, req, value):
+        """return value suitable for display"""
+        if self.allow_none:
+            if value is None:
+                return u''
+            if value is False:
+                return '0'
+        return super(BooleanField, self).format_single_value(req, value)
+
+    def _ensure_correctly_typed(self, form, value):
+        if self.allow_none:
+            if value:
+                return bool(int(value))
+            return None
+        return bool(value)
+
+
+class FloatField(IntField):
+    """Use this field to edit floats (`Float` yams type). This field additionally
+    support `min` and `max` attributes as the
+    :class:`~cubicweb.web.formfields.IntField`.
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.TextInput`.
+    """
+    def format_single_value(self, req, value):
+        formatstr = req.property_value('ui.float-format')
+        if value is None:
+            return u''
+        return formatstr % float(value)
+
+    def render_example(self, req):
+        return self.format_single_value(req, 1.234)
+
+    def _ensure_correctly_typed(self, form, value):
+        if isinstance(value, string_types):
+            value = value.strip()
+            if not value:
+                return None
+            try:
+                return float(value)
+            except ValueError:
+                raise ProcessFormError(form._cw._('a float is expected'))
+        return None
+
+
+class TimeIntervalField(StringField):
+    """Use this field to edit time interval (`Interval` yams type).
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.TextInput`.
+    """
+    widget = fw.TextInput
+
+    def format_single_value(self, req, value):
+        if value:
+            value = format_time(value.days * 24 * 3600 + value.seconds)
+            return text_type(value)
+        return u''
+
+    def example_format(self, req):
+        """return a sample string describing what can be given as input for this
+        field
+        """
+        return u'20s, 10min, 24h, 4d'
+
+    def _ensure_correctly_typed(self, form, value):
+        if isinstance(value, string_types):
+            value = value.strip()
+            if not value:
+                return None
+            try:
+                value = apply_units(value, TIME_UNITS)
+            except ValueError:
+                raise ProcessFormError(form._cw._('a number (in seconds) or 20s, 10min, 24h or 4d are expected'))
+        return timedelta(0, value)
+
+
+class DateField(StringField):
+    """Use this field to edit date (`Date` yams type).
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.JQueryDatePicker`.
+    """
+    widget = fw.JQueryDatePicker
+    format_prop = 'ui.date-format'
+    etype = 'Date'
+
+    def format_single_value(self, req, value):
+        if value:
+            return ustrftime(value, req.property_value(self.format_prop))
+        return u''
+
+    def render_example(self, req):
+        return self.format_single_value(req, datetime.now())
+
+    def _ensure_correctly_typed(self, form, value):
+        if isinstance(value, string_types):
+            value = value.strip()
+            if not value:
+                return None
+            try:
+                value = form._cw.parse_datetime(value, self.etype)
+            except ValueError as ex:
+                raise ProcessFormError(text_type(ex))
+        return value
+
+
+class DateTimeField(DateField):
+    """Use this field to edit datetime (`Datetime` yams type).
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.JQueryDateTimePicker`.
+    """
+    widget = fw.JQueryDateTimePicker
+    format_prop = 'ui.datetime-format'
+    etype = 'Datetime'
+
+
+class TimeField(DateField):
+    """Use this field to edit time (`Time` yams type).
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.JQueryTimePicker`.
+    """
+    widget = fw.JQueryTimePicker
+    format_prop = 'ui.time-format'
+    etype = 'Time'
+
+
+# XXX use cases where we don't actually want a better widget?
+class CompoundField(Field):
+    """This field shouldn't be used directly, it's designed to hold inner
+    fields that should be conceptually groupped together.
+    """
+    def __init__(self, fields, *args, **kwargs):
+        super(CompoundField, self).__init__(*args, **kwargs)
+        self.fields = fields
+
+    def subfields(self, form):
+        return self.fields
+
+    def actual_fields(self, form):
+        # don't add [self] to actual fields, compound field is usually kinda
+        # virtual, all interesting values are in subfield. Skipping it may avoid
+        # error when processed by the editcontroller : it may be marked as required
+        # while it has no value, hence generating a false error.
+        return list(self.fields)
+
+    @property
+    def needs_multipart(self):
+        return any(f.needs_multipart for f in self.fields)
+
+
+class RelationField(Field):
+    """Use this field to edit a relation of an entity.
+
+    Unless explicitly specified, the widget for this field will be a
+    :class:`~cubicweb.web.formwidgets.Select`.
+    """
+
+    @staticmethod
+    def fromcardinality(card, **kwargs):
+        kwargs.setdefault('widget', fw.Select(multiple=card in '*+'))
+        return RelationField(**kwargs)
+
+    def choices(self, form, limit=None):
+        """Take care, choices function for relation field instance should take
+        an extra 'limit' argument, with default to None.
+
+        This argument is used by the 'unrelateddivs' view (see in autoform) and
+        when it's specified (eg not None), vocabulary returned should:
+        * not include already related entities
+        * have a max size of `limit` entities
+        """
+        entity = form.edited_entity
+        # first see if its specified by __linkto form parameters
+        if limit is None:
+            linkedto = self.relvoc_linkedto(form)
+            if linkedto:
+                return linkedto
+            # it isn't, search more vocabulary
+            vocab = self.relvoc_init(form)
+        else:
+            vocab = []
+        vocab += self.relvoc_unrelated(form, limit)
+        if self.sort:
+            vocab = vocab_sort(vocab)
+        return vocab
+
+    def relvoc_linkedto(self, form):
+        linkedto = form.linked_to.get((self.name, self.role))
+        if linkedto:
+            buildent = form._cw.entity_from_eid
+            return [(buildent(eid).view('combobox'), text_type(eid))
+                    for eid in linkedto]
+        return []
+
+    def relvoc_init(self, form):
+        entity, rtype, role = form.edited_entity, self.name, self.role
+        vocab = []
+        if not self.required:
+            vocab.append(('', INTERNAL_FIELD_VALUE))
+        # vocabulary doesn't include current values, add them
+        if form.edited_entity.has_eid():
+            rset = form.edited_entity.related(self.name, self.role)
+            vocab += [(e.view('combobox'), text_type(e.eid))
+                      for e in rset.entities()]
+        return vocab
+
+    def relvoc_unrelated(self, form, limit=None):
+        entity = form.edited_entity
+        rtype = entity._cw.vreg.schema.rschema(self.name)
+        if entity.has_eid():
+            done = set(row[0] for row in entity.related(rtype, self.role))
+        else:
+            done = None
+        result = []
+        rsetsize = None
+        for objtype in rtype.targets(entity.e_schema, self.role):
+            if limit is not None:
+                rsetsize = limit - len(result)
+            result += self._relvoc_unrelated(form, objtype, rsetsize, done)
+            if limit is not None and len(result) >= limit:
+                break
+        return result
+
+    def _relvoc_unrelated(self, form, targettype, limit, done):
+        """return unrelated entities for a given relation and target entity type
+        for use in vocabulary
+        """
+        if done is None:
+            done = set()
+        res = []
+        entity = form.edited_entity
+        for entity in entity.unrelated(self.name, targettype, self.role, limit,
+                                       lt_infos=form.linked_to).entities():
+            if entity.eid in done:
+                continue
+            done.add(entity.eid)
+            res.append((entity.view('combobox'), text_type(entity.eid)))
+        return res
+
+    def format_single_value(self, req, value):
+        return text_type(value)
+
+    def process_form_value(self, form):
+        """process posted form and return correctly typed value"""
+        try:
+            return form.formvalues[(self, form)]
+        except KeyError:
+            value = self._process_form_value(form)
+            # if value is None, there are some remaining pending fields, we'll
+            # have to recompute this later -> don't cache in formvalues
+            if value is not None:
+                form.formvalues[(self, form)] = value
+            return value
+
+    def _process_form_value(self, form):
+        """process posted form and return correctly typed value"""
+        widget = self.get_widget(form)
+        values = widget.process_field_data(form, self)
+        if values is None:
+            values = ()
+        elif not isinstance(values, list):
+            values = (values,)
+        eids = set()
+        rschema = form._cw.vreg.schema.rschema(self.name)
+        for eid in values:
+            if not eid or eid == INTERNAL_FIELD_VALUE:
+                continue
+            typed_eid = form.actual_eid(eid)
+            # if entity doesn't exist yet
+            if typed_eid is None:
+                # inlined relations of to-be-created **subject entities** have
+                # to be handled separatly
+                if self.role == 'object' and rschema.inlined:
+                    form._cw.data['pending_inlined'][eid].add( (form, self) )
+                else:
+                    form._cw.data['pending_others'].add( (form, self) )
+                return None
+            eids.add(typed_eid)
+        return eids
+
+    @staticmethod
+    def no_value(value):
+        """return True if the value can be considered as no value for the field"""
+        # value is None is the 'not yet ready value, consider the empty set
+        return value is not None and not value
+
+
+_AFF_KWARGS = uicfg.autoform_field_kwargs
+
+def guess_field(eschema, rschema, role='subject', req=None, **kwargs):
+    """This function return the most adapted field to edit the given relation
+    (`rschema`) where the given entity type (`eschema`) is the subject or object
+    (`role`).
+
+    The field is initialized according to information found in the schema,
+    though any value can be explicitly specified using `kwargs`.
+    """
+    fieldclass = None
+    rdef = eschema.rdef(rschema, role)
+    if role == 'subject':
+        targetschema = rdef.object
+        if rschema.final:
+            if rdef.get('internationalizable'):
+                kwargs.setdefault('internationalizable', True)
+    else:
+        targetschema = rdef.subject
+    card = rdef.role_cardinality(role)
+    kwargs['name'] = rschema.type
+    kwargs['role'] = role
+    kwargs['eidparam'] = True
+    kwargs.setdefault('required', card in '1+')
+    if role == 'object':
+        kwargs.setdefault('label', (eschema.type, rschema.type + '_object'))
+    else:
+        kwargs.setdefault('label', (eschema.type, rschema.type))
+    kwargs.setdefault('help', rdef.description)
+    if rschema.final:
+        fieldclass = FIELDS[targetschema]
+        if fieldclass is StringField:
+            if eschema.has_metadata(rschema, 'format'):
+                # use RichTextField instead of StringField if the attribute has
+                # a "format" metadata. But getting information from constraints
+                # may be useful anyway...
+                for cstr in rdef.constraints:
+                    if isinstance(cstr, StaticVocabularyConstraint):
+                        raise Exception('rich text field with static vocabulary')
+                return RichTextField(**kwargs)
+            # init StringField parameters according to constraints
+            for cstr in rdef.constraints:
+                if isinstance(cstr, StaticVocabularyConstraint):
+                    kwargs.setdefault('choices', cstr.vocabulary)
+                    break
+            for cstr in rdef.constraints:
+                if isinstance(cstr, SizeConstraint) and cstr.max is not None:
+                    kwargs['max_length'] = cstr.max
+            return StringField(**kwargs)
+        if fieldclass is FileField:
+            if req:
+                aff_kwargs = req.vreg['uicfg'].select('autoform_field_kwargs', req)
+            else:
+                aff_kwargs = _AFF_KWARGS
+            for metadata in KNOWN_METAATTRIBUTES:
+                metaschema = eschema.has_metadata(rschema, metadata)
+                if metaschema is not None:
+                    metakwargs = aff_kwargs.etype_get(eschema, metaschema, 'subject')
+                    kwargs['%s_field' % metadata] = guess_field(eschema, metaschema,
+                                                                req=req, **metakwargs)
+        return fieldclass(**kwargs)
+    return RelationField.fromcardinality(card, **kwargs)
+
+
+FIELDS = {
+    'String' :  StringField,
+    'Bytes':    FileField,
+    'Password': PasswordField,
+
+    'Boolean':  BooleanField,
+    'Int':      IntField,
+    'BigInt':   BigIntField,
+    'Float':    FloatField,
+    'Decimal':  StringField,
+
+    'Date':       DateField,
+    'Datetime':   DateTimeField,
+    'TZDatetime': DateTimeField,
+    'Time':       TimeField,
+    'TZTime':     TimeField,
+    'Interval':   TimeIntervalField,
+    }