Ian Struble avatar Ian Struble committed c847724 Merge

Syncing up with the latest and greatest django-trunk.

Comments (0)

Files changed (8)

django/contrib/admin/media/css/widgets.css

     border-top: 1px solid #ddd;
 }
 
+/* AUTOCOMPLETE */
+/* *** NOTE ***
+   This is mostly culled from css generated by http://jqueryui.com/themeroller
+   */
+.ui-menu {
+    display: block;
+    float: left;
+    list-style: none outside none;
+    margin: 0;
+    padding: 2px;
+}
+.ui-menu .ui-menu-item a {
+    display: block;
+    line-height: 1.5;
+    padding: 0.2em 0.4em;
+    text-decoration: none;
+}
+
+.ui-autocomplete {
+    cursor: default;
+    position: absolute;
+}
+
+form .aligned ul.ui-autocomplete {
+    padding: 2px;
+}
+.ui-widget-content {
+    background: #fcfdfd;
+    border: 1px solid #a6c9e2;
+    color: #222222;
+}
+.ui-menu .ui-menu-item {
+    background: #fcfdfd;
+    clear: left;
+    float: left;
+    margin: 0;
+    padding: 0;
+    width: 100%;
+}
+.ui-menu .ui-menu-item a {
+    border: 1px solid #fcfdfd;
+}
+ul.ui-menu li {
+    list-style-type: none;
+}
+.ui-state-hover, .ui-state-focus {
+    color: #fff;
+    background: #417690;
+    border: 1px solid #417690;
+}
+
+.ui-autocomplete-value a {
+    margin-left: 0.5em;
+}

django/contrib/admin/media/js/admin/RelatedObjectLookups.js

 function dismissRelatedLookupPopup(win, chosenId) {
     var name = windowname_to_id(win.name);
     var elem = document.getElementById(name);
-    if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) {
+    var $ = django && django.jQuery;
+    var autocomplete_elem = !!$ && $(elem);
+    if (!!autocomplete_elem && !!autocomplete_elem.data('djangoautocomplete')) {
+        $.getJSON(
+                autocomplete_elem.data('djangoautocomplete').options.source,
+                {term: chosenId, by_id: 1},
+                function (data) {
+                    // Pass the returned item to the normal
+                    // autocomplete onSelect handler.
+                    autocomplete_elem.data('autocomplete')
+                        .options.select({}, {item: data[0]});
+                });
+    }
+    else if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) {
         elem.value += ',' + chosenId;
     } else {
-        document.getElementById(name).value = chosenId;
+        elem.value = chosenId;
     }
     win.close();
 }
             elem.options[elem.options.length] = o;
             o.selected = true;
         } else if (elem.nodeName == 'INPUT') {
-            if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) {
+            var autocomplete_elem = django && django.jQuery && django.jQuery(elem);
+            if (!!autocomplete_elem && !!autocomplete_elem.data('djangoautocomplete')) {
+                django.jQuery.getJSON(
+                    autocomplete_elem.data('djangoautocomplete').options.source,
+                    {term: newId, by_id: 1},
+                    function (data) {
+                        // Pass the returned item to the normal
+                        // autocomplete onSelect handler.
+                        autocomplete_elem.data('autocomplete')
+                            .options.select({}, {item: data[0]});
+                    });
+            } else if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) {
                 elem.value += ',' + newId;
             } else {
                 elem.value = newId;

django/contrib/admin/media/js/admin/autocomplete.js

                 .append( $( "<a></a>" ).append( item.label ) )
                 .appendTo( ul );
         },
