├── example ├── core │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_load_initial_data.py │ │ └── 0001_initial.py │ ├── urls.py │ ├── templates │ │ ├── nav.html │ │ ├── advanced.html │ │ ├── formset.html │ │ └── base.html │ ├── tests.py │ ├── models.py │ ├── lookups.py │ ├── views.py │ ├── admin.py │ └── forms.py ├── example │ ├── __init__.py │ ├── static │ │ ├── img │ │ │ ├── glyphicons-halflings.png │ │ │ └── glyphicons-halflings-white.png │ │ └── css │ │ │ └── style.css │ ├── urls.py │ ├── templates │ │ └── admin │ │ │ └── base_site.html │ ├── wsgi.py │ └── settings.py ├── requirements.txt └── manage.py ├── selectable ├── templatetags │ ├── __init__.py │ └── selectable_tags.py ├── locale │ ├── cs │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── it │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── zh_CN │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── forms │ ├── __init__.py │ ├── base.py │ ├── fields.py │ └── widgets.py ├── __init__.py ├── templates │ └── selectable │ │ ├── jquery-css.html │ │ └── jquery-js.html ├── urls.py ├── tests │ ├── urls.py │ ├── views.py │ ├── qunit │ │ ├── main.js │ │ ├── jquery-loader.js │ │ ├── index.html │ │ ├── helpers.js │ │ ├── test-options.js │ │ ├── test-events.js │ │ └── test-methods.js │ ├── __init__.py │ ├── test_forms.py │ ├── base.py │ ├── test_decorators.py │ ├── test_views.py │ ├── test_base.py │ ├── test_templatetags.py │ └── test_fields.py ├── models.py ├── apps.py ├── views.py ├── exceptions.py ├── static │ └── selectable │ │ └── css │ │ └── dj.selectable.css ├── registry.py ├── decorators.py └── base.py ├── .coveragerc ├── MANIFEST.in ├── .hgignore ├── setup.cfg ├── .gitignore ├── .tx └── config ├── CHANGES ├── docs ├── index.rst ├── overview.rst ├── fields.rst ├── contribute.rst ├── settings.rst ├── widgets.rst ├── Makefile ├── make.bat ├── testing.rst ├── admin.rst ├── quick-start.rst ├── conf.py └── lookups.rst ├── AUTHORS.txt ├── Makefile ├── tox.ini ├── runtests.py ├── LICENSE.txt ├── .hgtags ├── .github └── workflows │ └── tests.yml ├── setup.py ├── README.rst └── run-qunit.js /example/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /selectable/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.11 2 | django-localflavor 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = selectable 3 | omit = */tests*,*/urls.py 4 | -------------------------------------------------------------------------------- /selectable/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /selectable/locale/zh_CN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/selectable/locale/zh_CN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/example/static/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/example/example/static/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /example/example/static/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-selectable/HEAD/example/example/static/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /selectable/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from selectable.forms.base import * # noqa 2 | from selectable.forms.fields import * # noqa 3 | from selectable.forms.widgets import * # noqa 4 | -------------------------------------------------------------------------------- /selectable/__init__.py: -------------------------------------------------------------------------------- 1 | "Auto-complete selection widgets using Django and jQuery UI." 2 | 3 | __version__ = "1.5.0" 4 | 5 | default_app_config = "selectable.apps.SelectableConfig" 6 | -------------------------------------------------------------------------------- /selectable/templates/selectable/jquery-css.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /selectable/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | re_path(r"^(?P[-\w]+)/$", views.get_lookup, name="selectable-lookup"), 7 | ] 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.txt 2 | include README.rst 3 | include LICENSE.txt 4 | recursive-include selectable/locale * 5 | recursive-include selectable/static * 6 | recursive-include selectable/templates * 7 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | # use glob syntax. 2 | syntax: glob 3 | .project 4 | .pydevproject 5 | *.pyc 6 | *.pyo 7 | *~ 8 | *.db 9 | *.orig 10 | docs/_build/* 11 | docs/_static/* 12 | dist/* 13 | *.egg-info/* 14 | .coverage 15 | .tox/* 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | omit = */tests/*, example/*, .tox/*, setup.py, runtests.py 4 | source = . 5 | 6 | [coverage:report] 7 | show_missing = true 8 | 9 | 10 | [bdist_wheel] 11 | universal = 0 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | *.pyc 4 | *.pyo 5 | *~ 6 | *.db 7 | *.orig 8 | docs/_build/* 9 | docs/_static/* 10 | dist/* 11 | build/ 12 | *.egg-info/* 13 | .coverage 14 | .tox/* 15 | .idea/ 16 | .venv/ 17 | .envrc 18 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [django-selectable.txo] 5 | file_filter = selectable/locale//LC_MESSAGES/django.po 6 | source_file = selectable/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | type = PO 9 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | url(r"^admin/", admin.site.urls), 6 | url(r"^selectable/", include("selectable.urls")), 7 | url(r"^", include("core.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /selectable/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | 3 | handler404 = "selectable.tests.views.test_404" 4 | handler500 = "selectable.tests.views.test_500" 5 | 6 | urlpatterns = [ 7 | re_path(r"^selectable-tests/", include("selectable.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /selectable/templates/selectable/jquery-js.html: -------------------------------------------------------------------------------- 1 | {% if version %}{% endif %} 2 | {% if ui %}{% endif %} 3 | -------------------------------------------------------------------------------- /selectable/tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseNotFound, HttpResponseServerError 2 | 3 | 4 | def test_404(request, *args, **kwargs): 5 | return HttpResponseNotFound() 6 | 7 | 8 | def test_500(request, *args, **kwargs): 9 | return HttpResponseServerError() 10 | -------------------------------------------------------------------------------- /selectable/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | # Set default settings 4 | if not hasattr(settings, "SELECTABLE_MAX_LIMIT"): 5 | settings.SELECTABLE_MAX_LIMIT = 25 6 | 7 | if not hasattr(settings, "SELECTABLE_ESCAPED_KEYS"): 8 | settings.SELECTABLE_ESCAPED_KEYS = ("label",) 9 | -------------------------------------------------------------------------------- /selectable/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SelectableConfig(AppConfig): 5 | """App configuration for django-selectable.""" 6 | 7 | name = "selectable" 8 | 9 | def ready(self): 10 | from . import registry 11 | 12 | registry.autodiscover() 13 | -------------------------------------------------------------------------------- /example/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from .views import advanced, formset, index 4 | 5 | urlpatterns = [ 6 | re_path(r"^formset/", formset, name="example-formset"), 7 | re_path(r"^advanced/", advanced, name="example-advanced"), 8 | re_path(r"^", index, name="example-index"), 9 | ] 10 | -------------------------------------------------------------------------------- /example/example/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 60px; 3 | } 4 | input { 5 | -webkit-border-radius: 0; 6 | -moz-border-radius: 0; 7 | border-radius: 0; 8 | } 9 | input.ui-autocomplete-input { 10 | padding: 0; 11 | margin-top: -3px; 12 | margin-bottom: 0; 13 | height: 1.5em; 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /selectable/views.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | 3 | from selectable.registry import registry 4 | 5 | 6 | def get_lookup(request, lookup_name): 7 | lookup_cls = registry.get(lookup_name) 8 | if lookup_cls is None: 9 | raise Http404("Lookup %s not found" % lookup_name) 10 | 11 | lookup = lookup_cls() 12 | return lookup.results(request) 13 | -------------------------------------------------------------------------------- /selectable/exceptions.py: -------------------------------------------------------------------------------- 1 | class LookupAlreadyRegistered(Exception): 2 | "Exception when trying to register a lookup which is already registered." 3 | 4 | 5 | class LookupNotRegistered(Exception): 6 | "Exception when trying use a lookup which is not registered." 7 | 8 | 9 | class LookupInvalid(Exception): 10 | "Exception when register an invalid lookup class." 11 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Release 1.5 2 | ----------------- 3 | * fixed ajax support 4 | * added support to django 5 5 | 6 | 7 | Release 1.4 8 | ----------------- 9 | * added support to django 4.2 10 | 11 | 12 | Release 1.3 13 | ----------------- 14 | * added support to python 3.7 and 3.8 15 | * added support to django 3.0 16 | * added support to italian language 17 | * dropped support to python 2.7 -------------------------------------------------------------------------------- /example/core/templates/nav.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents: 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | overview 9 | quick-start 10 | lookups 11 | advanced 12 | admin 13 | testing 14 | fields 15 | widgets 16 | settings 17 | contribute 18 | releases 19 | 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | -------------------------------------------------------------------------------- /selectable/templatetags/selectable_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.inclusion_tag("selectable/jquery-js.html") 7 | def include_jquery_libs(version="1.12.4", ui="1.11.4"): 8 | return {"version": version, "ui": ui} 9 | 10 | 11 | @register.inclusion_tag("selectable/jquery-css.html") 12 | def include_ui_theme(theme="smoothness", version="1.11.4"): 13 | return {"theme": theme, "version": version} 14 | -------------------------------------------------------------------------------- /example/example/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | {% block title %}{{ title }} | Django-Selectable Example Site{% endblock title %} 3 | {% block extrahead %} 4 | {% load selectable_tags %} 5 | {% include_ui_theme 'smoothness' %} 6 | {% include_jquery_libs %} 7 | {% endblock %} 8 | {% block branding %} 9 |

Django-Selectable Administration

