diff --git a/deform/templates/autocomplete_input.pt b/deform/templates/autocomplete_input.pt index 9ddf6bd4..40bb730c 100644 --- a/deform/templates/autocomplete_input.pt +++ b/deform/templates/autocomplete_input.pt @@ -12,11 +12,11 @@ class string: form-control ${css_class}; style style" id="${oid}"/> - diff --git a/deform/tests/test_widget.py b/deform/tests/test_widget.py index b905bb93..b1f228ef 100644 --- a/deform/tests/test_widget.py +++ b/deform/tests/test_widget.py @@ -1,4 +1,5 @@ import unittest +import warnings from deform.compat import ( text_type, @@ -222,6 +223,25 @@ def test_deserialize_with_nondefault_thousands_separator(self): result = widget.deserialize(field, pstruct) self.assertEqual(result, '1000000.00') +class Test_serialize_js_config(unittest.TestCase): + def _makeLiteral(self, js): + from deform.widget import literal_js + return literal_js(js) + + def _callIt(self, obj): + from deform.widget import serialize_js_config + return serialize_js_config(obj) + + def test_serialize_literal(self): + literal = self._makeLiteral('func()') + serialized = self._callIt(literal) + self.assertEqual(serialized, 'func()') + + def test_serialize_literal_in_object(self): + literal = self._makeLiteral('func()') + serialized = self._callIt({'x': literal}) + self.assertEqual(serialized, '{"x": func()}') + class TestAutocompleteInputWidget(unittest.TestCase): def _makeOne(self, **kw): from deform.widget import AutocompleteInputWidget @@ -288,6 +308,49 @@ def test_serialize_iterable(self): "minLength": 1, "limit": 8}) + def test_serialize_dataset(self): + import json + widget = self._makeOne() + dataset = {'local': [5,6,7,8], + 'minLength': 2, + } + widget.datasets = dataset + renderer = DummyRenderer() + schema = DummySchema() + field = DummyField(schema, renderer=renderer) + cstruct = 'abc' + widget.serialize(field, cstruct) + self.assertEqual(renderer.template, widget.template) + self.assertEqual(renderer.kw['field'], field) + self.assertEqual(renderer.kw['cstruct'], cstruct) + self.assertEqual(json.loads(renderer.kw['options']), + [{"local": [5,6,7,8], + "minLength": 2, + "limit": 8}]) + + def test_serialize_warns_if_datasets_and_values(self): + widget = self._makeOne() + widget.datasets = [{'local': [42]}] + widget.values = [9] + renderer = DummyRenderer() + schema = DummySchema() + field = DummyField(schema, renderer=renderer) + cstruct = 'abc' + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + widget.serialize(field, cstruct) + self.assertEqual(len(w), 1) + self.assertTrue(' ignores ' in str(w[0].message)) + + def test_serialize_datasets_type_error(self): + widget = self._makeOne() + widget.datasets = 2 + renderer = DummyRenderer() + schema = DummySchema() + field = DummyField(schema, renderer=renderer) + cstruct = 'abc' + self.assertRaises(TypeError, widget.serialize, field, cstruct) + def test_serialize_not_null_readonly(self): widget = self._makeOne() renderer = DummyRenderer() diff --git a/deform/widget.py b/deform/widget.py index a69f121b..8b7fde62 100644 --- a/deform/widget.py +++ b/deform/widget.py @@ -3,6 +3,7 @@ import csv import json import random +from warnings import warn from colander import ( Invalid, @@ -17,6 +18,7 @@ string_types, StringIO, string, + text_type, url_quote, uppercase, ) @@ -369,11 +371,37 @@ def deserialize(self, field, pstruct): return null return pstruct -class AutocompleteInputWidget(Widget): +class literal_js(text_type): + """ A marker class which can be used to include literal javascript within + the configurations for javascript functions. + """ + def __json__(self): + return self + +class JSConfigSerializer(json.JSONEncoder): + """ A hack-up of the stock python JSON encoder to support the inclusion + of literal javascript in the JSON output. + + """ + def _iterencode(self, o, markers): + if hasattr(o, '__json__') and callable(o.__json__): + return (chunk for chunk in (o.__json__(),)) + else: + return super(JSConfigSerializer, self)._iterencode(o, markers) + + def encode(self, o): + # bypass "extremely simple cases and benchmarks" optimization + chunks = list(self.iterencode(o)) + return ''.join(chunks) + +serialize_js_config = JSConfigSerializer().encode + +class AutocompleteInputWidget(Widget): + """ Renders an ```` widget which provides - autocompletion via a list of values using bootstrap's typeahead plugin - http://twitter.github.com/bootstrap/javascript.html#typeahead. + autocompletion via a list of values using twitters's typeahead plugin + https://github.com/twitter/typeahead.js **Attributes/Arguments** @@ -423,6 +451,30 @@ class AutocompleteInputWidget(Widget): The max number of items to display in the dropdown. Defaults to ``8``. + datasets + This is an alternative way to configure the typeahead + javascript plugin. Use of this parameter provides access to + the complete set of functionality provided by ``typeahead.js``. + + If set to other than ``None`` (the default), a JSONified + version of this value is used to configure the typeahead + plugin. (In this case any value specified for the ``values`` + parameter is ignored. The ``min_length`` and ``items`` + parameters will be used to fill in ``minLength`` and ``limit`` + in ``datasets`` if they are not already there.) + + Literal javascript can be included in the configuration using + the :class:`literal_js` marker. For example, assuming your + data provider yields datums that have an ``html`` attribute + containing HTML markup you want displayed for the choice:: + + datasets = { + 'remote': 'https://example.com/search.json?q=%QUERY', + 'template': + literal_js('function (datum) { return datum.html; }'), + } + + might do the trick. """ min_length = 1 readonly_template = 'readonly/textinput' @@ -432,6 +484,7 @@ class AutocompleteInputWidget(Widget): style = None template = 'autocomplete_input' values = None + datasets = None requirements = (('typeahead', None), ('deform', None)) def __init__(self, **kw): @@ -445,18 +498,37 @@ def serialize(self, field, cstruct, **kw): ) if cstruct in (null, None): cstruct = '' - self.values = self.values or [] readonly = kw.get('readonly', self.readonly) - options = {} - if isinstance(self.values, string_types): - options['remote'] = '%s?term=%%QUERY' % self.values + options = { + 'minLength': kw.pop('min_length', self.min_length), + 'limit': kw.pop('items', self.items), + } + + datasets = kw.pop('datasets', self.datasets) + if datasets is None: + if isinstance(self.values, string_types): + options['remote'] = '%s?term=%%QUERY' % self.values + datasets = options + elif self.values: + options['local'] = self.values + datasets = options else: - options['local'] = self.values + if self.values: + warn('AutocompleteWidget ignores the *values* parameter ' + 'when *datasets* is also specified.') - options['minLength'] = kw.pop('min_length', self.min_length) - options['limit'] = kw.pop('items', self.items) - kw['options'] = json.dumps(options) + if isinstance(datasets, dict): + datasets = [datasets] + elif not isinstance(datasets, (list, tuple)): + raise TypeError( + 'Expected list, dict, or tuple, not %r' % datasets) + + def set_defaults(dataset): + return dict(options, **dataset) + datasets = map(set_defaults, datasets) + + kw['options'] = serialize_js_config(datasets) if datasets else None tmpl_values = self.get_template_values(field, cstruct, kw) template = readonly and self.readonly_template or self.template return field.renderer(template, **tmpl_values)