web/formwidgets.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Tue, 26 Jan 2010 13:36:55 +0100
changeset 4366 d51f28ba9399
parent 4305 3d731478d9a8
child 4371 0d337feb5374
permissions -rw-r--r--
fif inlined relation forms pb w/ new ajax forms. We've to ensure the varmaker is properly initialized when generating a form where we may add new subentities, which will use ajax call to generate the subform while this subform expect a varmaker to be set.

"""widget classes for form construction

:organization: Logilab
:copyright: 2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
"""
__docformat__ = "restructuredtext en"

from datetime import date
from warnings import warn

from logilab.mtconverter import xml_escape
from logilab.common.deprecation import deprecated

from cubicweb import tags, uilib
from cubicweb.web import stdmsgs, INTERNAL_FIELD_VALUE, ProcessFormError

class FieldWidget(object):
    """abstract widget class"""
    # javascript / css files required by the widget
    needs_js = ()
    needs_css = ()
    # automatically set id and tabindex attributes ?
    setdomid = True
    settabindex = True
    # does this widget expect a vocabulary
    vocabulary_widget = False

    def __init__(self, attrs=None, setdomid=None, settabindex=None):
        if attrs is None:
            attrs = {}
        self.attrs = attrs
        if setdomid is not None:
            # override class's default value
            self.setdomid = setdomid
        if settabindex is not None:
            # override class's default value
            self.settabindex = settabindex

    def add_media(self, form):
        """adds media (CSS & JS) required by this widget"""
        if self.needs_js:
            form._cw.add_js(self.needs_js)
        if self.needs_css:
            form._cw.add_css(self.needs_css)

    def render(self, form, field, renderer):
        """render the widget for the given `field` of `form`.
        To override in concrete class
        """
        raise NotImplementedError

    def typed_value(self, form, field):
        """return field's *typed* value specified in:
        3. extra form values given to render()
        4. field's typed value
        """
        qname = field.input_name(form)
        for key in (field, qname):
            try:
                return form.formvalues[key]
            except KeyError:
                continue
        if field.name != qname and field.name in form.formvalues:
            return form.formvalues[field.name]
        return field.typed_value(form)

    def format_value(self, form, field, value):
        return field.format_value(form._cw, value)

    def values_and_attributes(self, form, field):
        """found field's *string* value in:
        1. previously submitted form values if any (eg on validation error)
        2. req.form
        3. extra form values given to render()
        4. field's typed value

        values found in 1. and 2. are expected te be already some 'display'
        value while those found in 3. and 4. are expected to be correctly typed.

        3 and 4 are handle by the .typed_value(form, field) method
        """
        qname = field.input_name(form)
        if qname in form.form_previous_values:
            values = form.form_previous_values[qname]
        elif qname in form._cw.form:
            values = form._cw.form[qname]
        elif field.name != qname and field.name in form._cw.form:
            # compat: accept attr=value in req.form to specify value of attr-subject
            values = form._cw.form[field.name]
        else:
            values = self.typed_value(form, field)
            if values != INTERNAL_FIELD_VALUE:
                values = self.format_value(form, field, values)
        if not isinstance(values, (tuple, list)):
            values = (values,)
        attrs = dict(self.attrs)
        if self.setdomid:
            attrs['id'] = field.dom_id(form)
        if self.settabindex and not 'tabindex' in attrs:
            attrs['tabindex'] = form._cw.next_tabindex()
        return values, attrs

    def process_field_data(self, form, field):
        posted = form._cw.form
        val = posted.get(field.input_name(form))
        if isinstance(val, basestring):
            val = val.strip() or None
        return val

    @deprecated('[3.6] use values_and_attributes')
    def _render_attrs(self, form, field):
        """return html tag name, attributes and a list of values for the field
        """
        values, attrs = self.values_and_attributes(form, field)
        return field.input_name(form), values, attrs


class Input(FieldWidget):
    """abstract widget class for <input> tag based widgets"""
    type = None

    def render(self, form, field, renderer):
        """render the widget for the given `field` of `form`.

        Generate one <input> tag for each field's value
        """
        self.add_media(form)
        values, attrs = self.values_and_attributes(form, field)
        # ensure something is rendered
        if not values:
            values = (INTERNAL_FIELD_VALUE,)
        inputs = [tags.input(name=field.input_name(form), type=self.type,
                             value=value, **attrs)
                  for value in values]
        return u'\n'.join(inputs)


# basic html widgets ###########################################################

class TextInput(Input):
    """<input type='text'>"""
    type = 'text'


