Commits

Mark Lavin committed 1e17a4a Merge

Merging the latest default work into stable.

Comments (0)

Files changed (26)

 0000000000000000000000000000000000000000 version-0.2.0
 0000000000000000000000000000000000000000 version-0.2.0
 c6671545c9e32bf1635c684f16ee7fb645cae7dd version-0.2.0
+b0e1a2bcc104a8951db42f809aced3d3c9d018e1 version-0.3.0
 f04a62d8741f8ed6ff02da2ef9754b634b0b861d version-0.3.1
 The following people who have contributed to django-selectable:
 
 Colin Copeland
+Dan Poirier
 Karen Tracey
 
 Thanks for all of your work!
 - `v0.1.2 <http://readthedocs.org/docs/django-selectable/en/version-0.1.2/>`_
 
 
+Additional Help/Support
+-----------------------------------
+
+You can find additional help or support on the mailing list: http://groups.google.com/group/django-selectable
+
 
 Contributing
 --------------------------------------
 fields and widgets in the admin make sure you are familiar with the Django
 documentation on the `ModelAdmin.form <http://docs.djangoproject.com/en/1.3/ref/contrib/admin/#django.contrib.admin.ModelAdmin.form>`_ 
 and `ModelForms <http://docs.djangoproject.com/en/1.3/topics/forms/modelforms/>`_ particularly
-on `overriding the default widgets <http://docs.djangoproject.com/en/1.3/topics/forms/modelforms/#overriding-the-default-field-types-or-widgets>`_. As you will see integrating Django-Selectables in the admin
-is the same as working with regular forms.
+on `overriding the default widgets <http://docs.djangoproject.com/en/1.3/topics/forms/modelforms/#overriding-the-default-field-types-or-widgets>`_. 
+As you will see integrating django-selectable in the adminis the same as working with regular forms.
 
 .. _admin-jquery-include:
 
 the ``ModelForm`` will no longer do this for you. Since ``fruit`` does not allow new
 items you'll see these steps are not necessary.
 
+.. versionadded:: 0.4
+
+The django-selectable widgets are compatitible with the add anther popup in the
+admin. It's that little green plus sign that appears next to ``ForeignKey`` or
+``ManyToManyField`` items. This makes django-selectable a user friendly replacement
+for the `ModelAdmin.raw_id_fields <https://docs.djangoproject.com/en/1.3/ref/contrib/admin/#django.contrib.admin.ModelAdmin.raw_id_fields>`_ 
+when the default select box grows too long.
+
 
 .. _admin-inline-example:
 

docs/advanced.rst

 use of jQuery.
 
 
+.. _additional-parameters:
+
 Additional Parameters
 --------------------------------------
 
                 super(FruitForm, self).__init__(*args, **kwargs)
                 self.fields['autocomplete'].widget.update_query_parameters({'foo': 'bar'})
 
+.. versionadded:: 0.4
+
+You can also pass the query parameters into the widget using the ``query_params``
+keyword argument. It depends on your use case as to whether the parameters are
+know when the form is defined or when an instance of the form is created.
+
 
 .. _client-side-parameters:
 
 # built documents.
 #
 # The short X.Y version.
-version = '0.3'
+version = '0.4'
 # The full version, including alpha/beta/rc tags.
-release = '0.3.1'
+release = '0.4.0dev'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.

docs/contribute.rst

 
     python selectable/tests/runtests.py
 
-Tests for the client side code is planned. If javascript testing is something you
-are familiar with then it would be a great help to this project.
+Client side tests are written using `QUnit <http://docs.jquery.com/QUnit>`_. They
+can be found in `selectable/tests/qunit/`.
 
 
 Building the Documentation

docs/quick-start.rst

 
         registry.register(FruitLookup)
 
+.. note::
+
+    You should only register your lookup once. Attempting to register the same lookup class
+    more than once will lead to ``LookupAlreadyRegistered`` errors. A common problem related to the
+    ``LookupAlreadyRegistered`` error is related to inconsistant import paths in your project.
+    Prior to Django 1.4 the default ``manage.py`` allows for importing both with and without
+    the project name (i.e. ``from myproject.myapp import lookups`` or ``from myapp import lookups``).
+    This leads to the ``lookup.py`` file being imported twice and the registration code
+    executing twice. Thankfully this is no longer the default in Django 1.4. Keeping
+    your import consistant to include the project name (when your app is included inside the
+    project directory) will avoid these errors.
+
 
 Defining Forms
 --------------------------------

docs/releases.rst

 Release Notes
 ==================
 
+v0.4.0 (Released TBD)
+--------------------------------------
+
+Features
+_________________
+
+- Better compatibility with :ref:`AutoCompleteSelectWidget`/:ref:`AutoComboboxSelectWidget` and Django's ModelChoiceField
+- Better compatibility with the Django admin :ref:`add another popup <admin-basic-example>`
+- Easier passing of query parameters. See the :ref:`Additional Parameters <additional-parameters>` section
+- Additional documentation
+- QUnit tests for JS functionality
+
+
+Backwards Incompatible Changes
+________________________________
+
+- Support for ``ModelLookup.search_field`` string has been removed. You should use the ``ModelLookup.search_fields`` tuple instead.
+
+
 v0.3.1 (Released 2012-02-23)
 --------------------------------------
 

docs/settings.rst

 SELECTABLE_MAX_LIMIT
 --------------------------------------
 
-.. versionadded:: 0.2
-
 This setting is used to limit the number of results returned by the auto-complete fields.
 Each field/widget can individually lower this maximum. The result sets will be
 paginated allowing the client to ask for more results. The limit is passed as a
 removeIcon
 ______________________________________
 
-.. versionadded:: 0.2
 
 This is the class name used for the remove buttons for the multiple select widgets.
 The set of icon classes built into the jQuery UI framework can be found here:
 comboboxIcon
 ______________________________________
 
-.. versionadded:: 0.2
 
 This is the class name used for the combobox dropdown icon. The set of icon classes built 
 into the jQuery UI framework can be found here: http://jqueryui.com/themeroller/
 prepareQuery
 ______________________________________
 
-.. versionadded:: 0.2
 
 ``prepareQuery`` is a function that is run prior to sending the search request to
 the server. It is an oppotunity to add additional parameters to the search query.
 Below are the custom widgets defined by Django-Selectable. All widgets take the 
 lookup class as the first required argument.
 
+.. versionadded:: 0.4
+
+These widgets all support a ``query_params`` keyword argument which is used to pass
+additional query parameters to the lookup search. See the section on 
+:ref:`Adding Parameters on the Server Side <server-side-parameters>` for more
+information.
+
 
 .. _AutoCompleteWidget:
 
 This widget should be used in conjunction with the :ref:`AutoCompleteSelectField` as it will
 return both the text entered by the user and the id (if an item was selected/matched).
 
+.. versionadded:: 0.4
+
+Prior to 0.4 :ref:`AutoCompleteSelectWidget` could not work directly with Django's
+`ModelChoiceField <https://docs.djangoproject.com/en/1.3/ref/forms/fields/#modelchoicefield>`_.
+Starting with 0.4 you can simply replace the widget without replacing the entire field.
+
+    .. code-block:: python
+
+        class FarmAdminForm(forms.ModelForm):
+
+            class Meta(object):
+                model = Farm
+                widgets = {
+                    'owner': selectable.AutoCompleteSelectWidget(lookup_class=FruitLookup),
+                }
+
+The one catch is that you must use ``allow_new=False`` which is the default.
+
 
 .. _AutoComboboxSelectWidget:
 
 
 Similar to :ref:`AutoCompleteSelectWidget` but has a button to reveal all options.
 
+.. versionadded:: 0.4
+
+Prior to 0.4 :ref:`AutoComboboxSelectWidget` could not work directly with Django's
+`ModelChoiceField <https://docs.djangoproject.com/en/1.3/ref/forms/fields/#modelchoicefield>`_.
+Starting with 0.4 you can simply replace the widget without replacing the entire field.
+
+    .. code-block:: python
+
+        class FarmAdminForm(forms.ModelForm):
+
+            class Meta(object):
+                model = Farm
+                widgets = {
+                    'owner': selectable.AutoComboboxSelectWidget(lookup_class=FruitLookup),
+                }
+
+The one catch is that you must use ``allow_new=False`` which is the default.
+
 
 .. _AutoCompleteSelectMultipleWidget:
 

