Code

Ticket #25: select_filter.3.diff

File select_filter.3.diff, 12.1 KB (added by sebleier, 3 years ago)

fixed a bug. Development can be found at https://github.com/sebleier/django/tree/feature/select-filter

Line 
1diff --git a/AUTHORS b/AUTHORS
2index a19e49b..038b8f8 100644
3--- a/AUTHORS
4+++ b/AUTHORS
5@@ -85,6 +85,7 @@ answer newbie questions, and generally made Django that much better:
6     Natalia Bidart <nataliabidart@gmail.com>
7     Paul Bissex <http://e-scribe.com/>
8     Simon Blanchard
9+    Sean Bleier <sebleier@gmail.com>
10     David Blewett <david@dawninglight.net>
11     Matt Boersma <matt@sprout.org>
12     boobsd@gmail.com
13@@ -215,6 +216,7 @@ answer newbie questions, and generally made Django that much better:
14     Scot Hacker <shacker@birdhouse.org>
15     dAniel hAhler
16     hambaloney
17+    Chuck Harmston <chuck@chuckharmston.com>
18     Brian Harring <ferringb@gmail.com>
19     Brant Harris
20     Ronny Haryanto <http://ronny.haryan.to/>
21diff --git a/django/contrib/admin/media/css/widgets.css b/django/contrib/admin/media/css/widgets.css
22index 26400fa..870ffeb 100644
23--- a/django/contrib/admin/media/css/widgets.css
24+++ b/django/contrib/admin/media/css/widgets.css
25@@ -5,6 +5,10 @@
26     float: left;
27 }
28 
29+.selector-single{
30+       width: 282px !important;
31+}
32+
33 .selector select {
34     width: 270px;
35     height: 17.2em;
36diff --git a/django/contrib/admin/media/js/fkfilter.js b/django/contrib/admin/media/js/fkfilter.js
37new file mode 100644
38index 0000000..bffbb5c
39--- /dev/null
40+++ b/django/contrib/admin/media/js/fkfilter.js
41@@ -0,0 +1,79 @@
42+(function($) {
43+    $.fn.fk_filter = function(verbose_name) {
44+        return this.each(function() {
45+            var stash = [];
46+            $(this).attr('size', 2); // Force a multi-row <select>
47+            $(this).find('option[value=""]').remove(); // Remove the '-----'
48+
49+            // Create the wrappers
50+            var outerwrapper = $('<div />', {
51+                'class': 'selector selector-single'
52+            });
53+            $(this).wrap(outerwrapper);
54+            var innerwrapper = $('<div />', {
55+                'class': 'selector-available'
56+            });
57+            $(this).wrap(innerwrapper);
58+
59+            // Creates Header
60+            var header = $('<h2 />', {
61+                'text': interpolate(gettext('Available %s'), [verbose_name])
62+            }).insertBefore(this);
63+
64+            // Creates search bar
65+            var searchbar = $('<p />', {
66+                'html': '<img src="' + window.__admin_media_prefix__ + 'img/admin/selector-search.gif"> <input type="text" id="' + $(this).attr('id') + '_input">',
67+                'class': 'selector-filter'
68+            }).insertBefore(this);
69+
70+            var select = $(this);
71+            var filter = $('#' + $(this).attr('id') + '_input');
72+
73+            filter.bind('keyup.fkfilter', function(evt) {
74+                /*
75+                Procedure for filtering options::
76+
77+                    * Detach the select from the DOM so each change doesn't
78+                      trigger the browser to re-render.
79+                    * Iterate through the stash list for matches
80+                    * ``matched`` is incremented whenever an stashed select
81+                      option is matched so that we don't have to search
82+                      recently appended options twice.
83+                    * Iterate through select's options and stash mismatched
84+                      options
85+                    * Attach the select back into the DOM for rendering.
86+                */
87+                var i, size, option, options;
88+                var pattern = filter.val();
89+                var parent = select.parent();
90+                var matched = 0;
91+
92+                // detach from DOM
93+                select.detach();
94+
95+                // Iterate through the excluded list for matches
96+                size = stash.length;
97+                for (i = 0; i < size; i++) {
98+                    if (stash[i].text.indexOf(pattern) !== -1) {
99+                        select.append(stash.splice(i--, 1));
100+                        matched++;
101+                        size--;
102+                    }
103+                }
104+
105+                // Iterate through existing options for matches
106+                options = select.children();
107+                size = options.length - matched;
108+                for(i = 0; i < size; i++) {
109+                    if (options[i].text.indexOf(pattern) === -1) {
110+                        option = $(options[i]).detach();
111+                        stash.push(option[0]);
112+                    }
113+                }
114+
115+                // Attach back onto the DOM
116+                parent.append(select);
117+            });
118+        });
119+    };
120+})(django.jQuery);
121diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
122index fbda8b7..3b23dec 100644
123--- a/django/contrib/admin/options.py
124+++ b/django/contrib/admin/options.py
125@@ -159,6 +159,8 @@ class BaseModelAdmin(object):
126                 'class': get_ul_class(self.radio_fields[db_field.name]),
127             })
128             kwargs['empty_label'] = db_field.blank and _('None') or None
129+        elif db_field.name in self.filter_vertical:
130+            kwargs['widget'] = widgets.FilteredSelectSingle(db_field.verbose_name)
131 
132         return db_field.formfield(**kwargs)
133 
134@@ -1301,7 +1303,7 @@ class InlineModelAdmin(BaseModelAdmin):
135             js.append('js/urlify.js')
136             js.append('js/prepopulate.min.js')
137         if self.filter_vertical or self.filter_horizontal:
138-            js.extend(['js/SelectBox.js' , 'js/SelectFilter2.js'])
139+            js.extend(['js/SelectBox.js' , 'js/SelectFilter2.js', 'js/fkfilter.js'])
140         return forms.Media(js=['%s%s' % (settings.ADMIN_MEDIA_PREFIX, url) for url in js])
141     media = property(_media)
142 
143diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py
144index 159afa4..d318bd3 100644
145--- a/django/contrib/admin/validation.py
146+++ b/django/contrib/admin/validation.py
147@@ -296,9 +296,9 @@ def validate_base(cls, model):
148         check_isseq(cls, 'filter_vertical', cls.filter_vertical)
149         for idx, field in enumerate(cls.filter_vertical):
150             f = get_field(cls, model, opts, 'filter_vertical', field)
151-            if not isinstance(f, models.ManyToManyField):
152+            if not isinstance(f, (models.ManyToManyField, models.ForeignKey)):
153                 raise ImproperlyConfigured("'%s.filter_vertical[%d]' must be "
154-                    "a ManyToManyField." % (cls.__name__, idx))
155+                    "a ManyToManyField or ForeignKey." % (cls.__name__, idx))
156 
157     # filter_horizontal
158     if hasattr(cls, 'filter_horizontal'):
159diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py
160index f210d4e..f035586 100644
161--- a/django/contrib/admin/widgets.py
162+++ b/django/contrib/admin/widgets.py
163@@ -14,6 +14,32 @@ from django.utils.encoding import force_unicode
164 from django.conf import settings
165 from django.core.urlresolvers import reverse, NoReverseMatch
166 
167+
168+class FilteredSelectSingle(forms.Select):
169+    """
170+    A Select with a JavaScript filter interface.
171+    """
172+    class Media:
173+        js = (settings.ADMIN_MEDIA_PREFIX + "js/fkfilter.js",)
174+
175+    def __init__(self, verbose_name, attrs=None, choices=()):
176+        self.verbose_name = verbose_name
177+        super(FilteredSelectSingle, self).__init__(attrs, choices)
178+
179+    def render(self, name, value, attrs={}, choices=()):
180+        attrs['class'] = 'selectfilter'
181+        output = [super(FilteredSelectSingle, self).render(
182+            name, value, attrs, choices)]
183+        output.append((
184+            '<script type="text/javascript">'
185+            'django.jQuery(document).ready(function(){'
186+            'django.jQuery("#id_%s").fk_filter("%s")'
187+            '});'
188+            '</script>'
189+        ) % (name, self.verbose_name.replace('"', '\\"'),));
190+        return mark_safe(u''.join(output))
191+
192+
193 class FilteredSelectMultiple(forms.SelectMultiple):
194     """
195     A SelectMultiple with a JavaScript filter interface.
196diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
197index c28dc8b..20a3599 100644
198--- a/docs/ref/contrib/admin/index.txt
199+++ b/docs/ref/contrib/admin/index.txt
200@@ -276,7 +276,10 @@ subclass::
201 
202     Same as :attr:`~ModelAdmin.filter_horizontal`, but uses a vertical display
203     of the filter interface with the box of unselected options appearing above
204-    the box of selected options.
205+    the box of selected options.  You can also add a
206+    :class:`~django.db.models.ForeignKey` to this list and a text box will
207+    allow you to narrow your search for a particular object through a
208+    potentially large list of options.
209 
210 .. attribute:: ModelAdmin.form
211 
212diff --git a/tests/regressiontests/admin_widgets/tests.py b/tests/regressiontests/admin_widgets/tests.py
213index 7ad74a3..2351eb0 100644
214--- a/tests/regressiontests/admin_widgets/tests.py
215+++ b/tests/regressiontests/admin_widgets/tests.py
216@@ -8,7 +8,7 @@ from django.contrib import admin
217 from django.contrib.admin import widgets
218 from django.contrib.admin.widgets import (FilteredSelectMultiple,
219     AdminSplitDateTime, AdminFileWidget, ForeignKeyRawIdWidget, AdminRadioSelect,
220-    RelatedFieldWidgetWrapper, ManyToManyRawIdWidget)
221+    RelatedFieldWidgetWrapper, ManyToManyRawIdWidget, FilteredSelectSingle)
222 from django.core.files.storage import default_storage
223 from django.core.files.uploadedfile import SimpleUploadedFile
224 from django.db.models import DateField
225@@ -181,6 +181,15 @@ class AdminForeignKeyRawIdWidget(DjangoTestCase):
226                 'Select a valid choice. That choice is not one of the available choices.')
227 
228 
229+class FilteredSelectSingleWidgetTest(TestCase):
230+    def test_render(self):
231+        w = FilteredSelectSingle('test')
232+        self.assertEqual(
233+            conditional_escape(w.render('test', 'test')),
234+            '<select name="test" class="selectfilter">\n</select><script type="text/javascript">django.jQuery(document).ready(function(){django.jQuery("#id_test").fk_filter("test")});</script>'
235+        )
236+
237+
238 class FilteredSelectMultipleWidgetTest(TestCase):
239     def test_render(self):
240         w = FilteredSelectMultiple('test', False)
241diff --git a/tests/regressiontests/modeladmin/tests.py b/tests/regressiontests/modeladmin/tests.py
242index a20e579..a011114 100644
243--- a/tests/regressiontests/modeladmin/tests.py
244+++ b/tests/regressiontests/modeladmin/tests.py
245@@ -6,7 +6,8 @@ from django.contrib.admin.options import ModelAdmin, TabularInline, \
246     HORIZONTAL, VERTICAL
247 from django.contrib.admin.sites import AdminSite
248 from django.contrib.admin.validation import validate
249-from django.contrib.admin.widgets import AdminDateWidget, AdminRadioSelect
250+from django.contrib.admin.widgets import AdminDateWidget, AdminRadioSelect, \
251+    FilteredSelectSingle
252 from django.core.exceptions import ImproperlyConfigured
253 from django.forms.models import BaseModelFormSet
254 from django.forms.widgets import Select
255@@ -301,6 +302,38 @@ class ModelAdminTests(TestCase):
256             list(ma.get_formsets(request))[0]().forms[0].fields.keys(),
257             ['extra', 'transport', 'id', 'DELETE', 'main_band'])
258 
259+    def test_filter_vertical_foreignkey(self):
260+        class ConcertModelAdmin(ModelAdmin):
261+            filter_vertical = ('main_band',)
262+
263+        cma = ConcertModelAdmin(Concert, self.site)
264+        cmafa = cma.get_form(request)
265+
266+        self.assertEqual(type(cmafa.base_fields['main_band'].widget.widget),
267+            FilteredSelectSingle)
268+        self.assertEqual(type(cmafa.base_fields['opening_band'].widget.widget),
269+            Select)
270+
271+        self.assertEqual(
272+            list(cmafa.base_fields['main_band'].widget.choices),
273+            [(u'', u'---------'), (self.band.id, u'The Doors')])
274+
275+        self.assertEqual(
276+            type(cmafa.base_fields['opening_band'].widget.widget), Select)
277+        self.assertEqual(
278+            list(cmafa.base_fields['opening_band'].widget.choices),
279+            [(u'', u'---------'), (self.band.id, u'The Doors')])
280+
281+        self.assertEqual(type(cmafa.base_fields['day'].widget), Select)
282+        self.assertEqual(list(cmafa.base_fields['day'].widget.choices),
283+            [('', '---------'), (1, 'Fri'), (2, 'Sat')])
284+
285+        self.assertEqual(type(cmafa.base_fields['transport'].widget),
286+            Select)
287+        self.assertEqual(
288+            list(cmafa.base_fields['transport'].widget.choices),
289+            [('', '---------'), (1, 'Plane'), (2, 'Train'), (3, 'Bus')])
290+
291 
292 class ValidationTests(unittest.TestCase):
293     def test_validation_only_runs_in_debug(self):