class PasswordInput(Input):
    """<input type='password'> and its confirmation field (using
    <field's name>-confirm as name)
    """
    type = 'password'

    def render(self, form, field, renderer):
        self.add_media(form)
        values, attrs = self.values_and_attributes(form, field)
        assert len(values) == 1
        id = attrs.pop('id')
        inputs = [tags.input(name=field.input_name(form),
                             value=values[0], type=self.type, id=id, **attrs),
                  '<br/>',
                  tags.input(name=field.input_name(form, '-confirm'),
                             value=values[0], type=self.type, **attrs),
                  '&#160;', tags.span(form._cw._('confirm password'),
                                      **{'class': 'emphasis'})]
        return u'\n'.join(inputs)

    def process_field_data(self, form, field):
        passwd1 = super(PasswordInput, self).process_field_data(form, field)
        passwd2 = form._cw.form.get(field.input_name(form, '-confirm'))
        if passwd1 == passwd2:
            if passwd1 is None:
                return None
            return passwd1.encode('utf-8')
        raise ProcessFormError(form._cw._("password and confirmation don't match"))


class PasswordSingleInput(Input):
    """<input type='password'> without a confirmation field"""
    type = 'password'

    def process_field_data(self, form, field):
        value = super(PasswordSingleInput, self).process_field_data(form, field)
        if value is not None:
            return value.encode('utf-8')
        return value


class FileInput(Input):
    """<input type='file'>"""
    type = 'file'

    def values_and_attributes(self, form, field):
        # ignore value which makes no sense here (XXX even on form validation error?)
        values, attrs = super(FileInput, self).values_and_attributes(form, field)
        return ('',), attrs


class HiddenInput(Input):
    """<input type='hidden'>"""
    type = 'hidden'
    setdomid = False # by default, don't set id attribute on hidden input
    settabindex = False


class ButtonInput(Input):
    """<input type='button'>

    if you want a global form button, look at the Button, SubmitButton,
    ResetButton and ImgButton classes below.
    """
    type = 'button'


class TextArea(FieldWidget):
    """<textarea>"""

    def render(self, form, field, renderer):
        values, attrs = self.values_and_attributes(form, field)
        attrs.setdefault('onkeyup', 'autogrow(this)')
        if not values:
            value = u''
        elif len(values) == 1:
            value = values[0]
        else:
            raise ValueError('a textarea is not supposed to be multivalued')
        lines = value.splitlines()
        linecount = len(lines)
        for line in lines:
            linecount += len(line) / 80
        attrs.setdefault('cols', 80)
        attrs.setdefault('rows', min(15, linecount + 2))
        return tags.textarea(value, name=field.input_name(form), **attrs)


class FCKEditor(TextArea):
    """FCKEditor enabled <textarea>"""
    def __init__(self, *args, **kwargs):
        super(FCKEditor, self).__init__(*args, **kwargs)
        self.attrs['cubicweb:type'] = 'wysiwyg'

    def render(self, form, field, renderer):
        form._cw.fckeditor_config()
        return super(FCKEditor, self).render(form, field, renderer)


class Select(FieldWidget):
    """<select>, for field having a specific vocabulary"""
    vocabulary_widget = True

    def __init__(self, attrs=None, multiple=False):
        super(Select, self).__init__(attrs)
        self._multiple = multiple

    def render(self, form, field, renderer):
        curvalues, attrs = self.values_and_attributes(form, field)
        if not 'size' in attrs:
            attrs['size'] = self._multiple and '5' or '1'
        options = []
        optgroup_opened = False
        for option in field.vocabulary(form):
            try:
                label, value, oattrs = option
            except ValueError:
                label, value = option
                oattrs = {}
            if value is None:
                # handle separator
                if optgroup_opened:
                    options.append(u'</optgroup>')
                oattrs.setdefault('label', label or '')
                options.append(u'<optgroup %s>' % uilib.sgml_attributes(oattrs))
                optgroup_opened = True
            elif value in curvalues:
                options.append(tags.option(label, value=value,
                                           selected='selected', **oattrs))
            else:
                options.append(tags.option(label, value=value, **oattrs))
        if optgroup_opened:
            options.append(u'</optgroup>')
        return tags.select(name=field.input_name(form), multiple=self._multiple,
                           options=options, **attrs)