selectable/__init__.py

 
 __version_info__ = {
     'major': 0,
-    'minor': 3,
-    'micro': 1,
-    'releaselevel': 'final',
+    'minor': 4,
+    'micro': 0,
+    'releaselevel': 'dev',
 }
 
 def get_version():

selectable/base.py

 class ModelLookup(LookupBase):
     model = None
     filters = {}
-    search_field = ''
     search_fields = ()
 
-    def __init__(self):
-        super(ModelLookup, self).__init__()
-        if self.search_field and not self.search_fields:
-            self.search_fields = (self.search_field, )
-
     def get_query(self, request, term):
         qs = self.get_queryset()
         if term:

selectable/forms/fields.py

     def to_python(self, value):
         if value in EMPTY_VALUES:
             return None
+        lookup =self.lookup_class()
         if isinstance(value, list):
             # Input comes from an AutoComplete widget. It's two
             # components: text and id
             if len(value) != 2:
                 raise ValidationError(self.error_messages['invalid_choice'])
-            lookup =self.lookup_class()
             if value[1] in EMPTY_VALUES:
                 if not self.allow_new:
                     if value[0]:
                 value = lookup.get_item(value[1])
                 if value is None:
                     raise ValidationError(self.error_messages['invalid_choice'])
+        else:
+            value = lookup.get_item(value)
+            if value is None:
+                raise ValidationError(self.error_messages['invalid_choice'])
         return value
 
 

selectable/forms/widgets.py

     def __init__(self, lookup_class, *args, **kwargs):
         self.lookup_class = lookup_class
         self.allow_new = kwargs.pop('allow_new', False)
-        self.qs = {}
+        self.qs = kwargs.pop('query_params', {})
         self.limit = kwargs.pop('limit', None)
         super(AutoCompleteWidget, self).__init__(*args, **kwargs)
 
         self.lookup_class = lookup_class
         self.allow_new = kwargs.pop('allow_new', False)
         self.limit = kwargs.pop('limit', None)
+        query_params = kwargs.pop('query_params', {})
         widgets = [
-            AutoCompleteWidget(lookup_class, allow_new=self.allow_new, limit=self.limit),
+            AutoCompleteWidget(
+                lookup_class, allow_new=self.allow_new,
+                limit=self.limit, query_params=query_params
+            ),
             forms.HiddenInput(attrs={u'data-selectable-type': 'hidden'})
         ]
         super(AutoCompleteSelectWidget, self).__init__(widgets, *args, **kwargs)
             return [item_value, value]
         return [None, None]
 
+    def value_from_datadict(self, data, files, name):
+        value = super(AutoCompleteSelectWidget, self).value_from_datadict(data, files, name)
+        if not self.allow_new:
+            return value[1]
+        return value
+
 
 class AutoComboboxWidget(AutoCompleteWidget, SelectableMediaMixin):
 
         self.lookup_class = lookup_class
         self.allow_new = kwargs.pop('allow_new', False)
         self.limit = kwargs.pop('limit', None)
+        query_params = kwargs.pop('query_params', {})
         widgets = [
-            AutoComboboxWidget(lookup_class, allow_new=self.allow_new, limit=self.limit),
+            AutoComboboxWidget(
+                lookup_class, allow_new=self.allow_new,
+                limit=self.limit, query_params=query_params
+            ),
             forms.HiddenInput(attrs={u'data-selectable-type': 'hidden'})
         ]
         super(AutoComboboxSelectWidget, self).__init__(widgets, *args, **kwargs)
             return [item_value, value]
         return [None, None]
 
+    def value_from_datadict(self, data, files, name):
+        value = super(AutoComboboxSelectWidget, self).value_from_datadict(data, files, name)
+        if not self.allow_new:
+            return value[1]
+        return value
+
 
 class LookupMultipleHiddenInput(forms.MultipleHiddenInput):
 
             u'data-selectable-multiple': 'true',
             u'data-selectable-position': position
         }
+        query_params = kwargs.pop('query_params', {})
         widgets = [
-            AutoCompleteWidget(lookup_class, allow_new=False, limit=self.limit, attrs=attrs),
+            AutoCompleteWidget(
+                lookup_class, allow_new=False,
+                limit=self.limit, query_params=query_params, attrs=attrs
+            ),
             LookupMultipleHiddenInput(lookup_class)
         ]
         super(AutoCompleteSelectMultipleWidget, self).__init__(widgets, *args, **kwargs)
             u'data-selectable-multiple': 'true',
             u'data-selectable-position': position
         }
+        query_params = kwargs.pop('query_params', {})
         widgets = [
-            AutoComboboxWidget(lookup_class, allow_new=False, limit=self.limit, attrs=attrs),
+            AutoComboboxWidget(
+                lookup_class, allow_new=False,
+                limit=self.limit, query_params=query_params, attrs=attrs
+            ),
             LookupMultipleHiddenInput(lookup_class)
         ]
         super(AutoComboboxSelectMultipleWidget, self).__init__(widgets, *args, **kwargs)

selectable/registry.py

     def validate(self, lookup):
         if not issubclass(lookup, LookupBase):
             raise LookupInvalid(u'Registered lookups must inherit from the LookupBase class')
