[form] small api cleanup and refactoring before documenting the form system
"""widget classes for form construction:organization: Logilab:copyright: 2009-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"fromdatetimeimportdatefromwarningsimportwarnfromlogilab.mtconverterimportxml_escapefromlogilab.common.deprecationimportdeprecatedfromlogilab.common.dateimporttodatetimefromcubicwebimporttags,uilibfromcubicweb.webimportstdmsgs,INTERNAL_FIELD_VALUE,ProcessFormErrorclassFieldWidget(object):"""abstract widget class"""# javascript / css files required by the widgetneeds_js=()needs_css=()# automatically set id and tabindex attributes ?setdomid=Truesettabindex=True# to ease usage as a sub-widgets (eg widget used by another widget)suffix=None# does this widget expect a vocabularyvocabulary_widget=Falsedef__init__(self,attrs=None,setdomid=None,settabindex=None,suffix=None):ifattrsisNone:attrs={}self.attrs=attrsifsetdomidisnotNone:# override class's default valueself.setdomid=setdomidifsettabindexisnotNone:# override class's default valueself.settabindex=settabindexifsuffixisnotNone:self.suffix=suffixdefadd_media(self,form):"""adds media (CSS & JS) required by this widget"""ifself.needs_js:form._cw.add_js(self.needs_js)ifself.needs_css:form._cw.add_css(self.needs_css)defrender(self,form,field,renderer=None):self.add_media(form)returnself._render(form,field,renderer)def_render(self,form,field,renderer):raiseNotImplementedError()defformat_value(self,form,field,value):returnfield.format_value(form._cw,value)defattributes(self,form,field):"""Return HTML attributes for the widget, automatically setting DOM identifier and tabindex when desired (see :attr:`setdomid` and :attr:`settabindex` attributes) """attrs=dict(self.attrs)ifself.setdomid:attrs['id']=field.dom_id(form,self.suffix)ifself.settabindexandnot'tabindex'inattrs:attrs['tabindex']=form._cw.next_tabindex()returnattrsdefvalues(self,form,field):values=Noneifnotfield.ignore_req_params:qname=field.input_name(form,self.suffix)# value from a previous post that has raised a validation errorifqnameinform.form_previous_values:values=form.form_previous_values[qname]# value specified using form parameterselifqnameinform._cw.form:values=form._cw.form[qname]eliffield.name!=qnameandfield.nameinform._cw.form:# XXX compat: accept attr=value in req.form to specify value of# attr-subjectvalues=form._cw.form[field.name]ifvaluesisNone:values=self.typed_value(form,field)ifvalues!=INTERNAL_FIELD_VALUE:values=self.format_value(form,field,values)ifnotisinstance(values,(tuple,list)):values=(values,)returnvaluesdeftyped_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)forkeyin((field,form),qname):try:returnform.formvalues[key]exceptKeyError:continueiffield.name!=qnameandfield.nameinform.formvalues:returnform.formvalues[field.name]returnfield.typed_value(form)defprocess_field_data(self,form,field):posted=form._cw.formval=posted.get(field.input_name(form,self.suffix))ifisinstance(val,basestring):val=val.strip()returnval# XXX deprecatesdefvalues_and_attributes(self,form,field):returnself.values(form,field),self.attributes(form,field)@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)returnfield.input_name(form,self.suffix),values,attrsclassInput(FieldWidget):"""abstract widget class for <input> tag based widgets"""type=Nonedef_render(self,form,field,renderer):"""render the widget for the given `field` of `form`. Generate one <input> tag for each field's value """values,attrs=self.values_and_attributes(form,field)# ensure something is renderedifnotvalues:values=(INTERNAL_FIELD_VALUE,)inputs=[tags.input(name=field.input_name(form,self.suffix),type=self.type,value=value,**attrs)forvalueinvalues]returnu'\n'.join(inputs)# basic html widgets ###########################################################classTextInput(Input):"""<input type='text'>"""type='text'classPasswordInput(Input):"""<input type='password'> and its confirmation field (using <field's name>-confirm as name) """type='password'def_render(self,form,field,renderer):assertself.suffixisNone,'suffix not supported'values,attrs=self.values_and_attributes(form,field)assertlen(values)==1domid=attrs.pop('id')inputs=[tags.input(name=field.input_name(form),value=values[0],type=self.type,id=domid,**attrs),'<br/>',tags.input(name=field.input_name(form,'-confirm'),value=values[0],type=self.type,**attrs),' ',tags.span(form._cw._('confirm password'),**{'class':'emphasis'})]returnu'\n'.join(inputs)defprocess_field_data(self,form,field):passwd1=super(PasswordInput,self).process_field_data(form,field)passwd2=form._cw.form.get(field.input_name(form,'-confirm'))ifpasswd1==passwd2:ifpasswd1isNone:returnNonereturnpasswd1.encode('utf-8')raiseProcessFormError(form._cw._("password and confirmation don't match"))classPasswordSingleInput(Input):"""<input type='password'> without a confirmation field"""type='password'defprocess_field_data(self,form,field):value=super(PasswordSingleInput,self).process_field_data(form,field)ifvalueisnotNone:returnvalue.encode('utf-8')returnvalueclassFileInput(Input):"""<input type='file'>"""type='file'defvalues(self,form,field):# ignore value which makes no sense here (XXX even on form validation error?)return('',)classHiddenInput(Input):"""<input type='hidden'>"""type='hidden'setdomid=False# by default, don't set id attribute on hidden inputsettabindex=FalseclassButtonInput(Input):"""<input type='button'> if you want a global form button, look at the Button, SubmitButton, ResetButton and ImgButton classes below. """type='button'classTextArea(FieldWidget):"""<textarea>"""def_render(self,form,field,renderer):values,attrs=self.values_and_attributes(form,field)attrs.setdefault('onkeyup','autogrow(this)')ifnotvalues:value=u''eliflen(values)==1:value=values[0]else:raiseValueError('a textarea is not supposed to be multivalued')lines=value.splitlines()linecount=len(lines)forlineinlines:linecount+=len(line)/80attrs.setdefault('cols',80)attrs.setdefault('rows',min(15,linecount+2))returntags.textarea(value,name=field.input_name(form,self.suffix),**attrs)classFCKEditor(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()returnsuper(FCKEditor,self)._render(form,field,renderer)classSelect(FieldWidget):"""<select>, for field having a specific vocabulary"""vocabulary_widget=Truedef__init__(self,attrs=None,multiple=False,**kwargs):super(Select,self).__init__(attrs,**kwargs)self._multiple=multipledef_render(self,form,field,renderer):curvalues,attrs=self.values_and_attributes(form,field)ifnot'size'inattrs:attrs['size']=self._multipleand'5'or'1'options=[]optgroup_opened=Falseforoptioninfield.vocabulary(form):try:label,value,oattrs=optionexceptValueError:label,value=optionoattrs={}ifvalueisNone:# handle separatorifoptgroup_opened:options.append(u'</optgroup>')oattrs.setdefault('label',labelor'')options.append(u'<optgroup %s>'%uilib.sgml_attributes(oattrs))optgroup_opened=Trueelifvalueincurvalues:options.append(tags.option(label,value=value,selected='selected',**oattrs))else:options.append(tags.option(label,value=value,**oattrs))ifoptgroup_opened:options.append(u'</optgroup>')returntags.select(name=field.input_name(form,self.suffix),multiple=self._multiple,options=options,**attrs)classCheckBox(Input):"""<input type='checkbox'>, for field having a specific vocabulary. One input will be generated for each possible value. """type='checkbox'vocabulary_widget=Truedef__init__(self,attrs=None,separator=u'<br/>\n',**kwargs):super(CheckBox,self).__init__(attrs,**kwargs)self.separator=separatordef_render(self,form,field,renderer):curvalues,attrs=self.values_and_attributes(form,field)domid=attrs.pop('id',None)# XXX turn this as initializer argumenttry:sep=attrs.pop('separator')warn('[3.8] separator should be specified using initializer argument',DeprecationWarning)exceptKeyError:sep=self.separatoroptions=[]fori,optioninenumerate(field.vocabulary(form)):try:label,value,oattrs=optionexceptValueError:label,value=optionoattrs={}iattrs=attrs.copy()iattrs.update(oattrs)ifi==0anddomidisnotNone:iattrs.setdefault('id',domid)ifvalueincurvalues:iattrs['checked']=u'checked'tag=tags.input(name=field.input_name(form,self.suffix),type=self.type,value=value,**iattrs)options.append(u'%s %s'%(tag,label))returnsep.join(options)classRadio(CheckBox):"""<input type='radio'>, for field having a specific vocabulary. One input will be generated for each possible value. """type='radio'# javascript widgets ###########################################################classDateTimePicker(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',)@classmethoddefadd_localized_infos(cls,req):"""inserts JS variables defining localized months and days"""# import here to avoid dependancy from cubicweb to simplejson_=req._monthnames=[_(mname)formnameincls.monthnames]daynames=[_(dname)fordnameincls.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)returntxtwidget+cal_buttondef_render_calendar_popup(self,form,field):value=field.typed_value(form)ifnotvalue:value=date.today()inputid=field.dom_id(form)helperid='%shelper'%inputidyear,month=value.year,value.monthreturn(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))classJQueryDatePicker(FieldWidget):"""use jquery.ui.datepicker to define a date time picker"""needs_js=('jquery.ui.js',)needs_css=('jquery.ui.css',)def__init__(self,datestr=None,**kwargs):super(JQueryDatePicker,self).__init__(**kwargs)self.datestr=datestrdef_render(self,form,field,renderer):req=form._cwdomid=field.dom_id(form,self.suffix)# XXX find a way to understand every formatfmt=req.property_value('ui.date-format')fmt=fmt.replace('%Y','yy').replace('%m','mm').replace('%d','dd')req.add_onload(u'jqNode("%s").datepicker(''{buttonImage: "%s", dateFormat: "%s", firstDay: 1,'' showOn: "button", buttonImageOnly: true})'%(domid,req.external_resource('CALENDAR_ICON'),fmt))ifself.datestrisNone:value=self.values(form,field)[0]else:value=self.datestrreturntags.input(id=domid,name=domid,value=value,type='text',size='10')classJQueryTimePicker(FieldWidget):"""use jquery.timePicker.js to define a js time picker"""needs_js=('jquery.timePicker.js',)needs_css=('jquery.timepicker.css',)def__init__(self,timestr=None,timesteps=30,separator=u':',**kwargs):super(JQueryTimePicker,self).__init__(**kwargs)self.timestr=timestrself.timesteps=timestepsself.separator=separatordef_render(self,form,field,renderer):req=form._cwdomid=field.dom_id(form,self.suffix)req.add_onload(u'jqNode("%s").timePicker({selectedTime: "%s", step: %s, separator: "%s"})'%(domid,self.timestr,self.timesteps,self.separator))ifself.timestrisNone:value=self.values(form,field)[0]else:value=self.timestrreturntags.input(id=domid,name=domid,value=value,type='text',size='5')classJQueryDateTimePicker(FieldWidget):def__init__(self,initialtime=None,timesteps=15,**kwargs):super(JQueryDateTimePicker,self).__init__(**kwargs)self.initialtime=initialtimeself.timesteps=timestepsdef_render(self,form,field,renderer):"""render the widget for the given `field` of `form`. Generate one <input> tag for each field's value """req=form._cwdateqname=field.input_name(form,'date')timeqname=field.input_name(form,'time')ifdateqnameinform.form_previous_values:datestr=form.form_previous_values[dateqname]timestr=form.form_previous_values[timeqname]else:datestr=timestr=u''iffield.nameinreq.form:value=req.parse_datetime(req.form[field.name])else:value=self.typed_value(form,field)ifvalue:datestr=req.format_date(value)timestr=req.format_time(value)elifself.initialtime:timestr=req.format_time(self.initialtime)datepicker=JQueryDatePicker(datestr=datestr,suffix='date')timepicker=JQueryTimePicker(timestr=timestr,timesteps=self.timesteps,suffix='time')returnu'<div id="%s">%s%s</div>'%(field.dom_id(form),datepicker.render(form,field),timepicker.render(form,field))defprocess_field_data(self,form,field):req=form._cwdatestr=req.form.get(field.input_name(form,'date')).strip()orNonetimestr=req.form.get(field.input_name(form,'time')).strip()orNoneifdatestrisNone:returnNonedate=todatetime(req.parse_datetime(datestr,'Date'))iftimestrisNone:returndatetime=req.parse_datetime(timestr,'Time')returndate.replace(hour=time.hour,minute=time.minute,second=time.second)# ajax widgets ################################################################definit_ajax_attributes(attrs,wdgtype,loadtype=u'auto'):try:attrs['class']+=u' widget'exceptKeyError:attrs['class']=u'widget'attrs.setdefault('cubicweb:wdgtype',wdgtype)attrs.setdefault('cubicweb:loadtype',loadtype)classAjaxWidget(FieldWidget):"""simple <div> based ajax widget"""def__init__(self,wdgtype,inputid=None,**kwargs):super(AjaxWidget,self).__init__(**kwargs)init_ajax_attributes(self.attrs,wdgtype)ifinputidisnotNone:self.attrs['cubicweb:inputid']=inputiddef_render(self,form,field,renderer):attrs=self.values_and_attributes(form,field)[-1]returntags.div(**attrs)classAutoCompletionWidget(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')exceptKeyError:warn('[3.6] 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=Nonesuper(AutoCompletionWidget,self).__init__(*args,**kwargs)defvalues(self,form,field):values=super(AutoCompletionWidget,self).values(form,field)ifnotvalues:values=('',)returnvaluesdefattributes(self,form,field):attrs=super(AutoCompletionWidget,self).attributes(form,field)init_ajax_attributes(attrs,self.wdgtype,self.loadtype)# XXX entity form specificattrs['cubicweb:dataurl']=self._get_url(form.edited_entity,field)returnattrsdef_get_url(self,entity,field):ifself.autocomplete_initfuncisNone:# XXX for bw compatfname=entity.autocomplete_initfuncs[field.name]else:fname=self.autocomplete_initfuncreturnentity._cw.build_url('json',fname=fname,mode='remote',pageid=entity._cw.pageid)classStaticFileAutoCompletionWidget(AutoCompletionWidget):"""XXX describe me"""wdgtype='StaticFileSuggestField'def_get_url(self,entity,field):ifself.autocomplete_initfuncisNone:# XXX for bw compatfname=entity.autocomplete_initfuncs[field.name]else:fname=self.autocomplete_initfuncreturnentity._cw.datadir_url+fnameclassRestrictedAutoCompletionWidget(AutoCompletionWidget):"""XXX describe me"""wdgtype='RestrictedSuggestField'classLazyRestrictedAutoCompletionWidget(RestrictedAutoCompletionWidget):"""remote autocomplete """wdgtype='LazySuggestField'defvalues_and_attributes(self,form,field):"""override values_and_attributes to handle initial displayed values"""values,attrs=super(LazyRestrictedAutoCompletionWidget,self).values_and_attributes(form,field)assertlen(values)==1,"multiple selection is not supported yet by LazyWidget"ifnotvalues[0]:values=form.cw_extra_kwargs.get(field.name,'')ifnotisinstance(values,(tuple,list)):values=(values,)try:values=list(values)values[0]=int(values[0])attrs['cubicweb:initialvalue']=values[0]values=(self.display_value_for(form,values[0]),)except(TypeError,ValueError):passreturnvalues,attrsdefdisplay_value_for(self,form,value):entity=form._cw.entity_from_eid(value)returnentity.view('combobox')classAddComboBoxWidget(Select):defattributes(self,form,field):attrs=super(AddComboBoxWidget,self).attributes(form,field)init_ajax_attributes(attrs,'AddComboBox')# XXX entity form specificentity=form.edited_entityattrs['cubicweb:etype_to']=entity.e_schemaetype_from=entity.e_schema.subjrels[field.name].objects(entity.e_schema)[0]attrs['cubicweb:etype_from']=etype_fromreturnattrsdef_render(self,form,field,renderer):returnsuper(AddComboBoxWidget,self).render(form,field,renderer)+u'''<div id="newvalue"> <input type="text" id="newopt" /> <a href="javascript:noop()" id="add_newopt"> </a></div>'''# buttons ######################################################################classButton(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)ifisinstance(label,tuple):self.label=label[0]self.icon=label[1]else:self.label=labelself.icon=Noneself.name=nameself.value=''self.onclick=onclickself.cwaction=cwactionself.attrs.setdefault('class','validateButton')defrender(self,form,field=None,renderer=None):label=form._cw._(self.label)attrs=self.attrs.copy()ifself.cwaction:assertself.onclickisNoneattrs['onclick']="postForm('__action_%s', \'%s\', \'%s\')"%(self.cwaction,self.label,form.domid)elifself.onclick:attrs['onclick']=self.onclickifself.name:attrs['name']=self.nameifself.setdomid:attrs['id']=self.nameifself.settabindexandnot'tabindex'inattrs:attrs['tabindex']=form._cw.next_tabindex()ifself.icon:img=tags.img(src=form._cw.external_resource(self.icon),alt=self.icon)else:img=u''returntags.button(img+xml_escape(label),escapecontent=False,value=label,type=self.type,**attrs)classSubmitButton(Button):"""<input type='submit'>, main button to submit a form"""type='submit'classResetButton(Button):"""<input type='reset'>, main button to reset a form. You usually don't want this. """type='reset'classImgButton(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=domidself.href=hrefself.imgressource=imgressourceself.label=labeldefrender(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 #################################################################classEditableURLWidget(FieldWidget):"""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). """def_render(self,form,field,renderer):"""render the widget for the given `field` of `form`. Generate one <input> tag for each field's value """assertself.suffixisNone,'not supported'req=form._cwpathqname=field.input_name(form,'path')fqsqname=field.input_name(form,'fqs')# formatted query stringifpathqnameinform.form_previous_values:path=form.form_previous_values[pathqname]fqs=form.form_previous_values[fqsqname]else:iffield.nameinreq.form:value=req.form[field.name]else:value=self.typed_value(form,field)ifvalue:try:path,qs=value.split('?',1)exceptValueError:path=valueqs=''else:path=qs=''fqs=u'\n'.join(u'%s=%s'%(k,v)fork,vinreq.url_parse_qsl(qs))attrs=dict(self.attrs)ifself.setdomid:attrs['id']=field.dom_id(form)ifself.settabindexandnot'tabindex'inattrs:attrs['tabindex']=req.next_tabindex()# ensure something is renderedinputs=[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>']ifself.setdomid:attrs['id']=field.dom_id(form,'fqs')ifself.settabindex:attrs['tabindex']=req.next_tabindex()attrs.setdefault('cols',60)attrs.setdefault('onkeyup','autogrow(this)')inputs+=[tags.textarea(fqs,name=fqsqname,**attrs),u'</td></tr></table>']# surrounding div necessary for proper error localizationreturnu'<div id="%s">%s</div>'%(field.dom_id(form),u'\n'.join(inputs))defprocess_field_data(self,form,field):req=form._cwvalues={}path=req.form.get(field.input_name(form,'path'))ifisinstance(path,basestring):path=path.strip()orNonefqs=req.form.get(field.input_name(form,'fqs'))ifisinstance(fqs,basestring):fqs=fqs.strip()orNoneiffqs:fori,lineinenumerate(fqs.split('\n')):line=line.strip()ifline:try:key,val=line.split('=',1)exceptValueError:raiseProcessFormError(req._("wrong query parameter line %s")%(i+1))# value will be url quoted by build_url_paramsvalues.setdefault(key.encode(req.encoding),[]).append(val)ifnotvalues:returnpathreturnu'%s?%s'%(path,req.build_url_params(**values))