class CheckBox(Input):
    """<input type='checkbox'>, for field having a specific vocabulary. One
    input will be generated for each possible value.
    """
    type = 'checkbox'
    vocabulary_widget = True

    def render(self, form, field, renderer):
        curvalues, attrs = self.values_and_attributes(form, field)
        domid = attrs.pop('id', None)
        sep = attrs.pop('separator', u'<br/>\n')
        options = []
        for i, option in enumerate(field.vocabulary(form)):
            try:
                label, value, oattrs = option
            except ValueError:
                label, value = option
                oattrs = {}
            iattrs = attrs.copy()
            iattrs.update(oattrs)
            if i == 0 and domid is not None:
                iattrs.setdefault('id', domid)
            if value in curvalues:
                iattrs['checked'] = u'checked'
            tag = tags.input(name=field.input_name(form), type=self.type,
                             value=value, **iattrs)
            options.append(tag + label)
        return sep.join(options)


class Radio(CheckBox):
    """<input type='radio'>, for field having a specific vocabulary. One
    input will be generated for each possible value.
    """
    type = 'radio'


# compound widgets #############################################################

class IntervalWidget(FieldWidget):
    """custom widget to display an interval composed by 2 fields. This widget
    is expected to be used with a CompoundField containing the two actual
    fields.

    Exemple usage::

from uicfg import autoform_field, autoform_section
autoform_field.tag_attribute(('Concert', 'minprice'),
                              CompoundField(fields=(IntField(name='minprice'),
                                                    IntField(name='maxprice')),
                                            label=_('price'),
                                            widget=IntervalWidget()
                                            ))
# we've to hide the other field manually for now
autoform_section.tag_attribute(('Concert', 'maxprice'), 'generated')
    """
    def render(self, form, field, renderer):
        actual_fields = field.fields
        assert len(actual_fields) == 2
        return u'<div>%s %s %s %s</div>' % (
            form._cw._('from_interval_start'),
            actual_fields[0].render(form, renderer),
            form._cw._('to_interval_end'),
            actual_fields[1].render(form, renderer),
            )


class HorizontalLayoutWidget(FieldWidget):
    """custom widget to display a set of fields grouped together horizontally
    in a form. See `IntervalWidget` for example usage.
    """
    def render(self, form, field, renderer):
        if self.attrs.get('display_label', True):
            subst = self.attrs.get('label_input_substitution', '%(label)s %(input)s')
            fields = [subst % {'label': renderer.render_label(form, f),
                              'input': f.render(form, renderer)}
                      for f in field.subfields(form)]
        else:
            fields = [f.render(form, renderer) for f in field.subfields(form)]
        return u'<div>%s</div>' % ' '.join(fields)


# javascript widgets ###########################################################

class DateTimePicker(TextInput):
    """<input type='text' + javascript date/time picker for date or datetime
    fields
    """
    monthnames = ('january', 'february', 'march', 'april',
                  'may', 'june', 'july', 'august',
                  'september', 'october', 'november', 'december')
    daynames = ('monday', 'tuesday', 'wednesday', 'thursday',
                'friday', 'saturday', 'sunday')

    needs_js = ('cubicweb.calendar.js',)
    needs_css = ('cubicweb.calendar_popup.css',)

    @classmethod
    def add_localized_infos(cls, req):
        """inserts JS variables defining localized months and days"""
        # import here to avoid dependancy from cubicweb to simplejson
        _ = req._
        monthnames = [_(mname) for mname in cls.monthnames]
        daynames = [_(dname) for dname in cls.daynames]
        req.html_headers.define_var('MONTHNAMES', monthnames)
        req.html_headers.define_var('DAYNAMES', daynames)

    def render(self, form, field, renderer):
        txtwidget = super(DateTimePicker, self).render(form, field, renderer)
        self.add_localized_infos(form._cw)
        cal_button = self._render_calendar_popup(form, field)
        return txtwidget + cal_button

    def _render_calendar_popup(self, form, field):
        value = field.typed_value(form)
        if not value:
            value = date.today()
        inputid = field.dom_id(form)
        helperid = '%shelper' % inputid
        year, month = value.year, value.month
        return (u"""<a onclick="toggleCalendar('%s', '%s', %s, %s);" class="calhelper">
<img src="%s" title="%s" alt="" /></a><div class="calpopup hidden" id="%s"></div>"""
                % (helperid, inputid, year, month,
                   form._cw.external_resource('CALENDAR_ICON'),
                   form._cw._('calendar'), helperid) )



# ajax widgets ################################################################