-        if issubclass(lookup, ModelLookup) and getattr(lookup, 'search_field'):
+        if issubclass(lookup, ModelLookup) and getattr(lookup, 'search_field', None):
             import warnings
             warnings.warn(
                 u"ModelLookup.search_field is deprecated; Use ModelLookup.search_fields instead.", 

selectable/static/selectable/css/dj.selectable.css

+/* 
+ * django-selectable UI widget CSS
+ * Source: https://bitbucket.org/mlavin/django-selectable
+ * Docs: http://django-selectable.readthedocs.org/
+ *
+ * Copyright 2010-2012, Mark Lavin
+ * BSD License
+ *
+*/
 ul.selectable-deck, ul.ui-autocomplete {
     list-style: none outside none;
 }

selectable/static/selectable/js/jquery.dj.selectable.js

+/* 
+ * django-selectable UI widget
+ * Source: https://bitbucket.org/mlavin/django-selectable
+ * Docs: http://django-selectable.readthedocs.org/
+ *
+ * Depends:
+ *   - jQuery 1.4.3+
+ *   - jQuery UI 1.8 widget factory
+ *
+ * Copyright 2010-2012, Mark Lavin
+ * BSD License
+ *
+*/
 (function($) {
 
 	$.widget("ui.djselectable", {
             formatLabel: null
         },
         
-        _initDeck: function(hiddenInputs) {
+        _initDeck: function() {
+            /* Create list display for currently selected items for multi-select */
             var self = this;
             var data = $(this.element).data();
             var style = data.selectablePosition || data['selectable-position'] || 'bottom';
             } else {
                 $(this.element).before(this.deck);
             }
-            $(hiddenInputs).each(function(i, input) {
+            $(self.hiddenMultipleSelector).each(function(i, input) {
                 self._addDeckItem(input);
             });
         },
 
         _addDeckItem: function(input) {
+            /* Add new deck list item from a given hidden input */
             var self = this;
             $('<li>')
             .text($(input).attr('title'))
             );
         },
 
-        _create: function() {
+        select: function(item) {
+            /* Trigger selection of a given item.
+            Item should contain two properties: id and value */
             var self = this,
             input = this.element;
-            var data = $(input).data();
-            var allowNew = data.selectableAllowNew || data['selectable-allow-new'];
-            var allowMultiple = data.selectableMultiple || data['selectable-multiple'];
-            var textName = $(input).attr('name');
-            var hiddenName = textName.replace('_0', '_1');
-            var hiddenSelector = 'input[type=hidden][data-selectable-type=hidden-multiple][name=' + hiddenName + ']';
-            if (allowMultiple) {
-                allowNew = false;
+            $(input).removeClass('ui-state-error');
+            if (item) {
+                if (self.allowMultiple) {
+                    $(input).val("");
+                    $(input).data("autocomplete").term = "";
+                    if ($(self.hiddenMultipleSelector + '[value=' + item.id + ']').length === 0) {
+                        var newInput = $('<input />', {
+                            'type': 'hidden',
+                            'name': self.hiddenName,
+                            'value': item.id,
+                            'title': item.value,
+                            'data-selectable-type': 'hidden-multiple'
+                        });
+                        $(input).after(newInput);
+                        self._addDeckItem(newInput);
+                        return false;
+                    }
+                } else {
+                    $(input).val(item.value);
+                    var ui = {item: item};
+                    $(input).trigger('autocompleteselect', [ui ]);
+                }
+            }
+        },
+
+        _create: function() {
+            /* Initialize a new selectable widget */
+            var self = this,
+            input = this.element,
+            data = $(input).data();
+            self.allowNew = data.selectableAllowNew || data['selectable-allow-new'];
+            self.allowMultiple = data.selectableMultiple || data['selectable-multiple'];
+            self.textName = $(input).attr('name');
+            self.hiddenName = self.textName.replace('_0', '_1');
+            self.hiddenSelector = ':input[data-selectable-type=hidden][name=' + self.hiddenName + ']';
+            self.hiddenMultipleSelector = ':input[data-selectable-type=hidden-multiple][name=' + self.hiddenName + ']';
+            if (self.allowMultiple) {
+                self.allowNew = false;
                 $(input).val("");
-                this._initDeck(hiddenSelector);
+                this._initDeck();
             }
 
             function dataSource(request, response) {
+                /* Custom data source to uses the lookup url with pagination
+                Adds hook for adjusting query parameters.
+                Includes timestamp to prevent browser caching the lookup. */
                 var url = data.selectableUrl || data['selectable-url'];
                 var now = new Date().getTime();
                 var query = {term: request.term, timestamp: now};
                 }
 				$.getJSON(url, query, response);
             }
-
+            // Create base auto-complete lookup
             $(input).autocomplete({
                 source: dataSource,
                 change: function(event, ui) {
                     $(input).removeClass('ui-state-error');
                     if ($(input).val() && !ui.item) {
-                        if (!allowNew) {
+                        if (!self.allowNew) {
                             $(input).addClass('ui-state-error');
                         } 
                     } 
-                    if (allowMultiple && !$(input).hasClass('ui-state-error')) {
+                    if (self.allowMultiple && !$(input).hasClass('ui-state-error')) {
                         $(input).val("");
-	                    $(input).data("autocomplete").term = "";
+                        $(input).data("autocomplete").term = "";
                     }
                 },
                 select: function(event, ui) {
                     $(input).removeClass('ui-state-error');
                     if (ui.item && ui.item.page) {
-                        $(input).data("page", ui.item.page);
+                        // Set current page value
+                        $(input).data("page", ui.tem.page);
                         $('.selectable-paginator', self.menu).remove();
+                        // Search for next page of results
                         $(input).autocomplete("search");
                         return false;
                     }
-                    if (ui.item && allowMultiple) {
-                        $(input).val("");
-		                $(input).data("autocomplete").term = "";
-                        if ($(hiddenSelector + '[value=' + ui.item.id + ']').length === 0) {
-                            var newInput = $('<input />', {
-                                'type': 'hidden',
-                                'name': hiddenName,
-                                'value': ui.item.id,
-                                'title': ui.item.value,
-                                'data-selectable-type': 'hidden-multiple'
-                            });
-                            $(input).after(newInput);
-                            self._addDeckItem(newInput);
-                            return false;
-                        }
-                    }
+                    self.select(ui.item);
                 }
             }).addClass("ui-widget ui-widget-content ui-corner-all");
+            // Override the default auto-complete render.
             $(input).data("autocomplete")._renderItem = function(ul, item) {
+                /* Adds hook for additional formatting, allows HTML in the label,
+                highlights term matches and handles pagination. */
                 var label = item.label;
                 if (self.options.formatLabel) {
                     label = self.options.formatLabel(label, item);
                     label = label.replace(re, "<span class='highlight'>$1</span>");
                 }
                 var li =  $("<li></li>")
-			        .data("item.autocomplete", item)
-			        .append($("<a></a>").append(label))
-			        .appendTo(ul);
+                    .data("item.autocomplete", item)
+                    .append($("<a></a>").append(label))
+                    .appendTo(ul);
                 if (item.page) {
                     li.addClass('selectable-paginator');
                 }
-	            return li;
+                return li;
             };
+            // Override the default auto-complete suggest.
             $(input).data("autocomplete")._suggest = function(items) {
+                /* Needed for handling pagination links */
                 var page = $(input).data('page');
                 var ul = this.menu.element;
                 if (!page) {
                     ul.empty();
                 }
                 $(input).data('page', null);
-			    ul.zIndex(this.element.zIndex() + 1);
-		        this._renderMenu(ul, items);
-	            this.menu.deactivate();
+                ul.zIndex(this.element.zIndex() + 1);
+                this._renderMenu(ul, items);
+                this.menu.deactivate();
                 this.menu.refresh();
-		        // size and position menu
-		        ul.show();
-		        this._resizeMenu();
-		        ul.position($.extend({of: this.element}, this.options.position));
-		        if (this.options.autoFocus) {
-			        this.menu.next(new $.Event("mouseover"));
-		        }
-	        };
+                // size and position menu
+                ul.show();
+                this._resizeMenu();
+                ul.position($.extend({of: this.element}, this.options.position));
+                if (this.options.autoFocus) {
+                    this.menu.next(new $.Event("mouseover"));
+                }
+            };
+            // Additional work for combobox widgets
             var selectableType = data.selectableType || data['selectable-type'];
             if (selectableType === 'combobox') {
                 // Change auto-complete options
                 })
                 .removeClass("ui-corner-all")
                 .addClass("ui-corner-left ui-combo-input");
-
+                // Add show all items button
                 $("<button>&nbsp;</button>").attr("tabIndex", -1).attr("title", "Show All Items")
                 .insertAfter($(input))
                 .button({
                     text: false
                 })
                 .removeClass("ui-corner-all")
-		        .addClass("ui-corner-right ui-button-icon ui-combo-button")
+                .addClass("ui-corner-right ui-button-icon ui-combo-button")
                 .click(function() {
                     // close if already visible
                     if ($(input).autocomplete("widget").is(":visible")) {
                         $(input).autocomplete("close");
                         return false;
                     }
-
                     // pass empty string as value to search for, displaying all results
                     $(input).autocomplete("search", "");
                     $(input).focus();
 })(jQuery);
 
 function bindSelectables(context) {
+    /* Bind all selectable widgets in a given context.
+    Automatically called on document.ready.
+    Additional calls can be made for dynamically added widgets.
+    */
     $(":input[data-selectable-type=text]", context).djselectable();
     $(":input[data-selectable-type=combobox]", context).djselectable();
     $(":input[data-selectable-type=hidden]", context).each(function(i, elem) {
     });
 }
 
-if (typeof(django) != "undefined" && typeof(django.jQuery) != "undefined") {
+/* Monkey-patch Django's dynamic formset, if defined */
+if (typeof(django) !== "undefined" && typeof(django.jQuery) !== "undefined") {
     if (django.jQuery.fn.formset) {
         var oldformset = django.jQuery.fn.formset;
-	    django.jQuery.fn.formset = function(opts) {
+        django.jQuery.fn.formset = function(opts) {
             var options = $.extend({}, opts);
             var addedevent = function(row) {
                 bindSelectables($(row));
             };
             var added = null;
             if (options.added) {
+                // Wrap previous added function and include call to bindSelectables
                 var oldadded = options.added;
                 added = function(row) { oldadded(row); addedevent(row); };
             }
     }
 }
 
+/* Monkey-patch Django's dismissAddAnotherPopup(), if defined */
+if (typeof(dismissAddAnotherPopup) !== "undefined" && typeof(windowname_to_id) !== "undefined" && typeof(html_unescape) !== "undefined") {
+    var django_dismissAddAnotherPopup = dismissAddAnotherPopup;
+    dismissAddAnotherPopup = function(win, newId, newRepr) {
+        /* See if the popup came from a selectable field.
+           If not, pass control to Django's code.
+           If so, handle it. */
+        var fieldName = windowname_to_id(win.name); /* e.g. "id_fieldname" */
+        var field = $('#' + fieldName);
+        var multiField = $('#' + fieldName + '_0');
+        /* Check for bound selectable */
+        var singleWidget = field.data('djselectable');
+        var multiWidget = multiField.data('djselectable');
+        if (singleWidget || multiWidget) {
+            // newId and newRepr are expected to have previously been escaped by
+            // django.utils.html.escape.
+            var item =  {
+                id: html_unescape(newId),
+                value: html_unescape(newRepr)
+            };
+            if (singleWidget) {
+                field.djselectable('select', item);
+            }
+            if (multiWidget) {
+                multiField.djselectable('select', item);
+            }
+            win.close();
+        } else {
+            /* Not ours, pass on to original function. */
+            return django_dismissAddAnotherPopup(win, newId, newRepr);
+        }
+    };
+}
+
 $(document).ready(function() {
-    bindSelectables('body');
+    // Bind existing widgets on document ready
+    if (typeof(djselectableAutoLoad) === "undefined" || djselectableAutoLoad) {
+        bindSelectables('body');
+    }
 });

selectable/tests/__init__.py

 
 class ThingLookup(ModelLookup):
     model = Thing
-    search_field = 'name__icontains'
+    search_fields = ('name__icontains', )
 
 
 registry.register(ThingLookup)

selectable/tests/base.py

 __all__ = (
     'ModelLookupTestCase',
     'MultiFieldLookupTestCase',
-    'LegacyModelLookupTestCase',
 )
 
 
         qs = lookup.get_query(request=None, term='other')
         self.assertTrue(thing.pk not in qs.values_list('id', flat=True))
         self.assertTrue(other_thing.pk in qs.values_list('id', flat=True))
-
-
-class LegacyModelLookup(ModelLookup):
-    model = Thing
-    search_field = 'name__icontains'
-
-
-class LegacyModelLookupTestCase(ModelLookupTestCase):
-    lookup_cls = LegacyModelLookup
-
-    def test_get_name(self):
-        name = self.__class__.lookup_cls.name()
-        self.assertEqual(name, 'tests-legacymodellookup')
-
-    def test_get_url(self):
-        url = self.__class__.lookup_cls.url()
-        test_url = reverse('selectable-lookup', args=['tests-legacymodellookup'])
-        self.assertEqual(url, test_url)

selectable/tests/functests.py

 from django import forms
 
 from selectable.forms import AutoCompleteSelectField
+from selectable.forms import AutoCompleteSelectWidget, AutoComboboxSelectWidget
 from selectable.tests import OtherThing, ThingLookup
 from selectable.tests.base import BaseSelectableTestCase
 
 
 __all__ = (
     'FuncAutoCompleteSelectTestCase',
+    'FuncSelectModelChoiceTestCase',
+    'FuncComboboxModelChoiceTestCase',
 )
 
 
         # Selected pk should be populated
         thing_1 = 'name="thing_1" value="%s"' % self.test_thing.pk
         self.assertTrue(thing_1 in rendered_form, u"Didn't render selected pk.")
+
+
+class SelectWidgetForm(forms.ModelForm):
+
+    class Meta(object):
+        model = OtherThing
+        widgets = {
+            'thing': AutoCompleteSelectWidget(lookup_class=ThingLookup)
+        }
+
+
+class FuncSelectModelChoiceTestCase(BaseSelectableTestCase):
+    """
+    Functional tests for AutoCompleteSelectWidget compatibility
+    with a ModelChoiceField.
+    """
+
+    def setUp(self):
+        self.test_thing = self.create_thing()
+
+    def test_valid_form(self):
+        "Valid form using an AutoCompleteSelectField."
+        data = {
+            'name': self.get_random_string(),
+            'thing_0': self.test_thing.name, # Text input
+            'thing_1': self.test_thing.pk, # Hidden input
+        }
+        form = SelectWidgetForm(data=data)
+        self.assertTrue(form.is_valid(), str(form.errors))
+
+
+class ComboboxSelectWidgetForm(forms.ModelForm):
+
+    class Meta(object):
+        model = OtherThing
+        widgets = {
+            'thing': AutoComboboxSelectWidget(lookup_class=ThingLookup)
+        }
+
+
+class FuncComboboxModelChoiceTestCase(BaseSelectableTestCase):
+    """
+    Functional tests for AutoComboboxSelectWidget compatibility
+    with a ModelChoiceField.
+    """
+
+    def setUp(self):
+        self.test_thing = self.create_thing()
+
+    def test_valid_form(self):
+        "Valid form using an AutoCompleteSelectField."
+        data = {
+            'name': self.get_random_string(),
+            'thing_0': self.test_thing.name, # Text input
+            'thing_1': self.test_thing.pk, # Hidden input
+        }
+        form = ComboboxSelectWidgetForm(data=data)
+        self.assertTrue(form.is_valid(), str(form.errors))

selectable/tests/qunit/index.html

+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+<head>
+    <title>Django Selectable Qunit Test Suite</title>
+    <link rel="stylesheet" href="qunit.css" type="text/css" media="screen" />
+    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
+    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.13/jquery-ui.min.js"></script>
+    <script type="text/javascript" src="qunit.js"></script>
+    <script>
+        // Disable auto-binding of djselectable widgets to avoid test race conditions
+        var djselectableAutoLoad = false;
+    </script>
+    <script type="text/javascript" src="../../static/selectable/js/jquery.dj.selectable.js"></script>
+    <script>
+    $(document).ready(function(){
+        module("Text Autocomplete");
+        test("bind input", function() {
+            bindSelectables('#text');            
+            var input = $('#id_autocomplete');
+            ok(input.data('djselectable'), "input should be bound with djselecable widget");
+        });
+        test("item selection", function() {
+            bindSelectables('#text-initial');
+            var input = $('#id_autocomplete-initial');
+            ok(input.val(), 'Initial', "initial text value should not be lost");
+            var item = {id: "1", value: 'foo'};
+            input.djselectable('select', item);
+            ok(input.val(), item.value, "input should get item value");
+        });
+
+        module("Text Combobox");
+        test("bind input", function() {
+            bindSelectables('#combobox');
+            var input = $('#id_combobox');
+            ok(input.data('djselectable'), "input should be bound with djselecable widget");
+            var button = $('button', '#combobox');
+            equal(button.attr('title'), 'Show All Items', "combobox button should be created");
+        });
+        test("item selection", function() {
+            bindSelectables('#combobox-initial');
+            var input = $('#id_autocomplete-initial');
+            ok(input.val(), 'Initial', "initial text value should not be lost");
+            var item = {id: "1", value: 'foo'};
+            input.djselectable('select', item);
+            ok(input.val(), item.value, "input should get item value");
+        });
+
+        module("Select Autocomplete");
+        test("bind input", function() {
+            bindSelectables('#auto-select');
+            var input = $('#id_autoselect_0');
+            var hidden = $('#id_autoselect_1');
+            ok(input.data('djselectable'), "input should be bound with djselecable widget");
+            ok(!hidden.data('djselectable'), "hidden should not be bound with djselecable widget");
+        });
+        test("item selection", function() {
+            bindSelectables('#auto-select-initial');
+            var input = $('#id_autoselect-initial_0');
+            var hidden = $('#id_autoselect-initial_1');
+            equal(input.val(), 'Initial', 'initial text value should not be lost');
+            equal(hidden.val(), "0", 'initial hidden value should not be lost');
+            var item = {id: "1", value: 'foo'};
+            input.djselectable('select', item);
+            equal(input.val(), item.value, 'text input should get item value');
+            equal(hidden.val(), item.id, 'hidden input should get item id');
+        });
+
+        module("Select Combobox");
+        test("bind input", function() {
+            bindSelectables('#combo-select');
+            var input = $('#id_comboselect_0');
+            var hidden = $('#id_comboselect_1');
+            ok(input.data('djselectable'), "input should be bound with djselecable widget");
+            ok(!hidden.data('djselectable'), "hidden should not be bound with djselecable widget");
+            var button = $('button', '#combo-select');
+            equal(button.attr('title'), 'Show All Items', "combobox button should be created");
+        });
+        test("item selection", function() {
+            bindSelectables('#combo-select-initial');
+            var input = $('#id_comboselect-initial_0');
+            var hidden = $('#id_comboselect-initial_1');
+            equal(input.val(), 'Initial', 'initial text value should not be lost');
+            equal(hidden.val(), "0", 'initial hidden value should not be lost');
+            var item = {id: "1", value: 'foo'};
+            input.djselectable('select', item);
+            equal(input.val(), item.value, 'text input should get item value')
+            equal(hidden.val(), item.id, 'hidden input should get item id');
+        });
+
+        module("Select Multiple");
+        test("bind input", function() {
+            bindSelectables('#multi-select');
+            var input = $('#id_multiselect_0');
+            var deck = $('#multi-select ul');
+            ok(input.data('djselectable'), "input should be bound with djselecable widget");
+            ok(deck.hasClass('selectable-deck'), 'deck should have selectable-deck class');
+            equal($('li', deck).length, 0, 'no initial deck items');
+        }); 
+        test("item selection", function() {
+            bindSelectables('#multi-select-initial');
+            var input = $('#id_multiselect-initial_0');
+            var hidden = $(':input[type=hidden][name=multiselect-initial_1]');
+            var deck = $('#multi-select-initial ul');
+            equal($('li', deck).length, 1, 'one initial deck item');
+            equal(hidden.length, 1, 'one initial hidden input');
+            var item = {id: "1", value: 'foo'};
+            input.djselectable('select', item);
+            equal(input.val(), '', 'text input should be empty');
+            hidden = $(':input[type=hidden][name=multiselect-initial_1]');
+            equal(hidden.length, 2, 'new hidden input');
+            equal($('li', deck).length, 2, 'new deck item');
+            hidden = hidden.eq(0);
+            equal(hidden.val(), item.id, 'hidden input should get item id');
+            equal(hidden.attr('title'), item.value, 'hidden input title should be item value');
+        });
+
+        module("Combobox Multiple");
+        test("bind input", function() {
+            bindSelectables('#multi-combo');
+            var input = $('#id_multicombo_0');
+            var deck = $('#multi-combo ul');
+            ok(input.data('djselectable'), "input should be bound with djselecable widget");
+            ok(deck.hasClass('selectable-deck'), 'deck should have selectable-deck class');
+            equal($('li', deck).length, 0, 'no initial deck items');
+            var button = $('button', '#multi-combo');
+            equal(button.attr('title'), 'Show All Items', "combobox button should be created");
+        }); 
+        test("item selection", function() {
+            bindSelectables('#multi-combo-initial');
+            var input = $('#id_multicombo-initial_0');
+            var hidden = $(':input[type=hidden][name=multicombo-initial_1]');
+            var deck = $('#multi-combo-initial ul');
+            equal($('li', deck).length, 1, 'one initial deck item');
+            equal(hidden.length, 1, 'one initial hidden input');
+            var item = {id: "1", value: 'foo'};
+            input.djselectable('select', item);
+            equal(input.val(), '', 'text input should be empty');
+            hidden = $(':input[type=hidden][name=multicombo-initial_1]');
+            equal(hidden.length, 2, 'new hidden input');
+            equal($('li', deck).length, 2, 'new deck item');
+            hidden = hidden.eq(0);
+            equal(hidden.val(), item.id, 'hidden input should get item id');
+            equal(hidden.attr('title'), item.value, 'hidden input title should be item value');
+        });
+    });
+    </script>
+</head>
+<body>
+    <h1 id="qunit-header">Django Selectable Test Suite</h1>
+    <h2 id="qunit-banner"></h2>
+    <div id="qunit-testrunner-toolbar"></div>
+    <h2 id="qunit-userAgent"></h2>
+    <ol id="qunit-tests"></ol>
+    <div id="qunit-fixture">
+        <div id="text">
+            <input name="autocomplete" data-selectable-type="text" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_autocomplete" />
+        </div>
+        <div id="text-initial">
+            <input name="autocomplete-initial" data-selectable-type="text" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_autocomplete-initial" value="Initial" />
+        </div>
+        <div id="combobox">
+            <input name="combobox" data-selectable-type="combobox" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_combobox" />
+        </div>
+        <div id="combobox-initial">
+            <input name="combobox-initial" data-selectable-type="combobox" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_combobox-initial" value="Initial" />
+        </div>
+        <div id="auto-select">
+            <input name="autoselect_0" data-selectable-type="text" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_autoselect_0" />
+            <input name="autoselect_1" data-selectable-type="hidden" type="hidden" id="id_autoselect_1" />
+        </div>
+        <div id="auto-select-initial">
+            <input name="autoselect-initial_0" data-selectable-type="text" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_autoselect-initial_0" value="Initial" />
+            <input name="autoselect-initial_1" data-selectable-type="hidden" type="hidden" id="id_autoselect-initial_1" value="0" />
+        </div>
+        <div id="combo-select">
+            <input name="comboselect_0" data-selectable-type="combobox" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_comboselect_0" />
+            <input name="comboselect_1" data-selectable-type="hidden" type="hidden" id="id_comboselect_1" />
+        </div>
+        <div id="combo-select-initial">
+            <input name="comboselect-initial_0" data-selectable-type="combobox" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_comboselect-initial_0" value="Initial" />
+            <input name="comboselect-initial_1" data-selectable-type="hidden" type="hidden" id="id_comboselect-initial_1" value="0" />
+        </div>
+        <div id="multi-select">
+            <input data-selectable-multiple="true" name="multiselect_0" data-selectable-type="text" data-selectable-position="bottom" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_multiselect_0" />
+        </div>
+        <div id="multi-select-initial">
+            <input data-selectable-multiple="true" name="multiselect-initial_0" data-selectable-type="text" data-selectable-position="bottom" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_multiselect-initial_0" />
+            <input name="multiselect-initial_1" title="Initial" value="0" data-selectable-type="hidden-multiple" type="hidden" id="id_multiselect-initial_1_0">
+        </div>
+        <div id="multi-combo">
+            <input data-selectable-multiple="true" name="multicombo_0" data-selectable-type="combobox" data-selectable-position="bottom" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_multicombo_0" />
+        </div>
+        <div id="multi-combo-initial">
+            <input data-selectable-multiple="true" name="multicombo-initial_0" data-selectable-type="combobox" data-selectable-position="bottom" data-selectable-allow-new="false" data-selectable-url="/_/core-fruitlookup/" type="text" id="id_multicombo-initial_0" />
+            <input name="multicombo-initial_1" title="Initial" value="0" data-selectable-type="hidden-multiple" type="hidden" id="id_multicombo-initial_1_0">
+        </div>
+    </div>
+</body>
+</html>

selectable/tests/qunit/qunit.css

+/**
+ * QUnit v1.2.0 - A JavaScript Unit Testing Framework
+ *
+ * http://docs.jquery.com/QUnit
+ *
+ * Copyright (c) 2011 John Resig, Jörn Zaefferer
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * or GPL (GPL-LICENSE.txt) licenses.
+ */
+
+/** Font Family and Sizes */
+
+#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
+	font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
+}
+
+#qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
+#qunit-tests { font-size: smaller; }
+
+
+/** Resets */
+
+#qunit-tests, #qunit-tests ol, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult {
+	margin: 0;
+	padding: 0;
+}
+
+
+/** Header */
+
+#qunit-header {
+	padding: 0.5em 0 0.5em 1em;
+
+	color: #8699a4;
+	background-color: #0d3349;
+
+	font-size: 1.5em;
+	line-height: 1em;
+	font-weight: normal;
+
+	border-radius: 15px 15px 0 0;
+	-moz-border-radius: 15px 15px 0 0;
+	-webkit-border-top-right-radius: 15px;
+	-webkit-border-top-left-radius: 15px;
+}
+
+#qunit-header a {
+	text-decoration: none;
+	color: #c2ccd1;
+}
+
+#qunit-header a:hover,
+#qunit-header a:focus {
+	color: #fff;
+}
+
+#qunit-banner {
+	height: 5px;
+}
+
+#qunit-testrunner-toolbar {
+	padding: 0.5em 0 0.5em 2em;
+	color: #5E740B;
+	background-color: #eee;
+}
+
+#qunit-userAgent {
+	padding: 0.5em 0 0.5em 2.5em;
+	background-color: #2b81af;
+	color: #fff;
+	text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
+}
+
+
+/** Tests: Pass/Fail */
+
+#qunit-tests {
+	list-style-position: inside;
+}
+
+#qunit-tests li {
+	padding: 0.4em 0.5em 0.4em 2.5em;
+	border-bottom: 1px solid #fff;
+	list-style-position: inside;
+}
+
+#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running  {
+	display: none;
+}
+
+#qunit-tests li strong {
+	cursor: pointer;
+}
+
+#qunit-tests li a {
+	padding: 0.5em;
+	color: #c2ccd1;
+	text-decoration: none;
+}
+#qunit-tests li a:hover,
+#qunit-tests li a:focus {
+	color: #000;
+}
+
+#qunit-tests ol {
+	margin-top: 0.5em;
+	padding: 0.5em;
+
+	background-color: #fff;
+
+	border-radius: 15px;
+	-moz-border-radius: 15px;
+	-webkit-border-radius: 15px;
+
+	box-shadow: inset 0px 2px 13px #999;
+	-moz-box-shadow: inset 0px 2px 13px #999;
+	-webkit-box-shadow: inset 0px 2px 13px #999;
+}
+
+#qunit-tests table {
+	border-collapse: collapse;
+	margin-top: .2em;
+}
+
+#qunit-tests th {
+	text-align: right;
+	vertical-align: top;
+	padding: 0 .5em 0 0;
+}
+
+#qunit-tests td {
+	vertical-align: top;
+}
+
+#qunit-tests pre {
+	margin: 0;
+	white-space: pre-wrap;
+	word-wrap: break-word;
+}
+
+#qunit-tests del {
+	background-color: #e0f2be;
+	color: #374e0c;
+	text-decoration: none;
+}
+
+#qunit-tests ins {
+	background-color: #ffcaca;
+	color: #500;
+	text-decoration: none;
+}
+
+/*** Test Counts */
+
+#qunit-tests b.counts                       { color: black; }
+#qunit-tests b.passed                       { color: #5E740B; }
+#qunit-tests b.failed                       { color: #710909; }
+
+#qunit-tests li li {
+	margin: 0.5em;
+	padding: 0.4em 0.5em 0.4em 0.5em;
+	background-color: #fff;
+	border-bottom: none;
+	list-style-position: inside;
+}
+
+/*** Passing Styles */
+
+#qunit-tests li li.pass {
+	color: #5E740B;
+	background-color: #fff;
+	border-left: 26px solid #C6E746;
+}
+
+#qunit-tests .pass                          { color: #528CE0; background-color: #D2E0E6; }
+#qunit-tests .pass .test-name               { color: #366097; }
+
+#qunit-tests .pass .test-actual,
+#qunit-tests .pass .test-expected           { color: #999999; }
+
+#qunit-banner.qunit-pass                    { background-color: #C6E746; }
+
+/*** Failing Styles */
+
+#qunit-tests li li.fail {
+	color: #710909;
+	background-color: #fff;
+	border-left: 26px solid #EE5757;
+	white-space: pre;
+}
+
+#qunit-tests > li:last-child {
+	border-radius: 0 0 15px 15px;
+	-moz-border-radius: 0 0 15px 15px;
+	-webkit-border-bottom-right-radius: 15px;
+	-webkit-border-bottom-left-radius: 15px;
+}
+
+#qunit-tests .fail                          { color: #000000; background-color: #EE5757; }
+#qunit-tests .fail .test-name,
+#qunit-tests .fail .module-name             { color: #000000; }
+
+#qunit-tests .fail .test-actual             { color: #EE5757; }
+#qunit-tests .fail .test-expected           { color: green;   }
+
+#qunit-banner.qunit-fail                    { background-color: #EE5757; }
+
+
+/** Result */
+
+#qunit-testresult {
+	padding: 0.5em 0.5em 0.5em 2.5em;
+
+	color: #2b81af;
+	background-color: #D2E0E6;
+
+	border-bottom: 1px solid white;
+}
+
+/** Fixture */
+
+#qunit-fixture {
+	position: absolute;
+	top: -10000px;
+	left: -10000px;
+}