10 | {% endblock branding %} 11 | {% block nav-global %}{% endblock nav-global %} 12 | -------------------------------------------------------------------------------- /selectable/tests/qunit/main.js: -------------------------------------------------------------------------------- 1 | /*global require, QUnit*/ 2 | 3 | require.config({ 4 | baseUrl: '../../static/selectable/js/', 5 | paths: { 6 | selectable: 'jquery.dj.selectable' 7 | }, 8 | shim: { 9 | selectable: { 10 | exports: 'jQuery' 11 | } 12 | } 13 | }); 14 | 15 | require(['test-methods.js', 'test-events.js', 'test-options.js'], function () { 16 | //Tests loaded, run Tests 17 | QUnit.load(); 18 | QUnit.start(); 19 | }); -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | # add parent path to PYTHONPATH so we can use current selectable package instead of installing it from pipy 6 | sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | 11 | from django.core.management import execute_from_command_line 12 | 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /example/core/migrations/0002_load_initial_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.core.management import call_command 5 | from django.db import migrations 6 | 7 | 8 | def load_initial_data(apps, schema_editor): 9 | call_command("loaddata", "initial_data", app_label="core") 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [("core", "0001_initial")] 14 | 15 | operations = [ 16 | migrations.RunPython(load_initial_data), 17 | ] 18 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Primary author: 2 | 3 | Mark Lavin 4 | 5 | The following people who have contributed to django-selectable: 6 | 7 | Michael Manfre 8 | Luke Plant 9 | Augusto Men 10 | @dc 11 | Colin Copeland 12 | Sławomir Ehlert 13 | Dan Poirier 14 | Felipe Prenholato 15 | David Ray 16 | Rick Testore 17 | Karen Tracey 18 | Manuel Alvarez 19 | Ustun Ozgur 20 | @leo-the-manic 21 | Calvin Spealman 22 | Rebecca Lovewell 23 | Thomas Güttler 24 | Yuri Khrustalev 25 | @SaeX 26 | Tam Huynh 27 | Raphael Merx 28 | Josh Addington 29 | Tobias Zanke 30 | Petr Dlouhy 31 | Vinod Kurup 32 | Domenico Di Nicola 33 | 34 | Thanks for all of your work! 35 | -------------------------------------------------------------------------------- /example/core/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates two different styles of tests (one doctest and one 3 | unittest). These will both pass when you run "manage.py test". 4 | 5 | Replace these with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.failUnlessEqual(1 + 1, 2) 17 | 18 | 19 | __test__ = { 20 | "doctest": """ 21 | Another way to test that 1 + 1 is equal to 2. 22 | 23 | >>> 1 + 1 == 2 24 | True 25 | """ 26 | } 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | STATIC_DIR = ./selectable/static/selectable 2 | QUNIT_TESTS = file://`pwd`/selectable/tests/qunit/index.html 3 | 4 | test-js: 5 | # Run JS tests 6 | # Requires phantomjs 7 | phantomjs run-qunit.js ${QUNIT_TESTS}?jquery=1.11.2&ui=1.11.4 8 | phantomjs run-qunit.js ${QUNIT_TESTS}?jquery=1.11.2&ui=1.10.4 9 | phantomjs run-qunit.js ${QUNIT_TESTS}?jquery=1.10.2&ui=1.11.4 10 | phantomjs run-qunit.js ${QUNIT_TESTS}?jquery=1.10.2&ui=1.10.4 11 | phantomjs run-qunit.js ${QUNIT_TESTS}?jquery=1.9.1&ui=1.11.4 12 | phantomjs run-qunit.js ${QUNIT_TESTS}?jquery=1.9.1&ui=1.10.4 13 | 14 | 15 | lint-js: 16 | # Check JS for any problems 17 | # Requires jshint 18 | jshint ${STATIC_DIR}/js/jquery.dj.selectable.js 19 | 20 | 21 | .PHONY: lint-js test-js 22 | -------------------------------------------------------------------------------- /selectable/tests/qunit/jquery-loader.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | // Get any jquery=___ param from the query string. 3 | var jqversion = location.search.match(/[?&]jquery=(.*?)(?=&|$)/); 4 | var uiversion = location.search.match(/[?&]ui=(.*?)(?=&|$)/); 5 | var path; 6 | window.jqversion = jqversion && jqversion[1] || '1.11.2'; 7 | window.uiversion = uiversion && uiversion[1] || '1.11.4'; 8 | jqpath = 'http://code.jquery.com/jquery-' + window.jqversion + '.js'; 9 | uipath = 'http://code.jquery.com/ui/' + window.uiversion + '/jquery-ui.js'; 10 | // This is the only time I'll ever use document.write, I promise! 11 | document.write(''); 12 | document.write(''); 13 | }()); 14 | -------------------------------------------------------------------------------- /example/core/templates/advanced.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block nav %} 4 | {% include "nav.html" with page_advanced=True %} 5 | {% endblock nav %} 6 | 7 | {% block extra-css %} 8 | 17 | {% endblock %} 18 | 19 | {% block extra-js %} 20 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39,310,311,312,313}-dj{32,42,51} 4 | docs 5 | 6 | [flake8] 7 | max-line-length = 120 8 | ignore = 9 | 10 | exclude = 11 | */migrations, 12 | 13 | [testenv] 14 | deps = 15 | coverage>=4.0 16 | dj32: django==3.2.* 17 | dj42: django==4.2.* 18 | dj51: django==5.1.* 19 | commands = coverage run runtests.py 20 | 21 | [testenv:docs] 22 | basepython = python3.9 23 | deps = 24 | Sphinx 25 | Django 26 | commands = 27 | {envbindir}/sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html 28 | 29 | 30 | [testenv:lint] 31 | envdir={toxworkdir}/py39-d41/ 32 | skip_install = true 33 | commands = 34 | pip install flake8 isort 35 | flake8 selectable example 36 | isort -c selectabe example 37 | -------------------------------------------------------------------------------- /example/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from localflavor.us.models import USStateField 3 | 4 | 5 | class Fruit(models.Model): 6 | name = models.CharField(max_length=200) 7 | 8 | def __str__(self): 9 | return self.name 10 | 11 | 12 | class Farm(models.Model): 13 | name = models.CharField(max_length=200) 14 | owner = models.ForeignKey( 15 | "auth.User", related_name="farms", on_delete=models.CASCADE 16 | ) 17 | fruit = models.ManyToManyField(Fruit) 18 | 19 | def __str__(self): 20 | return "%s's Farm: %s" % (self.owner.username, self.name) 21 | 22 | 23 | class City(models.Model): 24 | name = models.CharField(max_length=200) 25 | state = USStateField() 26 | 27 | def __str__(self): 28 | return self.name 29 | -------------------------------------------------------------------------------- /selectable/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-10-21 20:14-0400\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: base.py:117 21 | msgid "Show more results" 22 | msgstr "" 23 | 24 | #: forms/fields.py:48 forms/fields.py:96 25 | msgid "Select a valid choice. That choice is not one of the available choices." 26 | msgstr "" 27 | -------------------------------------------------------------------------------- /selectable/tests/qunit/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Django Selectable Test Suite 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Django Selectable Test Suite

16 |

17 |
18 |

19 |
    20 |
    21 | 22 | 23 | -------------------------------------------------------------------------------- /selectable/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from ..base import ModelLookup 4 | from ..registry import registry 5 | 6 | 7 | class Thing(models.Model): 8 | name = models.CharField(max_length=100) 9 | description = models.CharField(max_length=100) 10 | 11 | def __str__(self): 12 | return self.name 13 | 14 | class Meta: 15 | ordering = ["id"] 16 | 17 | 18 | class OtherThing(models.Model): 19 | name = models.CharField(max_length=100) 20 | thing = models.ForeignKey(Thing, on_delete=models.CASCADE) 21 | 22 | def __str__(self): 23 | return self.name 24 | 25 | 26 | class ManyThing(models.Model): 27 | name = models.CharField(max_length=100) 28 | things = models.ManyToManyField(Thing) 29 | 30 | def __str__(self): 31 | return self.name 32 | 33 | 34 | class ThingLookup(ModelLookup): 35 | model = Thing 36 | search_fields = ("name__icontains",) 37 | 38 | 39 | registry.register(ThingLookup) 40 | -------------------------------------------------------------------------------- /selectable/locale/cs/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-03-29 23:31+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: base.py:106 21 | msgid "Show more results" 22 | msgstr "Zobrazit další výsledky" 23 | 24 | #: forms/fields.py:53 forms/fields.py:101 25 | msgid "Select a valid choice. That choice is not one of the available choices." 26 | msgstr "Vyberte platnou volbu. Tato volba není mezi platnými možnostmi." 27 | -------------------------------------------------------------------------------- /example/core/lookups.py: -------------------------------------------------------------------------------- 1 | from core.models import City, Fruit 2 | from django.contrib.auth.models import User 3 | 4 | from selectable.base import ModelLookup 5 | from selectable.registry import registry 6 | 7 | 8 | class FruitLookup(ModelLookup): 9 | model = Fruit 10 | search_fields = ("name__icontains",) 11 | 12 | 13 | registry.register(FruitLookup) 14 | 15 | 16 | class OwnerLookup(ModelLookup): 17 | model = User 18 | search_fields = ("username__icontains",) 19 | 20 | 21 | registry.register(OwnerLookup) 22 | 23 | 24 | class CityLookup(ModelLookup): 25 | model = City 26 | search_fields = ("name__icontains",) 27 | 28 | def get_query(self, request, term): 29 | results = super().get_query(request, term) 30 | state = request.GET.get("state", "") 31 | if state: 32 | results = results.filter(state=state) 33 | return results 34 | 35 | def get_item_label(self, item): 36 | return "%s, %s" % (item.name, item.state) 37 | 38 | 39 | registry.register(CityLookup) 40 | -------------------------------------------------------------------------------- /selectable/locale/it/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Mario Orlandi , 2019 7 | # 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: \n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2019-12-02 11:38-0600\n" 13 | "PO-Revision-Date: 2019-12-02 18:46+0100\n" 14 | "Language: it\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "Last-Translator: \n" 20 | "Language-Team: \n" 21 | "X-Generator: Poedit 2.2.4\n" 22 | 23 | #: base.py:112 24 | msgid "Show more results" 25 | msgstr "Visualizza ulteriori risultati" 26 | 27 | #: forms/fields.py:49 forms/fields.py:95 28 | msgid "Select a valid choice. That choice is not one of the available choices." 29 | msgstr "Selezionare una scelta valida. La scelta attuale non risulta fra quelle disponibili." 30 | -------------------------------------------------------------------------------- /selectable/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-selectable\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2012-10-06 15:02-0400\n" 11 | "PO-Revision-Date: 2013-11-20 10:18+0000\n" 12 | "Last-Translator: Manuel Alvarez \n" 13 | "Language-Team: Spanish (http://www.transifex.com/projects/p/django-selectable/language/es/)\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Language: es\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: base.py:115 21 | msgid "Show more results" 22 | msgstr "Mostrar más resultados" 23 | 24 | #: forms/fields.py:19 forms/fields.py:63 25 | msgid "" 26 | "Select a valid choice. That choice is not one of the available choices." 27 | msgstr "Seleccione una opción válida. La opción seleccionada no está disponible." 28 | -------------------------------------------------------------------------------- /selectable/locale/zh_CN/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # mozillazg , 2013 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-selectable\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-10-06 15:02-0400\n" 12 | "PO-Revision-Date: 2013-11-21 05:08+0000\n" 13 | "Last-Translator: mozillazg \n" 14 | "Language-Team: Chinese (China) (http://www.transifex.com/projects/p/django-selectable/language/zh_CN/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: zh_CN\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | 21 | #: base.py:115 22 | msgid "Show more results" 23 | msgstr "显示更多结果" 24 | 25 | #: forms/fields.py:19 forms/fields.py:63 26 | msgid "" 27 | "Select a valid choice. That choice is not one of the available choices." 28 | msgstr "请选择一个有效的选项。当前选项无效。" 29 | -------------------------------------------------------------------------------- /selectable/locale/pt_BR/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-selectable\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2012-10-06 15:02-0400\n" 11 | "PO-Revision-Date: 2013-11-20 10:18+0000\n" 12 | "Last-Translator: Mark Lavin \n" 13 | "Language-Team: Portuguese (Brazil) (http://www.transifex.com/projects/p/django-selectable/language/pt_BR/)\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Language: pt_BR\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | #: base.py:115 21 | msgid "Show more results" 22 | msgstr "Mostrar mais resultados" 23 | 24 | #: forms/fields.py:19 forms/fields.py:63 25 | msgid "" 26 | "Select a valid choice. That choice is not one of the available choices." 27 | msgstr "Selecione uma escolha valida. Esta escolha não é uma das disponíveis." 28 | -------------------------------------------------------------------------------- /selectable/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Tobias Zanke , 2016-2017 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-selectable\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-10-06 15:02-0400\n" 12 | "PO-Revision-Date: 2017-01-19 10:24+0000\n" 13 | "Last-Translator: Tobias Zanke \n" 14 | "Language-Team: German (http://www.transifex.com/mlavin/django-selectable/language/de/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: de\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: base.py:115 22 | msgid "Show more results" 23 | msgstr "Weitere Ergebnisse anzeigen" 24 | 25 | #: forms/fields.py:19 forms/fields.py:63 26 | msgid "" 27 | "Select a valid choice. That choice is not one of the available choices." 28 | msgstr "Bitte gültige Auswahl treffen. Diese Auswahl ist nicht verfügbar." -------------------------------------------------------------------------------- /selectable/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Mark Lavin , 2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-selectable\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-10-06 15:02-0400\n" 12 | "PO-Revision-Date: 2014-01-21 01:00+0000\n" 13 | "Last-Translator: Mark Lavin \n" 14 | "Language-Team: French (http://www.transifex.com/projects/p/django-selectable/language/fr/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: fr\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: base.py:115 22 | msgid "Show more results" 23 | msgstr "Afficher plus de résultats" 24 | 25 | #: forms/fields.py:19 forms/fields.py:63 26 | msgid "" 27 | "Select a valid choice. That choice is not one of the available choices." 28 | msgstr "Sélectionnez un choix valide. Ce choix ne fait pas partie de ceux disponibles." 29 | -------------------------------------------------------------------------------- /selectable/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # slafs , 2012 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-selectable\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-10-06 15:02-0400\n" 12 | "PO-Revision-Date: 2013-11-20 10:18+0000\n" 13 | "Last-Translator: slafs \n" 14 | "Language-Team: Polish (http://www.transifex.com/projects/p/django-selectable/language/pl/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: pl\n" 19 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 20 | 21 | #: base.py:115 22 | msgid "Show more results" 23 | msgstr "Pokaż więcej wyników" 24 | 25 | #: forms/fields.py:19 forms/fields.py:63 26 | msgid "" 27 | "Select a valid choice. That choice is not one of the available choices." 28 | msgstr "Dokonaj poprawnego wyboru. Ten wybór nie jest jednym z dostępnych." 29 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from django.conf import settings 6 | 7 | 8 | if not settings.configured: 9 | settings.configure( 10 | DATABASES={ 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': ':memory:', 14 | } 15 | }, 16 | MIDDLEWARE=(), 17 | INSTALLED_APPS=( 18 | 'selectable', 19 | ), 20 | SECRET_KEY='super-secret', 21 | ROOT_URLCONF='selectable.tests.urls', 22 | TEMPLATES=[{ 23 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 24 | 'DIRS': [os.path.join(os.path.normpath(os.path.join( 25 | os.path.dirname(__file__), 'selectable')), 'templates')]}]) 26 | 27 | 28 | from django import setup 29 | from django.test.utils import get_runner 30 | 31 | 32 | def runtests(): 33 | setup() 34 | TestRunner = get_runner(settings) 35 | test_runner = TestRunner(verbosity=1, interactive=True, failfast=False) 36 | args = sys.argv[1:] or ['selectable', ] 37 | failures = test_runner.run_tests(args) 38 | sys.exit(failures) 39 | 40 | 41 | if __name__ == '__main__': 42 | runtests() 43 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | 17 | import os 18 | 19 | from django.core.wsgi import get_wsgi_application 20 | 21 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 22 | 23 | # This application object is used by any WSGI server configured to use this 24 | # file. This includes Django's development server, if the WSGI_APPLICATION 25 | # setting points here. 26 | 27 | application = get_wsgi_application() 28 | 29 | # Apply WSGI middleware here. 30 | # from helloworld.wsgi import HelloWorldApplication 31 | # application = HelloWorldApplication(application) 32 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ================== 3 | 4 | Motivation 5 | -------------------------------------- 6 | 7 | There are many Django apps related to auto-completion why create another? One problem 8 | was varying support for the `jQuery UI auto-complete plugin `_ 9 | versus the now deprecated `bassistance version `_. 10 | Another was support for combo-boxes and multiple selects. And lastly was a simple syntax for 11 | defining the related backend views for the auto-completion. 12 | 13 | This library aims to meet all of these goals: 14 | - Built on jQuery UI auto-complete 15 | - Fields and widgets for a variety of use-cases: 16 | - Text inputs and combo-boxes 17 | - Text selection 18 | - Value/ID/Foreign key selection 19 | - Multiple object selection 20 | - Allowing new values 21 | - Simple and extendable syntax for defining backend views 22 | 23 | 24 | Related Projects 25 | -------------------------------------- 26 | 27 | Much of the work here was inspired by things that I like (and things I don't like) about 28 | `django-ajax-selects `_. To see some of the 29 | other Django apps for handling auto-completion see `Django-Packages `_. 30 | -------------------------------------------------------------------------------- /selectable/static/selectable/css/dj.selectable.css: -------------------------------------------------------------------------------- 1 | /* 2 | * django-selectable UI widget CSS 3 | * Source: https://github.com/mlavin/django-selectable 4 | * Docs: http://django-selectable.readthedocs.org/ 5 | * 6 | * Copyright 2010-2014, Mark Lavin 7 | * BSD License 8 | * 9 | */ 10 | ul.selectable-deck, ul.ui-autocomplete { 11 | list-style: none outside none; 12 | } 13 | ul.selectable-deck li.selectable-deck-item, 14 | ul.ui-autocomplete li.ui-menu-item { 15 | margin: 0; 16 | list-style-type: none; 17 | } 18 | ul.selectable-deck li.selectable-deck-item .selectable-deck-remove { 19 | float: right; 20 | } 21 | ul.selectable-deck-bottom-inline, 22 | ul.selectable-deck-top-inline { 23 | padding: 0; 24 | } 25 | ul.selectable-deck-bottom-inline li.selectable-deck-item, 26 | ul.selectable-deck-top-inline li.selectable-deck-item { 27 | display: inline; 28 | } 29 | ul.selectable-deck-bottom-inline li.selectable-deck-item .selectable-deck-remove, 30 | ul.selectable-deck-top-inline li.selectable-deck-item .selectable-deck-remove { 31 | margin-left: 0.4em; 32 | display: inline; 33 | float: none; 34 | } 35 | ul.ui-autocomplete li.ui-menu-item span.highlight { 36 | font-weight: bold; 37 | } 38 | input.ui-combo-input { 39 | margin-right: 0; 40 | line-height: 1.3; 41 | } 42 | a.ui-combo-button { 43 | margin-left: -1px; 44 | } 45 | a.ui-combo-button .ui-button-text { 46 | padding: 0; 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-201999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999, Mark Lavin 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /selectable/registry.py: -------------------------------------------------------------------------------- 1 | from django.utils.encoding import force_str 2 | from django.utils.module_loading import autodiscover_modules 3 | 4 | from selectable.base import LookupBase 5 | from selectable.exceptions import ( 6 | LookupAlreadyRegistered, 7 | LookupInvalid, 8 | LookupNotRegistered, 9 | ) 10 | 11 | 12 | class LookupRegistry: 13 | def __init__(self): 14 | self._registry = {} 15 | 16 | def validate(self, lookup): 17 | if not issubclass(lookup, LookupBase): 18 | raise LookupInvalid( 19 | "Registered lookups must inherit from the LookupBase class" 20 | ) 21 | 22 | def register(self, lookup): 23 | self.validate(lookup) 24 | name = force_str(lookup.name()) 25 | if name in self._registry: 26 | raise LookupAlreadyRegistered("The name %s is already registered" % name) 27 | self._registry[name] = lookup 28 | 29 | def unregister(self, lookup): 30 | self.validate(lookup) 31 | name = force_str(lookup.name()) 32 | if name not in self._registry: 33 | raise LookupNotRegistered("The name %s is not registered" % name) 34 | del self._registry[name] 35 | 36 | def get(self, key): 37 | return self._registry.get(key, None) 38 | 39 | 40 | registry = LookupRegistry() 41 | 42 | 43 | def autodiscover(): 44 | # Attempt to import the app's lookups module. 45 | autodiscover_modules("lookups", register_to=registry) 46 | -------------------------------------------------------------------------------- /example/core/views.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | from core.forms import ChainedForm, FarmFormset, FruitForm 4 | from django.shortcuts import render 5 | 6 | 7 | def index(request): 8 | if request.method == "POST": 9 | form = FruitForm(request.POST) 10 | else: 11 | if request.GET: 12 | form = FruitForm(initial=request.GET) 13 | else: 14 | form = FruitForm() 15 | 16 | raw_post = "" 17 | cleaned_data = "" 18 | if request.POST: 19 | raw_post = pprint.pformat(dict(request.POST)) 20 | if form.is_valid(): 21 | cleaned_data = pprint.pformat(getattr(form, "cleaned_data", "")) 22 | 23 | context = {"cleaned_data": cleaned_data, "form": form, "raw_post": raw_post} 24 | return render(request, "base.html", context) 25 | 26 | 27 | def advanced(request): 28 | if request.method == "POST": 29 | form = ChainedForm(request.POST) 30 | else: 31 | if request.GET: 32 | form = ChainedForm(initial=request.GET) 33 | else: 34 | form = ChainedForm() 35 | 36 | return render(request, "advanced.html", {"form": form}) 37 | 38 | 39 | def formset(request): 40 | if request.method == "POST": 41 | formset = FarmFormset(request.POST) 42 | else: 43 | if request.GET: 44 | formset = FarmFormset(initial=request.GET) 45 | else: 46 | formset = FarmFormset() 47 | 48 | return render(request, "formset.html", {"formset": formset}) 49 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | db90eb16f083397a4d655313ce9106a02616c043 version-0.1 2 | 3b971faaa99ceb46d5acc374cf4102662cedb2ee version-0.1.1 3 | c269d99ffb471f496ab43a3fab92dc6aea9a03ba version-0.1.2 4 | 970e9ae7f53cec6c6b0c0239f20d52a797ff124c version-0.2.0 5 | 970e9ae7f53cec6c6b0c0239f20d52a797ff124c version-0.2.0 6 | 0000000000000000000000000000000000000000 version-0.2.0 7 | 0000000000000000000000000000000000000000 version-0.2.0 8 | 1947dd8202ed9519fbdd8cee01c7b114b9c0064a version-0.2.0 9 | 1947dd8202ed9519fbdd8cee01c7b114b9c0064a version-0.2.0 10 | 0000000000000000000000000000000000000000 version-0.2.0 11 | 0000000000000000000000000000000000000000 version-0.2.0 12 | c6671545c9e32bf1635c684f16ee7fb645cae7dd version-0.2.0 13 | b0e1a2bcc104a8951db42f809aced3d3c9d018e1 version-0.3.0 14 | f04a62d8741f8ed6ff02da2ef9754b634b0b861d version-0.3.1 15 | 22d8ff41b897743aa1f8ea44ed1a7814f5334346 version-0.4.0 16 | c7dff80ff2daeccd12b17540559519202e61407c version-0.4.1 17 | d1b726b55b9093d058c416a46eb186fccc6280a4 version-0.4.2 18 | 4ca831efa36d0c879cde84921df0641f7806a74e version-0.5.0 19 | 4597983758f1b174c40c0a5c279952925d8d3085 version-0.5.1 20 | 26ccd03bdadc78c1861f63baff3bd9f401f58e5b version-0.5.2 21 | dd1cea7ff57aaf8610b6dbcb2caf64619bd687ab version-0.6.0 22 | 7a0836ffd427952ba31958dd097c49667d0e8880 version-0.6.1 23 | 5fb7551c6cdd56defb67cf15bc2bed674c37ddcf version-0.6.2 24 | 5b8105c96f436278f1541d2a52f0f708ffce4eb2 version-0.7.0 25 | 5b8105c96f436278f1541d2a52f0f708ffce4eb2 version-0.7.0 26 | 45e334300ad9327b97dded0f0525da756dcc5376 version-0.7.0 27 | -------------------------------------------------------------------------------- /selectable/forms/base.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django import forms 4 | from django.conf import settings 5 | 6 | __all__ = ( 7 | "BaseLookupForm", 8 | "import_lookup_class", 9 | ) 10 | 11 | 12 | class BaseLookupForm(forms.Form): 13 | term = forms.CharField(required=False) 14 | limit = forms.IntegerField(required=False, min_value=1) 15 | page = forms.IntegerField(required=False, min_value=1) 16 | 17 | def clean_limit(self): 18 | "Ensure given limit is less than default if defined" 19 | limit = self.cleaned_data.get("limit", None) 20 | if settings.SELECTABLE_MAX_LIMIT is not None and ( 21 | not limit or limit > settings.SELECTABLE_MAX_LIMIT 22 | ): 23 | limit = settings.SELECTABLE_MAX_LIMIT 24 | return limit 25 | 26 | def clean_page(self): 27 | """ 28 | Return the first page if no page or invalid number is given. 29 | """ 30 | return self.cleaned_data.get("page", 1) or 1 31 | 32 | 33 | def import_lookup_class(lookup_class): 34 | """ 35 | Import lookup_class as a dotted base and ensure it extends LookupBase 36 | """ 37 | from selectable.base import LookupBase 38 | 39 | if isinstance(lookup_class, str): 40 | mod_str, cls_str = lookup_class.rsplit(".", 1) 41 | mod = import_module(mod_str) 42 | lookup_class = getattr(mod, cls_str) 43 | if not issubclass(lookup_class, LookupBase): 44 | raise TypeError("lookup_class must extend from selectable.base.LookupBase") 45 | return lookup_class 46 | -------------------------------------------------------------------------------- /example/core/admin.py: -------------------------------------------------------------------------------- 1 | from core.lookups import FruitLookup, OwnerLookup 2 | from core.models import Farm, Fruit 3 | from django import forms 4 | from django.contrib import admin 5 | from django.contrib.auth.admin import UserAdmin 6 | from django.contrib.auth.models import User 7 | 8 | import selectable.forms as selectable 9 | 10 | 11 | class FarmAdminForm(forms.ModelForm): 12 | owner = selectable.AutoCompleteSelectField(lookup_class=OwnerLookup, allow_new=True) 13 | 14 | class Meta: 15 | model = Farm 16 | widgets = { 17 | "fruit": selectable.AutoCompleteSelectMultipleWidget( 18 | lookup_class=FruitLookup 19 | ), 20 | } 21 | exclude = ("owner",) 22 | 23 | def __init__(self, *args, **kwargs): 24 | super().__init__(*args, **kwargs) 25 | if self.instance and self.instance.pk and self.instance.owner: 26 | self.initial["owner"] = self.instance.owner.pk 27 | 28 | def save(self, *args, **kwargs): 29 | owner = self.cleaned_data["owner"] 30 | if owner and not owner.pk: 31 | owner = User.objects.create_user(username=owner.username, email="") 32 | self.instance.owner = owner 33 | return super().save(*args, **kwargs) 34 | 35 | 36 | class FarmAdmin(admin.ModelAdmin): 37 | form = FarmAdminForm 38 | 39 | 40 | class FarmInline(admin.TabularInline): 41 | model = Farm 42 | form = FarmAdminForm 43 | 44 | 45 | class NewUserAdmin(UserAdmin): 46 | inlines = [ 47 | FarmInline, 48 | ] 49 | 50 | 51 | admin.site.unregister(User) 52 | admin.site.register(User, NewUserAdmin) 53 | admin.site.register(Fruit) 54 | admin.site.register(Farm, FarmAdmin) 55 | -------------------------------------------------------------------------------- /example/core/templates/formset.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block nav %} 4 | {% include "nav.html" with page_formset=True %} 5 | {% endblock %} 6 | 7 | {% block extra-css %} 8 | {{ formset.media.css }} 9 | {% endblock %} 10 | 11 | {% block extra-js %} 12 | 13 | 23 | {{ formset.media.js }} 24 | {% endblock %} 25 | 26 | 27 | {% block content %} 28 |
    29 | {% csrf_token %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {{ formset.management_form }} 40 | {% for form in formset.forms %} 41 | 42 | 43 | 44 | 45 | 46 | {% endfor %} 47 | 48 |
    NameOwnerFruit
    {{ form.name }}{{ form.owner }}{{ form.fruit }}
    49 | 50 |
    51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v3 15 | 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip tox 19 | - name: Lint with flake8 20 | run: | 21 | tox -e lint 22 | test: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"] 28 | django-version: [ "3.2", "4.2", "5.1"] 29 | exclude: 30 | - python-version: "3.9" 31 | django-version: "5.1" 32 | - python-version: "3.13" 33 | django-version: "3.2" 34 | env: 35 | PY_VER: ${{ matrix.python-version}} 36 | DJ_VER: ${{ matrix.django-version}} 37 | 38 | steps: 39 | - uses: actions/checkout@v2 40 | 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v3 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | 46 | - name: Install dependencies 47 | run: python -m pip install --upgrade pip tox 48 | 49 | - name: Test with 50 | run: tox -e py${PY_VER//.}-dj${DJ_VER//.} 51 | 52 | - uses: codecov/codecov-action@v2 53 | with: 54 | verbose: true -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | 6 | def read_file(filename): 7 | """Read a file into a string""" 8 | path = os.path.abspath(os.path.dirname(__file__)) 9 | filepath = os.path.join(path, filename) 10 | try: 11 | return open(filepath).read() 12 | except IOError: 13 | return '' 14 | 15 | 16 | setup( 17 | name='django-selectable', 18 | version=__import__('selectable').__version__, 19 | author='Mark Lavin', 20 | author_email='markdlavin@gmail.com', 21 | packages=find_packages(exclude=['example']), 22 | include_package_data=True, 23 | url='https://github.com/mlavin/django-selectable', 24 | license='BSD', 25 | description=' '.join(__import__('selectable').__doc__.splitlines()).strip(), 26 | classifiers=[ 27 | 'Development Status :: 5 - Production/Stable', 28 | 'Intended Audience :: Developers', 29 | 'Framework :: Django', 30 | 'Framework :: Django :: 3.2', 31 | 'Framework :: Django :: 4.2', 32 | 'Framework :: Django :: 5.1', 33 | 'License :: OSI Approved :: BSD License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.9', 38 | 'Programming Language :: Python :: 3.10', 39 | 'Programming Language :: Python :: 3.11', 40 | 'Programming Language :: Python :: 3.12', 41 | 'Programming Language :: Python :: 3.13', 42 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 43 | ], 44 | long_description=read_file('README.rst'), 45 | test_suite="runtests.runtests", 46 | zip_safe=False, # because we're including media that Django needs 47 | ) 48 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ========== 3 | 4 | Django-Selectable defines a number of fields for selecting either single or multiple 5 | lookup items. Item in this context corresponds to the object return by the underlying 6 | lookup ``get_item``. The single select select field :ref:`AutoCompleteSelectField` 7 | allows for the creation of new items. To use this feature the field's 8 | lookup class must define ``create_item``. In the case of lookups extending from 9 | :ref:`ModelLookup` newly created items have not yet been saved into the database and saving 10 | should be handled by the form. All fields take the lookup class as the first required 11 | argument. 12 | 13 | 14 | .. _AutoCompleteSelectField: 15 | 16 | AutoCompleteSelectField 17 | -------------------------------------- 18 | 19 | Field tied to :ref:`AutoCompleteSelectWidget` to bind the selection to the form and 20 | create new items, if allowed. The ``allow_new`` keyword argument (default: ``False``) 21 | which determines if the field allows new items. This field cleans to a single item. 22 | 23 | .. code-block:: python 24 | 25 | from django import forms 26 | 27 | from selectable.forms import AutoCompleteSelectField 28 | 29 | from .lookups import FruitLookup 30 | 31 | 32 | class FruitSelectionForm(forms.Form): 33 | fruit = AutoCompleteSelectField(lookup_class=FruitLookup, label='Select a fruit') 34 | 35 | `lookup_class`` may also be a dotted path. 36 | 37 | 38 | .. _AutoCompleteSelectMultipleField: 39 | 40 | AutoCompleteSelectMultipleField 41 | -------------------------------------- 42 | 43 | Field tied to :ref:`AutoCompleteSelectMultipleWidget` to bind the selection to the form. 44 | This field cleans to a list of items. :ref:`AutoCompleteSelectMultipleField` does not 45 | allow for the creation of new items. 46 | 47 | 48 | .. code-block:: python 49 | 50 | from django import forms 51 | 52 | from selectable.forms import AutoCompleteSelectMultipleField 53 | 54 | from .lookups import FruitLookup 55 | 56 | 57 | class FruitsSelectionForm(forms.Form): 58 | fruits = AutoCompleteSelectMultipleField(lookup_class=FruitLookup, 59 | label='Select your favorite fruits') 60 | -------------------------------------------------------------------------------- /example/core/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load selectable_tags %} 2 | 3 | 4 | 5 | 6 | {% block title %}Django-Selectable Example{% endblock %} 7 | 8 | 9 | {% include_ui_theme %} 10 | 11 | {{ form.media.css }} 12 | {% block extra-css %}{% endblock %} 13 | 14 | 15 | 32 |
    33 |

    Example Project

    34 | {% block content %} 35 |
    36 | {% csrf_token %} 37 | {{ form.as_p }} 38 | 39 |
    40 | {% if form.is_valid %} 41 |
    42 |

    Cleaned Data

    43 |
    {{ cleaned_data }}
    44 |
    45 | {% endif %} 46 | {% if raw_post %} 47 |
    48 |

    Raw POST data:

    49 |
    {{ raw_post }}
    50 |
    51 | {% endif %} 52 | {% endblock %} 53 |
    54 | {% include_jquery_libs %} 55 | {{ form.media.js }} 56 | {% block extra-js %}{% endblock %} 57 | 58 | 59 | -------------------------------------------------------------------------------- /selectable/decorators.py: -------------------------------------------------------------------------------- 1 | "Decorators for additional lookup functionality." 2 | 3 | from functools import wraps 4 | 5 | from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden 6 | 7 | __all__ = ( 8 | "ajax_required", 9 | "login_required", 10 | "staff_member_required", 11 | ) 12 | 13 | 14 | def results_decorator(func): 15 | """ 16 | Helper for constructing simple decorators around Lookup.results. 17 | 18 | func is a function which takes a request as the first parameter. If func 19 | returns an HttpReponse it is returned otherwise the original Lookup.results 20 | is returned. 21 | """ 22 | 23 | # Wrap function to maintian the original doc string, etc 24 | @wraps(func) 25 | def decorator(lookup_cls): 26 | # Construct a class decorator from the original function 27 | original = lookup_cls.results 28 | 29 | def inner(self, request): 30 | # Wrap lookup_cls.results by first calling func and checking the result 31 | result = func(request) 32 | if isinstance(result, HttpResponse): 33 | return result 34 | return original(self, request) 35 | 36 | # Replace original lookup_cls.results with wrapped version 37 | lookup_cls.results = inner 38 | return lookup_cls 39 | 40 | # Return the constructed decorator 41 | return decorator 42 | 43 | 44 | @results_decorator 45 | def ajax_required(request): 46 | "Lookup decorator to require AJAX calls to the lookup view." 47 | if not request.headers.get("x-requested-with") == "XMLHttpRequest": 48 | return HttpResponseBadRequest() 49 | 50 | 51 | @results_decorator 52 | def login_required(request): 53 | "Lookup decorator to require the user to be authenticated." 54 | user = getattr(request, "user", None) 55 | if user is None or not user.is_authenticated: 56 | return HttpResponse(status=401) # Unauthorized 57 | 58 | 59 | @results_decorator 60 | def staff_member_required(request): 61 | "Lookup decorator to require the user is a staff member." 62 | user = getattr(request, "user", None) 63 | if user is None or not user.is_authenticated: 64 | return HttpResponse(status=401) # Unauthorized 65 | elif not user.is_staff: 66 | return HttpResponseForbidden() 67 | -------------------------------------------------------------------------------- /example/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.12 on 2017-02-25 01:13 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | import localflavor.us.models 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="City", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ("name", models.CharField(max_length=200)), 32 | ("state", localflavor.us.models.USStateField(max_length=2)), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name="Farm", 37 | fields=[ 38 | ( 39 | "id", 40 | models.AutoField( 41 | auto_created=True, 42 | primary_key=True, 43 | serialize=False, 44 | verbose_name="ID", 45 | ), 46 | ), 47 | ("name", models.CharField(max_length=200)), 48 | ], 49 | ), 50 | migrations.CreateModel( 51 | name="Fruit", 52 | fields=[ 53 | ( 54 | "id", 55 | models.AutoField( 56 | auto_created=True, 57 | primary_key=True, 58 | serialize=False, 59 | verbose_name="ID", 60 | ), 61 | ), 62 | ("name", models.CharField(max_length=200)), 63 | ], 64 | ), 65 | migrations.AddField( 66 | model_name="farm", 67 | name="fruit", 68 | field=models.ManyToManyField(to="core.Fruit"), 69 | ), 70 | migrations.AddField( 71 | model_name="farm", 72 | name="owner", 73 | field=models.ForeignKey( 74 | on_delete=django.db.models.deletion.CASCADE, 75 | related_name="farms", 76 | to=settings.AUTH_USER_MODEL, 77 | ), 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /selectable/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from ..forms import BaseLookupForm 2 | from .base import BaseSelectableTestCase 3 | 4 | __all__ = ("BaseLookupFormTestCase",) 5 | 6 | 7 | class BaseLookupFormTestCase(BaseSelectableTestCase): 8 | def get_valid_data(self): 9 | data = { 10 | "term": "foo", 11 | "limit": 10, 12 | } 13 | return data 14 | 15 | def test_valid_data(self): 16 | data = self.get_valid_data() 17 | form = BaseLookupForm(data) 18 | self.assertTrue(form.is_valid(), "%s" % form.errors) 19 | 20 | def test_invalid_limit(self): 21 | """ 22 | Test giving the form an invalid limit. 23 | """ 24 | 25 | data = self.get_valid_data() 26 | data["limit"] = "bar" 27 | form = BaseLookupForm(data) 28 | self.assertFalse(form.is_valid()) 29 | 30 | def test_no_limit(self): 31 | """ 32 | If SELECTABLE_MAX_LIMIT is set and limit is not given then 33 | the form will return SELECTABLE_MAX_LIMIT. 34 | """ 35 | 36 | with self.settings(SELECTABLE_MAX_LIMIT=25): 37 | data = self.get_valid_data() 38 | if "limit" in data: 39 | del data["limit"] 40 | form = BaseLookupForm(data) 41 | self.assertTrue(form.is_valid(), "%s" % form.errors) 42 | self.assertEqual(form.cleaned_data["limit"], 25) 43 | 44 | def test_no_max_set(self): 45 | """ 46 | If SELECTABLE_MAX_LIMIT is not set but given then the form 47 | will return the given limit. 48 | """ 49 | 50 | with self.settings(SELECTABLE_MAX_LIMIT=None): 51 | data = self.get_valid_data() 52 | form = BaseLookupForm(data) 53 | self.assertTrue(form.is_valid(), "%s" % form.errors) 54 | if "limit" in data: 55 | self.assertTrue(form.cleaned_data["limit"], data["limit"]) 56 | 57 | def test_no_max_set_not_given(self): 58 | """ 59 | If SELECTABLE_MAX_LIMIT is not set and not given then the form 60 | will return no limit. 61 | """ 62 | 63 | with self.settings(SELECTABLE_MAX_LIMIT=None): 64 | data = self.get_valid_data() 65 | if "limit" in data: 66 | del data["limit"] 67 | form = BaseLookupForm(data) 68 | self.assertTrue(form.is_valid(), "%s" % form.errors) 69 | self.assertFalse(form.cleaned_data.get("limit")) 70 | 71 | def test_over_limit(self): 72 | """ 73 | If SELECTABLE_MAX_LIMIT is set and limit given is greater then 74 | the form will return SELECTABLE_MAX_LIMIT. 75 | """ 76 | 77 | with self.settings(SELECTABLE_MAX_LIMIT=25): 78 | data = self.get_valid_data() 79 | data["limit"] = 125 80 | form = BaseLookupForm(data) 81 | self.assertTrue(form.is_valid(), "%s" % form.errors) 82 | self.assertEqual(form.cleaned_data["limit"], 25) 83 | -------------------------------------------------------------------------------- /selectable/tests/base.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from collections import defaultdict 4 | 5 | from django.test import TestCase, override_settings 6 | from django.test.html import parse_html 7 | 8 | from ..base import ModelLookup 9 | from . import Thing 10 | 11 | 12 | def parsed_inputs(html): 13 | "Returns a dictionary mapping name --> node of inputs found in the HTML." 14 | node = parse_html(html) 15 | inputs = {} 16 | for field in [c for c in node.children if c.name == "input"]: 17 | name = dict(field.attributes)["name"] 18 | current = inputs.get(name, []) 19 | current.append(field) 20 | inputs[name] = current 21 | return inputs 22 | 23 | 24 | @override_settings(ROOT_URLCONF="selectable.tests.urls") 25 | class BaseSelectableTestCase(TestCase): 26 | def get_random_string(self, length=10): 27 | return "".join(random.choice(string.ascii_letters) for x in range(length)) 28 | 29 | def create_thing(self, data=None): 30 | data = data or {} 31 | defaults = { 32 | "name": self.get_random_string(), 33 | "description": self.get_random_string(), 34 | } 35 | defaults.update(data) 36 | return Thing.objects.create(**defaults) 37 | 38 | 39 | class SimpleModelLookup(ModelLookup): 40 | model = Thing 41 | search_fields = ("name__icontains",) 42 | 43 | 44 | def parsed_widget_attributes(widget): 45 | """ 46 | Get a dictionary-like object containing all HTML attributes 47 | of the rendered widget. 48 | 49 | Lookups on this object raise ValueError if there is more than one attribute 50 | of the given name in the HTML, and they have different values. 51 | """ 52 | # For the tests that use this, it generally doesn't matter what the value 53 | # is, so we supply anything. 54 | rendered = widget.render("a_name", "a_value") 55 | return AttrMap(rendered) 56 | 57 | 58 | class AttrMap: 59 | def __init__(self, html): 60 | dom = parse_html(html) 61 | self._attrs = defaultdict(set) 62 | self._build_attr_map(dom) 63 | 64 | def _build_attr_map(self, dom): 65 | for node in _walk_nodes(dom): 66 | if node.attributes is not None: 67 | for k, v in node.attributes: 68 | self._attrs[k].add(v) 69 | 70 | def __contains__(self, key): 71 | return key in self._attrs and len(self._attrs[key]) > 0 72 | 73 | def __getitem__(self, key): 74 | if key not in self: 75 | raise KeyError(key) 76 | vals = self._attrs[key] 77 | if len(vals) > 1: 78 | raise ValueError( 79 | "More than one value for attribute {0}: {1}".format( 80 | key, ", ".join(vals) 81 | ) 82 | ) 83 | else: 84 | return list(vals)[0] 85 | 86 | 87 | def _walk_nodes(dom): 88 | yield dom 89 | for child in dom.children: 90 | for item in _walk_nodes(child): 91 | yield item 92 | -------------------------------------------------------------------------------- /selectable/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from ..decorators import ajax_required, login_required, staff_member_required 4 | from .base import BaseSelectableTestCase, SimpleModelLookup 5 | 6 | __all__ = ( 7 | "AjaxRequiredLookupTestCase", 8 | "LoginRequiredLookupTestCase", 9 | "StaffRequiredLookupTestCase", 10 | ) 11 | 12 | 13 | class AjaxRequiredLookupTestCase(BaseSelectableTestCase): 14 | def setUp(self): 15 | self.lookup = ajax_required(SimpleModelLookup)() 16 | 17 | def test_ajax_call(self): 18 | "Ajax call should yield a successful response." 19 | request = Mock(headers={"x-requested-with": "XMLHttpRequest"}) 20 | response = self.lookup.results(request) 21 | self.assertTrue(response.status_code, 200) 22 | 23 | def test_non_ajax_call(self): 24 | "Non-Ajax call should yield a bad request response." 25 | request = Mock() 26 | response = self.lookup.results(request) 27 | self.assertEqual(response.status_code, 400) 28 | 29 | 30 | class LoginRequiredLookupTestCase(BaseSelectableTestCase): 31 | def setUp(self): 32 | self.lookup = login_required(SimpleModelLookup)() 33 | 34 | def test_authenicated_call(self): 35 | "Authenicated call should yield a successful response." 36 | request = Mock() 37 | user = Mock() 38 | user.is_authenticated = True 39 | request.user = user 40 | response = self.lookup.results(request) 41 | self.assertTrue(response.status_code, 200) 42 | 43 | def test_non_authenicated_call(self): 44 | "Non-Authenicated call should yield an unauthorized response." 45 | request = Mock() 46 | user = Mock() 47 | user.is_authenticated = False 48 | request.user = user 49 | response = self.lookup.results(request) 50 | self.assertEqual(response.status_code, 401) 51 | 52 | 53 | class StaffRequiredLookupTestCase(BaseSelectableTestCase): 54 | def setUp(self): 55 | self.lookup = staff_member_required(SimpleModelLookup)() 56 | 57 | def test_staff_member_call(self): 58 | "Staff member call should yield a successful response." 59 | request = Mock() 60 | user = Mock() 61 | user.is_authenticated = True 62 | user.is_staff = True 63 | request.user = user 64 | response = self.lookup.results(request) 65 | self.assertTrue(response.status_code, 200) 66 | 67 | def test_authenicated_but_not_staff(self): 68 | "Authenicated but non staff call should yield a forbidden response." 69 | request = Mock() 70 | user = Mock() 71 | user.is_authenticated = True 72 | user.is_staff = False 73 | request.user = user 74 | response = self.lookup.results(request) 75 | self.assertTrue(response.status_code, 403) 76 | 77 | def test_non_authenicated_call(self): 78 | "Non-Authenicated call should yield an unauthorized response." 79 | request = Mock() 80 | user = Mock() 81 | user.is_authenticated = False 82 | user.is_staff = False 83 | request.user = user 84 | response = self.lookup.results(request) 85 | self.assertEqual(response.status_code, 401) 86 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-selectable 2 | =================== 3 | 4 | Tools and widgets for using/creating auto-complete selection widgets using Django and jQuery UI. 5 | 6 | .. image:: https://travis-ci.org/mlavin/django-selectable.svg?branch=master 7 | :target: https://travis-ci.org/mlavin/django-selectable 8 | 9 | .. image:: https://codecov.io/github/mlavin/django-selectable/coverage.svg?branch=master 10 | :target: https://codecov.io/github/mlavin/django-selectable?branch=master 11 | 12 | 13 | .. note:: 14 | 15 | This project is looking for additional maintainers to help with Django/jQuery compatibility 16 | issues as well as addressing support issues/questions. If you are looking to help out 17 | on this project and take a look at the open 18 | `help-wanted `_ 19 | or `question `_ 20 | and see if you can contribute a fix. Be bold! If you want to take a larger role on 21 | the project, please reach out on the 22 | `mailing list `_. I'm happy to work 23 | with you to get you going on an issue. 24 | 25 | 26 | Features 27 | ----------------------------------- 28 | 29 | - Works with the latest jQuery UI Autocomplete library 30 | - Auto-discovery/registration pattern for defining lookups 31 | 32 | 33 | Installation Requirements 34 | ----------------------------------- 35 | 36 | - Python 3.9+ 37 | - `Django `_ >= 3.2 38 | - `jQuery `_ >= 1.9, < 3.0 39 | - `jQuery UI `_ >= 1.10 40 | 41 | To install:: 42 | 43 | pip install django-selectable 44 | 45 | Next add `selectable` to your `INSTALLED_APPS` to include the related css/js:: 46 | 47 | INSTALLED_APPS = ( 48 | 'contrib.staticfiles', 49 | # Other apps here 50 | 'selectable', 51 | ) 52 | 53 | The jQuery and jQuery UI libraries are not included in the distribution but must be included 54 | in your templates. See the example project for an example using these libraries from the 55 | Google CDN. 56 | 57 | Once installed you should add the urls to your root url patterns:: 58 | 59 | urlpatterns = [ 60 | # Other patterns go here 61 | url(r'^selectable/', include('selectable.urls')), 62 | ] 63 | 64 | 65 | Documentation 66 | ----------------------------------- 67 | 68 | Documentation for django-selectable is available on `Read The Docs `_. 69 | 70 | 71 | Additional Help/Support 72 | ----------------------------------- 73 | 74 | You can find additional help or support on the mailing list: http://groups.google.com/group/django-selectable 75 | 76 | 77 | Contributing 78 | -------------------------------------- 79 | 80 | If you think you've found a bug or are interested in contributing to this project 81 | check out our `contributing guide `_. 82 | 83 | If you are interested in translating django-selectable into your native language 84 | you can join the `Transifex project `_. 85 | -------------------------------------------------------------------------------- /run-qunit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wait until the test condition is true or a timeout occurs. Useful for waiting 3 | * on a server response or for a ui change (fadeIn, etc.) to occur. 4 | * 5 | * @param testFx javascript condition that evaluates to a boolean, 6 | * it can be passed in as a string (e.g.: "1 == 1" or "$('#bar').is(':visible')" or 7 | * as a callback function. 8 | * @param onReady what to do when testFx condition is fulfilled, 9 | * it can be passed in as a string (e.g.: "1 == 1" or "$('#bar').is(':visible')" or 10 | * as a callback function. 11 | * @param timeOutMillis the max amount of time to wait. If not specified, 3 sec is used. 12 | */ 13 | function waitFor(testFx, onReady, timeOutMillis) { 14 | var maxtimeOutMillis = timeOutMillis ? timeOutMillis : 30001, //< Default Max Timout is 3s 15 | start = new Date().getTime(), 16 | condition = false, 17 | interval = setInterval(function() { 18 | if ( (new Date().getTime() - start < maxtimeOutMillis) && !condition ) { 19 | // If not time-out yet and condition not yet fulfilled 20 | condition = (typeof(testFx) === "string" ? eval(testFx) : testFx()); //< defensive code 21 | } else { 22 | if(!condition) { 23 | // If condition still not fulfilled (timeout but condition is 'false') 24 | console.log("'waitFor()' timeout"); 25 | phantom.exit(1); 26 | } else { 27 | // Condition fulfilled (timeout and/or condition is 'true') 28 | console.log("'waitFor()' finished in " + (new Date().getTime() - start) + "ms."); 29 | typeof(onReady) === "string" ? eval(onReady) : onReady(); //< Do what it's supposed to do once the condition is fulfilled 30 | clearInterval(interval); //< Stop this interval 31 | } 32 | } 33 | }, 100); //< repeat check every 250ms 34 | }; 35 | 36 | 37 | if (phantom.args.length === 0 || phantom.args.length > 2) { 38 | console.log('Usage: run-qunit.js URL'); 39 | phantom.exit(1); 40 | } 41 | 42 | var page = require('webpage').create(); 43 | 44 | // Route "console.log()" calls from within the Page context to the main Phantom context (i.e. current "this") 45 | page.onConsoleMessage = function(msg) { 46 | console.log(msg); 47 | }; 48 | 49 | page.open(phantom.args[0], function(status){ 50 | if (status !== "success") { 51 | console.log("Unable to access network"); 52 | phantom.exit(1); 53 | } else { 54 | waitFor(function(){ 55 | return page.evaluate(function(){ 56 | var el = document.getElementById('qunit-testresult'); 57 | if (el && el.innerText.match('completed')) { 58 | return true; 59 | } 60 | return false; 61 | }); 62 | }, function(){ 63 | var failedNum = page.evaluate(function(){ 64 | var el = document.getElementById('qunit-testresult'); 65 | console.log(el.innerText); 66 | try { 67 | return el.getElementsByClassName('failed')[0].innerHTML; 68 | } catch (e) { } 69 | return 10000; 70 | }); 71 | phantom.exit((parseInt(failedNum, 10) > 0) ? 1 : 0); 72 | }); 73 | } 74 | }); 75 | -------------------------------------------------------------------------------- /docs/contribute.rst: -------------------------------------------------------------------------------- 1 | .. _contributing-guide: 2 | 3 | Contributing 4 | ================== 5 | 6 | There are plenty of ways to contribute to this project. If you think you've found 7 | a bug please submit an issue. If there is a feature you'd like to see then please 8 | open an ticket proposal for it. If you've come up with some helpful examples then 9 | you can add to our example project. 10 | 11 | 12 | Getting the Source 13 | -------------------------------------- 14 | 15 | The source code is hosted on `Github `_. 16 | You can download the full source by cloning the git repo:: 17 | 18 | git clone git://github.com/mlavin/django-selectable.git 19 | 20 | Feel free to fork the project and make your own changes. If you think that it would 21 | be helpful for other then please submit a pull request to have it merged in. 22 | 23 | 24 | Submit an Issue 25 | -------------------------------------- 26 | 27 | The issues are also managed on `Github issue page `_. 28 | If you think you've found a bug it's helpful if you indicate the version of django-selectable 29 | you are using the ticket version flag. If you think your bug is javascript related it is 30 | also helpful to know the version of jQuery, jQuery UI, and the browser you are using. 31 | 32 | Issues are also used to track new features. If you have a feature you would like to see 33 | you can submit a proposal ticket. You can also see features which are planned here. 34 | 35 | 36 | Submit a Translation 37 | -------------------------------------- 38 | 39 | We are working towards translating django-selectable into different languages. There 40 | are not many strings to be translated so it is a reasonably easy task and a great way 41 | to be involved with the project. The translations are managed through 42 | `Transifex `_. 43 | 44 | Running the Test Suite 45 | -------------------------------------- 46 | 47 | There are a number of tests in place to test the server side code for this 48 | project. To run the tests you need Django installed and run:: 49 | 50 | python runtests.py 51 | 52 | `tox `_ is used to test django-selectable 53 | against multiple versions of Django/Python. With tox installed you can run:: 54 | 55 | tox 56 | 57 | to run all the version combinations. You can also run tox against a subset of supported 58 | environments:: 59 | 60 | tox -e py27-django15 61 | 62 | For more information on running/installing tox please see the 63 | tox documentation: http://tox.readthedocs.org/en/latest/index.html 64 | 65 | Client side tests are written using `QUnit `_. They 66 | can be found in ``selectable/tests/qunit/index.html``. The test suite also uses 67 | `PhantomJS `_ to 68 | run the tests. You can install PhantomJS from NPM:: 69 | 70 | # Install requirements 71 | npm install -g phantomjs jshint 72 | make test-js 73 | 74 | 75 | Building the Documentation 76 | -------------------------------------- 77 | 78 | The documentation is built using `Sphinx `_ 79 | and available on `Read the Docs `_. With 80 | Sphinx installed you can build the documentation by running:: 81 | 82 | make html 83 | 84 | inside the docs directory. Documentation fixes and improvements are always welcome. 85 | 86 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ================== 3 | 4 | 5 | .. _SELECTABLE_MAX_LIMIT: 6 | 7 | SELECTABLE_MAX_LIMIT 8 | -------------------------------------- 9 | 10 | This setting is used to limit the number of results returned by the auto-complete fields. 11 | Each field/widget can individually lower this maximum. The result sets will be 12 | paginated allowing the client to ask for more results. The limit is passed as a 13 | query parameter and validated against this value to ensure the client cannot manipulate 14 | the query string to retrive more values. 15 | 16 | Default: ``25`` 17 | 18 | 19 | .. _SELECTABLE_ESCAPED_KEYS: 20 | 21 | SELECTABLE_ESCAPED_KEYS 22 | -------------------------------------- 23 | 24 | The ``LookupBase.format_item`` will conditionally escape result keys based on this 25 | setting. The label is escaped by default to prevent a XSS flaw when using the 26 | jQuery UI autocomplete. If you are using the lookup responses for a different 27 | autocomplete plugin then you may need to esacpe more keys by default. 28 | 29 | Default: ``('label', )`` 30 | 31 | .. note:: 32 | You probably don't want to include ``id`` in this setting. 33 | 34 | 35 | .. _javascript-options: 36 | 37 | Javascript Plugin Options 38 | -------------------------------------- 39 | 40 | Below the options for configuring the Javascript behavior of the django-selectable 41 | widgets. 42 | 43 | 44 | .. _javascript-removeIcon: 45 | 46 | removeIcon 47 | ______________________________________ 48 | 49 | 50 | This is the class name used for the remove buttons for the multiple select widgets. 51 | The set of icon classes built into the jQuery UI framework can be found here: 52 | http://jqueryui.com/themeroller/ 53 | 54 | Default: ``ui-icon-close`` 55 | 56 | 57 | .. _javascript-comboboxIcon: 58 | 59 | comboboxIcon 60 | ______________________________________ 61 | 62 | 63 | This is the class name used for the combobox dropdown icon. The set of icon classes built 64 | into the jQuery UI framework can be found here: http://jqueryui.com/themeroller/ 65 | 66 | Default: ``ui-icon-triangle-1-s`` 67 | 68 | 69 | .. _javascript-prepareQuery: 70 | 71 | prepareQuery 72 | ______________________________________ 73 | 74 | 75 | ``prepareQuery`` is a function that is run prior to sending the search request to 76 | the server. It is an oppotunity to add additional parameters to the search query. 77 | It takes one argument which is the current search parameters as a dictionary. For 78 | more information on its usage see :ref:`Adding Parameters on the Client Side `. 79 | 80 | Default: ``null`` 81 | 82 | 83 | .. _javascript-highlightMatch: 84 | 85 | highlightMatch 86 | ______________________________________ 87 | 88 | 89 | If true the portions of the label which match the current search term will be wrapped 90 | in a span with the class ``highlight``. 91 | 92 | Default: ``true`` 93 | 94 | 95 | .. _javascript-formatLabel: 96 | 97 | formatLabel 98 | ______________________________________ 99 | 100 | 101 | ``formatLabel`` is a function that is run prior to rendering the search results in 102 | the dropdown menu. It takes two arguments: the current item label and the item data 103 | dictionary. It should return the label which should be used. For more information 104 | on its usage see :ref:`Label Formats on the Client Side `. 105 | 106 | Default: ``null`` 107 | 108 | -------------------------------------------------------------------------------- /selectable/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.conf import settings 4 | from django.test import override_settings 5 | from django.urls import reverse 6 | 7 | from . import ThingLookup 8 | from .base import BaseSelectableTestCase 9 | 10 | __all__ = ("SelectableViewTest",) 11 | 12 | 13 | @override_settings(SELECTABLE_MAX_LIMIT=25) 14 | class SelectableViewTest(BaseSelectableTestCase): 15 | def setUp(self): 16 | super().setUp() 17 | self.url = ThingLookup.url() 18 | self.lookup = ThingLookup() 19 | self.thing = self.create_thing() 20 | self.other_thing = self.create_thing() 21 | 22 | def test_response_type(self): 23 | response = self.client.get(self.url) 24 | self.assertEqual(response["Content-Type"], "application/json") 25 | 26 | def test_response_keys(self): 27 | response = self.client.get(self.url) 28 | data = json.loads(response.content.decode("utf-8")) 29 | for result in data.get("data"): 30 | self.assertTrue("id" in result) 31 | self.assertTrue("value" in result) 32 | self.assertTrue("label" in result) 33 | 34 | def test_no_term_lookup(self): 35 | data = {} 36 | response = self.client.get(self.url, data) 37 | data = json.loads(response.content.decode("utf-8")) 38 | self.assertEqual(len(data), 2) 39 | 40 | def test_simple_term_lookup(self): 41 | data = {"term": self.thing.name} 42 | response = self.client.get(self.url, data) 43 | data = json.loads(response.content.decode("utf-8")) 44 | self.assertEqual(len(data), 2) 45 | self.assertEqual(len(data.get("data")), 1) 46 | 47 | def test_unknown_lookup(self): 48 | unknown_url = reverse("selectable-lookup", args=["XXXXXXX"]) 49 | response = self.client.get(unknown_url) 50 | self.assertEqual(response.status_code, 404) 51 | 52 | def test_basic_limit(self): 53 | for i in range(settings.SELECTABLE_MAX_LIMIT): 54 | self.create_thing(data={"name": "Thing%s" % i}) 55 | response = self.client.get(self.url) 56 | data = json.loads(response.content.decode("utf-8")) 57 | self.assertEqual(len(data.get("data")), settings.SELECTABLE_MAX_LIMIT) 58 | meta = data.get("meta") 59 | self.assertTrue("next_page" in meta) 60 | 61 | def test_get_next_page(self): 62 | for i in range(settings.SELECTABLE_MAX_LIMIT * 2): 63 | self.create_thing(data={"name": "Thing%s" % i}) 64 | data = {"term": "Thing", "page": 2} 65 | response = self.client.get(self.url, data) 66 | data = json.loads(response.content.decode("utf-8")) 67 | self.assertEqual(len(data.get("data")), settings.SELECTABLE_MAX_LIMIT) 68 | # No next page 69 | meta = data.get("meta") 70 | self.assertFalse("next_page" in meta) 71 | 72 | def test_request_more_than_max(self): 73 | for i in range(settings.SELECTABLE_MAX_LIMIT): 74 | self.create_thing(data={"name": "Thing%s" % i}) 75 | data = {"term": "", "limit": settings.SELECTABLE_MAX_LIMIT * 2} 76 | response = self.client.get(self.url) 77 | data = json.loads(response.content.decode("utf-8")) 78 | self.assertEqual(len(data.get("data")), settings.SELECTABLE_MAX_LIMIT) 79 | 80 | def test_request_less_than_max(self): 81 | for i in range(settings.SELECTABLE_MAX_LIMIT): 82 | self.create_thing(data={"name": "Thing%s" % i}) 83 | new_limit = settings.SELECTABLE_MAX_LIMIT // 2 84 | data = {"term": "", "limit": new_limit} 85 | response = self.client.get(self.url, data) 86 | data = json.loads(response.content.decode("utf-8")) 87 | self.assertEqual(len(data.get("data")), new_limit) 88 | -------------------------------------------------------------------------------- /selectable/tests/qunit/helpers.js: -------------------------------------------------------------------------------- 1 | /* Test utility functions */ 2 | (function ($) { 3 | 4 | window.createTextComplete = function (name, attrs) { 5 | var inputAttrs = { 6 | 'name': name, 7 | 'data-selectable-type': 'text', 8 | 'data-selectable-url': '/lookup/core-fruitlookup/', 9 | 'type': 'text' 10 | }, finalAttrs = $.extend({}, inputAttrs, attrs || {}); 11 | return $('', finalAttrs); 12 | }; 13 | 14 | window.createTextCombobox = function (name, attrs) { 15 | // Force change of the name and type 16 | var inputAttrs = $.extend({ 17 | 'data-selectable-type': 'combobox' 18 | }, attrs || {}); 19 | return window.createTextComplete(name, inputAttrs); 20 | }; 21 | 22 | window.createTextSelect = function (name, attrs) { 23 | var inputAttrs = $.extend({ 24 | 'name': name + '_0' 25 | }, attrs || {}), textInput, hiddenInput, 26 | hiddenAttrs = { 27 | 'name': name + '_1', 28 | 'data-selectable-type': 'hidden', 29 | 'type': 'hidden' 30 | }; 31 | textInput = window.createTextComplete(name, inputAttrs); 32 | hiddenInput = $('', hiddenAttrs); 33 | return [textInput, hiddenInput]; 34 | }; 35 | 36 | window.createComboboxSelect = function (name, attrs) { 37 | var inputAttrs = $.extend({ 38 | 'name': name + '_0' 39 | }, attrs || {}), textInput, hiddenInput, 40 | hiddenAttrs = { 41 | 'name': name + '_1', 42 | 'data-selectable-type': 'hidden', 43 | 'type': 'hidden' 44 | }; 45 | textInput = window.createTextCombobox(name, inputAttrs); 46 | hiddenInput = $('', hiddenAttrs); 47 | return [textInput, hiddenInput]; 48 | }; 49 | 50 | window.createTextSelectMultiple = function (name, attrs) { 51 | var inputAttrs = $.extend({ 52 | 'name': name + '_0', 53 | 'data-selectable-multiple': true, 54 | 'data-selectable-allow-new': false 55 | }, attrs || {}), textInput, hiddenInput, 56 | hiddenAttrs = { 57 | 'name': name + '_1', 58 | 'data-selectable-type': 'hidden-multiple', 59 | 'type': 'hidden' 60 | }; 61 | textInput = window.createTextComplete(name, inputAttrs); 62 | hiddenInput = $('', hiddenAttrs); 63 | return [textInput, hiddenInput]; 64 | }; 65 | 66 | window.createComboboxSelectMultiple = function (name, attrs) { 67 | var inputAttrs = $.extend({ 68 | 'name': name + '_0', 69 | 'data-selectable-multiple': true, 70 | 'data-selectable-allow-new': false 71 | }, attrs || {}), textInput, hiddenInput, 72 | hiddenAttrs = { 73 | 'name': name + '_1', 74 | 'data-selectable-type': 'hidden-multiple', 75 | 'type': 'hidden' 76 | }; 77 | textInput = window.createTextCombobox(name, inputAttrs); 78 | hiddenInput = $('', hiddenAttrs); 79 | return [textInput, hiddenInput]; 80 | }; 81 | 82 | window.simpleLookupResponse = function () { 83 | var meta = { 84 | "term": "ap", 85 | "limit": 25, 86 | "page": 1, 87 | "more": "Show more results" 88 | }, data = [ 89 | {"id": 1, "value": "Apple", "label": "Apple"}, 90 | {"id": 3, "value": "Grape", "label": "Grape"} 91 | ]; 92 | return {"meta": meta, "data": data}; 93 | }; 94 | 95 | window.paginatedLookupResponse = function () { 96 | var meta = { 97 | "term": "ap", 98 | "limit": 2, 99 | "page": 1, 100 | "more": "Show more results" 101 | }, data = [ 102 | {"id": 1, "value": "Apple", "label": "Apple"}, 103 | {"id": 3, "value": "Grape", "label": "Grape"}, 104 | {"id": null, "page": 2, "label": "Show more results"} 105 | ]; 106 | return {"meta": meta, "data": data}; 107 | }; 108 | })(jQuery); -------------------------------------------------------------------------------- /example/core/forms.py: -------------------------------------------------------------------------------- 1 | from core.lookups import CityLookup, FruitLookup 2 | from core.models import Farm 3 | from django import forms 4 | from django.forms.models import modelformset_factory 5 | from localflavor.us.forms import USStateField, USStateSelect 6 | 7 | import selectable.forms as selectable 8 | 9 | 10 | class FruitForm(forms.Form): 11 | autocomplete = forms.CharField( 12 | label="Type the name of a fruit (AutoCompleteWidget)", 13 | widget=selectable.AutoCompleteWidget(FruitLookup), 14 | required=False, 15 | ) 16 | newautocomplete = forms.CharField( 17 | label="Type the name of a fruit (AutoCompleteWidget which allows new items)", 18 | widget=selectable.AutoCompleteWidget(FruitLookup, allow_new=True), 19 | required=False, 20 | ) 21 | combobox = forms.CharField( 22 | label="Type/select the name of a fruit (AutoComboboxWidget)", 23 | widget=selectable.AutoComboboxWidget(FruitLookup), 24 | required=False, 25 | ) 26 | newcombobox = forms.CharField( 27 | label="Type/select the name of a fruit (AutoComboboxWidget which allows new items)", 28 | widget=selectable.AutoComboboxWidget(FruitLookup, allow_new=True), 29 | required=False, 30 | ) 31 | # AutoCompleteSelectField (no new items) 32 | autocompleteselect = selectable.AutoCompleteSelectField( 33 | lookup_class=FruitLookup, 34 | label="Select a fruit (AutoCompleteField)", 35 | required=False, 36 | ) 37 | # AutoCompleteSelectField (allows new items) 38 | newautocompleteselect = selectable.AutoCompleteSelectField( 39 | lookup_class=FruitLookup, 40 | allow_new=True, 41 | label="Select a fruit (AutoCompleteField which allows new items)", 42 | required=False, 43 | ) 44 | # AutoCompleteSelectField (no new items) 45 | comboboxselect = selectable.AutoCompleteSelectField( 46 | lookup_class=FruitLookup, 47 | label="Select a fruit (AutoCompleteSelectField with combobox)", 48 | required=False, 49 | widget=selectable.AutoComboboxSelectWidget, 50 | ) 51 | # AutoComboboxSelect (allows new items) 52 | newcomboboxselect = selectable.AutoCompleteSelectField( 53 | lookup_class=FruitLookup, 54 | allow_new=True, 55 | label="Select a fruit (AutoCompleteSelectField with combobox which allows new items)", 56 | required=False, 57 | widget=selectable.AutoComboboxSelectWidget, 58 | ) 59 | # AutoCompleteSelectMultipleField 60 | multiautocompleteselect = selectable.AutoCompleteSelectMultipleField( 61 | lookup_class=FruitLookup, 62 | label="Select a fruit (AutoCompleteSelectMultipleField)", 63 | required=False, 64 | ) 65 | # AutoComboboxSelectMultipleField 66 | multicomboboxselect = selectable.AutoCompleteSelectMultipleField( 67 | lookup_class=FruitLookup, 68 | label="Select a fruit (AutoCompleteSelectMultipleField with combobox)", 69 | required=False, 70 | widget=selectable.AutoComboboxSelectMultipleWidget, 71 | ) 72 | # AutoComboboxSelectMultipleField with disabled attribute 73 | disabledmulticomboboxselect = selectable.AutoCompleteSelectMultipleField( 74 | lookup_class=FruitLookup, 75 | label="Disabled Selectable field", 76 | required=False, 77 | widget=selectable.AutoComboboxSelectMultipleWidget, 78 | initial={"1", "2"}, 79 | ) 80 | 81 | def __init__(self, *args, **kwargs): 82 | super().__init__(*args, **kwargs) 83 | self.fields["disabledmulticomboboxselect"].widget.attrs["disabled"] = "disabled" 84 | 85 | 86 | class ChainedForm(forms.Form): 87 | city = selectable.AutoCompleteSelectField( 88 | lookup_class=CityLookup, 89 | label="City", 90 | required=False, 91 | widget=selectable.AutoComboboxSelectWidget, 92 | ) 93 | state = USStateField(widget=USStateSelect, required=False) 94 | 95 | 96 | class FarmForm(forms.ModelForm): 97 | class Meta: 98 | model = Farm 99 | widgets = { 100 | "fruit": selectable.AutoCompleteSelectMultipleWidget( 101 | lookup_class=FruitLookup 102 | ), 103 | } 104 | fields = ( 105 | "name", 106 | "owner", 107 | "fruit", 108 | ) 109 | 110 | 111 | FarmFormset = modelformset_factory(Farm, FarmForm) 112 | -------------------------------------------------------------------------------- /docs/widgets.rst: -------------------------------------------------------------------------------- 1 | Widgets 2 | ========== 3 | 4 | Below are the custom widgets defined by Django-Selectable. All widgets take the 5 | lookup class as the first required argument. 6 | 7 | These widgets all support a ``query_params`` keyword argument which is used to pass 8 | additional query parameters to the lookup search. See the section on 9 | :ref:`Adding Parameters on the Server Side ` for more 10 | information. 11 | 12 | You can configure the plugin options by passing the configuration dictionary in the ``data-selectable-options`` 13 | attribute. The set of options availble include those define by the base 14 | `autocomplete plugin `_ as well as the 15 | :ref:`javascript-removeIcon`, :ref:`javascript-comboboxIcon`, and :ref:`javascript-highlightMatch` options 16 | which are unique to django-selectable. 17 | 18 | .. code-block:: python 19 | 20 | attrs = {'data-selectable-options': {'highlightMatch': True, 'minLength': 5}} 21 | selectable.AutoCompleteSelectWidget(lookup_class=FruitLookup, attrs=attrs) 22 | 23 | 24 | .. _AutoCompleteWidget: 25 | 26 | AutoCompleteWidget 27 | -------------------------------------- 28 | 29 | Basic widget for auto-completing text. The widget returns the item value as defined 30 | by the lookup ``get_item_value``. If the ``allow_new`` keyword argument is passed as 31 | true it will allow the user to type any text they wish. 32 | 33 | .. _AutoComboboxWidget: 34 | 35 | AutoComboboxWidget 36 | -------------------------------------- 37 | 38 | Similar to :ref:`AutoCompleteWidget` but has a button to reveal all options. 39 | 40 | 41 | .. _AutoCompleteSelectWidget: 42 | 43 | AutoCompleteSelectWidget 44 | -------------------------------------- 45 | 46 | Widget for selecting a value/id based on input text. Optionally allows selecting new items to be created. 47 | This widget should be used in conjunction with the :ref:`AutoCompleteSelectField` as it will 48 | return both the text entered by the user and the id (if an item was selected/matched). 49 | 50 | :ref:`AutoCompleteSelectWidget` works directly with Django's 51 | `ModelChoiceField `_. 52 | You can simply replace the widget without replacing the entire field. 53 | 54 | .. code-block:: python 55 | 56 | class FarmAdminForm(forms.ModelForm): 57 | 58 | class Meta: 59 | model = Farm 60 | widgets = { 61 | 'owner': selectable.AutoCompleteSelectWidget(lookup_class=FruitLookup), 62 | } 63 | 64 | The one catch is that you must use ``allow_new=False`` which is the default. 65 | 66 | ``lookup_class`` may also be a dotted path. 67 | 68 | .. code-block:: python 69 | 70 | widget = selectable.AutoCompleteWidget(lookup_class='core.lookups.FruitLookup') 71 | 72 | 73 | .. _AutoComboboxSelectWidget: 74 | 75 | AutoComboboxSelectWidget 76 | -------------------------------------- 77 | 78 | Similar to :ref:`AutoCompleteSelectWidget` but has a button to reveal all options. 79 | 80 | :ref:`AutoComboboxSelectWidget` works directly with Django's 81 | `ModelChoiceField `_. 82 | You can simply replace the widget without replacing the entire field. 83 | 84 | .. code-block:: python 85 | 86 | class FarmAdminForm(forms.ModelForm): 87 | 88 | class Meta: 89 | model = Farm 90 | widgets = { 91 | 'owner': selectable.AutoComboboxSelectWidget(lookup_class=FruitLookup), 92 | } 93 | 94 | The one catch is that you must use ``allow_new=False`` which is the default. 95 | 96 | 97 | .. _AutoCompleteSelectMultipleWidget: 98 | 99 | AutoCompleteSelectMultipleWidget 100 | -------------------------------------- 101 | 102 | Builds a list of selected items from auto-completion. This widget will return a list 103 | of item ids as defined by the lookup ``get_item_id``. Using this widget with the 104 | :ref:`AutoCompleteSelectMultipleField` will clean the items to the item objects. This does 105 | not allow for creating new items. There is another optional keyword argument ``postion`` 106 | which can take four possible values: `bottom`, `bottom-inline`, `top` or `top-inline`. 107 | This determine the position of the deck list of currently selected items as well as 108 | whether this list is stacked or inline. The default is `bottom`. 109 | 110 | 111 | .. _AutoComboboxSelectMultipleWidget: 112 | 113 | AutoComboboxSelectMultipleWidget 114 | -------------------------------------- 115 | 116 | Same as :ref:`AutoCompleteSelectMultipleWidget` but with a combobox. 117 | -------------------------------------------------------------------------------- /selectable/forms/fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.core.validators import EMPTY_VALUES 4 | from django.db.models import Model 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from selectable.forms.base import import_lookup_class 8 | from selectable.forms.widgets import ( 9 | AutoCompleteSelectMultipleWidget, 10 | AutoCompleteSelectWidget, 11 | ) 12 | 13 | __all__ = ( 14 | "AutoCompleteSelectField", 15 | "AutoCompleteSelectMultipleField", 16 | ) 17 | 18 | 19 | def model_vars(obj): 20 | fields = dict((field.name, getattr(obj, field.name)) for field in obj._meta.fields) 21 | return fields 22 | 23 | 24 | class BaseAutoCompleteField(forms.Field): 25 | def has_changed(self, initial, data): 26 | "Detects if the data was changed. This is added in 1.6." 27 | # Always return False if the field is disabled since self.bound_data 28 | # always uses the initial value in this case. 29 | if self.disabled: 30 | return False 31 | if initial is None and data is None: 32 | return False 33 | if data and not hasattr(data, "__iter__"): 34 | data = self.widget.decompress(data) 35 | initial = self.to_python(initial) 36 | data = self.to_python(data) 37 | if hasattr(self, "_coerce"): 38 | data = self._coerce(data) 39 | if isinstance(data, Model) and isinstance(initial, Model): 40 | return model_vars(data) != model_vars(initial) 41 | else: 42 | return data != initial 43 | 44 | 45 | class AutoCompleteSelectField(BaseAutoCompleteField): 46 | widget = AutoCompleteSelectWidget 47 | 48 | default_error_messages = { 49 | "invalid_choice": _( 50 | "Select a valid choice. That choice is not one of the available choices." 51 | ), 52 | } 53 | 54 | def __init__(self, lookup_class, *args, **kwargs): 55 | self.lookup_class = import_lookup_class(lookup_class) 56 | self.allow_new = kwargs.pop("allow_new", False) 57 | self.limit = kwargs.pop("limit", None) 58 | widget = kwargs.get("widget", self.widget) or self.widget 59 | if isinstance(widget, type): 60 | kwargs["widget"] = widget( 61 | lookup_class, allow_new=self.allow_new, limit=self.limit 62 | ) 63 | super().__init__(*args, **kwargs) 64 | 65 | def to_python(self, value): 66 | if value in EMPTY_VALUES: 67 | return None 68 | lookup = self.lookup_class() 69 | if isinstance(value, list): 70 | # Input comes from an AutoComplete widget. It's two 71 | # components: text and id 72 | if len(value) != 2: 73 | raise ValidationError(self.error_messages["invalid_choice"]) 74 | label, pk = value 75 | if pk in EMPTY_VALUES: 76 | if not self.allow_new: 77 | if label: 78 | raise ValidationError(self.error_messages["invalid_choice"]) 79 | else: 80 | return None 81 | if label in EMPTY_VALUES: 82 | return None 83 | value = lookup.create_item(label) 84 | else: 85 | value = lookup.get_item(pk) 86 | if value is None: 87 | raise ValidationError(self.error_messages["invalid_choice"]) 88 | else: 89 | value = lookup.get_item(value) 90 | if value is None: 91 | raise ValidationError(self.error_messages["invalid_choice"]) 92 | return value 93 | 94 | 95 | class AutoCompleteSelectMultipleField(BaseAutoCompleteField): 96 | widget = AutoCompleteSelectMultipleWidget 97 | 98 | default_error_messages = { 99 | "invalid_choice": _( 100 | "Select a valid choice. That choice is not one of the available choices." 101 | ), 102 | } 103 | 104 | def __init__(self, lookup_class, *args, **kwargs): 105 | self.lookup_class = import_lookup_class(lookup_class) 106 | self.limit = kwargs.pop("limit", None) 107 | widget = kwargs.get("widget", self.widget) or self.widget 108 | if isinstance(widget, type): 109 | kwargs["widget"] = widget(lookup_class, limit=self.limit) 110 | super().__init__(*args, **kwargs) 111 | 112 | def to_python(self, value): 113 | if value in EMPTY_VALUES: 114 | return [] 115 | lookup = self.lookup_class() 116 | items = [] 117 | for v in value: 118 | if v not in EMPTY_VALUES: 119 | item = lookup.get_item(v) 120 | if item is None: 121 | raise ValidationError(self.error_messages["invalid_choice"]) 122 | items.append(item) 123 | return items 124 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | import os 3 | 4 | BASE_DIR = os.path.dirname(__file__) 5 | 6 | DEBUG = True 7 | 8 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 9 | 10 | DATABASES = { 11 | "default": { 12 | "ENGINE": "django.db.backends.sqlite3", 13 | "NAME": "example.db", 14 | "USER": "", 15 | "PASSWORD": "", 16 | "HOST": "", 17 | "PORT": "", 18 | } 19 | } 20 | 21 | # Local time zone for this installation. Choices can be found here: 22 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 23 | # although not all choices may be available on all operating systems. 24 | # In a Windows environment this must be set to your system time zone. 25 | TIME_ZONE = "America/Chicago" 26 | 27 | # Language code for this installation. All choices can be found here: 28 | # http://www.i18nguy.com/unicode/language-identifiers.html 29 | LANGUAGE_CODE = "en-us" 30 | 31 | # If you set this to False, Django will make some optimizations so as not 32 | # to load the internationalization machinery. 33 | USE_I18N = True 34 | 35 | # If you set this to False, Django will not format dates, numbers and 36 | # calendars according to the current locale. 37 | USE_L10N = True 38 | 39 | # If you set this to False, Django will not use timezone-aware datetimes. 40 | USE_TZ = True 41 | 42 | # Absolute filesystem path to the directory that will hold user-uploaded files. 43 | # Example: "/home/media/media.lawrence.com/media/" 44 | MEDIA_ROOT = "" 45 | 46 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 47 | # trailing slash. 48 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 49 | MEDIA_URL = "" 50 | 51 | # Absolute path to the directory static files should be collected to. 52 | # Don't put anything in this directory yourself; store your static files 53 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 54 | # Example: "/home/media/media.lawrence.com/static/" 55 | STATIC_ROOT = "" 56 | 57 | # URL prefix for static files. 58 | # Example: "http://media.lawrence.com/static/" 59 | STATIC_URL = "/static/" 60 | 61 | # Additional locations of static files 62 | STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) 63 | 64 | # Make this unique, and don't share it with anybody. 65 | SECRET_KEY = "9i1lt2qz$#&21tqxqhq@ep21(8f#^kpih!5yynr+ba1sq5w8+&" 66 | 67 | MIDDLEWARE = ( 68 | "django.middleware.common.CommonMiddleware", 69 | "django.contrib.sessions.middleware.SessionMiddleware", 70 | "django.middleware.csrf.CsrfViewMiddleware", 71 | "django.contrib.auth.middleware.AuthenticationMiddleware", 72 | "django.contrib.messages.middleware.MessageMiddleware", 73 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 74 | ) 75 | 76 | ROOT_URLCONF = "example.urls" 77 | 78 | # Python dotted path to the WSGI application used by Django's runserver. 79 | WSGI_APPLICATION = "example.wsgi.application" 80 | 81 | TEMPLATES = [ 82 | { 83 | "BACKEND": "django.template.backends.django.DjangoTemplates", 84 | "DIRS": [ 85 | os.path.join(BASE_DIR, "templates"), 86 | ], 87 | "OPTIONS": { 88 | "context_processors": [ 89 | "django.contrib.auth.context_processors.auth", 90 | "django.template.context_processors.debug", 91 | "django.template.context_processors.media", 92 | "django.template.context_processors.request", 93 | "django.template.context_processors.i18n", 94 | "django.template.context_processors.static", 95 | ], 96 | "loaders": [ 97 | "django.template.loaders.filesystem.Loader", 98 | "django.template.loaders.app_directories.Loader", 99 | ], 100 | }, 101 | }, 102 | ] 103 | 104 | INSTALLED_APPS = ( 105 | "django.contrib.auth", 106 | "django.contrib.contenttypes", 107 | "django.contrib.sessions", 108 | "django.contrib.messages", 109 | "django.contrib.staticfiles", 110 | "django.contrib.admin", 111 | "selectable", 112 | "core", 113 | ) 114 | 115 | # A sample logging configuration. The only tangible logging 116 | # performed by this configuration is to send an email to 117 | # the site admins on every HTTP 500 error when DEBUG=False. 118 | # See http://docs.djangoproject.com/en/dev/topics/logging for 119 | # more details on how to customize your logging configuration. 120 | LOGGING = { 121 | "version": 1, 122 | "disable_existing_loggers": False, 123 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 124 | "handlers": { 125 | "mail_admins": { 126 | "level": "ERROR", 127 | "filters": ["require_debug_false"], 128 | "class": "django.utils.log.AdminEmailHandler", 129 | } 130 | }, 131 | "loggers": { 132 | "django.request": { 133 | "handlers": ["mail_admins"], 134 | "level": "ERROR", 135 | "propagate": True, 136 | }, 137 | }, 138 | } 139 | -------------------------------------------------------------------------------- /selectable/tests/test_base.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.utils.html import escape 3 | from django.utils.safestring import SafeData, mark_safe 4 | 5 | from ..base import ModelLookup 6 | from . import Thing 7 | from .base import BaseSelectableTestCase, SimpleModelLookup 8 | 9 | __all__ = ( 10 | "ModelLookupTestCase", 11 | "MultiFieldLookupTestCase", 12 | "LookupEscapingTestCase", 13 | ) 14 | 15 | 16 | class ModelLookupTestCase(BaseSelectableTestCase): 17 | lookup_cls = SimpleModelLookup 18 | 19 | def get_lookup_instance(self): 20 | return self.__class__.lookup_cls() 21 | 22 | def test_get_name(self): 23 | name = self.__class__.lookup_cls.name() 24 | self.assertEqual(name, "tests-simplemodellookup") 25 | 26 | def test_get_url(self): 27 | url = self.__class__.lookup_cls.url() 28 | test_url = reverse("selectable-lookup", args=["tests-simplemodellookup"]) 29 | self.assertEqual(url, test_url) 30 | 31 | def test_format_item(self): 32 | lookup = self.get_lookup_instance() 33 | thing = Thing() 34 | item_info = lookup.format_item(thing) 35 | self.assertTrue("id" in item_info) 36 | self.assertTrue("value" in item_info) 37 | self.assertTrue("label" in item_info) 38 | 39 | def test_get_query(self): 40 | lookup = self.get_lookup_instance() 41 | thing = self.create_thing(data={"name": "Thing"}) 42 | other_thing = self.create_thing(data={"name": "Other Thing"}) 43 | qs = lookup.get_query(request=None, term="other") 44 | self.assertTrue(thing.pk not in qs.values_list("id", flat=True)) 45 | self.assertTrue(other_thing.pk in qs.values_list("id", flat=True)) 46 | 47 | def test_create_item(self): 48 | value = self.get_random_string() 49 | lookup = self.get_lookup_instance() 50 | thing = lookup.create_item(value) 51 | self.assertEqual(thing.__class__, Thing) 52 | self.assertEqual(thing.name, value) 53 | self.assertFalse(thing.pk) 54 | 55 | def test_get_item(self): 56 | lookup = self.get_lookup_instance() 57 | thing = self.create_thing(data={"name": "Thing"}) 58 | item = lookup.get_item(thing.pk) 59 | self.assertEqual(thing, item) 60 | 61 | def test_format_item_escaping(self): 62 | "Id, value and label should be escaped." 63 | lookup = self.get_lookup_instance() 64 | thing = self.create_thing(data={"name": "Thing"}) 65 | item_info = lookup.format_item(thing) 66 | self.assertFalse(isinstance(item_info["id"], SafeData)) 67 | self.assertFalse(isinstance(item_info["value"], SafeData)) 68 | self.assertTrue(isinstance(item_info["label"], SafeData)) 69 | 70 | 71 | class MultiFieldLookup(ModelLookup): 72 | model = Thing 73 | search_fields = ( 74 | "name__icontains", 75 | "description__icontains", 76 | ) 77 | 78 | 79 | class MultiFieldLookupTestCase(ModelLookupTestCase): 80 | lookup_cls = MultiFieldLookup 81 | 82 | def test_get_name(self): 83 | name = self.__class__.lookup_cls.name() 84 | self.assertEqual(name, "tests-multifieldlookup") 85 | 86 | def test_get_url(self): 87 | url = self.__class__.lookup_cls.url() 88 | test_url = reverse("selectable-lookup", args=["tests-multifieldlookup"]) 89 | self.assertEqual(url, test_url) 90 | 91 | def test_description_search(self): 92 | lookup = self.get_lookup_instance() 93 | thing = self.create_thing(data={"description": "Thing"}) 94 | other_thing = self.create_thing(data={"description": "Other Thing"}) 95 | qs = lookup.get_query(request=None, term="other") 96 | self.assertTrue(thing.pk not in qs.values_list("id", flat=True)) 97 | self.assertTrue(other_thing.pk in qs.values_list("id", flat=True)) 98 | 99 | 100 | class HTMLLookup(ModelLookup): 101 | model = Thing 102 | search_fields = ("name__icontains",) 103 | 104 | 105 | class SafeHTMLLookup(ModelLookup): 106 | model = Thing 107 | search_fields = ("name__icontains",) 108 | 109 | def get_item_label(self, item): 110 | "Mark label as safe." 111 | return mark_safe(item.name) 112 | 113 | 114 | class LookupEscapingTestCase(BaseSelectableTestCase): 115 | def test_escape_html(self): 116 | "HTML should be escaped by default." 117 | lookup = HTMLLookup() 118 | bad_name = "" 119 | escaped_name = escape(bad_name) 120 | thing = self.create_thing(data={"name": bad_name}) 121 | item_info = lookup.format_item(thing) 122 | self.assertEqual(item_info["label"], escaped_name) 123 | 124 | def test_conditional_escape(self): 125 | "Methods should be able to mark values as safe." 126 | lookup = SafeHTMLLookup() 127 | bad_name = "" 128 | escape(bad_name) 129 | thing = self.create_thing(data={"name": bad_name}) 130 | item_info = lookup.format_item(thing) 131 | self.assertEqual(item_info["label"], bad_name) 132 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Django-Selectable.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Django-Selectable.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Django-Selectable" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Django-Selectable" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Django-Selectable.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Django-Selectable.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /selectable/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | 3 | from .base import BaseSelectableTestCase 4 | 5 | __all__ = ( 6 | "JqueryTagTestCase", 7 | "ThemeTagTestCase", 8 | ) 9 | 10 | 11 | class JqueryTagTestCase(BaseSelectableTestCase): 12 | def assertJQueryVersion(self, result, version): 13 | expected = "//ajax.googleapis.com/ajax/libs/jquery/%s/jquery.min.js" % version 14 | self.assertTrue(expected in result) 15 | 16 | def assertUIVersion(self, result, version): 17 | expected = "//ajax.googleapis.com/ajax/libs/jqueryui/%s/jquery-ui.js" % version 18 | self.assertTrue(expected in result) 19 | 20 | def test_render(self): 21 | "Render template tag with default versions." 22 | template = Template("{% load selectable_tags %}{% include_jquery_libs %}") 23 | context = Context({}) 24 | result = template.render(context) 25 | self.assertJQueryVersion(result, "1.12.4") 26 | self.assertUIVersion(result, "1.11.4") 27 | 28 | def test_render_jquery_version(self): 29 | "Render template tag with specified jQuery version." 30 | template = Template( 31 | "{% load selectable_tags %}{% include_jquery_libs '1.4.3' %}" 32 | ) 33 | context = Context({}) 34 | result = template.render(context) 35 | self.assertJQueryVersion(result, "1.4.3") 36 | 37 | def test_render_variable_jquery_version(self): 38 | "Render using jQuery version from the template context." 39 | version = "1.4.3" 40 | template = Template( 41 | "{% load selectable_tags %}{% include_jquery_libs version %}" 42 | ) 43 | context = Context({"version": version}) 44 | result = template.render(context) 45 | self.assertJQueryVersion(result, "1.4.3") 46 | 47 | def test_render_jquery_ui_version(self): 48 | "Render template tag with specified jQuery UI version." 49 | template = Template( 50 | "{% load selectable_tags %}{% include_jquery_libs '1.4.3' '1.8.13' %}" 51 | ) 52 | context = Context({}) 53 | result = template.render(context) 54 | self.assertUIVersion(result, "1.8.13") 55 | 56 | def test_render_variable_jquery_ui_version(self): 57 | "Render using jQuery UI version from the template context." 58 | version = "1.8.13" 59 | template = Template( 60 | "{% load selectable_tags %}{% include_jquery_libs '1.4.3' version %}" 61 | ) 62 | context = Context({"version": version}) 63 | result = template.render(context) 64 | self.assertUIVersion(result, "1.8.13") 65 | 66 | def test_render_no_jquery(self): 67 | "Render template tag without jQuery." 68 | template = Template("{% load selectable_tags %}{% include_jquery_libs '' %}") 69 | context = Context({}) 70 | result = template.render(context) 71 | self.assertTrue("jquery.min.js" not in result) 72 | 73 | def test_render_no_jquery_ui(self): 74 | "Render template tag without jQuery UI." 75 | template = Template( 76 | "{% load selectable_tags %}{% include_jquery_libs '1.7.2' '' %}" 77 | ) 78 | context = Context({}) 79 | result = template.render(context) 80 | self.assertTrue("jquery-ui.js" not in result) 81 | 82 | 83 | class ThemeTagTestCase(BaseSelectableTestCase): 84 | def assertUICSS(self, result, theme, version): 85 | expected = ( 86 | "//ajax.googleapis.com/ajax/libs/jqueryui/%s/themes/%s/jquery-ui.css" 87 | % (version, theme) 88 | ) 89 | self.assertTrue(expected in result) 90 | 91 | def test_render(self): 92 | "Render template tag with default settings." 93 | template = Template("{% load selectable_tags %}{% include_ui_theme %}") 94 | context = Context({}) 95 | result = template.render(context) 96 | self.assertUICSS(result, "smoothness", "1.11.4") 97 | 98 | def test_render_version(self): 99 | "Render template tag with alternate version." 100 | template = Template( 101 | "{% load selectable_tags %}{% include_ui_theme 'base' '1.8.13' %}" 102 | ) 103 | context = Context({}) 104 | result = template.render(context) 105 | self.assertUICSS(result, "base", "1.8.13") 106 | 107 | def test_variable_version(self): 108 | "Render using version from content variable." 109 | version = "1.8.13" 110 | template = Template( 111 | "{% load selectable_tags %}{% include_ui_theme 'base' version %}" 112 | ) 113 | context = Context({"version": version}) 114 | result = template.render(context) 115 | self.assertUICSS(result, "base", version) 116 | 117 | def test_render_theme(self): 118 | "Render template tag with alternate theme." 119 | template = Template( 120 | "{% load selectable_tags %}{% include_ui_theme 'ui-lightness' %}" 121 | ) 122 | context = Context({}) 123 | result = template.render(context) 124 | self.assertUICSS(result, "ui-lightness", "1.11.4") 125 | 126 | def test_variable_theme(self): 127 | "Render using theme from content variable." 128 | theme = "ui-lightness" 129 | template = Template("{% load selectable_tags %}{% include_ui_theme theme %}") 130 | context = Context({"theme": theme}) 131 | result = template.render(context) 132 | self.assertUICSS(result, theme, "1.11.4") 133 | -------------------------------------------------------------------------------- /selectable/tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from selectable.forms import fields, widgets 4 | from selectable.tests import ThingLookup 5 | from selectable.tests.base import BaseSelectableTestCase 6 | 7 | __all__ = ( 8 | "AutoCompleteSelectFieldTestCase", 9 | "AutoCompleteSelectMultipleFieldTestCase", 10 | ) 11 | 12 | 13 | class FieldTestMixin: 14 | field_cls = None 15 | lookup_cls = None 16 | 17 | def get_field_instance(self, allow_new=False, limit=None, widget=None): 18 | return self.field_cls( 19 | self.lookup_cls, allow_new=allow_new, limit=limit, widget=widget 20 | ) 21 | 22 | def test_init(self): 23 | field = self.get_field_instance() 24 | self.assertEqual(field.lookup_class, self.lookup_cls) 25 | 26 | def test_init_with_limit(self): 27 | field = self.get_field_instance(limit=10) 28 | self.assertEqual(field.limit, 10) 29 | self.assertEqual(field.widget.limit, 10) 30 | 31 | def test_clean(self): 32 | self.fail("This test has not yet been written") 33 | 34 | def test_dotted_path(self): 35 | """ 36 | Ensure lookup_class can be imported from a dotted path. 37 | """ 38 | dotted_path = ".".join([self.lookup_cls.__module__, self.lookup_cls.__name__]) 39 | field = self.field_cls(dotted_path) 40 | self.assertEqual(field.lookup_class, self.lookup_cls) 41 | 42 | def test_invalid_dotted_path(self): 43 | """ 44 | An invalid lookup_class dotted path should raise an ImportError. 45 | """ 46 | with self.assertRaises(ImportError): 47 | self.field_cls("that.is.an.invalid.path") 48 | 49 | def test_dotted_path_wrong_type(self): 50 | """ 51 | lookup_class must be a subclass of LookupBase. 52 | """ 53 | dotted_path = "selectable.forms.fields.AutoCompleteSelectField" 54 | with self.assertRaises(TypeError): 55 | self.field_cls(dotted_path) 56 | 57 | 58 | class AutoCompleteSelectFieldTestCase(BaseSelectableTestCase, FieldTestMixin): 59 | field_cls = fields.AutoCompleteSelectField 60 | lookup_cls = ThingLookup 61 | 62 | def test_clean(self): 63 | thing = self.create_thing() 64 | field = self.get_field_instance() 65 | value = field.clean([thing.name, thing.id]) 66 | self.assertEqual(thing, value) 67 | 68 | def test_new_not_allowed(self): 69 | field = self.get_field_instance() 70 | value = self.get_random_string() 71 | self.assertRaises(forms.ValidationError, field.clean, [value, ""]) 72 | 73 | def test_new_allowed(self): 74 | field = self.get_field_instance(allow_new=True) 75 | value = self.get_random_string() 76 | value = field.clean([value, ""]) 77 | self.assertTrue(isinstance(value, ThingLookup.model)) 78 | 79 | def test_default_widget(self): 80 | field = self.get_field_instance() 81 | self.assertTrue(isinstance(field.widget, widgets.AutoCompleteSelectWidget)) 82 | 83 | def test_alternate_widget(self): 84 | widget_cls = widgets.AutoComboboxWidget 85 | field = self.get_field_instance(widget=widget_cls) 86 | self.assertTrue(isinstance(field.widget, widget_cls)) 87 | 88 | def test_alternate_widget_instance(self): 89 | widget = widgets.AutoComboboxWidget(self.lookup_cls) 90 | field = self.get_field_instance(widget=widget) 91 | self.assertTrue(isinstance(field.widget, widgets.AutoComboboxWidget)) 92 | 93 | def test_invalid_pk(self): 94 | field = self.get_field_instance() 95 | value = self.get_random_string() 96 | self.assertRaises(forms.ValidationError, field.clean, [value, "XXX"]) 97 | 98 | 99 | class AutoCompleteSelectMultipleFieldTestCase(BaseSelectableTestCase, FieldTestMixin): 100 | field_cls = fields.AutoCompleteSelectMultipleField 101 | lookup_cls = ThingLookup 102 | 103 | def get_field_instance(self, limit=None, widget=None): 104 | return self.field_cls(self.lookup_cls, limit=limit, widget=widget) 105 | 106 | def test_clean(self): 107 | thing = self.create_thing() 108 | field = self.get_field_instance() 109 | value = field.clean([thing.id]) 110 | self.assertEqual([thing], value) 111 | 112 | def test_clean_multiple(self): 113 | thing = self.create_thing() 114 | other_thing = self.create_thing() 115 | field = self.get_field_instance() 116 | ids = [thing.id, other_thing.id] 117 | value = field.clean(ids) 118 | self.assertEqual([thing, other_thing], value) 119 | 120 | def test_default_widget(self): 121 | field = self.get_field_instance() 122 | self.assertTrue( 123 | isinstance(field.widget, widgets.AutoCompleteSelectMultipleWidget) 124 | ) 125 | 126 | def test_alternate_widget(self): 127 | widget_cls = widgets.AutoComboboxSelectMultipleWidget 128 | field = self.get_field_instance(widget=widget_cls) 129 | self.assertTrue(isinstance(field.widget, widget_cls)) 130 | 131 | def test_alternate_widget_instance(self): 132 | widget = widgets.AutoComboboxSelectMultipleWidget(self.lookup_cls) 133 | field = self.get_field_instance(widget=widget) 134 | self.assertTrue( 135 | isinstance(field.widget, widgets.AutoComboboxSelectMultipleWidget) 136 | ) 137 | 138 | def test_invalid_pk(self): 139 | field = self.get_field_instance() 140 | self.get_random_string() 141 | self.assertRaises( 142 | forms.ValidationError, 143 | field.clean, 144 | [ 145 | "XXX", 146 | ], 147 | ) 148 | -------------------------------------------------------------------------------- /selectable/base.py: -------------------------------------------------------------------------------- 1 | "Base classes for lookup creation." 2 | 3 | import operator 4 | import re 5 | from functools import reduce 6 | 7 | from django.conf import settings 8 | from django.core.paginator import EmptyPage, InvalidPage, Paginator 9 | from django.db.models import Model, Q 10 | from django.http import JsonResponse 11 | from django.urls import reverse 12 | from django.utils.encoding import smart_str 13 | from django.utils.html import conditional_escape 14 | from django.utils.translation import gettext as _ 15 | 16 | from .forms import BaseLookupForm 17 | 18 | __all__ = ( 19 | "LookupBase", 20 | "ModelLookup", 21 | ) 22 | 23 | 24 | class LookupBase: 25 | "Base class for all django-selectable lookups." 26 | 27 | form = BaseLookupForm 28 | response = JsonResponse 29 | 30 | def _name(cls): 31 | app_name = cls.__module__.split(".")[-2].lower() 32 | class_name = cls.__name__.lower() 33 | name = "%s-%s" % (app_name, class_name) 34 | return name 35 | 36 | name = classmethod(_name) 37 | 38 | def split_term(self, term): 39 | """ 40 | Split searching term into array of subterms 41 | that will be searched separately. 42 | """ 43 | return term.split() 44 | 45 | def _url(cls): 46 | return reverse("selectable-lookup", args=[cls.name()]) 47 | 48 | url = classmethod(_url) 49 | 50 | def get_query(self, request, term): 51 | return [] 52 | 53 | def get_item_label(self, item): 54 | return smart_str(item) 55 | 56 | def get_item_id(self, item): 57 | return smart_str(item) 58 | 59 | def get_item_value(self, item): 60 | return smart_str(item) 61 | 62 | def get_item(self, value): 63 | return value 64 | 65 | def create_item(self, value): 66 | raise NotImplementedError() 67 | 68 | def format_item(self, item): 69 | "Construct result dictionary for the match item." 70 | result = { 71 | "id": self.get_item_id(item), 72 | "value": self.get_item_value(item), 73 | "label": self.get_item_label(item), 74 | } 75 | for key in settings.SELECTABLE_ESCAPED_KEYS: 76 | if key in result: 77 | result[key] = conditional_escape(result[key]) 78 | return result 79 | 80 | def paginate_results(self, results, options): 81 | "Return a django.core.paginator.Page of results." 82 | limit = options.get("limit", settings.SELECTABLE_MAX_LIMIT) 83 | paginator = Paginator(results, limit) 84 | page = options.get("page", 1) 85 | try: 86 | results = paginator.page(page) 87 | except (EmptyPage, InvalidPage): 88 | results = paginator.page(paginator.num_pages) 89 | return results 90 | 91 | def results(self, request): 92 | "Match results to given term and return the serialized HttpResponse." 93 | results = {} 94 | form = self.form(request.GET) 95 | if form.is_valid(): 96 | options = form.cleaned_data 97 | term = options.get("term", "") 98 | raw_data = self.get_query(request, term) 99 | results = self.format_results(raw_data, options) 100 | return self.response(results) 101 | 102 | def format_results(self, raw_data, options): 103 | """ 104 | Returns a python structure that later gets serialized. 105 | raw_data 106 | full list of objects matching the search term 107 | options 108 | a dictionary of the given options 109 | """ 110 | page_data = self.paginate_results(raw_data, options) 111 | results = {} 112 | meta = options.copy() 113 | meta["more"] = _("Show more results") 114 | if page_data and page_data.has_next(): 115 | meta["next_page"] = page_data.next_page_number() 116 | if page_data and page_data.has_previous(): 117 | meta["prev_page"] = page_data.previous_page_number() 118 | results["data"] = [self.format_item(item) for item in page_data.object_list] 119 | results["meta"] = meta 120 | return results 121 | 122 | 123 | class ModelLookup(LookupBase): 124 | "Lookup class for easily defining lookups based on Django models." 125 | 126 | model = None 127 | filters = {} 128 | search_fields = () 129 | 130 | def get_query(self, request, term): 131 | qs = self.get_queryset() 132 | if term: 133 | if self.search_fields: 134 | for t in self.split_term(term): 135 | search_filters = [] 136 | for field in self.search_fields: 137 | search_filters.append(Q(**{field: t})) 138 | qs = qs.filter(reduce(operator.or_, search_filters)) 139 | return qs 140 | 141 | def get_queryset(self): 142 | qs = self.model._default_manager.get_queryset() 143 | if self.filters: 144 | qs = qs.filter(**self.filters) 145 | return qs 146 | 147 | def get_item_id(self, item): 148 | return item.pk 149 | 150 | def get_item(self, value): 151 | item = None 152 | if value: 153 | value = value.pk if isinstance(value, Model) else value 154 | try: 155 | item = self.get_queryset().get(pk=value) 156 | except (ValueError, self.model.DoesNotExist): 157 | item = None 158 | return item 159 | 160 | def create_item(self, value): 161 | data = {} 162 | if self.search_fields: 163 | field_name = re.sub(r"__\w+$", "", self.search_fields[0]) 164 | if field_name: 165 | data = {field_name: value} 166 | return self.model(**data) 167 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Testing Forms and Lookups 2 | ==================================== 3 | 4 | django-selectable has its own test suite for testing the rendering, validation 5 | and server-side logic it provides. However, depending on the additional customizations 6 | you add to your forms and lookups you most likely will want to include tests of your 7 | own. This section contains some tips or techniques for testing your lookups. 8 | 9 | This guide assumes that you are reasonable familiar with the concepts of unit testing 10 | including Python's `unittest `_ module and 11 | Django's `testing guide `_. 12 | 13 | 14 | Testing Forms with django-selectable 15 | -------------------------------------------------- 16 | 17 | For the most part testing forms which use django-selectable's custom fields 18 | and widgets is the same as testing any Django form. One point that is slightly 19 | different is that the select and multi-select widgets are 20 | `MultiWidgets `_. 21 | The effect of this is that there are two names in the post rather than one. Take the below 22 | form for example. 23 | 24 | .. code-block:: python 25 | 26 | # models.py 27 | 28 | from django.db import models 29 | 30 | class Thing(models.Model): 31 | name = models.CharField(max_length=100) 32 | description = models.CharField(max_length=100) 33 | 34 | def __unicode__(self): 35 | return self.name 36 | 37 | .. code-block:: python 38 | 39 | # lookups.py 40 | 41 | from selectable.base import ModelLookup 42 | from selectable.registry import registry 43 | 44 | from .models import Thing 45 | 46 | class ThingLookup(ModelLookup): 47 | model = Thing 48 | search_fields = ('name__icontains', ) 49 | 50 | registry.register(ThingLookup) 51 | 52 | .. code-block:: python 53 | 54 | # forms.py 55 | 56 | from django import forms 57 | 58 | from selectable.forms import AutoCompleteSelectField 59 | 60 | from .lookups import ThingLookup 61 | 62 | class SimpleForm(forms.Form): 63 | "Basic form for testing." 64 | thing = AutoCompleteSelectField(lookup_class=ThingLookup) 65 | 66 | This form has a single field to select a ``Thing``. It does not allow 67 | new items. Let's write some simple tests for this form. 68 | 69 | .. code-block:: python 70 | 71 | # tests.py 72 | 73 | from django.test import TestCase 74 | 75 | from .forms import SimpleForm 76 | from .models import Thing 77 | 78 | class SimpleFormTestCase(TestCase): 79 | 80 | def test_valid_form(self): 81 | "Submit valid data." 82 | thing = Thing.objects.create(name='Foo', description='Bar') 83 | data = { 84 | 'thing_0': thing.name, 85 | 'thing_1': thing.pk, 86 | } 87 | form = SimpleForm(data=data) 88 | self.assertTrue(form.is_valid()) 89 | 90 | def test_invalid_form(self): 91 | "Thing is required but missing." 92 | data = { 93 | 'thing_0': 'Foo', 94 | 'thing_1': '', 95 | } 96 | form = SimpleForm(data=data) 97 | self.assertFalse(form.is_valid()) 98 | 99 | Here you will note that while there is only one field ``thing`` it requires 100 | two items in the POST the first is for the text input and the second is for 101 | the hidden input. This is again due to the use of MultiWidget for the selection. 102 | 103 | There is compatibility code in the widgets to lookup the original name 104 | from the POST. This makes it easier to transition to the the selectable widgets without 105 | breaking existing tests. 106 | 107 | 108 | Testing Lookup Results 109 | -------------------------------------------------- 110 | 111 | Testing the lookups used by django-selectable is similar to testing your Django views. 112 | While it might be tempting to use the Django 113 | `test client `_, 114 | it is slightly easier to use the 115 | `request factory `_. 116 | A simple example is given below. 117 | 118 | .. code-block:: python 119 | 120 | # tests.py 121 | 122 | import json 123 | 124 | from django.test import TestCase 125 | from django.test.client import RequestFactory 126 | 127 | from .lookups import ThingLookup 128 | from .models import Thing 129 | 130 | class ThingLookupTestCase(TestCase): 131 | 132 | def setUp(self): 133 | self.factory = RequestFactory() 134 | self.lookup = ThingLookup() 135 | self.test_thing = Thing.objects.create(name='Foo', description='Bar') 136 | 137 | def test_results(self): 138 | "Test full response." 139 | request = self.factory.get("/", {'term': 'Fo'}) 140 | response = self.lookup.results(request) 141 | data = json.loads(response.content)['data'] 142 | self.assertEqual(1, len(data)) 143 | self.assertEqual(self.test_thing.pk, data[1]['id']) 144 | 145 | def test_label(self): 146 | "Test item label." 147 | label = self.lookup.get_item_label(self.test_thing) 148 | self.assertEqual(self.test_thing.name, label) 149 | 150 | As shown in the ``test_label`` example it is not required to test the full 151 | request/response. You can test each of the methods in the lookup API individually. 152 | When testing your lookups you should focus on testing the portions which have been 153 | customized by your application. -------------------------------------------------------------------------------- /docs/admin.rst: -------------------------------------------------------------------------------- 1 | Admin Integration 2 | ==================== 3 | 4 | Overview 5 | -------------------------------------- 6 | 7 | Django-Selectables will work in the admin. To get started on integrated the 8 | fields and widgets in the admin make sure you are familiar with the Django 9 | documentation on the `ModelAdmin.form `_ 10 | and `ModelForms `_ particularly 11 | on `overriding the default widgets `_. 12 | As you will see integrating django-selectable in the adminis the same as working with regular forms. 13 | 14 | 15 | .. _admin-jquery-include: 16 | 17 | Including jQuery & jQuery UI 18 | -------------------------------------- 19 | 20 | As noted :ref:`in the quick start guide `, the jQuery and jQuery UI libraries 21 | are not included in the distribution but must be included in your templates. For the 22 | Django admin that means overriding 23 | `admin/base_site.html `_. 24 | You can include this media in the block name `extrahead` which is defined in 25 | `admin/base.html `_. 26 | 27 | .. code-block:: html 28 | 29 | {% block extrahead %} 30 | {% load selectable_tags %} 31 | {% include_ui_theme %} 32 | {% include_jquery_libs %} 33 | {{ block.super }} 34 | {% endblock %} 35 | 36 | See the Django documentation on 37 | `overriding admin templates `_. 38 | See the example project for the full template example. 39 | 40 | 41 | .. _admin-grappelli: 42 | 43 | Using Grappelli 44 | -------------------------------------- 45 | 46 | `Grappelli `_ is a popular customization of the Django 47 | admin interface. It includes a number of interface improvements which are also built on top of 48 | jQuery UI. When using Grappelli you do not need to make any changes to the ``admin/base_site.html`` 49 | template. django-selectable will detect jQuery and jQuery UI versions included by Grappelli 50 | and make use of them. 51 | 52 | 53 | .. _admin-basic-example: 54 | 55 | Basic Example 56 | -------------------------------------- 57 | 58 | For example, we may have a ``Farm`` model with a foreign key to ``auth.User`` and 59 | a many to many relation to our ``Fruit`` model. 60 | 61 | .. code-block:: python 62 | 63 | from django.db import models 64 | 65 | 66 | class Fruit(models.Model): 67 | name = models.CharField(max_length=200) 68 | 69 | def __str__(self): 70 | return self.name 71 | 72 | 73 | class Farm(models.Model): 74 | name = models.CharField(max_length=200) 75 | owner = models.ForeignKey('auth.User', related_name='farms', on_delete=models.CASCADE) 76 | fruit = models.ManyToManyField(Fruit) 77 | 78 | def __str__(self): 79 | return "%s's Farm: %s" % (self.owner.username, self.name) 80 | 81 | In `admin.py` we will define the form and associate it with the `FarmAdmin`. 82 | 83 | .. code-block:: python 84 | 85 | from django.contrib import admin 86 | from django.contrib.auth.admin import UserAdmin 87 | from django.contrib.auth.models import User 88 | from django import forms 89 | 90 | from selectable.forms import AutoCompleteSelectField, AutoCompleteSelectMultipleWidget 91 | 92 | from .models import Fruit, Farm 93 | from .lookups import FruitLookup, OwnerLookup 94 | 95 | 96 | class FarmAdminForm(forms.ModelForm): 97 | owner = AutoCompleteSelectField(lookup_class=OwnerLookup, allow_new=True) 98 | 99 | class Meta: 100 | model = Farm 101 | widgets = { 102 | 'fruit': AutoCompleteSelectMultipleWidget(lookup_class=FruitLookup), 103 | } 104 | exclude = ('owner', ) 105 | 106 | def __init__(self, *args, **kwargs): 107 | super().__init__(*args, **kwargs) 108 | if self.instance and self.instance.pk and self.instance.owner: 109 | self.initial['owner'] = self.instance.owner.pk 110 | 111 | def save(self, *args, **kwargs): 112 | owner = self.cleaned_data['owner'] 113 | if owner and not owner.pk: 114 | owner = User.objects.create_user(username=owner.username, email='') 115 | self.instance.owner = owner 116 | return super().save(*args, **kwargs) 117 | 118 | 119 | class FarmAdmin(admin.ModelAdmin): 120 | form = FarmAdminForm 121 | 122 | 123 | admin.site.register(Farm, FarmAdmin) 124 | 125 | 126 | You'll note this form also allows new users to be created and associated with the 127 | farm, if no user is found matching the given name. To make use of this feature we 128 | need to add ``owner`` to the exclude so that it will pass model validation. Unfortunately 129 | that means we must set the owner manual in the save and in the initial data because 130 | the ``ModelForm`` will no longer do this for you. Since ``fruit`` does not allow new 131 | items you'll see these steps are not necessary. 132 | 133 | The django-selectable widgets are compatitible with the add another popup in the 134 | admin. It's that little green plus sign that appears next to ``ForeignKey`` or 135 | ``ManyToManyField`` items. This makes django-selectable a user friendly replacement 136 | for the `ModelAdmin.raw_id_fields `_ 137 | when the default select box grows too long. 138 | 139 | 140 | .. _admin-inline-example: 141 | 142 | Inline Example 143 | -------------------------------------- 144 | 145 | With our ``Farm`` model we can also associate the ``UserAdmin`` with a ``Farm`` 146 | by making use of the `InlineModelAdmin 147 | `_. 148 | We can even make use of the same ``FarmAdminForm``. 149 | 150 | .. code-block:: python 151 | 152 | # continued from above 153 | 154 | class FarmInline(admin.TabularInline): 155 | model = Farm 156 | form = FarmAdminForm 157 | 158 | 159 | class NewUserAdmin(UserAdmin): 160 | inlines = [ 161 | FarmInline, 162 | ] 163 | 164 | 165 | admin.site.unregister(User) 166 | admin.site.register(User, NewUserAdmin) 167 | 168 | The auto-complete functions will be bound as new forms are added dynamically. 169 | -------------------------------------------------------------------------------- /docs/quick-start.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | ================== 3 | 4 | The workflow for using `django-selectable` involves two main parts: 5 | - Defining your lookups 6 | - Defining your forms 7 | 8 | This guide assumes that you have a basic knowledge of creating Django models and 9 | forms. If not you should first read through the documentation on 10 | `defining models `_ 11 | and `using forms `_. 12 | 13 | .. _start-include-jquery: 14 | 15 | Including jQuery & jQuery UI 16 | -------------------------------------- 17 | 18 | The widgets in django-selectable define the media they need as described in the 19 | Django documentation on `Form Media `_. 20 | That means to include the javascript and css you need to make the widgets work you 21 | can include ``{{ form.media.css }}`` and ``{{ form.media.js }}`` in your template. This is 22 | assuming your form is called `form` in the template context. For more information 23 | please check out the `Django documentation `_. 24 | 25 | The jQuery and jQuery UI libraries are not included in the distribution but must be included 26 | in your templates. However there is a template tag to easily add these libraries from 27 | the from the `Google CDN `_. 28 | 29 | .. code-block:: html 30 | 31 | {% load selectable_tags %} 32 | {% include_jquery_libs %} 33 | 34 | By default these will use jQuery v1.11.2 and jQuery UI v1.11.3. You can customize the versions 35 | used by pass them to the tag. The first version is the jQuery version and the second is the 36 | jQuery UI version. 37 | 38 | .. code-block:: html 39 | 40 | {% load selectable_tags %} 41 | {% include_jquery_libs '1.11.2' '1.11.3' %} 42 | 43 | Django-Selectable should work with `jQuery `_ >= 1.9 and 44 | `jQuery UI `_ >= 1.10. 45 | 46 | You must also include a `jQuery UI theme `_ stylesheet. There 47 | is also a template tag to easily add this style sheet from the Google CDN. 48 | 49 | .. code-block:: html 50 | 51 | {% load selectable_tags %} 52 | {% include_ui_theme %} 53 | 54 | By default this will use the `base `_ theme for jQuery UI v1.11.4. 55 | You can configure the theme and version by passing them in the tag. 56 | 57 | .. code-block:: html 58 | 59 | {% load selectable_tags %} 60 | {% include_ui_theme 'ui-lightness' '1.11.4' %} 61 | 62 | Or only change the theme. 63 | 64 | .. code-block:: html 65 | 66 | {% load selectable_tags %} 67 | {% include_ui_theme 'ui-lightness' %} 68 | 69 | See the the jQuery UI documentation for a full list of available stable themes: http://jqueryui.com/download#stable-themes 70 | 71 | Of course you can choose to include these rescources manually:: 72 | 73 | .. code-block:: html 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | .. note:: 82 | 83 | jQuery UI shares a few plugin names with the popular Twitter Bootstrap framework. There 84 | are notes on using Bootstrap along with django-selectable in the :ref:`advanced usage 85 | section `. 86 | 87 | 88 | Defining a Lookup 89 | -------------------------------- 90 | 91 | The lookup classes define the backend views. The most common case is defining a 92 | lookup which searchs models based on a particular field. Let's define a simple model: 93 | 94 | .. code-block:: python 95 | 96 | from django.db import models 97 | 98 | 99 | class Fruit(models.Model): 100 | name = models.CharField(max_length=200) 101 | 102 | def __str__(self): 103 | return self.name 104 | 105 | In a `lookups.py` we will define our lookup: 106 | 107 | .. code-block:: python 108 | 109 | from selectable.base import ModelLookup 110 | from selectable.registry import registry 111 | 112 | from .models import Fruit 113 | 114 | 115 | class FruitLookup(ModelLookup): 116 | model = Fruit 117 | search_fields = ('name__icontains', ) 118 | 119 | 120 | This lookups extends ``selectable.base.ModelLookup`` and defines two things: one is 121 | the model on which we will be searching and the other is the field which we are searching. 122 | This syntax should look familiar as it is the same as the `field lookup syntax `_ 123 | for making queries in Django. 124 | 125 | Below this definition we will register our lookup class. 126 | 127 | .. code-block:: python 128 | 129 | registry.register(FruitLookup) 130 | 131 | .. note:: 132 | 133 | You should only register your lookup once. Attempting to register the same lookup class 134 | more than once will lead to ``LookupAlreadyRegistered`` errors. A common problem related to the 135 | ``LookupAlreadyRegistered`` error is related to inconsistant import paths in your project. 136 | Prior to Django 1.4 the default ``manage.py`` allows for importing both with and without 137 | the project name (i.e. ``from myproject.myapp import lookups`` or ``from myapp import lookups``). 138 | This leads to the ``lookup.py`` file being imported twice and the registration code 139 | executing twice. Thankfully this is no longer the default in Django 1.4. Keeping 140 | your import consistant to include the project name (when your app is included inside the 141 | project directory) will avoid these errors. 142 | 143 | 144 | Defining Forms 145 | -------------------------------- 146 | 147 | Now that we have a working lookup we will define a form which uses it: 148 | 149 | .. code-block:: python 150 | 151 | from django import forms 152 | 153 | from selectable.forms import AutoCompleteWidget 154 | 155 | from .lookups import FruitLookup 156 | 157 | 158 | class FruitForm(forms.Form): 159 | autocomplete = forms.CharField( 160 | label='Type the name of a fruit (AutoCompleteWidget)', 161 | widget=AutoCompleteWidget(FruitLookup), 162 | required=False, 163 | ) 164 | 165 | 166 | This replaces the default widget for the ``CharField`` with the ``AutoCompleteWidget``. 167 | This will allow the user to fill this field with values taken from the names of 168 | existing ``Fruit`` models. 169 | 170 | And that's pretty much it. Keep on reading if you want to learn about the other 171 | types of fields and widgets that are available as well as defining more complicated 172 | lookups. 173 | -------------------------------------------------------------------------------- /selectable/tests/qunit/test-options.js: -------------------------------------------------------------------------------- 1 | /*global define, module, test, expect, equal, ok*/ 2 | 3 | define(['selectable'], function ($) { 4 | 5 | module("Plugin Options Tests", { 6 | setup: function () { 7 | // Patch AJAX requests 8 | var self = this; 9 | this.xhr = sinon.useFakeXMLHttpRequest(); 10 | this.requests = []; 11 | this.xhr.onCreate = function (xhr) { 12 | self.requests.push(xhr); 13 | }; 14 | this.input = createTextComplete('autocomplete'); 15 | $('#qunit-fixture').append(this.input); 16 | bindSelectables('#qunit-fixture'); 17 | }, 18 | teardown: function () { 19 | this.xhr.restore(); 20 | this.input.djselectable('destroy'); 21 | } 22 | }); 23 | 24 | test("Highlight Match On", function () { 25 | expect(2); 26 | var response = simpleLookupResponse(), 27 | self = this, 28 | menu, item, highlight; 29 | this.input.djselectable("option", "highlightMatch", true); 30 | this.input.val("ap").keydown(); 31 | stop(); 32 | setTimeout(function () { 33 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 34 | JSON.stringify(response) 35 | ); 36 | menu = $('ul.ui-autocomplete.ui-menu:visible'); 37 | item = $('li', menu).eq(0); 38 | highlight = $('.highlight', item); 39 | equal(highlight.length, 1, "Highlight should be present"); 40 | equal(highlight.text(), "Ap", "Highlight text should match"); 41 | start(); 42 | }, 300); 43 | }); 44 | 45 | test("Highlight Match Off", function () { 46 | expect(1); 47 | var response = simpleLookupResponse(), 48 | self = this, 49 | menu, item, highlight; 50 | this.input.djselectable("option", "highlightMatch", false); 51 | this.input.val("ap").keydown(); 52 | stop(); 53 | setTimeout(function () { 54 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 55 | JSON.stringify(response) 56 | ); 57 | menu = $('ul.ui-autocomplete.ui-menu:visible'); 58 | item = $('li', menu).eq(0); 59 | highlight = $('.highlight', item); 60 | equal(highlight.length, 0, "Highlight should not be present"); 61 | start(); 62 | }, 300); 63 | }); 64 | 65 | test("Format Label String (No Highlight)", function () { 66 | expect(3); 67 | var response = simpleLookupResponse(), 68 | self = this, 69 | menu, item, custom, highlight; 70 | function customFormat(label, item) { 71 | return "" + label + ""; 72 | } 73 | this.input.djselectable("option", "formatLabel", customFormat); 74 | this.input.djselectable("option", "highlightMatch", false); 75 | this.input.val("ap").keydown(); 76 | stop(); 77 | setTimeout(function () { 78 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 79 | JSON.stringify(response) 80 | ); 81 | menu = $('ul.ui-autocomplete.ui-menu:visible'); 82 | item = $('li', menu).eq(0); 83 | custom = $('.custom', item); 84 | equal(custom.length, 1, "Custom label should be present"); 85 | equal(custom.text(), "Apple", "Label text should match"); 86 | highlight = $('.highlight', item); 87 | equal(highlight.length, 0, "Highlight should not be present"); 88 | start(); 89 | }, 300); 90 | }); 91 | 92 | test("Format Label jQuery Object (No Highlight)", function () { 93 | expect(3); 94 | var response = simpleLookupResponse(), 95 | self = this, 96 | menu, item, custom, highlight 97 | function customFormat(label, item) { 98 | return $("").addClass("custom").text(label); 99 | } 100 | this.input.djselectable("option", "formatLabel", customFormat); 101 | this.input.djselectable("option", "highlightMatch", false); 102 | this.input.val("ap").keydown(); 103 | stop(); 104 | setTimeout(function () { 105 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 106 | JSON.stringify(response) 107 | ); 108 | menu = $('ul.ui-autocomplete.ui-menu:visible'); 109 | item = $('li', menu).eq(0); 110 | custom = $('.custom', item); 111 | equal(custom.length, 1, "Custom label should be present"); 112 | equal(custom.text(), "Apple", "Label text should match"); 113 | highlight = $('.highlight', item); 114 | equal(highlight.length, 0, "Highlight should not be present"); 115 | start(); 116 | }, 300); 117 | }); 118 | 119 | test("Format Label String (With Highlight)", function () { 120 | expect(4); 121 | var response = simpleLookupResponse(), 122 | self = this, 123 | menu, item, custom, highlight; 124 | function customFormat(label, item) { 125 | return "" + label + ""; 126 | } 127 | this.input.djselectable("option", "formatLabel", customFormat); 128 | this.input.djselectable("option", "highlightMatch", true); 129 | this.input.val("ap").keydown(); 130 | stop(); 131 | setTimeout(function () { 132 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 133 | JSON.stringify(response) 134 | ); 135 | menu = $('ul.ui-autocomplete.ui-menu:visible'); 136 | item = $('li', menu).eq(0); 137 | custom = $('.custom', item); 138 | equal(custom.length, 1, "Custom label should be present"); 139 | equal(custom.text(), "Apple", "Label text should match"); 140 | highlight = $('.highlight', custom); 141 | equal(highlight.length, 1, "Highlight should be present"); 142 | equal(highlight.text(), "Ap", "Highlight text should match"); 143 | start(); 144 | }, 300); 145 | }); 146 | 147 | test("Format Label jQuery Object (With Highlight)", function () { 148 | expect(4); 149 | var response = simpleLookupResponse(), 150 | self = this, 151 | menu, item, custom, highlight; 152 | function customFormat(label, item) { 153 | return $("").addClass("custom").text(label); 154 | } 155 | this.input.djselectable("option", "formatLabel", customFormat); 156 | this.input.djselectable("option", "highlightMatch", true); 157 | this.input.val("ap").keydown(); 158 | stop(); 159 | setTimeout(function () { 160 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 161 | JSON.stringify(response) 162 | ); 163 | menu = $('ul.ui-autocomplete.ui-menu:visible'); 164 | item = $('li', menu).eq(0); 165 | custom = $('.custom', item); 166 | equal(custom.length, 1, "Custom label should be present"); 167 | equal(custom.text(), "Apple", "Label text should match"); 168 | highlight = $('.highlight', custom); 169 | equal(highlight.length, 1, "Highlight should be present"); 170 | equal(highlight.text(), "Ap", "Highlight text should match"); 171 | start(); 172 | }, 300); 173 | }); 174 | }); -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django-Selectable documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Mar 12 14:14:16 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import datetime 15 | import selectable 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | # sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | # needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = [] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ["_templates"] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = ".rst" 36 | 37 | # The encoding of source files. 38 | # source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = "index" 42 | 43 | # General information about the project. 44 | project = "Django-Selectable" 45 | copyright = "2011-%s, Mark Lavin" % datetime.date.today().year 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = ".".join(selectable.__version__.split(".")[0:2]) 53 | # The full version, including alpha/beta/rc tags. 54 | release = selectable.__version__ 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | # language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | # today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | # today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ["_build"] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | # default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | # add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | # add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | # show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = "sphinx" 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | # modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = "default" 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | # html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | # html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | # html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | # html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | # html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | # html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | # html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | # html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | # html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | # html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | # html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | # html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | # html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | # html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | # html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | # html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | # html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | # html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = "Django-Selectabledoc" 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | # The paper size ('letter' or 'a4'). 174 | # latex_paper_size = 'letter' 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | # latex_font_size = '10pt' 178 | 179 | # Grouping the document tree into LaTeX files. List of tuples 180 | # (source start file, target name, title, author, documentclass [howto/manual]). 181 | latex_documents = [ 182 | ( 183 | "index", 184 | "Django-Selectable.tex", 185 | "Django-Selectable Documentation", 186 | "Mark Lavin", 187 | "manual", 188 | ), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | # latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | # latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | # latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | # latex_show_urls = False 204 | 205 | # Additional stuff for the LaTeX preamble. 206 | # latex_preamble = '' 207 | 208 | # Documents to append as an appendix to all manuals. 209 | # latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | # latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ("index", "django-selectable", "Django-Selectable Documentation", ["Mark Lavin"], 1) 221 | ] 222 | -------------------------------------------------------------------------------- /selectable/tests/qunit/test-events.js: -------------------------------------------------------------------------------- 1 | /*global define, module, test, expect, equal, ok*/ 2 | 3 | define(['selectable'], function ($) { 4 | 5 | module("Basic Event Tests", { 6 | setup: function () { 7 | // Patch AJAX requests 8 | var self = this; 9 | this.xhr = sinon.useFakeXMLHttpRequest(); 10 | this.requests = []; 11 | this.xhr.onCreate = function (xhr) { 12 | self.requests.push(xhr); 13 | }; 14 | this.inputs = createTextSelect('autocompleteselect'); 15 | this.textInput = this.inputs[0]; 16 | this.hiddenInput = this.inputs[1]; 17 | $('#qunit-fixture').append(this.textInput); 18 | $('#qunit-fixture').append(this.hiddenInput); 19 | bindSelectables('#qunit-fixture'); 20 | }, 21 | teardown: function () { 22 | this.xhr.restore(); 23 | this.textInput.djselectable('destroy'); 24 | } 25 | }); 26 | 27 | test("Manual Selection", function() { 28 | expect(1); 29 | var count = 0, 30 | item = {id: "1", value: 'foo'}; 31 | this.textInput.bind('djselectableselect', function(e, item) { 32 | count = count + 1; 33 | }); 34 | var item = {id: "1", value: 'foo'}; 35 | this.textInput.djselectable('select', item); 36 | equal(count, 1, "djselectableselect should fire once when manually selected."); 37 | }); 38 | 39 | test("Manual Selection with Double Bind", function() { 40 | expect(1); 41 | var count = 0, 42 | item = {id: "1", value: 'foo'}; 43 | bindSelectables('#qunit-fixture'); 44 | this.textInput.bind('djselectableselect', function(e, item) { 45 | count = count + 1; 46 | }); 47 | this.textInput.djselectable('select', item); 48 | equal(count, 1, "djselectableselect should fire once when manually selected."); 49 | }); 50 | 51 | test("Menu Selection", function() { 52 | expect(2); 53 | var count = 0, 54 | down = jQuery.Event("keydown"), 55 | enter = jQuery.Event("keydown"), 56 | response = simpleLookupResponse(), 57 | self = this; 58 | down.keyCode = $.ui.keyCode.DOWN; 59 | enter.keyCode = $.ui.keyCode.ENTER; 60 | this.textInput.bind('djselectableselect', function(e, item) { 61 | count = count + 1; 62 | }); 63 | this.textInput.val("ap").keydown(); 64 | stop(); 65 | setTimeout(function () { 66 | equal(self.requests.length, 1, "AJAX request should be triggered."); 67 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 68 | JSON.stringify(response) 69 | ); 70 | self.textInput.trigger(down); 71 | self.textInput.trigger(enter); 72 | equal(count, 1, "djselectableselect should only fire once."); 73 | start(); 74 | }, 300); 75 | }); 76 | 77 | test("Pagination Click", function() { 78 | expect(3); 79 | var count = 0, 80 | response = paginatedLookupResponse(), 81 | self = this; 82 | this.textInput.bind('djselectableselect', function(e, item) { 83 | count = count + 1; 84 | }); 85 | this.textInput.val("ap").keydown(); 86 | stop(); 87 | setTimeout(function () { 88 | equal(self.requests.length, 1, "AJAX request should be triggered."); 89 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 90 | JSON.stringify(response) 91 | ); 92 | $('.ui-menu-item.selectable-paginator:visible a').trigger("mouseenter"); 93 | $('.ui-menu-item.selectable-paginator:visible a').trigger("click"); 94 | equal(self.requests.length, 2, "Another AJAX request should be triggered."); 95 | equal(count, 0, "djselectableselect should not fire for new page."); 96 | start(); 97 | }, 300); 98 | }); 99 | 100 | test("Pagination Enter", function() { 101 | expect(3); 102 | var count = 0, 103 | down = jQuery.Event("keydown"), 104 | enter = jQuery.Event("keydown"), 105 | response = paginatedLookupResponse(), 106 | self = this; 107 | down.keyCode = $.ui.keyCode.DOWN; 108 | enter.keyCode = $.ui.keyCode.ENTER; 109 | this.textInput.bind('djselectableselect', function(e, item) { 110 | count = count + 1; 111 | }); 112 | this.textInput.val("ap").keydown(); 113 | stop(); 114 | setTimeout(function () { 115 | equal(self.requests.length, 1, "AJAX request should be triggered."); 116 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 117 | JSON.stringify(response) 118 | ); 119 | self.textInput.trigger(down); 120 | self.textInput.trigger(down); 121 | self.textInput.trigger(down); 122 | self.textInput.trigger(enter); 123 | equal(self.requests.length, 2, "Another AJAX request should be triggered."); 124 | equal(count, 0, "djselectableselect should not fire for new page."); 125 | start(); 126 | }, 300); 127 | }); 128 | 129 | test("Pagination Render", function() { 130 | expect(2); 131 | var count = 0, 132 | down = jQuery.Event("keydown"), 133 | enter = jQuery.Event("keydown"), 134 | response = paginatedLookupResponse(), 135 | self = this; 136 | down.keyCode = $.ui.keyCode.DOWN; 137 | enter.keyCode = $.ui.keyCode.ENTER; 138 | this.textInput.val("ap").keydown(); 139 | stop(); 140 | setTimeout(function () { 141 | var options; 142 | self.requests[0].respond(200, {"Content-Type": "application/json"}, 143 | JSON.stringify(response) 144 | ); 145 | options = $('li.ui-menu-item:visible'); 146 | equal(options.length, 3, "Currently 3 menu items."); 147 | // $('.selectable-paginator:visible').click(); 148 | self.textInput.trigger(down); 149 | self.textInput.trigger(down); 150 | self.textInput.trigger(down); 151 | self.textInput.trigger(enter); 152 | self.requests[1].respond(200, {"Content-Type": "application/json"}, 153 | JSON.stringify(response) 154 | ); 155 | options = $('li.ui-menu-item:visible'); 156 | equal(options.length, 5, "Now 5 menu items."); 157 | start(); 158 | }, 300); 159 | }); 160 | 161 | module("Custom Event Tests", { 162 | setup: function () { 163 | this.inputs = createTextSelectMultiple('autocompleteselectmultiple'); 164 | this.textInput = this.inputs[0]; 165 | this.hiddenInput = this.inputs[1]; 166 | $('#qunit-fixture').append(this.textInput); 167 | bindSelectables('#qunit-fixture'); 168 | } 169 | }); 170 | 171 | test("Add Deck Item", function() { 172 | expect(1); 173 | var count = 0, 174 | item = {id: "1", value: 'foo'}; 175 | this.textInput.bind('djselectableadd', function(e, item) { 176 | count = count + 1; 177 | }); 178 | this.textInput.djselectable('select', item); 179 | equal(count, 1, "djselectableadd should fire once when manually selected."); 180 | }); 181 | 182 | test("Prevent Add Deck Item", function() { 183 | expect(1); 184 | var count = 0, 185 | item = {id: "1", value: 'foo'}, 186 | deck = $('.selectable-deck', '#qunit-fixture'); 187 | this.textInput.bind('djselectableadd', function(e, item) { 188 | return false; 189 | }); 190 | this.textInput.djselectable('select', item); 191 | deck = $('.selectable-deck', '#qunit-fixture'); 192 | equal($('li', deck).length, 0, "Item should not be added."); 193 | }); 194 | 195 | test("Remove Deck Item", function() { 196 | expect(1); 197 | var count = 0, 198 | item = {id: "1", value: 'foo'}, 199 | deck = $('.selectable-deck', '#qunit-fixture'); 200 | this.textInput.bind('djselectableremove', function(e, item) { 201 | count = count + 1; 202 | }); 203 | this.textInput.djselectable('select', item); 204 | $('.selectable-deck-remove', deck).click(); 205 | equal(count, 1, "djselectableremove should fire once when item is removed."); 206 | }); 207 | 208 | test("Prevent Remove Deck Item", function() { 209 | expect(1); 210 | var count = 0, 211 | item = {id: "1", value: 'foo'}, 212 | deck = $('.selectable-deck', '#qunit-fixture'); 213 | this.textInput.bind('djselectableremove', function(e, item) { 214 | return false; 215 | }); 216 | var item = {id: "1", value: 'foo'}; 217 | this.textInput.djselectable('select', item); 218 | $('.selectable-deck-remove', deck).click(); 219 | equal($('li', deck).length, 1, "Item should not be removed."); 220 | }); 221 | }); -------------------------------------------------------------------------------- /selectable/forms/widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.utils.encoding import force_str 6 | from django.utils.http import urlencode 7 | 8 | from selectable import __version__ 9 | from selectable.forms.base import import_lookup_class 10 | 11 | __all__ = ( 12 | "AutoCompleteWidget", 13 | "AutoCompleteSelectWidget", 14 | "AutoComboboxWidget", 15 | "AutoComboboxSelectWidget", 16 | "AutoCompleteSelectMultipleWidget", 17 | "AutoComboboxSelectMultipleWidget", 18 | ) 19 | 20 | 21 | STATIC_PREFIX = "%sselectable/" % settings.STATIC_URL 22 | 23 | 24 | class SelectableMediaMixin: 25 | class Media: 26 | css = {"all": ("%scss/dj.selectable.css?v=%s" % (STATIC_PREFIX, __version__),)} 27 | js = ("%sjs/jquery.dj.selectable.js?v=%s" % (STATIC_PREFIX, __version__),) 28 | 29 | 30 | class AutoCompleteWidget(forms.TextInput, SelectableMediaMixin): 31 | def __init__(self, lookup_class, *args, **kwargs): 32 | self.lookup_class = import_lookup_class(lookup_class) 33 | self.allow_new = kwargs.pop("allow_new", False) 34 | self.qs = kwargs.pop("query_params", {}) 35 | self.limit = kwargs.pop("limit", None) 36 | super().__init__(*args, **kwargs) 37 | 38 | def update_query_parameters(self, qs_dict): 39 | self.qs.update(qs_dict) 40 | 41 | def build_attrs(self, base_attrs, extra_attrs=None): 42 | attrs = super().build_attrs(base_attrs, extra_attrs) 43 | url = self.lookup_class.url() 44 | if self.limit and "limit" not in self.qs: 45 | self.qs["limit"] = self.limit 46 | if self.qs: 47 | url = "%s?%s" % (url, urlencode(self.qs)) 48 | if "data-selectable-options" in attrs: 49 | attrs["data-selectable-options"] = json.dumps( 50 | attrs["data-selectable-options"] 51 | ) 52 | attrs["data-selectable-url"] = url 53 | attrs["data-selectable-type"] = "text" 54 | attrs["data-selectable-allow-new"] = str(self.allow_new).lower() 55 | return attrs 56 | 57 | 58 | class SelectableMultiWidget(forms.MultiWidget): 59 | def update_query_parameters(self, qs_dict): 60 | self.widgets[0].update_query_parameters(qs_dict) 61 | 62 | def decompress(self, value): 63 | if value: 64 | lookup = self.lookup_class() 65 | model = getattr(self.lookup_class, "model", None) 66 | if model and isinstance(value, model): 67 | item = value 68 | value = lookup.get_item_id(item) 69 | else: 70 | item = lookup.get_item(value) 71 | item_value = lookup.get_item_value(item) 72 | return [item_value, value] 73 | return [None, None] 74 | 75 | def get_compatible_postdata(self, data, name): 76 | """Get postdata built for a normal ``-compatibile post variable 85 | is not found. 86 | """ 87 | return data.get(name, None) 88 | 89 | 90 | class _BaseSingleSelectWidget(SelectableMultiWidget, SelectableMediaMixin): 91 | """ 92 | Common base class for AutoCompleteSelectWidget and AutoComboboxSelectWidget 93 | each which use one widget (primary_widget) to select text and a single 94 | hidden input to hold the selected id. 95 | """ 96 | 97 | primary_widget = None 98 | 99 | def __init__(self, lookup_class, *args, **kwargs): 100 | self.lookup_class = import_lookup_class(lookup_class) 101 | self.allow_new = kwargs.pop("allow_new", False) 102 | self.limit = kwargs.pop("limit", None) 103 | query_params = kwargs.pop("query_params", {}) 104 | widgets = [ 105 | self.primary_widget( 106 | lookup_class, 107 | allow_new=self.allow_new, 108 | limit=self.limit, 109 | query_params=query_params, 110 | attrs=kwargs.get("attrs"), 111 | ), 112 | forms.HiddenInput(attrs={"data-selectable-type": "hidden"}), 113 | ] 114 | super().__init__(widgets, *args, **kwargs) 115 | 116 | def value_from_datadict(self, data, files, name): 117 | value = super().value_from_datadict(data, files, name) 118 | if not value[1]: 119 | compatible_postdata = self.get_compatible_postdata(data, name) 120 | if compatible_postdata: 121 | value[1] = compatible_postdata 122 | if not self.allow_new: 123 | return value[1] 124 | return value 125 | 126 | 127 | class AutoCompleteSelectWidget(_BaseSingleSelectWidget): 128 | primary_widget = AutoCompleteWidget 129 | 130 | 131 | class AutoComboboxWidget(AutoCompleteWidget, SelectableMediaMixin): 132 | def build_attrs(self, base_attrs, extra_attrs=None): 133 | attrs = super().build_attrs(base_attrs, extra_attrs) 134 | attrs["data-selectable-type"] = "combobox" 135 | return attrs 136 | 137 | 138 | class AutoComboboxSelectWidget(_BaseSingleSelectWidget): 139 | primary_widget = AutoComboboxWidget 140 | 141 | 142 | class LookupMultipleHiddenInput(forms.MultipleHiddenInput): 143 | def __init__(self, lookup_class, *args, **kwargs): 144 | self.lookup_class = import_lookup_class(lookup_class) 145 | super().__init__(*args, **kwargs) 146 | 147 | def get_context(self, name, value, attrs): 148 | lookup = self.lookup_class() 149 | values = self._normalize_value(value) 150 | values = list(values) # force evaluation 151 | 152 | context = super().get_context(name, values, attrs) 153 | 154 | # Now override/add to what super() did: 155 | subwidgets = context["widget"]["subwidgets"] 156 | for widget_ctx, item in zip(subwidgets, values): 157 | input_value, title = self._lookup_value_and_title(lookup, item) 158 | widget_ctx["value"] = input_value # override what super() did 159 | if title: 160 | widget_ctx["attrs"]["title"] = title 161 | return context 162 | 163 | def build_attrs(self, base_attrs, extra_attrs=None): 164 | attrs = super().build_attrs(base_attrs, extra_attrs) 165 | attrs["data-selectable-type"] = "hidden-multiple" 166 | return attrs 167 | 168 | def _normalize_value(self, value): 169 | if value is None: 170 | value = [] 171 | return value 172 | 173 | def _lookup_value_and_title(self, lookup, v): 174 | model = getattr(self.lookup_class, "model", None) 175 | item = None 176 | if model and isinstance(v, model): 177 | item = v 178 | v = lookup.get_item_id(item) 179 | title = None 180 | if v: 181 | item = item or lookup.get_item(v) 182 | title = lookup.get_item_value(item) 183 | return force_str(v), title 184 | 185 | 186 | class _BaseMultipleSelectWidget(SelectableMultiWidget, SelectableMediaMixin): 187 | """ 188 | Common base class for AutoCompleteSelectMultipleWidget and AutoComboboxSelectMultipleWidget 189 | each which use one widget (primary_widget) to select text and a multiple 190 | hidden inputs to hold the selected ids. 191 | """ 192 | 193 | primary_widget = None 194 | 195 | def __init__(self, lookup_class, *args, **kwargs): 196 | self.lookup_class = import_lookup_class(lookup_class) 197 | self.limit = kwargs.pop("limit", None) 198 | position = kwargs.pop("position", "bottom") 199 | attrs = { 200 | "data-selectable-multiple": "true", 201 | "data-selectable-position": position, 202 | } 203 | attrs.update(kwargs.get("attrs", {})) 204 | query_params = kwargs.pop("query_params", {}) 205 | widgets = [ 206 | self.primary_widget( 207 | lookup_class, 208 | allow_new=False, 209 | limit=self.limit, 210 | query_params=query_params, 211 | attrs=attrs, 212 | ), 213 | LookupMultipleHiddenInput(lookup_class), 214 | ] 215 | super().__init__(widgets, *args, **kwargs) 216 | 217 | def value_from_datadict(self, data, files, name): 218 | value = self.widgets[1].value_from_datadict(data, files, name + "_1") 219 | if not value: 220 | # Fall back to the compatible POST name 221 | value = self.get_compatible_postdata(data, name) 222 | return value 223 | 224 | def build_attrs(self, base_attrs, extra_attrs=None): 225 | attrs = super().build_attrs(base_attrs, extra_attrs) 226 | if "required" in attrs: 227 | attrs.pop("required") 228 | return attrs 229 | 230 | def render(self, name, value, attrs=None, renderer=None): 231 | if value and not hasattr(value, "__iter__"): 232 | value = [value] 233 | value = ["", value] 234 | return super().render(name, value, attrs, renderer) 235 | 236 | 237 | class AutoCompleteSelectMultipleWidget(_BaseMultipleSelectWidget): 238 | primary_widget = AutoCompleteWidget 239 | 240 | 241 | class AutoComboboxSelectMultipleWidget(_BaseMultipleSelectWidget): 242 | primary_widget = AutoComboboxWidget 243 | -------------------------------------------------------------------------------- /docs/lookups.rst: -------------------------------------------------------------------------------- 1 | Defining Lookups 2 | ================== 3 | 4 | What are Lookups? 5 | -------------------------------------- 6 | 7 | Lookups define the corresponding ajax views used by the auto-completion 8 | fields and widgets. They take in the current request and return the JSON 9 | needed by the jQuery auto-complete plugin. 10 | 11 | 12 | Defining a Lookup 13 | -------------------------------------- 14 | 15 | django-selectable uses a registration pattern similar to the Django admin. 16 | Lookups should be defined in a `lookups.py` in your application's module. Once defined 17 | you must register in with django-selectable. All lookups must extend from 18 | ``selectable.base.LookupBase`` which defines the API for every lookup. 19 | 20 | .. code-block:: python 21 | 22 | from selectable.base import LookupBase 23 | from selectable.registry import registry 24 | 25 | class MyLookup(LookupBase): 26 | def get_query(self, request, term): 27 | data = ['Foo', 'Bar'] 28 | return [x for x in data if x.startswith(term)] 29 | 30 | registry.register(MyLookup) 31 | 32 | 33 | Lookup API 34 | -------------------------------------- 35 | 36 | .. py:method:: LookupBase.get_query(request, term) 37 | 38 | This is the main method which takes the current request 39 | from the user and returns the data which matches their search. 40 | 41 | :param request: The current request object. 42 | :param term: The search term from the widget input. 43 | :return: An iterable set of data of items matching the search term. 44 | 45 | .. _lookup-get-item-label: 46 | 47 | .. py:method:: LookupBase.get_item_label(item) 48 | 49 | This is first of three formatting methods. The label is shown in the 50 | drop down menu of search results. This defaults to ``item.__unicode__``. 51 | 52 | :param item: An item from the search results. 53 | :return: A string representation of the item to be shown in the search results. 54 | The label can include HTML. For changing the label format on the client side 55 | see :ref:`Advanced Label Formats `. 56 | 57 | 58 | .. py:method:: LookupBase.get_item_id(item) 59 | 60 | This is second of three formatting methods. The id is the value that will eventually 61 | be returned by the field/widget. This defaults to ``item.__unicode__``. 62 | 63 | :param item: An item from the search results. 64 | :return: A string representation of the item to be returned by the field/widget. 65 | 66 | 67 | .. py:method:: LookupBase.split_term(term) 68 | 69 | Split searching term into array of subterms that will be searched separately. 70 | You can override this function to achieve different splitting of the term. 71 | 72 | :param term: The search term. 73 | :return: Array with subterms 74 | 75 | .. py:method:: LookupBase.get_item_value(item) 76 | 77 | This is last of three formatting methods. The value is shown in the 78 | input once the item has been selected. This defaults to ``item.__unicode__``. 79 | 80 | :param item: An item from the search results. 81 | :return: A string representation of the item to be shown in the input. 82 | 83 | .. py:method:: LookupBase.get_item(value) 84 | 85 | ``get_item`` is the reverse of ``get_item_id``. This should take the value 86 | from the form initial values and return the current item. This defaults 87 | to simply return the value. 88 | 89 | :param value: Value from the form inital value. 90 | :return: The item corresponding to the initial value. 91 | 92 | .. py:method:: LookupBase.create_item(value) 93 | 94 | If you plan to use a lookup with a field or widget which allows the user 95 | to input new values then you must define what it means to create a new item 96 | for your lookup. By default this raises a ``NotImplemented`` error. 97 | 98 | :param value: The user given value. 99 | :return: The new item created from the item. 100 | 101 | .. _lookup-format-item: 102 | 103 | .. py:method:: LookupBase.format_item(item) 104 | 105 | By default ``format_item`` creates a dictionary with the three keys used by 106 | the UI plugin: id, value, label. These are generated from the calls to 107 | ``get_item_id``, ``get_item_value`` and ``get_item_label``. If you want to 108 | add additional keys you should add them here. 109 | 110 | The results of ``get_item_label`` is conditionally escaped to prevent 111 | Cross Site Scripting (XSS) similar to the templating language. 112 | If you know that the content is safe and you want to use these methods 113 | to include HTML should mark the content as safe with ``django.utils.safestring.mark_safe`` 114 | inside the ``get_item_label`` method. 115 | 116 | ``get_item_id`` and ``get_item_value`` are not escapted by default. These are 117 | not a XSS vector with the built-in JS. If you are doing additional formating using 118 | these values you should be conscience of this fake and be sure to escape these 119 | values. 120 | 121 | :param item: An item from the search results. 122 | :return: A dictionary of information for this item to be sent back to the client. 123 | 124 | There are also some additional methods that you could want to use/override. These 125 | are for more advanced use cases such as using the lookups with JS libraries other 126 | than jQuery UI. Most users will not need to override these methods. 127 | 128 | .. _lookup-format-results: 129 | 130 | .. py:method:: LookupBase.format_results(self, raw_data, options) 131 | 132 | Returns a python structure that later gets serialized. This makes a call to 133 | :ref:`paginate_results` prior to calling 134 | :ref:`format_item` on each item in the current page. 135 | 136 | :param raw_data: The set of all matched results. 137 | :param options: Dictionary of ``cleaned_data`` from the lookup form class. 138 | :return: A dictionary with two keys ``meta`` and ``data``. 139 | The value of ``data`` is an iterable extracted from page_data. 140 | The value of ``meta`` is a dictionary. This is a copy of options with one additional element 141 | ``more`` which is a translatable "Show more" string 142 | (useful for indicating more results on the javascript side). 143 | 144 | .. _lookup-paginate-results: 145 | 146 | .. py:method:: LookupBase.paginate_results(results, options) 147 | 148 | If :ref:`SELECTABLE_MAX_LIMIT` is defined or ``limit`` is passed in request.GET 149 | then ``paginate_results`` will return the current page using Django's 150 | built in pagination. See the Django docs on 151 | `pagination `_ 152 | for more info. 153 | 154 | :param results: The set of all matched results. 155 | :param options: Dictionary of ``cleaned_data`` from the lookup form class. 156 | :return: The current `Page object `_ 157 | of results. 158 | 159 | 160 | .. _ModelLookup: 161 | 162 | Lookups Based on Models 163 | -------------------------------------- 164 | 165 | Perhaps the most common use case is to define a lookup based on a given Django model. 166 | For this you can extend ``selectable.base.ModelLookup``. To extend ``ModelLookup`` you 167 | should set two class attributes: ``model`` and ``search_fields``. 168 | 169 | .. code-block:: python 170 | 171 | from selectable.base import ModelLookup 172 | from selectable.registry import registry 173 | 174 | from .models import Fruit 175 | 176 | 177 | class FruitLookup(ModelLookup): 178 | model = Fruit 179 | search_fields = ('name__icontains', ) 180 | 181 | registry.register(FruitLookup) 182 | 183 | The syntax for ``search_fields`` is the same as the Django 184 | `field lookup syntax `_. 185 | Each of these lookups are combined as OR so any one of them matching will return a 186 | result. You may optionally define a third class attribute ``filters`` which is a dictionary of 187 | filters to be applied to the model queryset. The keys should be a string defining a field lookup 188 | and the value should be the value for the field lookup. Filters on the other hand are 189 | combined with AND. 190 | 191 | 192 | User Lookup Example 193 | -------------------------------------- 194 | 195 | Below is a larger model lookup example using multiple search fields, filters 196 | and display options for the `auth.User `_ 197 | model. 198 | 199 | .. code-block:: python 200 | 201 | from django.contrib.auth.models import User 202 | from selectable.base import ModelLookup 203 | from selectable.registry import registry 204 | 205 | 206 | class UserLookup(ModelLookup): 207 | model = User 208 | search_fields = ( 209 | 'username__icontains', 210 | 'first_name__icontains', 211 | 'last_name__icontains', 212 | ) 213 | filters = {'is_active': True, } 214 | 215 | def get_item_value(self, item): 216 | # Display for currently selected item 217 | return item.username 218 | 219 | def get_item_label(self, item): 220 | # Display for choice listings 221 | return u"%s (%s)" % (item.username, item.get_full_name()) 222 | 223 | registry.register(UserLookup) 224 | 225 | 226 | .. _lookup-decorators: 227 | 228 | Lookup Decorators 229 | -------------------------------------- 230 | 231 | Registering lookups with django-selectable creates a small API for searching the 232 | lookup data. While the amount of visible data is small there are times when you want 233 | to restrict the set of requests which can view the data. For this purpose there are 234 | lookup decorators. To use them you simply decorate your lookup class. 235 | 236 | .. code-block:: python 237 | 238 | from django.contrib.auth.models import User 239 | from selectable.base import ModelLookup 240 | from selectable.decorators import login_required 241 | from selectable.registry import registry 242 | 243 | 244 | @login_required 245 | class UserLookup(ModelLookup): 246 | model = User 247 | search_fields = ('username__icontains', ) 248 | filters = {'is_active': True, } 249 | 250 | registry.register(UserLookup) 251 | 252 | .. note:: 253 | 254 | The class decorator syntax was introduced in Python 2.6. If you are using 255 | django-selectable with Python 2.5 you can still make use of these decorators 256 | by applying the without the decorator syntax. 257 | 258 | .. code-block:: python 259 | 260 | class UserLookup(ModelLookup): 261 | model = User 262 | search_fields = ('username__icontains', ) 263 | filters = {'is_active': True, } 264 | 265 | UserLookup = login_required(UserLookup) 266 | 267 | registry.register(UserLookup) 268 | 269 | Below are the descriptions of the available lookup decorators. 270 | 271 | 272 | ajax_required 273 | ______________________________________ 274 | 275 | The django-selectable javascript will always request the lookup data via 276 | XMLHttpRequest (AJAX) request. This decorator enforces that the lookup can only 277 | be accessed in this way. If the request is not an AJAX request then it will return 278 | a 400 Bad Request response. 279 | 280 | 281 | login_required 282 | ______________________________________ 283 | 284 | This decorator requires the user to be authenticated via ``request.user.is_authenticated``. 285 | If the user is not authenticated this will return a 401 Unauthorized response. 286 | ``request.user`` is set by the ``django.contrib.auth.middleware.AuthenticationMiddleware`` 287 | which is required for this decorator to work. This middleware is enabled by default. 288 | 289 | staff_member_required 290 | ______________________________________ 291 | 292 | This decorator builds from ``login_required`` and in addition requires that 293 | ``request.user.is_staff`` is ``True``. If the user is not authenticatated this will 294 | continue to return at 401 response. If the user is authenticated but not a staff member 295 | then this will return a 403 Forbidden response. 296 | -------------------------------------------------------------------------------- /selectable/tests/qunit/test-methods.js: -------------------------------------------------------------------------------- 1 | /*global define, module, test, expect, equal, ok*/ 2 | 3 | define(['selectable'], function ($) { 4 | 5 | var expectedNamespace = 'djselectable', 6 | useData = true; 7 | if (window.uiversion.lastIndexOf('1.10', 0) === 0) { 8 | // jQuery UI 1.10 introduces a namespace change to include ui-prefix 9 | expectedNamespace = 'ui-' + expectedNamespace; 10 | } 11 | if (window.uiversion.lastIndexOf('1.11', 0) === 0) { 12 | // jQuery UI 1.11 introduces an instance method to get the current instance 13 | useData = false; 14 | } 15 | 16 | module("Autocomplete Text Methods Tests"); 17 | 18 | test("Bind Input", function () { 19 | expect(2); 20 | var input = createTextComplete('autocomplete'); 21 | $('#qunit-fixture').append(input); 22 | bindSelectables('#qunit-fixture'); 23 | ok(input.hasClass('ui-autocomplete-input'), "input should be bound with djselecable widget"); 24 | if (useData) { 25 | ok(input.data(expectedNamespace), "input should be bound with djselecable widget"); 26 | } else { 27 | ok(input.djselectable('instance'), "input should be bound with djselecable widget"); 28 | } 29 | }); 30 | 31 | test("Manual Selection", function () { 32 | expect(1); 33 | var item = {id: "1", value: 'foo'}, 34 | input = createTextComplete('autocomplete'); 35 | $('#qunit-fixture').append(input); 36 | bindSelectables('#qunit-fixture'); 37 | input.djselectable('select', item); 38 | equal(input.val(), item.value, "input should get item value"); 39 | }); 40 | 41 | test("Initial Data", function () { 42 | expect(1); 43 | var input = createTextComplete('autocomplete'); 44 | input.val('Foo'); 45 | $('#qunit-fixture').append(input); 46 | bindSelectables('#qunit-fixture'); 47 | equal(input.val(), 'Foo', "initial text value should not be lost"); 48 | }); 49 | 50 | 51 | module("Autocombobox Text Methods Tests"); 52 | 53 | test("Bind Input", function () { 54 | expect(3); 55 | var input = createTextCombobox('autocombobox'), button; 56 | $('#qunit-fixture').append(input); 57 | bindSelectables('#qunit-fixture'); 58 | button = $('.ui-combo-button', '#qunit-fixture'); 59 | ok(input.hasClass('ui-autocomplete-input'), "input should be bound with djselecable widget"); 60 | if (useData) { 61 | ok(input.data(expectedNamespace), "input should be bound with djselecable widget"); 62 | } else { 63 | ok(input.djselectable('instance'), "input should be bound with djselecable widget"); 64 | } 65 | equal(button.length, 1, "combobox button should be created"); 66 | }); 67 | 68 | test("Manual Selection", function () { 69 | expect(1); 70 | var item = {id: "1", value: 'foo'}, 71 | input = createTextCombobox('autocombobox'); 72 | $('#qunit-fixture').append(input); 73 | bindSelectables('#qunit-fixture'); 74 | input.djselectable('select', item); 75 | equal(input.val(), item.value, "input should get item value"); 76 | }); 77 | 78 | test("Initial Data", function () { 79 | expect(1); 80 | var input = createTextCombobox('autocombobox'); 81 | input.val('Foo'); 82 | $('#qunit-fixture').append(input); 83 | bindSelectables('#qunit-fixture'); 84 | equal(input.val(), 'Foo', "initial text value should not be lost"); 85 | }); 86 | 87 | module("Autocomplete Select Methods Tests"); 88 | 89 | test("Bind Input", function () { 90 | expect(2); 91 | var inputs = createTextSelect('autocompleteselect'), 92 | textInput = inputs[0], hiddenInput = inputs[1]; 93 | $('#qunit-fixture').append(textInput); 94 | $('#qunit-fixture').append(hiddenInput); 95 | bindSelectables('#qunit-fixture'); 96 | ok(textInput.hasClass('ui-autocomplete-input'), "input should be bound with djselecable widget"); 97 | if (useData) { 98 | ok(textInput.data(expectedNamespace), "input should be bound with djselecable widget"); 99 | } else { 100 | ok(textInput.djselectable('instance'), "input should be bound with djselecable widget"); 101 | } 102 | }); 103 | 104 | test("Manual Selection", function () { 105 | expect(2); 106 | var item = {id: "1", value: 'foo'}, 107 | inputs = createTextSelect('autocompleteselect'), 108 | textInput = inputs[0], hiddenInput = inputs[1]; 109 | $('#qunit-fixture').append(textInput); 110 | $('#qunit-fixture').append(hiddenInput); 111 | bindSelectables('#qunit-fixture'); 112 | textInput.djselectable('select', item); 113 | equal(textInput.val(), item.value, "input should get item value"); 114 | equal(hiddenInput.val(), item.id, "input should get item id"); 115 | }); 116 | 117 | test("Initial Data", function () { 118 | expect(2); 119 | var inputs = createTextSelect('autocompleteselect'), 120 | textInput = inputs[0], hiddenInput = inputs[1]; 121 | $('#qunit-fixture').append(textInput); 122 | $('#qunit-fixture').append(hiddenInput); 123 | textInput.val('Foo'); 124 | hiddenInput.val('1'); 125 | bindSelectables('#qunit-fixture'); 126 | equal(textInput.val(), 'Foo', "initial text value should not be lost"); 127 | equal(hiddenInput.val(), '1', "initial pk value should not be lost"); 128 | }); 129 | 130 | module("Autocombobox Select Methods Tests"); 131 | 132 | test("Bind Input", function () { 133 | expect(3); 134 | var inputs = createComboboxSelect('autocomboboxselect'), 135 | textInput = inputs[0], hiddenInput = inputs[1], button; 136 | $('#qunit-fixture').append(textInput); 137 | $('#qunit-fixture').append(hiddenInput); 138 | bindSelectables('#qunit-fixture'); 139 | button = $('.ui-combo-button', '#qunit-fixture'); 140 | ok(textInput.hasClass('ui-autocomplete-input'), "input should be bound with djselecable widget"); 141 | if (useData) { 142 | ok(textInput.data(expectedNamespace), "input should be bound with djselecable widget"); 143 | } else { 144 | ok(textInput.djselectable('instance'), "input should be bound with djselecable widget"); 145 | } 146 | equal(button.length, 1, "combobox button should be created"); 147 | }); 148 | 149 | test("Manual Selection", function () { 150 | expect(2); 151 | var item = {id: "1", value: 'foo'}, 152 | inputs = createComboboxSelect('autocomboboxselect'), 153 | textInput = inputs[0], hiddenInput = inputs[1]; 154 | $('#qunit-fixture').append(textInput); 155 | $('#qunit-fixture').append(hiddenInput); 156 | bindSelectables('#qunit-fixture'); 157 | textInput.djselectable('select', item); 158 | equal(textInput.val(), item.value, "input should get item value"); 159 | equal(hiddenInput.val(), item.id, "input should get item id"); 160 | }); 161 | 162 | test("Initial Data", function () { 163 | expect(2); 164 | var inputs = createComboboxSelect('autocomboboxselect'), 165 | textInput = inputs[0], hiddenInput = inputs[1]; 166 | $('#qunit-fixture').append(textInput); 167 | $('#qunit-fixture').append(hiddenInput); 168 | textInput.val('Foo'); 169 | hiddenInput.val('1'); 170 | bindSelectables('#qunit-fixture'); 171 | equal(textInput.val(), 'Foo', "initial text value should not be lost"); 172 | equal(hiddenInput.val(), '1', "initial pk value should not be lost"); 173 | }); 174 | 175 | module("Autocomplete Select Multiple Methods Tests"); 176 | 177 | test("Bind Input", function () { 178 | expect(3); 179 | var inputs = createTextSelectMultiple('autocompletemultiple'), 180 | textInput = inputs[0], deck; 181 | $('#qunit-fixture').append(textInput); 182 | bindSelectables('#qunit-fixture'); 183 | deck = $('.selectable-deck', '#qunit-fixture'); 184 | ok(textInput.hasClass('ui-autocomplete-input'), "input should be bound with djselecable widget"); 185 | if (useData) { 186 | ok(textInput.data(expectedNamespace), "input should be bound with djselecable widget"); 187 | } else { 188 | ok(textInput.djselectable('instance'), "input should be bound with djselecable widget"); 189 | } 190 | equal($('li', deck).length, 0, "no initial deck items"); 191 | }); 192 | 193 | test("Manual Selection", function () { 194 | expect(2); 195 | var item = {id: "1", value: 'foo'}, 196 | inputs = createTextSelectMultiple('autocompletemultiple'), 197 | textInput = inputs[0], hiddenInput; 198 | $('#qunit-fixture').append(textInput); 199 | bindSelectables('#qunit-fixture'); 200 | textInput.djselectable('select', item); 201 | hiddenInput = $(':input[type=hidden][name=autocompletemultiple_1]', '#qunit-fixture'); 202 | equal(textInput.val(), '', "input should be empty"); 203 | equal(hiddenInput.val(), item.id, "input should get item id"); 204 | }); 205 | 206 | test("Initial Data", function () { 207 | expect(3); 208 | var inputs = createTextSelectMultiple('autocomboboxselect'), 209 | textInput = inputs[0], hiddenInput = inputs[1], deck; 210 | $('#qunit-fixture').append(textInput); 211 | $('#qunit-fixture').append(hiddenInput); 212 | textInput.val('Foo'); 213 | hiddenInput.val('1'); 214 | bindSelectables('#qunit-fixture'); 215 | deck = $('.selectable-deck', '#qunit-fixture'); 216 | equal(textInput.val(), '', "input should be empty"); 217 | equal(hiddenInput.val(), '1', "initial pk value should not be lost"); 218 | equal($('li', deck).length, 1, "one initial deck items"); 219 | }); 220 | 221 | module("Autocombobox Select Multiple Methods Tests"); 222 | 223 | test("Bind Input", function () { 224 | expect(4); 225 | var inputs = createComboboxSelectMultiple('autocomboboxmultiple'), 226 | textInput = inputs[0], deck, button; 227 | $('#qunit-fixture').append(textInput); 228 | bindSelectables('#qunit-fixture'); 229 | deck = $('.selectable-deck', '#qunit-fixture'); 230 | button = $('.ui-combo-button', '#qunit-fixture'); 231 | ok(textInput.hasClass('ui-autocomplete-input'), "input should be bound with djselecable widget"); 232 | if (useData) { 233 | ok(textInput.data(expectedNamespace), "input should be bound with djselecable widget"); 234 | } else { 235 | ok(textInput.djselectable('instance'), "input should be bound with djselecable widget"); 236 | } 237 | equal($('li', deck).length, 0, "no initial deck items"); 238 | equal(button.length, 1, "combobox button should be created"); 239 | }); 240 | 241 | test("Manual Selection", function () { 242 | expect(2); 243 | var item = {id: "1", value: 'foo'}, 244 | inputs = createComboboxSelectMultiple('autocomboboxmultiple'), 245 | textInput = inputs[0], hiddenInput; 246 | $('#qunit-fixture').append(textInput); 247 | bindSelectables('#qunit-fixture'); 248 | textInput.djselectable('select', item); 249 | hiddenInput = $(':input[type=hidden][name=autocomboboxmultiple_1]', '#qunit-fixture'); 250 | equal(textInput.val(), '', "input should be empty"); 251 | equal(hiddenInput.val(), item.id, "input should get item id"); 252 | }); 253 | 254 | test("Initial Data", function () { 255 | expect(3); 256 | var inputs = createComboboxSelectMultiple('autocomboboxmultiple'), 257 | textInput = inputs[0], hiddenInput = inputs[1], deck; 258 | $('#qunit-fixture').append(textInput); 259 | $('#qunit-fixture').append(hiddenInput); 260 | textInput.val('Foo'); 261 | hiddenInput.val('1'); 262 | bindSelectables('#qunit-fixture'); 263 | deck = $('.selectable-deck', '#qunit-fixture'); 264 | equal(textInput.val(), '', "input should be empty"); 265 | equal(hiddenInput.val(), '1', "initial pk value should not be lost"); 266 | equal($('li', deck).length, 1, "one initial deck items"); 267 | }); 268 | }); --------------------------------------------------------------------------------