def init_ajax_attributes(attrs, wdgtype, loadtype=u'auto'):
    try:
        attrs['klass'] += u' widget'
    except KeyError:
        attrs['klass'] = u'widget'
    attrs.setdefault('cubicweb:wdgtype', wdgtype)
    attrs.setdefault('cubicweb:loadtype', loadtype)


class AjaxWidget(FieldWidget):
    """simple <div> based ajax widget"""
    def __init__(self, wdgtype, inputid=None, **kwargs):
        super(AjaxWidget, self).__init__(**kwargs)
        init_ajax_attributes(self.attrs, wdgtype)
        if inputid is not None:
            self.attrs['cubicweb:inputid'] = inputid

    def render(self, form, field, renderer):
        self.add_media(form)
        attrs = self.values_and_attributes(form, field)[-1]
        return tags.div(**attrs)


class AutoCompletionWidget(TextInput):
    """ajax widget for StringField, proposing matching existing values as you
    type.
    """
    needs_js = ('cubicweb.widgets.js', 'jquery.autocomplete.js')
    needs_css = ('jquery.autocomplete.css',)
    wdgtype = 'SuggestField'
    loadtype = 'auto'

    def __init__(self, *args, **kwargs):
        try:
            self.autocomplete_initfunc = kwargs.pop('autocomplete_initfunc')
        except KeyError:
            warn('use autocomplete_initfunc argument of %s constructor '
                 'instead of relying on autocomplete_initfuncs dictionary on '
                 'the entity class' % self.__class__.__name__,
                 DeprecationWarning)
            self.autocomplete_initfunc = None
        super(AutoCompletionWidget, self).__init__(*args, **kwargs)

    def values_and_attributes(self, form, field):
        values, attrs = super(AutoCompletionWidget, self).values_and_attributes(form, field)
        init_ajax_attributes(attrs, self.wdgtype, self.loadtype)
        # XXX entity form specific
        attrs['cubicweb:dataurl'] = self._get_url(form.edited_entity, field)
        if not values:
            values = ('',)
        return values, attrs

    def _get_url(self, entity, field):
        if self.autocomplete_initfunc is None:
            # XXX for bw compat
            fname = entity.autocomplete_initfuncs[field.name]
        else:
            fname = self.autocomplete_initfunc
        return entity._cw.build_url('json', fname=fname, mode='remote',
                                    pageid=entity._cw.pageid)


class StaticFileAutoCompletionWidget(AutoCompletionWidget):
    """XXX describe me"""
    wdgtype = 'StaticFileSuggestField'

    def _get_url(self, entity, field):
        if self.autocomplete_initfunc is None:
            # XXX for bw compat
            fname = entity.autocomplete_initfuncs[field.name]
        else:
            fname = self.autocomplete_initfunc
        return entity._cw.datadir_url + fname


class RestrictedAutoCompletionWidget(AutoCompletionWidget):
    """XXX describe me"""
    wdgtype = 'RestrictedSuggestField'


class AddComboBoxWidget(Select):
    def values_and_attributes(self, form, field):
        values, attrs = super(AddComboBoxWidget, self).values_and_attributes(form, field)
        init_ajax_attributes(self.attrs, 'AddComboBox')
        # XXX entity form specific
        entity = form.edited_entity
        attrs['cubicweb:etype_to'] = entity.e_schema
        etype_from = entity.e_schema.subjrels[field.name].objects(entity.e_schema)[0]
        attrs['cubicweb:etype_from'] = etype_from
        return values, attrs

    def render(self, form, field, renderer):
        return super(AddComboBoxWidget, self).render(form, field, renderer) + u'''
<div id="newvalue">
  <input type="text" id="newopt" />
  <a href="javascript:noop()" id="add_newopt">&#160;</a></div>
'''

# buttons ######################################################################

class Button(Input):
    """<input type='button'>, base class for global form buttons

    note label is a msgid which will be translated at form generation time, you
    should not give an already translated string.
    """
    type = 'button'
    def __init__(self, label=stdmsgs.BUTTON_OK, attrs=None,
                 setdomid=None, settabindex=None,
                 name='', value='', onclick=None, cwaction=None):
        super(Button, self).__init__(attrs, setdomid, settabindex)
        if isinstance(label, tuple):
            self.label = label[0]
            self.icon = label[1]
        else:
            self.label = label
            self.icon = None
        self.name = name
        self.value = ''
        self.onclick = onclick
        self.cwaction = cwaction
        self.attrs.setdefault('klass', 'validateButton')

    def render(self, form, field=None, renderer=None):
        label = form._cw._(self.label)
        attrs = self.attrs.copy()
        if self.cwaction:
            assert self.onclick is None
            attrs['onclick'] = "postForm('__action_%s', \'%s\', \'%s\')" % (
                self.cwaction, self.label, form.domid)
        elif self.onclick:
            attrs['onclick'] = self.onclick
        if self.name:
            attrs['name'] = self.name
            if self.setdomid:
                attrs['id'] = self.name
        if self.settabindex and not 'tabindex' in attrs:
            attrs['tabindex'] = form._cw.next_tabindex()
        if self.icon:
            img = tags.img(src=form._cw.external_resource(self.icon), alt=self.icon)
        else:
            img = u''
        return tags.button(img + xml_escape(label), escapecontent=False,
                           value=label, type=self.type, **attrs)