selectable/tests/qunit/qunit.js

+/**
+ * QUnit v1.2.0 - A JavaScript Unit Testing Framework
+ *
+ * http://docs.jquery.com/QUnit
+ *
+ * Copyright (c) 2011 John Resig, Jörn Zaefferer
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * or GPL (GPL-LICENSE.txt) licenses.
+ */
+
+(function(window) {
+
+var defined = {
+	setTimeout: typeof window.setTimeout !== "undefined",
+	sessionStorage: (function() {
+		try {
+			return !!sessionStorage.getItem;
+		} catch(e) {
+			return false;
+		}
+	})()
+};
+
+var	testId = 0,
+	toString = Object.prototype.toString,
+	hasOwn = Object.prototype.hasOwnProperty;
+
+var Test = function(name, testName, expected, testEnvironmentArg, async, callback) {
+	this.name = name;
+	this.testName = testName;
+	this.expected = expected;
+	this.testEnvironmentArg = testEnvironmentArg;
+	this.async = async;
+	this.callback = callback;
+	this.assertions = [];
+};
+Test.prototype = {
+	init: function() {
+		var tests = id("qunit-tests");
+		if (tests) {
+			var b = document.createElement("strong");
+				b.innerHTML = "Running " + this.name;
+			var li = document.createElement("li");
+				li.appendChild( b );
+				li.className = "running";
+				li.id = this.id = "test-output" + testId++;
+			tests.appendChild( li );
+		}
+	},
+	setup: function() {
+		if (this.module != config.previousModule) {
+			if ( config.previousModule ) {
+				runLoggingCallbacks('moduleDone', QUnit, {
+					name: config.previousModule,
+					failed: config.moduleStats.bad,
+					passed: config.moduleStats.all - config.moduleStats.bad,
+					total: config.moduleStats.all
+				} );
+			}
+			config.previousModule = this.module;
+			config.moduleStats = { all: 0, bad: 0 };
+			runLoggingCallbacks( 'moduleStart', QUnit, {
+				name: this.module
+			} );
+		}
+
+		config.current = this;
+		this.testEnvironment = extend({
+			setup: function() {},
+			teardown: function() {}
+		}, this.moduleTestEnvironment);
+		if (this.testEnvironmentArg) {
+			extend(this.testEnvironment, this.testEnvironmentArg);
+		}
+
+		runLoggingCallbacks( 'testStart', QUnit, {
+			name: this.testName,
+			module: this.module
+		});
+
+		// allow utility functions to access the current test environment
+		// TODO why??
+		QUnit.current_testEnvironment = this.testEnvironment;
+
+		try {
+			if ( !config.pollution ) {
+				saveGlobal();
+			}
+
+			this.testEnvironment.setup.call(this.testEnvironment);
+		} catch(e) {
+			QUnit.ok( false, "Setup failed on " + this.testName + ": " + e.message );
+		}
+	},
+	run: function() {
+		config.current = this;
+		if ( this.async ) {
+			QUnit.stop();
+		}
+
+		if ( config.notrycatch ) {
+			this.callback.call(this.testEnvironment);
+			return;
+		}
+		try {
+			this.callback.call(this.testEnvironment);
+		} catch(e) {
+			fail("Test " + this.testName + " died, exception and test follows", e, this.callback);
+			QUnit.ok( false, "Died on test #" + (this.assertions.length + 1) + ": " + e.message + " - " + QUnit.jsDump.parse(e) );
+			// else next test will carry the responsibility
+			saveGlobal();
+
+			// Restart the tests if they're blocking
+			if ( config.blocking ) {
+				QUnit.start();
+			}
+		}
+	},
+	teardown: function() {
+		config.current = this;
+		try {
+			this.testEnvironment.teardown.call(this.testEnvironment);
+			checkPollution();
+		} catch(e) {
+			QUnit.ok( false, "Teardown failed on " + this.testName + ": " + e.message );
+		}
+	},
+	finish: function() {
+		config.current = this;
+		if ( this.expected != null && this.expected != this.assertions.length ) {
+			QUnit.ok( false, "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run" );
+		}
+
+		var good = 0, bad = 0,
+			tests = id("qunit-tests");
+
+		config.stats.all += this.assertions.length;
+		config.moduleStats.all += this.assertions.length;
+
+		if ( tests ) {
+			var ol = document.createElement("ol");
+
+			for ( var i = 0; i < this.assertions.length; i++ ) {
+				var assertion = this.assertions[i];
+
+				var li = document.createElement("li");
+				li.className = assertion.result ? "pass" : "fail";
+				li.innerHTML = assertion.message || (assertion.result ? "okay" : "failed");
+				ol.appendChild( li );
+
+				if ( assertion.result ) {
+					good++;
+				} else {
+					bad++;
+					config.stats.bad++;
+					config.moduleStats.bad++;
+				}
+			}
+
+			// store result when possible
+			if ( QUnit.config.reorder && defined.sessionStorage ) {
+				if (bad) {
+					sessionStorage.setItem("qunit-" + this.module + "-" + this.testName, bad);
+				} else {
+					sessionStorage.removeItem("qunit-" + this.module + "-" + this.testName);
+				}
+			}
+
+			if (bad == 0) {
+				ol.style.display = "none";
+			}
+
+			var b = document.createElement("strong");
+			b.innerHTML = this.name + " <b class='counts'>(<b class='failed'>" + bad + "</b>, <b class='passed'>" + good + "</b>, " + this.assertions.length + ")</b>";
+
+			var a = document.createElement("a");
+			a.innerHTML = "Rerun";
+			a.href = QUnit.url({ filter: getText([b]).replace(/\([^)]+\)$/, "").replace(/(^\s*|\s*$)/g, "") });
+
+			addEvent(b, "click", function() {
+				var next = b.nextSibling.nextSibling,
+					display = next.style.display;
+				next.style.display = display === "none" ? "block" : "none";
+			});
+
+			addEvent(b, "dblclick", function(e) {
+				var target = e && e.target ? e.target : window.event.srcElement;
+				if ( target.nodeName.toLowerCase() == "span" || target.nodeName.toLowerCase() == "b" ) {
+					target = target.parentNode;
+				}
+				if ( window.location && target.nodeName.toLowerCase() === "strong" ) {
+					window.location = QUnit.url({ filter: getText([target]).replace(/\([^)]+\)$/, "").replace(/(^\s*|\s*$)/g, "") });
+				}
+			});
+
+			var li = id(this.id);
+			li.className = bad ? "fail" : "pass";
+			li.removeChild( li.firstChild );
+			li.appendChild( b );
+			li.appendChild( a );
+			li.appendChild( ol );
+
+		} else {
+			for ( var i = 0; i < this.assertions.length; i++ ) {
+				if ( !this.assertions[i].result ) {
+					bad++;
+					config.stats.bad++;
+					config.moduleStats.bad++;
+				}
+			}
+		}
+
+		try {
+			QUnit.reset();
+		} catch(e) {
+			fail("reset() failed, following Test " + this.testName + ", exception and reset fn follows", e, QUnit.reset);
+		}
+
+		runLoggingCallbacks( 'testDone', QUnit, {
+			name: this.testName,
+			module: this.module,
+			failed: bad,
+			passed: this.assertions.length - bad,
+			total: this.assertions.length
+		} );
+	},
+
+	queue: function() {
+		var test = this;
+		synchronize(function() {
+			test.init();
+		});
+		function run() {
+			// each of these can by async
+			synchronize(function() {
+				test.setup();
+			});
+			synchronize(function() {
+				test.run();
+			});
+			synchronize(function() {
+				test.teardown();
+			});
+			synchronize(function() {
+				test.finish();
+			});
+		}
+		// defer when previous test run passed, if storage is available
+		var bad = QUnit.config.reorder && defined.sessionStorage && +sessionStorage.getItem("qunit-" + this.module + "-" + this.testName);
+		if (bad) {
+			run();
+		} else {
+			synchronize(run, true);
+		};
+	}
+
+};
+
+var QUnit = {
+
+	// call on start of module test to prepend name to all tests
+	module: function(name, testEnvironment) {
+		config.currentModule = name;
+		config.currentModuleTestEnviroment = testEnvironment;
+	},
+
+	asyncTest: function(testName, expected, callback) {
+		if ( arguments.length === 2 ) {
+			callback = expected;
+			expected = null;
+		}
+
+		QUnit.test(testName, expected, callback, true);
+	},
+
+	test: function(testName, expected, callback, async) {
+		var name = '<span class="test-name">' + testName + '</span>', testEnvironmentArg;
+
+		if ( arguments.length === 2 ) {
+			callback = expected;
+			expected = null;
+		}
+		// is 2nd argument a testEnvironment?
+		if ( expected && typeof expected === 'object') {
+			testEnvironmentArg = expected;
+			expected = null;
+		}
+
+		if ( config.currentModule ) {
+			name = '<span class="module-name">' + config.currentModule + "</span>: " + name;
+		}
+
+		if ( !validTest(config.currentModule + ": " + testName) ) {
+			return;
+		}
+
+		var test = new Test(name, testName, expected, testEnvironmentArg, async, callback);
+		test.module = config.currentModule;
+		test.moduleTestEnvironment = config.currentModuleTestEnviroment;
+		test.queue();
+	},
+
+	/**
+	 * Specify the number of expected assertions to gurantee that failed test (no assertions are run at all) don't slip through.
+	 */
+	expect: function(asserts) {
+		config.current.expected = asserts;
+	},
+
+	/**
+	 * Asserts true.
+	 * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" );
+	 */
+	ok: function(a, msg) {
+		a = !!a;
+		var details = {
+			result: a,
+			message: msg
+		};
+		msg = escapeInnerText(msg);
+		runLoggingCallbacks( 'log', QUnit, details );
+		config.current.assertions.push({
+			result: a,
+			message: msg
+		});
+	},
+
+	/**
+	 * Checks that the first two arguments are equal, with an optional message.
+	 * Prints out both actual and expected values.
+	 *
+	 * Prefered to ok( actual == expected, message )
+	 *
+	 * @example equal( format("Received {0} bytes.", 2), "Received 2 bytes." );
+	 *
+	 * @param Object actual
+	 * @param Object expected
+	 * @param String message (optional)
+	 */
+	equal: function(actual, expected, message) {
+		QUnit.push(expected == actual, actual, expected, message);
+	},
+
+	notEqual: function(actual, expected, message) {
+		QUnit.push(expected != actual, actual, expected, message);
+	},
+
+	deepEqual: function(actual, expected, message) {
+		QUnit.push(QUnit.equiv(actual, expected), actual, expected, message);
+	},
+
+	notDeepEqual: function(actual, expected, message) {
+		QUnit.push(!QUnit.equiv(actual, expected), actual, expected, message);
+	},
+
+	strictEqual: function(actual, expected, message) {
+		QUnit.push(expected === actual, actual, expected, message);
+	},
+
+	notStrictEqual: function(actual, expected, message) {
+		QUnit.push(expected !== actual, actual, expected, message);
+	},
+
+	raises: function(block, expected, message) {
+		var actual, ok = false;
+
+		if (typeof expected === 'string') {
+			message = expected;
+			expected = null;
+		}
+
+		try {
+			block();
+		} catch (e) {
+			actual = e;
+		}
+
+		if (actual) {
+			// we don't want to validate thrown error
+			if (!expected) {
+				ok = true;
+			// expected is a regexp
+			} else if (QUnit.objectType(expected) === "regexp") {
+				ok = expected.test(actual);
+			// expected is a constructor
+			} else if (actual instanceof expected) {
+				ok = true;
+			// expected is a validation function which returns true is validation passed
+			} else if (expected.call({}, actual) === true) {
+				ok = true;
+			}
+		}
+
+		QUnit.ok(ok, message);
+	},
+
+	start: function(count) {
+		config.semaphore -= count || 1;
+		if (config.semaphore > 0) {
+			// don't start until equal number of stop-calls
+			return;
+		}
+		if (config.semaphore < 0) {
+			// ignore if start is called more often then stop
+			config.semaphore = 0;
+		}
+		// A slight delay, to avoid any current callbacks
+		if ( defined.setTimeout ) {
+			window.setTimeout(function() {
+				if (config.semaphore > 0) {
+					return;
+				}
+				if ( config.timeout ) {
+					clearTimeout(config.timeout);
+				}
+
+				config.blocking = false;
+				process(true);
+			}, 13);
+		} else {
+			config.blocking = false;
+			process(true);
+		}
+	},
+
+	stop: function(count) {
+		config.semaphore += count || 1;
+		config.blocking = true;
+
+		if ( config.testTimeout && defined.setTimeout ) {
+			clearTimeout(config.timeout);
+			config.timeout = window.setTimeout(function() {
+				QUnit.ok( false, "Test timed out" );
+				config.semaphore = 1;
+				QUnit.start();
+			}, config.testTimeout);
+		}
+	}
+};
+
+//We want access to the constructor's prototype
+(function() {
+	function F(){};
+	F.prototype = QUnit;
+	QUnit = new F();
+	//Make F QUnit's constructor so that we can add to the prototype later
+	QUnit.constructor = F;
+})();
+
+// Backwards compatibility, deprecated
+QUnit.equals = QUnit.equal;
+QUnit.same = QUnit.deepEqual;
+
+// Maintain internal state
+var config = {
+	// The queue of tests to run
+	queue: [],
+
+	// block until document ready
+	blocking: true,
+
+	// when enabled, show only failing tests
+	// gets persisted through sessionStorage and can be changed in UI via checkbox
+	hidepassed: false,
+
+	// by default, run previously failed tests first
+	// very useful in combination with "Hide passed tests" checked
+	reorder: true,
+
+	// by default, modify document.title when suite is done
+	altertitle: true,
+
+	urlConfig: ['noglobals', 'notrycatch'],
+
+	//logging callback queues
+	begin: [],
+	done: [],
+	log: [],
+	testStart: [],
+	testDone: [],
+	moduleStart: [],
+	moduleDone: []
+};
+
+// Load paramaters
+(function() {
+	var location = window.location || { search: "", protocol: "file:" },
+		params = location.search.slice( 1 ).split( "&" ),
+		length = params.length,
+		urlParams = {},
+		current;
+
+	if ( params[ 0 ] ) {
+		for ( var i = 0; i < length; i++ ) {
+			current = params[ i ].split( "=" );
+			current[ 0 ] = decodeURIComponent( current[ 0 ] );
+			// allow just a key to turn on a flag, e.g., test.html?noglobals
+			current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true;
+			urlParams[ current[ 0 ] ] = current[ 1 ];
+		}
+	}
+
+	QUnit.urlParams = urlParams;
+	config.filter = urlParams.filter;
+
+	// Figure out if we're running the tests from a server or not
+	QUnit.isLocal = !!(location.protocol === 'file:');
+})();
+
+// Expose the API as global variables, unless an 'exports'
+// object exists, in that case we assume we're in CommonJS
+if ( typeof exports === "undefined" || typeof require === "undefined" ) {
+	extend(window, QUnit);
+	window.QUnit = QUnit;
+} else {
+	extend(exports, QUnit);
+	exports.QUnit = QUnit;
+}
+
+// define these after exposing globals to keep them in these QUnit namespace only
+extend(QUnit, {
+	config: config,
+
+	// Initialize the configuration options
+	init: function() {
+		extend(config, {
+			stats: { all: 0, bad: 0 },
+			moduleStats: { all: 0, bad: 0 },
+			started: +new Date,
+			updateRate: 1000,
+			blocking: false,
+			autostart: true,
+			autorun: false,
+			filter: "",
+			queue: [],
+			semaphore: 0
+		});
+
+		var tests = id( "qunit-tests" ),
+			banner = id( "qunit-banner" ),
+			result = id( "qunit-testresult" );
+
+		if ( tests ) {
+			tests.innerHTML = "";
+		}
+
+		if ( banner ) {
+			banner.className = "";
+		}
+
+		if ( result ) {
+			result.parentNode.removeChild( result );
+		}
+
+		if ( tests ) {
+			result = document.createElement( "p" );
+			result.id = "qunit-testresult";
+			result.className = "result";
+			tests.parentNode.insertBefore( result, tests );
+			result.innerHTML = 'Running...<br/>&nbsp;';
+		}
+	},
+
+	/**
+	 * Resets the test setup. Useful for tests that modify the DOM.
+	 *
+	 * If jQuery is available, uses jQuery's html(), otherwise just innerHTML.
+	 */
+	reset: function() {
+		if ( window.jQuery ) {
+			jQuery( "#qunit-fixture" ).html( config.fixture );
+		} else {
+			var main = id( 'qunit-fixture' );
+			if ( main ) {
+				main.innerHTML = config.fixture;
+			}
+		}
+	},
+
+	/**
+	 * Trigger an event on an element.
+	 *
+	 * @example triggerEvent( document.body, "click" );
+	 *
+	 * @param DOMElement elem
+	 * @param String type
+	 */
+	triggerEvent: function( elem, type, event ) {
+		if ( document.createEvent ) {
+			event = document.createEvent("MouseEvents");
+			event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView,
+				0, 0, 0, 0, 0, false, false, false, false, 0, null);
+			elem.dispatchEvent( event );
+
+		} else if ( elem.fireEvent ) {
+			elem.fireEvent("on"+type);
+		}
+	},
+
+	// Safe object type checking
+	is: function( type, obj ) {
+		return QUnit.objectType( obj ) == type;
+	},
+
+	objectType: function( obj ) {
+		if (typeof obj === "undefined") {
+				return "undefined";
+
+		// consider: typeof null === object
+		}
+		if (obj === null) {
+				return "null";
+		}
+
+		var type = toString.call( obj ).match(/^\[object\s(.*)\]$/)[1] || '';
+
+		switch (type) {
+				case 'Number':
+						if (isNaN(obj)) {
+								return "nan";
+						} else {
+								return "number";
+						}
+				case 'String':
+				case 'Boolean':
+				case 'Array':
+				case 'Date':
+				case 'RegExp':
+				case 'Function':
+						return type.toLowerCase();
+		}
+		if (typeof obj === "object") {
+				return "object";
+		}
+		return undefined;
+	},
+
+	push: function(result, actual, expected, message) {
+		var details = {
+			result: result,
+			message: message,
+			actual: actual,
+			expected: expected
+		};
+
+		message = escapeInnerText(message) || (result ? "okay" : "failed");
+		message = '<span class="test-message">' + message + "</span>";
+		expected = escapeInnerText(QUnit.jsDump.parse(expected));
+		actual = escapeInnerText(QUnit.jsDump.parse(actual));
+		var output = message + '<table><tr class="test-expected"><th>Expected: </th><td><pre>' + expected + '</pre></td></tr>';
+		if (actual != expected) {
+			output += '<tr class="test-actual"><th>Result: </th><td><pre>' + actual + '</pre></td></tr>';
+			output += '<tr class="test-diff"><th>Diff: </th><td><pre>' + QUnit.diff(expected, actual) +'</pre></td></tr>';
+		}
+		if (!result) {
+			var source = sourceFromStacktrace();
+			if (source) {
+				details.source = source;
+				output += '<tr class="test-source"><th>Source: </th><td><pre>' + escapeInnerText(source) + '</pre></td></tr>';
+			}
+		}
+		output += "</table>";
+
+		runLoggingCallbacks( 'log', QUnit, details );
+
+		config.current.assertions.push({
+			result: !!result,
+			message: output
+		});
+	},
+
+	url: function( params ) {
+		params = extend( extend( {}, QUnit.urlParams ), params );
+		var querystring = "?",
+			key;
+		for ( key in params ) {