|
1 # copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved. |
|
2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr |
|
3 # |
|
4 # This file is part of CubicWeb. |
|
5 # |
|
6 # CubicWeb is free software: you can redistribute it and/or modify it under the |
|
7 # terms of the GNU Lesser General Public License as published by the Free |
|
8 # Software Foundation, either version 2.1 of the License, or (at your option) |
|
9 # any later version. |
|
10 # |
|
11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT |
|
12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
|
13 # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more |
|
14 # details. |
|
15 # |
|
16 # You should have received a copy of the GNU Lesser General Public License along |
|
17 # with CubicWeb. If not, see <http://www.gnu.org/licenses/>. |
|
18 """ |
|
19 Widgets |
|
20 ~~~~~~~ |
|
21 |
|
22 .. Note:: |
|
23 A widget is responsible for the display of a field. It may use more than one |
|
24 HTML input tags. When the form is posted, a widget is also reponsible to give |
|
25 back to the field something it can understand. |
|
26 |
|
27 Of course you can not use any widget with any field... |
|
28 |
|
29 .. autoclass:: cubicweb.web.formwidgets.FieldWidget |
|
30 |
|
31 |
|
32 HTML <input> based widgets |
|
33 '''''''''''''''''''''''''' |
|
34 |
|
35 .. autoclass:: cubicweb.web.formwidgets.HiddenInput |
|
36 .. autoclass:: cubicweb.web.formwidgets.TextInput |
|
37 .. autoclass:: cubicweb.web.formwidgets.EmailInput |
|
38 .. autoclass:: cubicweb.web.formwidgets.PasswordSingleInput |
|
39 .. autoclass:: cubicweb.web.formwidgets.FileInput |
|
40 .. autoclass:: cubicweb.web.formwidgets.ButtonInput |
|
41 |
|
42 |
|
43 Other standard HTML widgets |
|
44 ''''''''''''''''''''''''''' |
|
45 |
|
46 .. autoclass:: cubicweb.web.formwidgets.TextArea |
|
47 .. autoclass:: cubicweb.web.formwidgets.Select |
|
48 .. autoclass:: cubicweb.web.formwidgets.CheckBox |
|
49 .. autoclass:: cubicweb.web.formwidgets.Radio |
|
50 |
|
51 |
|
52 Date and time widgets |
|
53 ''''''''''''''''''''' |
|
54 |
|
55 .. autoclass:: cubicweb.web.formwidgets.DateTimePicker |
|
56 .. autoclass:: cubicweb.web.formwidgets.JQueryDateTimePicker |
|
57 .. autoclass:: cubicweb.web.formwidgets.JQueryDatePicker |
|
58 .. autoclass:: cubicweb.web.formwidgets.JQueryTimePicker |
|
59 |
|
60 |
|
61 Ajax / javascript widgets |
|
62 ''''''''''''''''''''''''' |
|
63 |
|
64 .. autoclass:: cubicweb.web.formwidgets.FCKEditor |
|
65 .. autoclass:: cubicweb.web.formwidgets.AjaxWidget |
|
66 .. autoclass:: cubicweb.web.formwidgets.AutoCompletionWidget |
|
67 .. autoclass:: cubicweb.web.formwidgets.InOutWidget |
|
68 |
|
69 .. kill or document StaticFileAutoCompletionWidget |
|
70 .. kill or document LazyRestrictedAutoCompletionWidget |
|
71 .. kill or document RestrictedAutoCompletionWidget |
|
72 |
|
73 |
|
74 Other widgets |
|
75 ''''''''''''' |
|
76 |
|
77 .. autoclass:: cubicweb.web.formwidgets.PasswordInput |
|
78 .. autoclass:: cubicweb.web.formwidgets.IntervalWidget |
|
79 .. autoclass:: cubicweb.web.formwidgets.BitSelect |
|
80 .. autoclass:: cubicweb.web.formwidgets.HorizontalLayoutWidget |
|
81 .. autoclass:: cubicweb.web.formwidgets.EditableURLWidget |
|
82 |
|
83 |
|
84 Form controls |
|
85 ''''''''''''' |
|
86 |
|
87 Those classes are not proper widget (they are not associated to field) but are |
|
88 used as form controls. Their API is similar to widgets except that `field` |
|
89 argument given to :meth:`render` will be `None`. |
|
90 |
|
91 .. autoclass:: cubicweb.web.formwidgets.Button |
|
92 .. autoclass:: cubicweb.web.formwidgets.SubmitButton |
|
93 .. autoclass:: cubicweb.web.formwidgets.ResetButton |
|
94 .. autoclass:: cubicweb.web.formwidgets.ImgButton |
|
95 """ |
|
96 __docformat__ = "restructuredtext en" |
|
97 |
|
98 from functools import reduce |
|
99 from datetime import date |
|
100 |
|
101 from six import text_type, string_types |
|
102 |
|
103 from logilab.mtconverter import xml_escape |
|
104 from logilab.common.date import todatetime |
|
105 |
|
106 from cubicweb import tags, uilib |
|
107 from cubicweb.utils import json_dumps |
|
108 from cubicweb.web import stdmsgs, INTERNAL_FIELD_VALUE, ProcessFormError |
|
109 |
|
110 |
|
111 class FieldWidget(object): |
|
112 """The abstract base class for widgets. |
|
113 |
|
114 **Attributes** |
|
115 |
|
116 Here are standard attributes of a widget, that may be set on concrete class |
|
117 to override default behaviours: |
|
118 |
|
119 :attr:`needs_js` |
|
120 list of javascript files needed by the widget. |
|
121 |
|
122 :attr:`needs_css` |
|
123 list of css files needed by the widget. |
|
124 |
|
125 :attr:`setdomid` |
|
126 flag telling if HTML DOM identifier should be set on input. |
|
127 |
|
128 :attr:`settabindex` |
|
129 flag telling if HTML tabindex attribute of inputs should be set. |
|
130 |
|
131 :attr:`suffix` |
|
132 string to use a suffix when generating input, to ease usage as a |
|
133 sub-widgets (eg widget used by another widget) |
|
134 |
|
135 :attr:`vocabulary_widget` |
|
136 flag telling if this widget expect a vocabulary |
|
137 |
|
138 Also, widget instances takes as first argument a `attrs` dictionary which |
|
139 will be stored in the attribute of the same name. It contains HTML |
|
140 attributes that should be set in the widget's input tag (though concrete |
|
141 classes may ignore it). |
|
142 |
|
143 .. currentmodule:: cubicweb.web.formwidgets |
|
144 |
|
145 **Form generation methods** |
|
146 |
|
147 .. automethod:: render |
|
148 .. automethod:: _render |
|
149 .. automethod:: values |
|
150 .. automethod:: attributes |
|
151 |
|
152 **Post handling methods** |
|
153 |
|
154 .. automethod:: process_field_data |
|
155 |
|
156 """ |
|
157 needs_js = () |
|
158 needs_css = () |
|
159 setdomid = True |
|
160 settabindex = True |
|
161 suffix = None |
|
162 # does this widget expect a vocabulary |
|
163 vocabulary_widget = False |
|
164 |
|
165 def __init__(self, attrs=None, setdomid=None, settabindex=None, suffix=None): |
|
166 if attrs is None: |
|
167 attrs = {} |
|
168 self.attrs = attrs |
|
169 if setdomid is not None: |
|
170 # override class's default value |
|
171 self.setdomid = setdomid |
|
172 if settabindex is not None: |
|
173 # override class's default value |
|
174 self.settabindex = settabindex |
|
175 if suffix is not None: |
|
176 self.suffix = suffix |
|
177 |
|
178 def add_media(self, form): |
|
179 """adds media (CSS & JS) required by this widget""" |
|
180 if self.needs_js: |
|
181 form._cw.add_js(self.needs_js) |
|
182 if self.needs_css: |
|
183 form._cw.add_css(self.needs_css) |
|
184 |
|
185 def render(self, form, field, renderer=None): |
|
186 """Called to render the widget for the given `field` in the given |
|
187 `form`. Return a unicode string containing the HTML snippet. |
|
188 |
|
189 You will usually prefer to override the :meth:`_render` method so you |
|
190 don't have to handle addition of needed javascript / css files. |
|
191 """ |
|
192 self.add_media(form) |
|
193 return self._render(form, field, renderer) |
|
194 |
|
195 def _render(self, form, field, renderer): |
|
196 """This is the method you have to implement in concrete widget classes. |
|
197 """ |
|
198 raise NotImplementedError() |
|
199 |
|
200 def format_value(self, form, field, value): |
|
201 return field.format_value(form._cw, value) |
|
202 |
|
203 def attributes(self, form, field): |
|
204 """Return HTML attributes for the widget, automatically setting DOM |
|
205 identifier and tabindex when desired (see :attr:`setdomid` and |
|
206 :attr:`settabindex` attributes) |
|
207 """ |
|
208 attrs = dict(self.attrs) |
|
209 if self.setdomid: |
|
210 attrs['id'] = field.dom_id(form, self.suffix) |
|
211 if self.settabindex and 'tabindex' not in attrs: |
|
212 attrs['tabindex'] = form._cw.next_tabindex() |
|
213 if 'placeholder' in attrs: |
|
214 attrs['placeholder'] = form._cw._(attrs['placeholder']) |
|
215 return attrs |
|
216 |
|
217 def values(self, form, field): |
|
218 """Return the current *string* values (i.e. for display in an HTML |
|
219 string) for the given field. This method returns a list of values since |
|
220 it's suitable for all kind of widgets, some of them taking multiple |
|
221 values, but you'll get a single value in the list in most cases. |
|
222 |
|
223 Those values are searched in: |
|
224 |
|
225 1. previously submitted form values if any (on validation error) |
|
226 |
|
227 2. req.form (specified using request parameters) |
|
228 |
|
229 3. extra form values given to form.render call (specified the code |
|
230 generating the form) |
|
231 |
|
232 4. field's typed value (returned by its |
|
233 :meth:`~cubicweb.web.formfields.Field.typed_value` method) |
|
234 |
|
235 Values found in 1. and 2. are expected te be already some 'display |
|
236 value' (eg a string) while those found in 3. and 4. are expected to be |
|
237 correctly typed value. |
|
238 |
|
239 3 and 4 are handle by the :meth:`typed_value` method to ease reuse in |
|
240 concrete classes. |
|
241 """ |
|
242 values = None |
|
243 if not field.ignore_req_params: |
|
244 qname = field.input_name(form, self.suffix) |
|
245 # value from a previous post that has raised a validation error |
|
246 if qname in form.form_previous_values: |
|
247 values = form.form_previous_values[qname] |
|
248 # value specified using form parameters |
|
249 elif qname in form._cw.form: |
|
250 values = form._cw.form[qname] |
|
251 elif field.name != qname and field.name in form._cw.form: |
|
252 # XXX compat: accept attr=value in req.form to specify value of |
|
253 # attr-subject |
|
254 values = form._cw.form[field.name] |
|
255 if values is None: |
|
256 values = self.typed_value(form, field) |
|
257 if values != INTERNAL_FIELD_VALUE: |
|
258 values = self.format_value(form, field, values) |
|
259 if not isinstance(values, (tuple, list)): |
|
260 values = (values,) |
|
261 return values |
|
262 |
|
263 def typed_value(self, form, field): |
|
264 """return field's *typed* value specified in: |
|
265 3. extra form values given to render() |
|
266 4. field's typed value |
|
267 """ |
|
268 qname = field.input_name(form) |
|
269 for key in ((field, form), qname): |
|
270 try: |
|
271 return form.formvalues[key] |
|
272 except KeyError: |
|
273 continue |
|
274 if field.name != qname and field.name in form.formvalues: |
|
275 return form.formvalues[field.name] |
|
276 return field.typed_value(form) |
|
277 |
|
278 def process_field_data(self, form, field): |
|
279 """Return process posted value(s) for widget and return something |
|
280 understandable by the associated `field`. That value may be correctly |
|
281 typed or a string that the field may parse. |
|
282 """ |
|
283 posted = form._cw.form |
|
284 val = posted.get(field.input_name(form, self.suffix)) |
|
285 if isinstance(val, string_types): |
|
286 val = val.strip() |
|
287 return val |
|
288 |
|
289 # XXX deprecates |
|
290 def values_and_attributes(self, form, field): |
|
291 return self.values(form, field), self.attributes(form, field) |
|
292 |
|
293 |
|
294 class Input(FieldWidget): |
|
295 """abstract widget class for <input> tag based widgets""" |
|
296 type = None |
|
297 |
|
298 def _render(self, form, field, renderer): |
|
299 """render the widget for the given `field` of `form`. |
|
300 |
|
301 Generate one <input> tag for each field's value |
|
302 """ |
|
303 values, attrs = self.values_and_attributes(form, field) |
|
304 # ensure something is rendered |
|
305 if not values: |
|
306 values = (INTERNAL_FIELD_VALUE,) |
|
307 inputs = [tags.input(name=field.input_name(form, self.suffix), |
|
308 type=self.type, value=value, **attrs) |
|
309 for value in values] |
|
310 return u'\n'.join(inputs) |
|
311 |
|
312 |
|
313 # basic html widgets ########################################################### |
|
314 |
|
315 class TextInput(Input): |
|
316 """Simple <input type='text'>, will return a unicode string.""" |
|
317 type = 'text' |
|
318 |
|
319 |
|
320 class EmailInput(Input): |
|
321 """Simple <input type='email'>, will return a unicode string.""" |
|
322 type = 'email' |
|
323 |
|
324 |
|
325 class PasswordSingleInput(Input): |
|
326 """Simple <input type='password'>, will return a utf-8 encoded string. |
|
327 |
|
328 You may prefer using the :class:`~cubicweb.web.formwidgets.PasswordInput` |
|
329 widget which handles password confirmation. |
|
330 """ |
|
331 type = 'password' |
|
332 |
|
333 def process_field_data(self, form, field): |
|
334 value = super(PasswordSingleInput, self).process_field_data(form, field) |
|
335 if value is not None: |
|
336 return value.encode('utf-8') |
|
337 return value |
|
338 |
|
339 |
|
340 class PasswordInput(Input): |
|
341 """<input type='password'> and a confirmation input. Form processing will |
|
342 fail if password and confirmation differs, else it will return the password |
|
343 as a utf-8 encoded string. |
|
344 """ |
|
345 type = 'password' |
|
346 |
|
347 def _render(self, form, field, renderer): |
|
348 assert self.suffix is None, 'suffix not supported' |
|
349 values, attrs = self.values_and_attributes(form, field) |
|
350 assert len(values) == 1 |
|
351 domid = attrs.pop('id') |
|
352 inputs = [tags.input(name=field.input_name(form), |
|
353 value=values[0], type=self.type, id=domid, **attrs), |
|
354 '<br/>', |
|
355 tags.input(name=field.input_name(form, '-confirm'), |
|
356 value=values[0], type=self.type, **attrs), |
|
357 ' ', tags.span(form._cw._('confirm password'), |
|
358 **{'class': 'emphasis'})] |
|
359 return u'\n'.join(inputs) |
|
360 |
|
361 def process_field_data(self, form, field): |
|
362 passwd1 = super(PasswordInput, self).process_field_data(form, field) |
|
363 passwd2 = form._cw.form.get(field.input_name(form, '-confirm')) |
|
364 if passwd1 == passwd2: |
|
365 if passwd1 is None: |
|
366 return None |
|
367 return passwd1.encode('utf-8') |
|
368 raise ProcessFormError(form._cw._("password and confirmation don't match")) |
|
369 |
|
370 |
|
371 class FileInput(Input): |
|
372 """Simple <input type='file'>, will return a tuple (name, stream) where |
|
373 name is the posted file name and stream a file like object containing the |
|
374 posted file data. |
|
375 """ |
|
376 type = 'file' |
|
377 |
|
378 def values(self, form, field): |
|
379 # ignore value which makes no sense here (XXX even on form validation error?) |
|
380 return ('',) |
|
381 |
|
382 |
|
383 class HiddenInput(Input): |
|
384 """Simple <input type='hidden'> for hidden value, will return a unicode |
|
385 string. |
|
386 """ |
|
387 type = 'hidden' |
|
388 setdomid = False # by default, don't set id attribute on hidden input |
|
389 settabindex = False |
|
390 |
|
391 |
|
392 class ButtonInput(Input): |
|
393 """Simple <input type='button'>, will return a unicode string. |
|
394 |
|
395 If you want a global form button, look at the :class:`Button`, |
|
396 :class:`SubmitButton`, :class:`ResetButton` and :class:`ImgButton` below. |
|
397 """ |
|
398 type = 'button' |
|
399 |
|
400 |
|
401 class TextArea(FieldWidget): |
|
402 """Simple <textarea>, will return a unicode string.""" |
|
403 _minrows = 2 |
|
404 _maxrows = 15 |
|
405 _columns = 80 |
|
406 |
|
407 def _render(self, form, field, renderer): |
|
408 values, attrs = self.values_and_attributes(form, field) |
|
409 attrs.setdefault('onkeyup', 'autogrow(this)') |
|
410 if not values: |
|
411 value = u'' |
|
412 elif len(values) == 1: |
|
413 value = values[0] |
|
414 else: |
|
415 raise ValueError('a textarea is not supposed to be multivalued') |
|
416 lines = value.splitlines() |
|
417 linecount = len(lines) |
|
418 for line in lines: |
|
419 linecount += len(line) // self._columns |
|
420 attrs.setdefault('cols', self._columns) |
|
421 attrs.setdefault('rows', min(self._maxrows, linecount + self._minrows)) |
|
422 return tags.textarea(value, name=field.input_name(form, self.suffix), |
|
423 **attrs) |
|
424 |
|
425 |
|
426 class FCKEditor(TextArea): |
|
427 """FCKEditor enabled <textarea>, will return a unicode string containing |
|
428 HTML formated text. |
|
429 """ |
|
430 def __init__(self, *args, **kwargs): |
|
431 super(FCKEditor, self).__init__(*args, **kwargs) |
|
432 self.attrs['cubicweb:type'] = 'wysiwyg' |
|
433 |
|
434 def _render(self, form, field, renderer): |
|
435 form._cw.fckeditor_config() |
|
436 return super(FCKEditor, self)._render(form, field, renderer) |
|
437 |
|
438 |
|
439 class Select(FieldWidget): |
|
440 """Simple <select>, for field having a specific vocabulary. Will return |
|
441 a unicode string, or a list of unicode strings. |
|
442 """ |
|
443 vocabulary_widget = True |
|
444 default_size = 10 |
|
445 |
|
446 def __init__(self, attrs=None, multiple=False, **kwargs): |
|
447 super(Select, self).__init__(attrs, **kwargs) |
|
448 self._multiple = multiple |
|
449 |
|
450 def _render(self, form, field, renderer): |
|
451 curvalues, attrs = self.values_and_attributes(form, field) |
|
452 options = [] |
|
453 optgroup_opened = False |
|
454 vocab = field.vocabulary(form) |
|
455 for option in vocab: |
|
456 try: |
|
457 label, value, oattrs = option |
|
458 except ValueError: |
|
459 label, value = option |
|
460 oattrs = {} |
|
461 if value is None: |
|
462 # handle separator |
|
463 if optgroup_opened: |
|
464 options.append(u'</optgroup>') |
|
465 oattrs.setdefault('label', label or '') |
|
466 options.append(u'<optgroup %s>' % uilib.sgml_attributes(oattrs)) |
|
467 optgroup_opened = True |
|
468 elif self.value_selected(value, curvalues): |
|
469 options.append(tags.option(label, value=value, |
|
470 selected='selected', **oattrs)) |
|
471 else: |
|
472 options.append(tags.option(label, value=value, **oattrs)) |
|
473 if optgroup_opened: |
|
474 options.append(u'</optgroup>') |
|
475 if 'size' not in attrs: |
|
476 if self._multiple: |
|
477 size = text_type(min(self.default_size, len(vocab) or 1)) |
|
478 else: |
|
479 size = u'1' |
|
480 attrs['size'] = size |
|
481 return tags.select(name=field.input_name(form, self.suffix), |
|
482 multiple=self._multiple, options=options, **attrs) |
|
483 |
|
484 def value_selected(self, value, curvalues): |
|
485 return value in curvalues |
|
486 |
|
487 |
|
488 class InOutWidget(Select): |
|
489 needs_js = ('cubicweb.widgets.js', ) |
|
490 default_size = 10 |
|
491 template = """ |
|
492 <table id="%(widgetid)s"> |
|
493 <tr> |
|
494 <td>%(inoutinput)s</td> |
|
495 <td><div style="margin-bottom:3px">%(addinput)s</div> |
|
496 <div>%(removeinput)s</div> |
|
497 </td> |
|
498 <td>%(resinput)s</td> |
|
499 </tr> |
|
500 </table> |
|
501 """ |
|
502 add_button = ('<input type="button" class="wdgButton cwinoutadd" ' |
|
503 'value=">>" size="10" />') |
|
504 remove_button = ('<input type="button" class="wdgButton cwinoutremove" ' |
|
505 'value="<<" size="10" />') |
|
506 |
|
507 def __init__(self, *args, **kwargs): |
|
508 super(InOutWidget, self).__init__(*args, **kwargs) |
|
509 self._multiple = True |
|
510 |
|
511 def render_select(self, form, field, name, selected=False): |
|
512 values, attrs = self.values_and_attributes(form, field) |
|
513 options = [] |
|
514 inputs = [] |
|
515 for option in field.vocabulary(form): |
|
516 try: |
|
517 label, value, _oattrs = option |
|
518 except ValueError: |
|
519 label, value = option |
|
520 if selected: |
|
521 # add values |
|
522 if value in values: |
|
523 options.append(tags.option(label, value=value)) |
|
524 # add hidden inputs |
|
525 inputs.append(tags.input(value=value, |
|
526 name=field.dom_id(form), |
|
527 type="hidden")) |
|
528 else: |
|
529 if value not in values: |
|
530 options.append(tags.option(label, value=value)) |
|
531 if 'size' not in attrs: |
|
532 attrs['size'] = self.default_size |
|
533 if 'id' in attrs: |
|
534 attrs.pop('id') |
|
535 return tags.select(name=name, multiple=self._multiple, id=name, |
|
536 options=options, **attrs) + '\n'.join(inputs) |
|
537 |
|
538 def _render(self, form, field, renderer): |
|
539 domid = field.dom_id(form) |
|
540 jsnodes = {'widgetid': domid, |
|
541 'from': 'from_' + domid, |
|
542 'to': 'to_' + domid} |
|
543 form._cw.add_onload(u'$(cw.jqNode("%s")).cwinoutwidget("%s", "%s");' |
|
544 % (jsnodes['widgetid'], jsnodes['from'], jsnodes['to'])) |
|
545 field.required = True |
|
546 return (self.template % |
|
547 {'widgetid': jsnodes['widgetid'], |
|
548 # helpinfo select tag |
|
549 'inoutinput': self.render_select(form, field, jsnodes['from']), |
|
550 # select tag with resultats |
|
551 'resinput': self.render_select(form, field, jsnodes['to'], selected=True), |
|
552 'addinput': self.add_button % jsnodes, |
|
553 'removeinput': self.remove_button % jsnodes |
|
554 }) |
|
555 |
|
556 |
|
557 class BitSelect(Select): |
|
558 """Select widget for IntField using a vocabulary with bit masks as values. |
|
559 |
|
560 See also :class:`~cubicweb.web.facet.BitFieldFacet`. |
|
561 """ |
|
562 def __init__(self, attrs=None, multiple=True, **kwargs): |
|
563 super(BitSelect, self).__init__(attrs, multiple=multiple, **kwargs) |
|
564 |
|
565 def value_selected(self, value, curvalues): |
|
566 mask = reduce(lambda x, y: int(x) | int(y), curvalues, 0) |
|
567 return int(value) & mask |
|
568 |
|
569 def process_field_data(self, form, field): |
|
570 """Return process posted value(s) for widget and return something |
|
571 understandable by the associated `field`. That value may be correctly |
|
572 typed or a string that the field may parse. |
|
573 """ |
|
574 val = super(BitSelect, self).process_field_data(form, field) |
|
575 if isinstance(val, list): |
|
576 val = reduce(lambda x, y: int(x) | int(y), val, 0) |
|
577 elif val: |
|
578 val = int(val) |
|
579 else: |
|
580 val = 0 |
|
581 return val |
|
582 |
|
583 |
|
584 class CheckBox(Input): |
|
585 """Simple <input type='checkbox'>, for field having a specific |
|
586 vocabulary. One input will be generated for each possible value. |
|
587 |
|
588 You can specify separator using the `separator` constructor argument, by |
|
589 default <br/> is used. |
|
590 """ |
|
591 type = 'checkbox' |
|
592 default_separator = u'<br/>\n' |
|
593 vocabulary_widget = True |
|
594 |
|
595 def __init__(self, attrs=None, separator=None, **kwargs): |
|
596 super(CheckBox, self).__init__(attrs, **kwargs) |
|
597 self.separator = separator or self.default_separator |
|
598 |
|
599 def _render(self, form, field, renderer): |
|
600 curvalues, attrs = self.values_and_attributes(form, field) |
|
601 domid = attrs.pop('id', None) |
|
602 sep = self.separator |
|
603 options = [] |
|
604 for i, option in enumerate(field.vocabulary(form)): |
|
605 try: |
|
606 label, value, oattrs = option |
|
607 except ValueError: |
|
608 label, value = option |
|
609 oattrs = {} |
|
610 iattrs = attrs.copy() |
|
611 iattrs.update(oattrs) |
|
612 if i == 0 and domid is not None: |
|
613 iattrs.setdefault('id', domid) |
|
614 if value in curvalues: |
|
615 iattrs['checked'] = u'checked' |
|
616 tag = tags.input(name=field.input_name(form, self.suffix), |
|
617 type=self.type, value=value, **iattrs) |
|
618 options.append(u'<label>%s %s</label>' % (tag, xml_escape(label))) |
|
619 return sep.join(options) |
|
620 |
|
621 |
|
622 class Radio(CheckBox): |
|
623 """Simle <input type='radio'>, for field having a specific vocabulary. One |
|
624 input will be generated for each possible value. |
|
625 |
|
626 You can specify separator using the `separator` constructor argument, by |
|
627 default <br/> is used. |
|
628 """ |
|
629 type = 'radio' |
|
630 |
|
631 |
|
632 # javascript widgets ########################################################### |
|
633 |
|
634 class DateTimePicker(TextInput): |
|
635 """<input type='text'> + javascript date/time picker for date or datetime |
|
636 fields. Will return the date or datetime as a unicode string. |
|
637 """ |
|
638 monthnames = ('january', 'february', 'march', 'april', |
|
639 'may', 'june', 'july', 'august', |
|
640 'september', 'october', 'november', 'december') |
|
641 daynames = ('monday', 'tuesday', 'wednesday', 'thursday', |
|
642 'friday', 'saturday', 'sunday') |
|
643 |
|
644 needs_js = ('cubicweb.calendar.js',) |
|
645 needs_css = ('cubicweb.calendar_popup.css',) |
|
646 |
|
647 @classmethod |
|
648 def add_localized_infos(cls, req): |
|
649 """inserts JS variables defining localized months and days""" |
|
650 _ = req._ |
|
651 monthnames = [_(mname) for mname in cls.monthnames] |
|
652 daynames = [_(dname) for dname in cls.daynames] |
|
653 req.html_headers.define_var('MONTHNAMES', monthnames) |
|
654 req.html_headers.define_var('DAYNAMES', daynames) |
|
655 |
|
656 def _render(self, form, field, renderer): |
|
657 txtwidget = super(DateTimePicker, self)._render(form, field, renderer) |
|
658 self.add_localized_infos(form._cw) |
|
659 cal_button = self._render_calendar_popup(form, field) |
|
660 return txtwidget + cal_button |
|
661 |
|
662 def _render_calendar_popup(self, form, field): |
|
663 value = field.typed_value(form) |
|
664 if not value: |
|
665 value = date.today() |
|
666 inputid = field.dom_id(form) |
|
667 helperid = '%shelper' % inputid |
|
668 year, month = value.year, value.month |
|
669 return (u"""<a onclick="toggleCalendar('%s', '%s', %s, %s);" class="calhelper"> |
|
670 <img src="%s" title="%s" alt="" /></a><div class="calpopup hidden" id="%s"></div>""" |
|
671 % (helperid, inputid, year, month, |
|
672 form._cw.uiprops['CALENDAR_ICON'], |
|
673 form._cw._('calendar'), helperid)) |
|
674 |
|
675 |
|
676 class JQueryDatePicker(FieldWidget): |
|
677 """Use jquery.ui.datepicker to define a date picker. Will return the date as |
|
678 a unicode string. |
|
679 |
|
680 You can couple DatePickers by using the min_of and/or max_of parameters. |
|
681 The DatePicker identified by the value of min_of(/max_of) will force the user to |
|
682 choose a date anterior(/posterior) to this DatePicker. |
|
683 |
|
684 example: |
|
685 start and end are two JQueryDatePicker and start must always be before end |
|
686 affk.set_field_kwargs(etype, 'start_date', widget=JQueryDatePicker(min_of='end_date')) |
|
687 affk.set_field_kwargs(etype, 'end_date', widget=JQueryDatePicker(max_of='start_date')) |
|
688 That way, on change of end(/start) value a new max(/min) will be set for start(/end) |
|
689 The invalid dates will be gray colored in the datepicker |
|
690 """ |
|
691 needs_js = ('jquery.ui.js', ) |
|
692 needs_css = ('jquery.ui.css',) |
|
693 default_size = 10 |
|
694 |
|
695 def __init__(self, datestr=None, min_of=None, max_of=None, **kwargs): |
|
696 super(JQueryDatePicker, self).__init__(**kwargs) |
|
697 self.min_of = min_of |
|
698 self.max_of = max_of |
|
699 self.value = datestr |
|
700 |
|
701 def attributes(self, form, field): |
|
702 form._cw.add_js('cubicweb.widgets.js') |
|
703 attrs = super(JQueryDatePicker, self).attributes(form, field) |
|
704 if self.max_of: |
|
705 attrs['data-max-of'] = '%s-subject:%s' % (self.max_of, form.edited_entity.eid) |
|
706 if self.min_of: |
|
707 attrs['data-min-of'] = '%s-subject:%s' % (self.min_of, form.edited_entity.eid) |
|
708 return attrs |
|
709 |
|
710 def _render(self, form, field, renderer): |
|
711 req = form._cw |
|
712 if req.lang != 'en': |
|
713 req.add_js('jquery.ui.datepicker-%s.js' % req.lang) |
|
714 domid = field.dom_id(form, self.suffix) |
|
715 # XXX find a way to understand every format |
|
716 fmt = req.property_value('ui.date-format') |
|
717 picker_fmt = fmt.replace('%Y', 'yy').replace('%m', 'mm').replace('%d', 'dd') |
|
718 max_date = min_date = None |
|
719 if self.min_of: |
|
720 current = getattr(form.edited_entity, self.min_of) |
|
721 if current is not None: |
|
722 max_date = current.strftime(fmt) |
|
723 if self.max_of: |
|
724 current = getattr(form.edited_entity, self.max_of) |
|
725 if current is not None: |
|
726 min_date = current.strftime(fmt) |
|
727 req.add_onload(u'renderJQueryDatePicker("%s", "%s", "%s", %s, %s);' |
|
728 % (domid, req.uiprops['CALENDAR_ICON'], picker_fmt, json_dumps(min_date), |
|
729 json_dumps(max_date))) |
|
730 return self._render_input(form, field) |
|
731 |
|
732 def _render_input(self, form, field): |
|
733 if self.value is None: |
|
734 value = self.values(form, field)[0] |
|
735 else: |
|
736 value = self.value |
|
737 attrs = self.attributes(form, field) |
|
738 attrs.setdefault('size', text_type(self.default_size)) |
|
739 return tags.input(name=field.input_name(form, self.suffix), |
|
740 value=value, type='text', **attrs) |
|
741 |
|
742 |
|
743 class JQueryTimePicker(JQueryDatePicker): |
|
744 """Use jquery.timePicker to define a time picker. Will return the time as a |
|
745 unicode string. |
|
746 """ |
|
747 needs_js = ('jquery.timePicker.js',) |
|
748 needs_css = ('jquery.timepicker.css',) |
|
749 default_size = 5 |
|
750 |
|
751 def __init__(self, timestr=None, timesteps=30, separator=u':', **kwargs): |
|
752 super(JQueryTimePicker, self).__init__(timestr, **kwargs) |
|
753 self.timesteps = timesteps |
|
754 self.separator = separator |
|
755 |
|
756 def _render(self, form, field, renderer): |
|
757 domid = field.dom_id(form, self.suffix) |
|
758 form._cw.add_onload(u'cw.jqNode("%s").timePicker({step: %s, separator: "%s"})' % ( |
|
759 domid, self.timesteps, self.separator)) |
|
760 return self._render_input(form, field) |
|
761 |
|
762 |
|
763 class JQueryDateTimePicker(FieldWidget): |
|
764 """Compound widget using :class:`JQueryDatePicker` and |
|
765 :class:`JQueryTimePicker` widgets to define a date and time picker. Will |
|
766 return the date and time as python datetime instance. |
|
767 """ |
|
768 def __init__(self, initialtime=None, timesteps=15, **kwargs): |
|
769 super(JQueryDateTimePicker, self).__init__(**kwargs) |
|
770 self.initialtime = initialtime |
|
771 self.timesteps = timesteps |
|
772 |
|
773 def _render(self, form, field, renderer): |
|
774 """render the widget for the given `field` of `form`. |
|
775 |
|
776 Generate one <input> tag for each field's value |
|
777 """ |
|
778 req = form._cw |
|
779 dateqname = field.input_name(form, 'date') |
|
780 timeqname = field.input_name(form, 'time') |
|
781 if dateqname in form.form_previous_values: |
|
782 datestr = form.form_previous_values[dateqname] |
|
783 timestr = form.form_previous_values[timeqname] |
|
784 else: |
|
785 datestr = timestr = u'' |
|
786 if field.name in req.form: |
|
787 value = req.parse_datetime(req.form[field.name]) |
|
788 else: |
|
789 value = self.typed_value(form, field) |
|
790 if value: |
|
791 datestr = req.format_date(value) |
|
792 timestr = req.format_time(value) |
|
793 elif self.initialtime: |
|
794 timestr = req.format_time(self.initialtime) |
|
795 datepicker = JQueryDatePicker(datestr=datestr, suffix='date') |
|
796 timepicker = JQueryTimePicker(timestr=timestr, timesteps=self.timesteps, |
|
797 suffix='time') |
|
798 return u'<div id="%s">%s%s</div>' % (field.dom_id(form), |
|
799 datepicker.render(form, field, renderer), |
|
800 timepicker.render(form, field, renderer)) |
|
801 |
|
802 def process_field_data(self, form, field): |
|
803 req = form._cw |
|
804 datestr = req.form.get(field.input_name(form, 'date')).strip() or None |
|
805 timestr = req.form.get(field.input_name(form, 'time')).strip() or None |
|
806 if datestr is None: |
|
807 return None |
|
808 try: |
|
809 date = todatetime(req.parse_datetime(datestr, 'Date')) |
|
810 except ValueError as exc: |
|
811 raise ProcessFormError(text_type(exc)) |
|
812 if timestr is None: |
|
813 return date |
|
814 try: |
|
815 time = req.parse_datetime(timestr, 'Time') |
|
816 except ValueError as exc: |
|
817 raise ProcessFormError(text_type(exc)) |
|
818 return date.replace(hour=time.hour, minute=time.minute, second=time.second) |
|
819 |
|
820 |
|
821 # ajax widgets ################################################################ |
|
822 |
|
823 def init_ajax_attributes(attrs, wdgtype, loadtype=u'auto'): |
|
824 try: |
|
825 attrs['class'] += u' widget' |
|
826 except KeyError: |
|
827 attrs['class'] = u'widget' |
|
828 attrs.setdefault('cubicweb:wdgtype', wdgtype) |
|
829 attrs.setdefault('cubicweb:loadtype', loadtype) |
|
830 |
|
831 |
|
832 class AjaxWidget(FieldWidget): |
|
833 """Simple <div> based ajax widget, requiring a `wdgtype` argument telling |
|
834 which javascript widget should be used. |
|
835 """ |
|
836 def __init__(self, wdgtype, inputid=None, **kwargs): |
|
837 super(AjaxWidget, self).__init__(**kwargs) |
|
838 init_ajax_attributes(self.attrs, wdgtype) |
|
839 if inputid is not None: |
|
840 self.attrs['cubicweb:inputid'] = inputid |
|
841 |
|
842 def _render(self, form, field, renderer): |
|
843 attrs = self.values_and_attributes(form, field)[-1] |
|
844 return tags.div(**attrs) |
|
845 |
|
846 |
|
847 class AutoCompletionWidget(TextInput): |
|
848 """<input type='text'> based ajax widget, taking a `autocomplete_initfunc` |
|
849 argument which should specify the name of a method of the json |
|
850 controller. This method is expected to return allowed values for the input, |
|
851 that the widget will use to propose matching values as you type. |
|
852 """ |
|
853 needs_js = ('cubicweb.widgets.js', 'jquery.ui.js') |
|
854 needs_css = ('jquery.ui.css',) |
|
855 default_settings = {} |
|
856 |
|
857 def __init__(self, *args, **kwargs): |
|
858 self.autocomplete_settings = kwargs.pop('autocomplete_settings', |
|
859 self.default_settings) |
|
860 self.autocomplete_initfunc = kwargs.pop('autocomplete_initfunc') |
|
861 super(AutoCompletionWidget, self).__init__(*args, **kwargs) |
|
862 |
|
863 def values(self, form, field): |
|
864 values = super(AutoCompletionWidget, self).values(form, field) |
|
865 if not values: |
|
866 values = ('',) |
|
867 return values |
|
868 |
|
869 def _render(self, form, field, renderer): |
|
870 entity = form.edited_entity |
|
871 domid = field.dom_id(form).replace(':', r'\\:') |
|
872 if callable(self.autocomplete_initfunc): |
|
873 data = self.autocomplete_initfunc(form, field) |
|
874 else: |
|
875 data = xml_escape(self._get_url(entity, field)) |
|
876 form._cw.add_onload(u'$("#%s").cwautocomplete(%s, %s);' |
|
877 % (domid, json_dumps(data), |
|
878 json_dumps(self.autocomplete_settings))) |
|
879 return super(AutoCompletionWidget, self)._render(form, field, renderer) |
|
880 |
|
881 def _get_url(self, entity, field): |
|
882 fname = self.autocomplete_initfunc |
|
883 return entity._cw.build_url('ajax', fname=fname, mode='remote', |
|
884 pageid=entity._cw.pageid) |
|
885 |
|
886 |
|
887 class StaticFileAutoCompletionWidget(AutoCompletionWidget): |
|
888 """XXX describe me""" |
|
889 wdgtype = 'StaticFileSuggestField' |
|
890 |
|
891 def _get_url(self, entity, field): |
|
892 return entity._cw.data_url(self.autocomplete_initfunc) |
|
893 |
|
894 |
|
895 class RestrictedAutoCompletionWidget(AutoCompletionWidget): |
|
896 """XXX describe me""" |
|
897 default_settings = {'mustMatch': True} |
|
898 |
|
899 |
|
900 class LazyRestrictedAutoCompletionWidget(RestrictedAutoCompletionWidget): |
|
901 """remote autocomplete """ |
|
902 |
|
903 def values_and_attributes(self, form, field): |
|
904 """override values_and_attributes to handle initial displayed values""" |
|
905 values, attrs = super(LazyRestrictedAutoCompletionWidget, self).values_and_attributes( |
|
906 form, field) |
|
907 assert len(values) == 1, "multiple selection is not supported yet by LazyWidget" |
|
908 if not values[0]: |
|
909 values = form.cw_extra_kwargs.get(field.name, '') |
|
910 if not isinstance(values, (tuple, list)): |
|
911 values = (values,) |
|
912 try: |
|
913 values = list(values) |
|
914 values[0] = int(values[0]) |
|
915 attrs['cubicweb:initialvalue'] = values[0] |
|
916 values = (self.display_value_for(form, values[0]),) |
|
917 except (TypeError, ValueError): |
|
918 pass |
|
919 return values, attrs |
|
920 |
|
921 def display_value_for(self, form, value): |
|
922 entity = form._cw.entity_from_eid(value) |
|
923 return entity.view('combobox') |
|
924 |
|
925 |
|
926 # more widgets ################################################################# |
|
927 |
|
928 class IntervalWidget(FieldWidget): |
|
929 """Custom widget to display an interval composed by 2 fields. This widget is |
|
930 expected to be used with a :class:`CompoundField` containing the two actual |
|
931 fields. |
|
932 |
|
933 Exemple usage:: |
|
934 |
|
935 class MyForm(FieldsForm): |
|
936 price = CompoundField(fields=(IntField(name='minprice'), |
|
937 IntField(name='maxprice')), |
|
938 label=_('price'), |
|
939 widget=IntervalWidget()) |
|
940 """ |
|
941 def _render(self, form, field, renderer): |
|
942 actual_fields = field.fields |
|
943 assert len(actual_fields) == 2 |
|
944 return u'<div>%s %s %s %s</div>' % ( |
|
945 form._cw._('from_interval_start'), |
|
946 actual_fields[0].render(form, renderer), |
|
947 form._cw._('to_interval_end'), |
|
948 actual_fields[1].render(form, renderer), |
|
949 ) |
|
950 |
|
951 |
|
952 class HorizontalLayoutWidget(FieldWidget): |
|
953 """Custom widget to display a set of fields grouped together horizontally in |
|
954 a form. See `IntervalWidget` for example usage. |
|
955 """ |
|
956 def _render(self, form, field, renderer): |
|
957 if self.attrs.get('display_label', True): |
|
958 subst = self.attrs.get('label_input_substitution', '%(label)s %(input)s') |
|
959 fields = [subst % {'label': renderer.render_label(form, f), |
|
960 'input': f.render(form, renderer)} |
|
961 for f in field.subfields(form)] |
|
962 else: |
|
963 fields = [f.render(form, renderer) for f in field.subfields(form)] |
|
964 return u'<div>%s</div>' % ' '.join(fields) |
|
965 |
|
966 |
|
967 class EditableURLWidget(FieldWidget): |
|
968 """Custom widget to edit separatly a URL path / query string (used by |
|
969 default for the `path` attribute of `Bookmark` entities). |
|
970 |
|
971 It deals with url quoting nicely so that the user edit the unquoted value. |
|
972 """ |
|
973 |
|
974 def _render(self, form, field, renderer): |
|
975 assert self.suffix is None, 'not supported' |
|
976 req = form._cw |
|
977 pathqname = field.input_name(form, 'path') |
|
978 fqsqname = field.input_name(form, 'fqs') # formatted query string |
|
979 if pathqname in form.form_previous_values: |
|
980 path = form.form_previous_values[pathqname] |
|
981 fqs = form.form_previous_values[fqsqname] |
|
982 else: |
|
983 if field.name in req.form: |
|
984 value = req.form[field.name] |
|
985 else: |
|
986 value = self.typed_value(form, field) |
|
987 if value: |
|
988 try: |
|
989 path, qs = value.split('?', 1) |
|
990 except ValueError: |
|
991 path = value |
|
992 qs = '' |
|
993 else: |
|
994 path = qs = '' |
|
995 fqs = u'\n'.join(u'%s=%s' % (k, v) for k, v in req.url_parse_qsl(qs)) |
|
996 attrs = dict(self.attrs) |
|
997 if self.setdomid: |
|
998 attrs['id'] = field.dom_id(form) |
|
999 if self.settabindex and 'tabindex' not in attrs: |
|
1000 attrs['tabindex'] = req.next_tabindex() |
|
1001 # ensure something is rendered |
|
1002 inputs = [u'<table><tr><th>', |
|
1003 req._('i18n_bookmark_url_path'), |
|
1004 u'</th><td>', |
|
1005 tags.input(name=pathqname, type='string', value=path, **attrs), |
|
1006 u'</td></tr><tr><th>', |
|
1007 req._('i18n_bookmark_url_fqs'), |
|
1008 u'</th><td>'] |
|
1009 if self.setdomid: |
|
1010 attrs['id'] = field.dom_id(form, 'fqs') |
|
1011 if self.settabindex: |
|
1012 attrs['tabindex'] = req.next_tabindex() |
|
1013 attrs.setdefault('cols', 60) |
|
1014 attrs.setdefault('onkeyup', 'autogrow(this)') |
|
1015 inputs += [tags.textarea(fqs, name=fqsqname, **attrs), |
|
1016 u'</td></tr></table>'] |
|
1017 # surrounding div necessary for proper error localization |
|
1018 return u'<div id="%s">%s</div>' % ( |
|
1019 field.dom_id(form), u'\n'.join(inputs)) |
|
1020 |
|
1021 def process_field_data(self, form, field): |
|
1022 req = form._cw |
|
1023 values = {} |
|
1024 path = req.form.get(field.input_name(form, 'path')) |
|
1025 if isinstance(path, string_types): |
|
1026 path = path.strip() |
|
1027 if path is None: |
|
1028 path = u'' |
|
1029 fqs = req.form.get(field.input_name(form, 'fqs')) |
|
1030 if isinstance(fqs, string_types): |
|
1031 fqs = fqs.strip() or None |
|
1032 if fqs: |
|
1033 for i, line in enumerate(fqs.split('\n')): |
|
1034 line = line.strip() |
|
1035 if line: |
|
1036 try: |
|
1037 key, val = line.split('=', 1) |
|
1038 except ValueError: |
|
1039 msg = req._("wrong query parameter line %s") % (i + 1) |
|
1040 raise ProcessFormError(msg) |
|
1041 # value will be url quoted by build_url_params |
|
1042 values.setdefault(key, []).append(val) |
|
1043 if not values: |
|
1044 return path |
|
1045 return u'%s?%s' % (path, req.build_url_params(**values)) |
|
1046 |
|
1047 |
|
1048 # form controls ###################################################################### |
|
1049 |
|
1050 class Button(Input): |
|
1051 """Simple <input type='button'>, base class for global form buttons. |
|
1052 |
|
1053 Note that `label` is a msgid which will be translated at form generation |
|
1054 time, you should not give an already translated string. |
|
1055 """ |
|
1056 type = 'button' |
|
1057 css_class = 'validateButton' |
|
1058 |
|
1059 def __init__(self, label=stdmsgs.BUTTON_OK, attrs=None, |
|
1060 setdomid=None, settabindex=None, |
|
1061 name='', value='', onclick=None, cwaction=None): |
|
1062 super(Button, self).__init__(attrs, setdomid, settabindex) |
|
1063 if isinstance(label, tuple): |
|
1064 self.label = label[0] |
|
1065 self.icon = label[1] |
|
1066 else: |
|
1067 self.label = label |
|
1068 self.icon = None |
|
1069 self.name = name |
|
1070 self.value = '' |
|
1071 self.onclick = onclick |
|
1072 self.cwaction = cwaction |
|
1073 |
|
1074 def render(self, form, field=None, renderer=None): |
|
1075 label = form._cw._(self.label) |
|
1076 attrs = self.attrs.copy() |
|
1077 attrs.setdefault('class', self.css_class) |
|
1078 if self.cwaction: |
|
1079 assert self.onclick is None |
|
1080 attrs['onclick'] = "postForm('__action_%s', \'%s\', \'%s\')" % ( |
|
1081 self.cwaction, self.label, form.domid) |
|
1082 elif self.onclick: |
|
1083 attrs['onclick'] = self.onclick |
|
1084 if self.name: |
|
1085 attrs['name'] = self.name |
|
1086 if self.setdomid: |
|
1087 attrs['id'] = self.name |
|
1088 if self.settabindex and 'tabindex' not in attrs: |
|
1089 attrs['tabindex'] = form._cw.next_tabindex() |
|
1090 if self.icon: |
|
1091 img = tags.img(src=form._cw.uiprops[self.icon], alt=self.icon) |
|
1092 else: |
|
1093 img = u'' |
|
1094 return tags.button(img + xml_escape(label), escapecontent=False, |
|
1095 value=label, type=self.type, **attrs) |
|
1096 |
|
1097 |
|
1098 class SubmitButton(Button): |
|
1099 """Simple <input type='submit'>, main button to submit a form""" |
|
1100 type = 'submit' |
|
1101 |
|
1102 |
|
1103 class ResetButton(Button): |
|
1104 """Simple <input type='reset'>, main button to reset a form. You usually |
|
1105 don't want to use this. |
|
1106 """ |
|
1107 type = 'reset' |
|
1108 |
|
1109 |
|
1110 class ImgButton(object): |
|
1111 """Simple <img> wrapped into a <a> tag with href triggering something (usually a |
|
1112 javascript call). |
|
1113 """ |
|
1114 def __init__(self, domid, href, label, imgressource): |
|
1115 self.domid = domid |
|
1116 self.href = href |
|
1117 self.imgressource = imgressource |
|
1118 self.label = label |
|
1119 |
|
1120 def render(self, form, field=None, renderer=None): |
|
1121 label = form._cw._(self.label) |
|
1122 imgsrc = form._cw.uiprops[self.imgressource] |
|
1123 return '<a id="%(domid)s" href="%(href)s">'\ |
|
1124 '<img src="%(imgsrc)s" alt="%(label)s"/>%(label)s</a>' % { |
|
1125 'label': label, 'imgsrc': imgsrc, |
|
1126 'domid': self.domid, 'href': self.href} |