Support dynamic inline formsets w/ select2 fields.

* Previously, when using {{ form.empty_form }} the inline js would
    automatically be called on the `empty_form`. This made it impossible
    to dynamically add inlines to the page because of how
    `__prefix__` is duplicated to the new inlines.

    This commit wraps all the inline js in a function which is attached
    to global `window.django_select2` plugin. On page load only non
    `empty_form`'s are initialized with select2, giving the developer to
    attach to the 'add new inline' click and call the `django_select2`
    plugin with the proper inline formset id. I am using this now with
    `django-superformset` and dynamically inserted inlines that contain
    select2 fields are working as expected.

    Additionally all the inline js for each formset is identicaly, a
    future cleanup could be to only inline the field and form id
    variables.

  * First converted `widgets.py` to Unix file type so the diff does not
    contain windows line endings.

  * Since all the inline js is now run post page load, we can put all of
    the`django_select2` js libraries at the page bottom with other js assets.
    refs #51

  * As a bonus, this removes all the js code generation libs from utils with
    json.dumps().

  * I have not tried formsets with the django admin, however this work
    will allow inline formsets support to be added to the admin with
    less developer effort than before.

Refs: #125, #65, #49, #32, #109
This commit is contained in:
Thomas Schreiber 2014-09-11 22:08:19 +02:00
parent a9df6ad390
commit 36bac0570f
2 changed files with 68 additions and 204 deletions

View file