class SubmitButton(Button):
    """<input type='submit'>, main button to submit a form"""
    type = 'submit'


class ResetButton(Button):
    """<input type='reset'>, main button to reset a form.
    You usually don't want this.
    """
    type = 'reset'


class ImgButton(object):
    """<img> wrapped into a <a> tag with href triggering something (usually a
    javascript call)

    note label is a msgid which will be translated at form generation time, you
    should not give an already translated string.
    """
    def __init__(self, domid, href, label, imgressource):
        self.domid = domid
        self.href = href
        self.imgressource = imgressource
        self.label = label

    def render(self, form, field=None, renderer=None):
        label = form._cw._(self.label)
        imgsrc = form._cw.external_resource(self.imgressource)
        return '<a id="%(domid)s" href="%(href)s">'\
               '<img src="%(imgsrc)s" alt="%(label)s"/>%(label)s</a>' % {
            'label': label, 'imgsrc': imgsrc,
            'domid': self.domid, 'href': self.href}


# more widgets #################################################################

class EditableURLWidget(TextInput):
    """custom widget to edit separatly an url path / query string (used by
    default for Bookmark.path for instance), dealing with url quoting nicely
    (eg user edit the unquoted value).
    """
    type = 'text'

    def render(self, form, field, renderer):
        """render the widget for the given `field` of `form`.

        Generate one <input> tag for each field's value
        """
        self.add_media(form)
        req = form._cw
        pathqname = field.input_name(form, 'path')
        fqsqname = field.input_name(form, 'fqs') # formatted query string
        if pathqname in form.form_previous_values:
            path = form.form_previous_values[pathqname]
            fqs = form.form_previous_values[fqsqname]
        else:
            if field.name in req.form:
                value = req.form[field.name]
            else:
                value = self.typed_value(form, field)
            try:
                path, qs = value.split('?', 1)
            except ValueError:
                path = value
                qs = ''
            fqs = u'\n'.join(u'%s=%s' % (k, v) for k, v in req.url_parse_qsl(qs))
        attrs = dict(self.attrs)
        if self.setdomid:
            attrs['id'] = field.dom_id(form)
        if self.settabindex and not 'tabindex' in attrs:
            attrs['tabindex'] = req.next_tabindex()
        # ensure something is rendered
        inputs = [u'<table><tr><th>',
                  req._('i18n_bookmark_url_path'),
                  u'</th><td>',
                  tags.input(name=pathqname, type='string', value=path, **attrs),
                  u'</td></tr><tr><th>',
                  req._('i18n_bookmark_url_fqs'),
                  u'</th><td>']
        if self.setdomid:
            attrs['id'] = field.dom_id(form, 'fqs')
        if self.settabindex:
            attrs['tabindex'] = req.next_tabindex()
        attrs.setdefault('onkeyup', 'autogrow(this)')
        inputs += [tags.textarea(fqs, name=fqsqname, **attrs),
                   u'</td></tr></table>']
        return u'\n'.join(inputs)

    def process_field_data(self, form, field):
        req = form._cw
        values = {}
        path = req.form.get(field.input_name(form, 'path'))
        if isinstance(path, basestring):
            path = path.strip() or None
        fqs = req.form.get(field.input_name(form, 'fqs'))
        if isinstance(fqs, basestring):
            fqs = fqs.strip() or None
            if fqs:
                for i, line in enumerate(fqs.split('\n')):
                    line = line.strip()
                    if line:
                        try:
                            key, val = line.split('=', 1)
                        except ValueError:
                            raise ProcessFormError(req._("wrong query parameter line %s") % (i+1))
                        # value will be url quoted by build_url_params
                        values.setdefault(key.encode(req.encoding), []).append(val)
        if not values:
            return path
        return u'%s?%s' % (path, req.build_url_params(**values))