Chained select2
This commit is contained in:
parent
5840622794
commit
1931a98240
|
@ -1,6 +1,9 @@
|
|||
Changelog Summary
|
||||
=================
|
||||
|
||||
### v5.10.0
|
||||
* Add support for dependent select fields [321](github.com/applegrew/django-select2/pull/321/).
|
||||
|
||||
### v5.9.0
|
||||
* Add support for Django 1.11 LTS
|
||||
* Drop support for Django 1.9
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
"""
|
||||
This is a Django_ integration of Select2_.
|
||||
|
||||
The app includes Select2 driven Django Widgets and Form Fields.
|
||||
The application includes Select2 driven Django Widgets and Form Fields.
|
||||
|
||||
.. _Django: https://www.djangoproject.com/
|
||||
.. _Select2: http://ivaynberg.github.com/select2/
|
||||
|
||||
"""
|
||||
|
||||
__version__ = "5.9.0"
|
||||
__version__ = "5.10.0"
|
||||
|
|
|
@ -185,6 +185,8 @@ class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
|
|||
class HeavySelect2Mixin(object):
|
||||
"""Mixin that adds select2's AJAX options and registers itself on Django's cache."""
|
||||
|
||||
dependent_fields = {}
|
||||
|
||||
def __init__(self, attrs=None, choices=(), **kwargs):
|
||||
"""
|
||||
Return HeavySelect2Mixin.
|
||||
|
@ -192,7 +194,12 @@ class HeavySelect2Mixin(object):
|
|||
Args:
|
||||
data_view (str): URL pattern name
|
||||
data_url (str): URL
|
||||
|
||||
dependent_fields (dict): Dictionary of dependent parent fields.
|
||||
The value of the dependent field will be passed as to :func:`.filter_queryset`.
|
||||
It can be used to further restrict the search results. For example, a city
|
||||
widget could be dependent on a country.
|
||||
Key is a name of a field in a form.
|
||||
Value is a name of a field in a model (used in `queryset`).
|
||||
"""
|
||||
self.choices = choices
|
||||
if attrs is not None:
|
||||
|
@ -202,6 +209,10 @@ class HeavySelect2Mixin(object):
|
|||
|
||||
self.data_view = kwargs.pop('data_view', None)
|
||||
self.data_url = kwargs.pop('data_url', None)
|
||||
|
||||
dependent_fields = kwargs.pop('dependent_fields', None)
|
||||
if dependent_fields is not None:
|
||||
self.dependent_fields = dict(dependent_fields)
|
||||
if not (self.data_view or self.data_url):
|
||||
raise ValueError('You must ether specify "data_view" or "data_url".')
|
||||
self.userGetValTextFuncName = kwargs.pop('userGetValTextFuncName', 'null')
|
||||
|
@ -224,6 +235,8 @@ class HeavySelect2Mixin(object):
|
|||
attrs.setdefault('data-ajax--cache', "true")
|
||||
attrs.setdefault('data-ajax--type', "GET")
|
||||
attrs.setdefault('data-minimum-input-length', 2)
|
||||
if self.dependent_fields:
|
||||
attrs.setdefault('data-select2-dependent-fields', " ".join(self.dependent_fields))
|
||||
|
||||
attrs['class'] += ' django-select2-heavy'
|
||||
return attrs
|
||||
|
@ -334,7 +347,7 @@ class ModelSelect2Mixin(object):
|
|||
|
||||
Args:
|
||||
model (django.db.models.Model): Model to select choices from.
|
||||
queryset (django.db.models.QuerySet): QuerySet to select choices from.
|
||||
queryset (django.db.models.query.QuerySet): QuerySet to select choices from.
|
||||
search_fields (list): List of model lookup strings.
|
||||
max_results (int): Max. JsonResponse view page size.
|
||||
|
||||
|
@ -364,14 +377,19 @@ class ModelSelect2Mixin(object):
|
|||
'search_fields': self.search_fields,
|
||||
'max_results': self.max_results,
|
||||
'url': self.get_url(),
|
||||
'dependent_fields': self.dependent_fields,
|
||||
})
|
||||
|
||||
def filter_queryset(self, term, queryset=None):
|
||||
def filter_queryset(self, term, queryset=None, **dependent_fields):
|
||||
"""
|
||||
Return QuerySet filtered by search_fields matching the passed term.
|
||||
|
||||
Args:
|
||||
term (str): Search term
|
||||
queryset (django.db.models.query.QuerySet): QuerySet to select choices from.
|
||||
**dependent_fields: Dependent fields and their values. If you want to inherit
|
||||
from ModelSelect2Mixin and later call to this method, be sure to pop
|
||||
from kwargs everything if it is not a dependent field.
|
||||
|
||||
Returns:
|
||||
QuerySet: Filtered QuerySet
|
||||
|
@ -386,6 +404,9 @@ class ModelSelect2Mixin(object):
|
|||
for t in [t for t in term.split(' ') if not t == '']:
|
||||
select &= reduce(lambda x, y: x | Q(**{y: t}), search_fields,
|
||||
Q(**{search_fields[0]: t}))
|
||||
if dependent_fields:
|
||||
select &= Q(**dependent_fields)
|
||||
|
||||
return queryset.filter(select).distinct()
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
|
@ -7,11 +7,21 @@
|
|||
var settings = $.extend({
|
||||
ajax: {
|
||||
data: function (params) {
|
||||
return {
|
||||
var result = {
|
||||
term: params.term,
|
||||
page: params.page,
|
||||
field_id: $element.data('field_id')
|
||||
}
|
||||
|
||||
var dependentFields = $element.data('select2-dependent-fields')
|
||||
if (dependentFields) {
|
||||
dependentFields = dependentFields.trim().split(/\s+/)
|
||||
$.each(dependentFields, function (i, dependentField) {
|
||||
result[dependentField] = $('[name=' + dependentField + ']', $element.closest('form')).val()
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
processResults: function (data, page) {
|
||||
return {
|
||||
|
|
|
@ -52,7 +52,12 @@ class AutoResponseView(BaseListView):
|
|||
|
||||
def get_queryset(self):
|
||||
"""Get QuerySet from cached widget."""
|
||||
return self.widget.filter_queryset(self.term, self.queryset)
|
||||
kwargs = {
|
||||
model_field_name: self.request.GET.get(form_field_name)
|
||||
for form_field_name, model_field_name in self.widget.dependent_fields.items()
|
||||
if form_field_name in self.request.GET and self.request.GET.get(form_field_name, '') != ''
|
||||
}
|
||||
return self.widget.filter_queryset(self.term, self.queryset, **kwargs)
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
"""Paginate response by size of widget's `max_results` parameter."""
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
Extra
|
||||
=====
|
||||
|
||||
Chained select2
|
||||
---------------
|
||||
|
||||
Suppose you have an address form where a user should choose a Country and a City.
|
||||
When the user selects a country we want to show only cities belonging to that country.
|
||||
So the one selector depends on another one.
|
||||
|
||||
Models
|
||||
``````
|
||||
|
||||
Here are our two models:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class Country(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
|
||||
class City(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
country = models.ForeignKey('Country', related_name="cities")
|
||||
|
||||
|
||||
Customizing a Form
|
||||
``````````````````
|
||||
|
||||
Lets link two widgets via *dependent_fields*.
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 15
|
||||
|
||||
class AddressForm(forms.Form):
|
||||
country = forms.ModelChoiceField(
|
||||
queryset=Country.objects.all(),
|
||||
label=u"Country",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
)
|
||||
)
|
||||
|
||||
city = forms.ModelChoiceField(
|
||||
queryset=City.objects.all(),
|
||||
label=u"City",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'country': 'country'},
|
||||
max_results=500,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Interdependent select2
|
||||
----------------------
|
||||
|
||||
Also you may want not to restrict the user to which field should be selected first.
|
||||
Instead you want to suggest to the user options for any select2 depending of his selection in another one.
|
||||
|
||||
Customize the form in a manner:
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 7
|
||||
|
||||
class AddressForm(forms.Form):
|
||||
country = forms.ModelChoiceField(
|
||||
queryset=Country.objects.all(),
|
||||
label=u"Country",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'city': 'cities'},
|
||||
)
|
||||
)
|
||||
|
||||
city = forms.ModelChoiceField(
|
||||
queryset=City.objects.all(),
|
||||
label=u"City",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'country': 'country'},
|
||||
max_results=500,
|
||||
)
|
||||
)
|
||||
|
||||
Take attention to country's dependent_fields. The value of 'city' is 'cities' because of
|
||||
related name used in a filter condition `cities` which differs from widget field name `city`.
|
||||
|
||||
.. caution::
|
||||
Be aware of using interdependent select2 in parent-child relation.
|
||||
When a child is selected, you are restricted to change parent (only one value is available).
|
||||
Probably you should let the user reset the child first to release parent select2.
|
||||
|
||||
|
||||
Multi-dependent select2
|
||||
-----------------------
|
||||
|
||||
Furthermore you may want to filter options on two or more select2 selections (some code is dropped for clarity):
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 14
|
||||
|
||||
class SomeForm(forms.Form):
|
||||
field1 = forms.ModelChoiceField(
|
||||
widget=ModelSelect2Widget(
|
||||
)
|
||||
)
|
||||
|
||||
field2 = forms.ModelChoiceField(
|
||||
widget=ModelSelect2Widget(
|
||||
)
|
||||
)
|
||||
|
||||
field3 = forms.ModelChoiceField(
|
||||
widget=ModelSelect2Widget(
|
||||
dependent_fields={'field1': 'field1', 'field2': 'field2'},
|
||||
)
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ Contents:
|
|||
|
||||
get_started
|
||||
django_select2
|
||||
extra
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
|
|
@ -25,6 +25,11 @@ def random_string(n):
|
|||
)
|
||||
|
||||
|
||||
def random_name(n):
|
||||
words = ''.join(random.choice(string.ascii_lowercase + ' ') for _ in range(n)).strip().split(' ')
|
||||
return '-'.join([x.capitalize() for x in words])
|
||||
|
||||
|
||||
@pytest.yield_fixture(scope='session', params=sorted(browsers.keys()))
|
||||
def driver(request):
|
||||
if 'DISPLAY' not in os.environ:
|
||||
|
@ -58,3 +63,19 @@ def artists(db):
|
|||
return Artist.objects.bulk_create(
|
||||
[Artist(pk=pk, title=random_string(50)) for pk in range(100)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def countries(db):
|
||||
from .testapp.models import Country
|
||||
return Country.objects.bulk_create(
|
||||
[Country(pk=pk, name=random_name(random.randint(10, 20))) for pk in range(10)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cities(db, countries):
|
||||
from .testapp.models import City
|
||||
return City.objects.bulk_create(
|
||||
[City(pk=pk, name=random_name(random.randint(5, 15)), country=random.choice(countries)) for pk in range(100)]
|
||||
)
|
||||
|
|
|
@ -23,7 +23,7 @@ from tests.testapp import forms
|
|||
from tests.testapp.forms import (
|
||||
NUMBER_CHOICES, HeavySelect2MultipleWidgetForm, TitleModelSelect2Widget
|
||||
)
|
||||
from tests.testapp.models import Genre
|
||||
from tests.testapp.models import City, Country, Genre
|
||||
|
||||
try:
|
||||
from django.urls import reverse
|
||||
|
@ -359,3 +359,65 @@ class TestHeavySelect2MultipleWidget(object):
|
|||
# genres should still have One as selected option
|
||||
result_title = driver.find_element_by_css_selector('.select2-selection--multiple li').get_attribute('title')
|
||||
assert result_title == 'One'
|
||||
|
||||
|
||||
class TestAddressChainedSelect2Widget(object):
|
||||
url = reverse('model_chained_select2_widget')
|
||||
form = forms.AddressChainedSelect2WidgetForm()
|
||||
|
||||
def test_widgets_selected_after_validation_error(self, db, live_server, driver, countries, cities):
|
||||
driver.get(live_server + self.url)
|
||||
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-selection--single'))
|
||||
)
|
||||
country_container, city_container = driver.find_elements_by_css_selector('.select2-selection--single')
|
||||
|
||||
# clicking city select2 lists all available cities
|
||||
city_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li'))
|
||||
)
|
||||
city_options = driver.find_elements_by_css_selector('.select2-results li')
|
||||
city_names_from_browser = {option.text for option in city_options}
|
||||
city_names_from_db = set(City.objects.values_list('name', flat=True))
|
||||
assert len(city_names_from_browser) == City.objects.count()
|
||||
assert city_names_from_browser == city_names_from_db
|
||||
|
||||
# selecting a country really does it
|
||||
country_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:nth-child(2)'))
|
||||
)
|
||||
country_option = driver.find_element_by_css_selector('.select2-results li:nth-child(2)')
|
||||
country_name = country_option.text
|
||||
country_option.click()
|
||||
assert country_name == country_container.text
|
||||
|
||||
# clicking city select2 lists reduced list of cities belonging to the country
|
||||
city_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li'))
|
||||
)
|
||||
city_options = driver.find_elements_by_css_selector('.select2-results li')
|
||||
city_names_from_browser = {option.text for option in city_options}
|
||||
city_names_from_db = set(Country.objects.get(name=country_name).cities.values_list('name', flat=True))
|
||||
assert len(city_names_from_browser) != City.objects.count()
|
||||
assert city_names_from_browser == city_names_from_db
|
||||
|
||||
# selecting a city reaaly does it
|
||||
city_option = driver.find_element_by_css_selector('.select2-results li:nth-child(2)')
|
||||
city_name = city_option.text
|
||||
city_option.click()
|
||||
assert city_name == city_container.text
|
||||
|
||||
# clicking country select2 lists reduced list to the only country available to the city
|
||||
country_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li'))
|
||||
)
|
||||
country_options = driver.find_elements_by_css_selector('.select2-results li')
|
||||
country_names_from_browser = {option.text for option in country_options}
|
||||
country_names_from_db = {City.objects.get(name=city_name).country.name}
|
||||
assert len(country_names_from_browser) != Country.objects.count()
|
||||
assert country_names_from_browser == country_names_from_db
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
!coverage.py: This is a private format, don't read it directly!{"lines":{}}
|
|
@ -10,7 +10,7 @@ from django_select2.forms import (
|
|||
Select2Widget
|
||||
)
|
||||
from tests.testapp import models
|
||||
from tests.testapp.models import Album
|
||||
from tests.testapp.models import Album, City, Country
|
||||
|
||||
|
||||
class TitleSearchFieldMixin(object):
|
||||
|
@ -174,3 +174,27 @@ class ModelSelect2TagWidgetForm(forms.ModelForm):
|
|||
widgets = {
|
||||
'genres': GenreSelect2TagWidget
|
||||
}
|
||||
|
||||
|
||||
class AddressChainedSelect2WidgetForm(forms.Form):
|
||||
country = forms.ModelChoiceField(
|
||||
queryset=Country.objects.all(),
|
||||
label='Country',
|
||||
widget=ModelSelect2Widget(
|
||||
model=Country,
|
||||
search_fields=['name__icontains'],
|
||||
max_results=500,
|
||||
dependent_fields={'city': 'cities'},
|
||||
)
|
||||
)
|
||||
|
||||
city = forms.ModelChoiceField(
|
||||
queryset=City.objects.all(),
|
||||
label='City',
|
||||
widget=ModelSelect2Widget(
|
||||
model=City,
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'country': 'country'},
|
||||
max_results=500,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -33,3 +33,20 @@ class Album(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Country(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class City(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
country = models.ForeignKey('Country', related_name="cities", on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
@ -4,8 +4,9 @@ from __future__ import absolute_import, unicode_literals
|
|||
from django.conf.urls import include, url
|
||||
|
||||
from .forms import (
|
||||
AlbumModelSelect2WidgetForm, HeavySelect2MultipleWidgetForm,
|
||||
HeavySelect2WidgetForm, ModelSelect2TagWidgetForm, Select2WidgetForm
|
||||
AddressChainedSelect2WidgetForm, AlbumModelSelect2WidgetForm,
|
||||
HeavySelect2MultipleWidgetForm, HeavySelect2WidgetForm,
|
||||
ModelSelect2TagWidgetForm, Select2WidgetForm
|
||||
)
|
||||
from .views import TemplateFormView, heavy_data_1, heavy_data_2
|
||||
|
||||
|
@ -26,6 +27,10 @@ urlpatterns = [
|
|||
TemplateFormView.as_view(form_class=ModelSelect2TagWidgetForm),
|
||||
name='model_select2_tag_widget'),
|
||||
|
||||
url(r'^model_chained_select2_widget/$',
|
||||
TemplateFormView.as_view(form_class=AddressChainedSelect2WidgetForm),
|
||||
name='model_chained_select2_widget'),
|
||||
|
||||
url(r'^heavy_data_1/$', heavy_data_1, name='heavy_data_1'),
|
||||
url(r'^heavy_data_2/$', heavy_data_2, name='heavy_data_2'),
|
||||
|
||||
|
|
Loading…
Reference in New Issue