@ -10,62 +10,6 @@ from django.utils.encoding import force_unicode
logger = logging.getLogger(__name__)
class JSVar(unicode):
"""
A JS variable.
This is a simple Unicode string. This class type acts as a marker that this string is a JS variable name,
so it must not be quoted by :py:func:`.convert_py_to_js_data` while rendering the JS code.
"""
pass
class JSFunction(JSVar):
"""
A JS function name.
From rendering point of view, rendering this is no different from :py:class:`JSVar`. After all, a JS variable
can refer a function instance, primitive constant or any other object. They are still all variables.
.. tip:: Do use this marker for JS functions. This will make the code clearer, and the purpose more easier to
understand.
"""
pass
class JSFunctionInContext(JSVar):
"""
A JS function name to run in context of some other HTML DOM element.
Like :py:class:`JSFunction`, this too flags the string as JS function, but with a special requirement. The JS function
needs to be invoked in the context of a HTML DOM, such that, ``this`` inside the function refers to that DOM instead of
``window``.
.. tip:: JS functions of this type are wrapped inside special another JS function -- ``django_select2.runInContextHelper``.
"""
pass
def render_js_script(inner_code):
"""
This wraps ``inner_code`` string inside the following code block::
<script type="text/javascript">
jQuery(function ($) {
// inner_code here
});
</script>
:rtype: :py:obj:`unicode`
"""
return u"""
<script type="text/javascript">
jQuery(function ($) {
%s
});
</script>""" % inner_code
def extract_some_key_val(dct, keys):
"""
Gets a sub-set of a :py:obj:`dict`.
@ -86,110 +30,6 @@ def extract_some_key_val(dct, keys):
return edct
def convert_to_js_str(val):
val = force_unicode(val).replace('\'', '\\\'')
return u"'%s'" % val
def convert_py_to_js_data(val, id_):
"""
Converts Python data type to JS data type.
Practically what this means is, convert ``False`` to ``false``, ``True`` to ``true`` and so on.
It also takes care of the conversion of :py:class:`.JSVar`, :py:class:`.JSFunction`
and :py:class:`.JSFunctionInContext`. It takes care of recursively converting lists and dictionaries
too.
:param val: The Python data to convert.
:type val: Any
:param id_: The DOM id of the element in which context :py:class:`.JSFunctionInContext` functions
should run. (This is not needed if ``val`` contains no :py:class:`.JSFunctionInContext`)
:type id_: :py:obj:`str`
:rtype: :py:obj:`unicode`
"""
if type(val) == types.BooleanType:
return u'true' if val else u'false'
elif type(val) in [types.IntType, types.LongType, types.FloatType]:
return force_unicode(val)
elif isinstance(val, JSFunctionInContext):
return u"django_select2.runInContextHelper(%s, '%s')" % (val, id_)
elif isinstance(val, JSVar):
return val # No quotes here
elif isinstance(val, dict):
return convert_dict_to_js_map(val, id_)
elif isinstance(val, list):
return convert_to_js_arr(val, id_)
else:
return convert_to_js_str(val)
def convert_dict_to_js_map(dct, id_):
"""
Converts a Python dictionary to JS map.
:param dct: The Python dictionary to convert.
:type dct: :py:obj:`dict`
:param id_: The DOM id of the element in which context :py:class:`.JSFunctionInContext` functions
should run. (This is not needed if ``dct`` contains no :py:class:`.JSFunctionInContext`)
:type id_: :py:obj:`str`
:rtype: :py:obj:`unicode`
"""
out = u'{'
is_first = True
for name in dct:
if not is_first:
out += u", "
else:
is_first = False
out += u"%s: " % convert_to_js_str(name)
out += convert_py_to_js_data(dct[name], id_)
return out + u'}'
def convert_to_js_arr(lst, id_):
"""
Converts a Python list (or any iterable) to JS array.
:param lst: The Python iterable to convert.
:type lst: :py:obj:`list` or Any iterable
:param id_: The DOM id of the element in which context :py:class:`.JSFunctionInContext` functions
should run. (This is not needed if ``lst`` contains no :py:class:`.JSFunctionInContext`)
:type id_: :py:obj:`str`
:rtype: :py:obj:`unicode`
"""
out = u'['
is_first = True
for val in lst:
if not is_first:
out += u", "
else:
is_first = False
out += convert_py_to_js_data(val, id_)
return out + u']'
def convert_to_js_string_arr(lst):
"""
Converts a Python list (or any iterable) of strings to JS array.
:py:func:`convert_to_js_arr` can always be used instead of this. However, since it
knows that it only contains strings, it cuts down on unnecessary computations.
:rtype: :py:obj:`unicode`
"""
lst = [convert_to_js_str(l) for l in lst]
return u"[%s]" % (",".join(lst))
### Auto view helper utils ###
from . import __ENABLE_MULTI_PROCESS_SUPPORT as ENABLE_MULTI_PROCESS_SUPPORT, \

View file

@ -1,9 +1,10 @@
"""
Contains all the Django widgets for Select2.
"""
import json
import logging
from itertools import chain
import re
import util
from django import forms
@ -13,9 +14,6 @@ from django.utils.safestring import mark_safe
from django.core.urlresolvers import reverse
from django.utils.datastructures import MultiValueDict, MergeDict
from .util import render_js_script, convert_to_js_string_arr, JSVar, JSFunction, JSFunctionInContext, \
convert_dict_to_js_map, convert_to_js_arr
from . import __RENDER_SELECT2_STATICS as RENDER_SELECT2_STATICS
logger = logging.getLogger(__name__)
@ -154,14 +152,10 @@ class Select2Mixin(object):
Example::
def init_options(self):
self.options['createSearchChoice'] = JSFunction('Your_js_function')
self.options['createSearchChoice'] = 'Your_js_function'
In the above example we are setting ``Your_js_function`` as Select2's ``createSearchChoice``
function.
.. tip:: If you want to run ``Your_js_function`` in the context of the Select2 DOM element,
i.e. ``this`` inside your JS function should point to the component instead of ``window``, then
use :py:class:`~.util.JSFunctionInContext` instead of :py:class:`~.util.JSFunction`.
"""
pass
@ -184,15 +178,6 @@ class Select2Mixin(object):
options['allowClear'] = not self.is_required
return options
def render_select2_options_code(self, options, id_):
"""
Renders options for Select2 JS.
:return: The rendered JS code.
:rtype: :py:obj:`unicode`
"""
return convert_dict_to_js_map(options, id_)
def render_js_code(self, id_, *args):
"""
Renders the ``<script>`` block which contains the JS code for this widget.
@ -201,9 +186,29 @@ class Select2Mixin(object):
:rtype: :py:obj:`unicode`
"""
if id_:
return render_js_script(self.render_inner_js_code(id_, *args))
return self.render_js_script(self.render_inner_js_code(id_, *args))
return u''
def render_js_script(self, inner_code):
"""
This wraps ``inner_code`` string inside the following code block::
<script type="text/javascript">
jQuery(function ($) {
// inner_code here
});
</script>
:rtype: :py:obj:`unicode`
"""
return u"""
<script type="text/javascript">
jQuery(function ($) {
%s
});
</script>
""" % inner_code
def render_inner_js_code(self, id_, *args):
"""
Renders all the JS code required for this widget.
@ -211,10 +216,10 @@ class Select2Mixin(object):
:return: The rendered JS code which will be later enclosed inside ``<script>`` block.
:rtype: :py:obj:`unicode`
"""
options = dict(self.get_options())
options = self.render_select2_options_code(options, id_)
return u'$("#%s").select2(%s);' % (id_, options)
options = json.dumps(self.get_options())
options = options.replace('"*START*', '').replace('*END*"', '')
# selector variable must already be passed to this
return u'$(hashedSelector).select2(%s);' % (options)
def render(self, name, value, attrs=None, choices=()):
"""
@ -312,10 +317,9 @@ class MultipleSelect2HiddenInput(forms.TextInput):
if id_:
jscode = u''
if value:
jscode = u"$('#%s').val(django_select2.convertArrToStr(%s));" \
% (id_, convert_to_js_arr(value, id_))
jscode = u'$("#%s").val(django_select2.convertArrToStr(%s));' % (id_, json.dumps(value))
jscode += u"django_select2.initMultipleHidden($('#%s'));" % id_
s += render_js_script(jscode)
s += self.render_js_script(jscode)
return mark_safe(s)
def value_from_datadict(self, data, files, name):
@ -343,12 +347,12 @@ class HeavySelect2Mixin(Select2Mixin):
This mixin adds more Select2 options to :py:attr:`.Select2Mixin.options`. These are:-
* minimumInputLength: ``2``
* initSelection: ``JSFunction('django_select2.onInit')``
* initSelection: ``'django_select2.onInit'``
* ajax:
* dataType: ``'json'``
* quietMillis: ``100``
* data: ``JSFunctionInContext('django_select2.get_url_params')``
* results: ``JSFunctionInContext('django_select2.process_results')``
* data: ``'django_select2.get_url_params'``
* results: ``'django_select2.process_results'``
.. tip:: You can override these options by passing ``select2_options`` kwarg to :py:meth:`.__init__`.
"""
@ -409,11 +413,11 @@ class HeavySelect2Mixin(Select2Mixin):
self.options['ajax'] = {
'dataType': 'json',
'quietMillis': 100,
'data': JSFunctionInContext('django_select2.get_url_params'),
'results': JSFunctionInContext('django_select2.process_results'),
'data': '*START*django_select2.runInContextHelper(django_select2.get_url_params, selector)*END*',
'results': '*START*django_select2.runInContextHelper(django_select2.process_results, selector)*END*',
}
self.options['minimumInputLength'] = 2
self.options['initSelection'] = JSFunction('django_select2.onInit')
self.options['initSelection'] = '*START*django_select2.onInit*END*'
super(HeavySelect2Mixin, self).__init__(**kwargs)
def render_texts(self, selected_choices, choices):
@ -456,7 +460,7 @@ class HeavySelect2Mixin(Select2Mixin):
if txt is not None:
txts.append(txt)
if txts:
return convert_to_js_string_arr(txts)
return json.dumps(txts)
def get_options(self):
if self.url is None:
@ -495,8 +499,7 @@ class HeavySelect2Mixin(Select2Mixin):
return u"$('#%s').txt(%s);" % (id_, texts)
def render_inner_js_code(self, id_, name, value, attrs=None, choices=(), *args):
js = u"$('#%s').change(django_select2.onValChange).data('userGetValText', %s);" \
% (id_, self.userGetValTextFuncName)
js = u'$(hashedSelector).change(django_select2.onValChange).data("userGetValText", null);'
texts = self.render_texts_for_value(id_, value, choices)
if texts:
js += texts
@ -534,7 +537,7 @@ class HeavySelect2MultipleWidget(HeavySelect2Mixin, MultipleSelect2HiddenInput):
Following Select2 options from :py:attr:`.Select2Mixin.options` are added or set:-
* multiple: ``True``
* separator: ``JSVar('django_select2.MULTISEPARATOR')``
* separator: ``django_select2.MULTISEPARATOR``
"""
@ -542,7 +545,7 @@ class HeavySelect2MultipleWidget(HeavySelect2Mixin, MultipleSelect2HiddenInput):
self.options['multiple'] = True
self.options.pop('allowClear', None)
self.options.pop('minimumResultsForSearch', None)
self.options['separator'] = JSVar('django_select2.MULTISEPARATOR')
self.options['separator'] = '*START*django_select2.MULTISEPARATOR*END*'
def render_texts_for_value(self, id_, value, choices):
"""
@ -566,7 +569,8 @@ class HeavySelect2MultipleWidget(HeavySelect2Mixin, MultipleSelect2HiddenInput):
if value:
texts = self.render_texts(value, choices)
if texts:
return u"$('#%s').txt(%s);" % (id_, texts)
return u'$("#%s").txt(%s);' % (id_, texts)
class HeavySelect2TagWidget(HeavySelect2MultipleWidget):
"""
@ -582,10 +586,10 @@ class HeavySelect2TagWidget(HeavySelect2MultipleWidget):
Following Select2 options from :py:attr:`.Select2Mixin.options` are added or set:-
* multiple: ``True``
* separator: ``JSVar('django_select2.MULTISEPARATOR')``
* separator: ``django_select2.MULTISEPARATOR``
* tags: ``True``
* tokenSeparators: ``,`` and `` ``
* createSearchChoice: ``JSFunctionInContext('django_select2.createSearchChoice')``
* createSearchChoice: ``django_select2.createSearchChoice``
* minimumInputLength: ``1``
"""
@ -595,7 +599,8 @@ class HeavySelect2TagWidget(HeavySelect2MultipleWidget):
self.options['minimumInputLength'] = 1
self.options['tags'] = True
self.options['tokenSeparators'] = [",", " "]
self.options['createSearchChoice'] = JSFunctionInContext('django_select2.createSearchChoice')
self.options['createSearchChoice'] = '*START*django_select2.createSearchChoice*END*'
### Auto Heavy widgets ###
@ -608,6 +613,15 @@ class AutoHeavySelect2Mixin(object):
:py:func:`~.util.register_field` when the Auto field is registered. The client side (DOM) sends this
id along with the Ajax request, so that the central view can identify which field should be used to
serve the request.
The js call to dynamically add the `django_select2` is as follows::
django_select2.id_cities('id_cities', django_select2.id_cities_field_id);
For an inline formset::
django_select2.id_musician_set_name(
'id_musician_set-0-name', django_select2.id_musician_set_name_field_id);
"""
def __init__(self, *args, **kwargs):
@ -615,9 +629,19 @@ class AutoHeavySelect2Mixin(object):
super(AutoHeavySelect2Mixin, self).__init__(*args, **kwargs)
def render_inner_js_code(self, id_, *args):
js = u"$('#%s').data('field_id', '%s');" % (id_, self.field_id)
js += super(AutoHeavySelect2Mixin, self).render_inner_js_code(id_, *args)
return js
fieldset_compatible_id = re.sub(r'-\d+-', '_', id_)
if '__prefix__' in id_:
return ''
else:
js = u'''
window.django_select2.%s = function (selector, fieldID) {
var hashedSelector = "#" + selector;
$(hashedSelector).data("field_id", fieldID);
''' % (fieldset_compatible_id)
js += super(AutoHeavySelect2Mixin, self).render_inner_js_code(id_, *args)
js += '};'
js += 'django_select2.%s("%s", "%s");' % (fieldset_compatible_id, id_, self.field_id)
return js
class AutoHeavySelect2Widget(AutoHeavySelect2Mixin, HeavySelect2Widget):