-
+      is_djangoautocomplete: true
     },
     _create: function() {
         var self = this;
         this.element.autocomplete({
             appendTo: this.element.parent(),
             select: function( event, ui ) {
-                self.lastSelected = ui.item;
-                if ( self.options.multiple ) {
-                    if ( $.inArray( ui.item.id, self.values ) < 0 ) {
-                        $('<li></li>')
-                            .addClass( "ui-autocomplete-value" )
-                            .data( "value.autocomplete", ui.item.id )
-                            .append( ui.item.label+'<a href="#">x</a>' )
-                            .appendTo( self.values_ul );
-                        self.values.push( ui.item.id );
+                var item = ui.item.data ? ui.item.data( "item.autocomplete" ) : ui.item;
+                self.lastSelected = item
+                if (self.options.is_djangoautocomplete === true) {
+                    if ( self.options.multiple ) {
+                        if ( $.inArray( item.id, self.values ) < 0 ) {
+                            $('<li></li>')
+                                .addClass( "ui-autocomplete-value" )
+                                .data( "value.autocomplete", item.id )
+                                .append( item.label+'<a href="#">x</a>' )
+                                .appendTo( self.values_ul );
+                            self.values.push( item.id );
+                        }
+                    } else {
+                        self.term = item.value;
+                        self.element.val(item.value);
                     }
                     return false;
                 }

django/contrib/admin/options.py

     limit = 5,
     value = lambda o: unicode(o),
     label = lambda o: unicode(o),
+    show_search = True,
 )
 
 csrf_protect_m = method_decorator(csrf_protect)
 
         settings = self.autocomplete_fields[field]
         queryset = settings['queryset']
+        search_fields = settings['fields']
+        if request.GET.get('by_id', None) is not None:
+            # lookup only via an exact match on id
+            search_fields = ('=id',)
 
         def construct_search(field_name):
             # use different lookup methods depending on the notation
         for bit in query.split():
             or_queries = [models.Q(**{construct_search(
                 smart_str(field_name)): bit})
-                    for field_name in settings['fields']]
+                    for field_name in search_fields]
 
             queryset = queryset.filter(reduce(operator.or_, or_queries))
         

django/contrib/admin/widgets.py

     template_with_clear = (u'<span class="clearable-file-input">%s</span>'
                            % forms.ClearableFileInput.template_with_clear)
 
+def _get_search_icon(model, name, value, params, attrs):
+    related_url = '../../../%s/%s/' % (model._meta.app_label, model._meta.object_name.lower())
+    if params:
+        url = '?' + '&amp;'.join(['%s=%s' % (k, v) for k, v in params.items()])
+    else:
+        url = ''
+    # TODO: "id_" is hard-coded here. This should instead use the correct
+    # API to determine the ID dynamically.
+    output = []
+    output.append('<a href="%s%s" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);"> ' % \
+        (related_url, url, name))
+    output.append('<img src="%simg/admin/selector-search.gif" width="16" height="16" alt="%s" /></a>' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup')))
+    return output
 
 class ForeignKeyRawIdWidget(forms.TextInput):
     """
     def render(self, name, value, attrs=None):
         if attrs is None:
             attrs = {}
-        related_url = '../../../%s/%s/' % (self.rel.to._meta.app_label, self.rel.to._meta.object_name.lower())
-        params = self.url_parameters()
-        if params:
-            url = '?' + '&amp;'.join(['%s=%s' % (k, v) for k, v in params.items()])
-        else:
-            url = ''
         if "class" not in attrs:
             attrs['class'] = 'vForeignKeyRawIdAdminField' # The JavaScript looks for this hook.
         output = [super(ForeignKeyRawIdWidget, self).render(name, value, attrs)]
-        # TODO: "id_" is hard-coded here. This should instead use the correct
-        # API to determine the ID dynamically.
-        output.append('<a href="%s%s" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);"> ' % \
-            (related_url, url, name))
-        output.append('<img src="%simg/admin/selector-search.gif" width="16" height="16" alt="%s" /></a>' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup')))
+
+        output += _get_search_icon(self.rel.to, name, value, self.url_parameters(), attrs)
         if value:
             output.append(self.label_for_value(value))
         return mark_safe(u''.join(output))
         hidden_id = 'id_hidden_%s' % name
         hidden_attrs = self.build_attrs(type='hidden', name=name, value=value, id=hidden_id)
         normal_attrs = self.build_attrs(attrs, type='text')
-        if value:
-            normal_attrs['value'] = self.label_for_value(value)
+        normal_attrs['value'] = self.label_for_value(value)
         if not self.js_options.get('source'):
             self.js_options['source'] = self.get_autocomplete_url(name)
         options = simplejson.dumps(self.js_options)
+        if self.settings.get('show_search'):
+            target_key = self.settings.get('id')
+            search_icon = _get_search_icon(self.settings.get('queryset').model, 
+                                           name, value, {'t': target_key}, attrs)
+            search_icon = u''.join(search_icon) + '\n'
+        else:
+            search_icon = u''
         return mark_safe(u''.join((
             u'<input%s />\n' % flatatt(hidden_attrs),
             u'<input%s />\n' % flatatt(normal_attrs),
+            search_icon,
             initial_objects,
             u'<script type="text/javascript">',
             u'django.jQuery("#id_%s").djangoautocomplete(%s);' % (name, options),
 
     def label_for_value(self, value):
         qs, key, value_fmt = [self.settings[k] for k in ('queryset','id','value')]
+        if not value:
+            return value
         try:
             obj = qs.get(**{key: value})
             return value_fmt(obj)

docs/ref/contrib/admin/index.txt

     class AuthorAdmin(admin.ModelAdmin):
         date_hierarchy = 'pub_date'
 
+.. attribute:: ModelAdmin.autocomplete_fields
+
+By default, Django's admin uses a select-box interface (<select>) for fields 
+that are ``ForeignKey`` or ``ManyToMany``.  If you know that your users' 
+browsers will have javascript enabled you can give them autocomplete behavior.
+
+``autocomplete_fields`` is a list of fields you would like to change
+into a smart ``Input`` widget for either a ``ForeignKey`` or ``ManyToManyField``::
+
+    class BookAdmin(admin.ModelAdmin):
+        autocomplete_fields = {
+            'author': { 'fields': ('name',) },
+        }
+
+``autocomplete_fields`` is a dictionary that connects a ``field_name`` to 
+a dictionary of ``field_options``.  The ``field_options`` can have the 
+following keys:
+
+    * ``fields``
+        A tuple of field names used to search for objects associated with 
+        ``field_name``. This key is required.
+
+    * ``label``
+        A subroutine that controls how each choice is displayed in the 
+        list of autocomplete choices.
+
+        Example::
+
+            'label': lambda o: o.name.lower()
+
+    * ``limit``
+        An integer that limits the size of the autocomplete choices displayed.
+
+    * ``show_search``
+        A boolean that controls the rendering of a clickable search icon.  
+        Defaults to `True`. 
+
+    * ``value``
+       A subroutine that controls how a selected item is displayed.
+
+        Example::
+
+            'value': lambda o: o.name
+
 .. attribute:: ModelAdmin.date_hierarchy
 
     Set ``date_hierarchy`` to the name of a ``DateField`` or ``DateTimeField``

tests/regressiontests/admin_widgets/tests.py

 from django.contrib.admin import widgets
 from django.contrib.admin.widgets import (FilteredSelectMultiple,
     AdminSplitDateTime, AdminFileWidget, ForeignKeyRawIdWidget, AdminRadioSelect,
-    RelatedFieldWidgetWrapper, ManyToManyRawIdWidget)
+    RelatedFieldWidgetWrapper, ManyToManyRawIdWidget,
+    AutocompleteWidget, MultipleAutocompleteWidget)
 from django.core.files.storage import default_storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db.models import DateField
 from django.test import TestCase as DjangoTestCase
+from django.utils import simplejson
 from django.utils.html import conditional_escape
 from django.utils.translation import activate, deactivate
 from django.utils.unittest import TestCase
         # Used to fail with a name error.
         w = RelatedFieldWidgetWrapper(w, rel, admin.site)
         self.assertFalse(w.can_add_related)
+
+
+class AutocompleteWidgetTest(DjangoTestCase):
+    def test_render(self):
+        band = models.Band.objects.create(name='Linkin Park')
+        band.album_set.create(
+            name='Hybrid Theory', cover_art=r'albums\hybrid_theory.jpg'
+        )
+
+        widget_settings = {
+            'fields': ('name',),
+            'id': 'id',
+            'limit': 5,
+            'value': lambda o: unicode(o),
+            'label': lambda o: unicode(o),
+            'queryset': models.Band.objects.all(),
+            }
+        w = AutocompleteWidget(widget_settings)
+        field_name = 'test'
+        expected = "\n".join((
+            '<input type="hidden" name="test" value="%(band_pk)s" id="id_hidden_%(field_name)s" />',
+            '<input type="text" value="%(band_name)s" />',
+            '<script type="text/javascript">django.jQuery("#id_%(field_name)s").djangoautocomplete({"force_selection": true, "multiple": %(multiple)s, "source": "../autocomplete/%(field_name)s/"});</script>',
+            ''
+            )) % {'field_name': field_name,
+                  'band_pk': band.pk,
+                  'band_name': band.name,
+                  'multiple': 'false',
+                  }
+        self.assertEqual(
+            conditional_escape(w.render('test', band.pk, attrs={})),
+            expected,
+        )
+
+
+class MultipleAutocompleteWidgetTest(DjangoTestCase):
+    fixtures = ["admin-widgets-users.xml"]
+    admin_root = '/widget_admin'
+
+    def setUp(self):
+        band = models.Band.objects.create(name='Linkin Park')
+        band.album_set.create(
+            name='Hybrid Theory', cover_art=r'albums\hybrid_theory.jpg'
+        )
+        band2 = models.Band.objects.create(name='Johnny Cash')
+        band2.album_set.create(
+            name='At San Quentin'
+        )
+        self.bands = (band, band2)
+    
+    def _get_expected(self, name, bands, show_search=False):
+        bands = [] if bands is None else bands
+        expected = [
+            '<input type="hidden" name="%(field_name)s" value="%(band_pks)s" id="id_hidden_%(field_name)s" />',
+            '<input type="text" value="" />',  # input is for data entry only
+            ]
+        if show_search:
+            expected.append('<a href="../../../admin_widgets/band/?t=id" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % (name, settings.ADMIN_MEDIA_PREFIX))
+        if bands:                
+                expected += ['<ul class="ui-autocomplete-values">']
+                expected += ['<li>%s</li>' % b.name for b in bands]
+                expected += ['</ul>']
+        expected += [
+            '<script type="text/javascript">django.jQuery("#id_%(field_name)s").djangoautocomplete({"force_selection": true, "multiple": %(multiple)s, "source": "../autocomplete/%(field_name)s/"});</script>',
+            ''
+            ]
+        expected = "\n".join(expected) % {
+            'field_name': name,
+            'band_pks': ','.join([str(b.pk) for b in bands]),
+            'multiple': 'true',
+            }
+        return expected
+
+    def test_render(self):
+        bands = self.bands
+
+        widget_settings = {
+            'fields': ('name',),
+            'id': 'id',
+            'limit': 5,
+            'value': lambda o: unicode(o),
+            'label': lambda o: unicode(o),
+            'queryset': models.Band.objects.all(),
+            'show_search': False,
+            }
+        with_search_settings = widget_settings.copy()
+        with_search_settings['show_search'] = True
+
+        w = MultipleAutocompleteWidget(widget_settings)
+        expected_multiple = self._get_expected('test_multiple', bands)
+        self.assertEqual(
+            conditional_escape(w.render('test_multiple', [b.pk for b in bands], attrs={})),
+            expected_multiple,
+        )
+
+        w = MultipleAutocompleteWidget(widget_settings)
+        bands_just_one = bands[:1]
+        expected_single = self._get_expected('test_single', bands_just_one)
+        self.assertEqual(
+            conditional_escape(w.render('test_single', [b.pk for b in bands_just_one], attrs={})),
+            expected_single,
+        )
+
+        w = MultipleAutocompleteWidget(widget_settings)
+        bands_none = None
+        expected_none = self._get_expected('test_none', bands_none)
+        self.assertEqual(
+            conditional_escape(w.render('test_none', bands_none, attrs={})),
+            expected_none,
+        )
+
+        w = MultipleAutocompleteWidget(with_search_settings)
+        bands_none = None
+        expected_none = self._get_expected('test_none', bands_none, True)
+        self.assertEqual(
+            conditional_escape(w.render('test_none', bands_none, attrs={})),
+            expected_none,
+        )
+        
+
+    def test_autocomplete_lookup(self):
+        band = self.bands[1]
+        self.client.login(username="super", password="secret")
+        response = self.client.get('%s/admin_widgets/album/autocomplete/band/?term=johnny' % self.admin_root )
+        self.assertEqual(simplejson.loads(response.content),
+                         [{'id': band.id, 
+                           'value': band.name,
+                           'label': band.name}])
+        response = self.client.get('%s/admin_widgets/album/autocomplete/band/?term=a' % self.admin_root )
+        self.assertEqual(simplejson.loads(response.content),
+                         [{'id': band.id, 
+                           'value': band.name,
+                           'label': band.name} for band in self.bands])
+        response = self.client.get('%s/admin_widgets/album/autocomplete/band/?term=%s&by_id=1' % (self.admin_root, band.id ))
+        self.assertEqual(simplejson.loads(response.content),
+                         [{'id': band.id, 
+                           'value': band.name,
+                           'label': band.name}])
+        

tests/regressiontests/admin_widgets/widgetadmin.py

 class EventAdmin(admin.ModelAdmin):
     raw_id_fields = ['band']
 
+
+class AlbumAdmin(admin.ModelAdmin):
+    autocomplete_fields = {
+        'band': { 'fields': ('name',) }
+            }
+
 site = WidgetAdmin(name='widget-admin')
 
 site.register(models.User)
+site.register(models.Album, AlbumAdmin)
 site.register(models.Car, CarAdmin)
 site.register(models.CarTire, CarTireAdmin)
 site.register(models.Event, EventAdmin)
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.