Permalink
Find file
Fetching contributors…
Cannot retrieve contributors at this time
399 lines (260 sloc) 14.8 KB
Enables editing of `ForeignKey`, `ManyToMany` and simple text fields using the Autocomplete - `jQuery` plugin.
django-ajax-selects will work in any normal form as well as in the admin.
==User experience==
selecting...
http://crucial-systems.com/crucialwww/uploads/posts/selecting.png
selected.
http://crucial-systems.com/crucialwww/uploads/posts/selected.png
The user is presented with a text field. They type a search term or a few letters of a name they are looking for, an ajax request is sent to the server, a search channel returns possible results. Results are displayed as a drop down menu. When an item is selected it is added to a display area just below the text field.
A single view services all of the ajax search requests, delegating the searches to named 'channels'.
A channel is a simple class that handles the actual searching, defines how you want to treat the query input (split first name and last name, which fields to search etc.) and returns id and formatted results back to the view which sends it to the browser.
For instance the search channel 'contacts' would search for Contact models. The class would be named ContactLookup. This channel can be used for both AutoCompleteSelect ( foreign key, single item ) and AutoCompleteSelectMultiple (many to many) fields.
Simple search channels can also be automatically generated, you merely specify the model and the field to search against (see examples below).
Custom search channels can be written when you need to do a more complex search, check the user's permissions, format the results differently or customize the sort order of the results.
==Requirements==
* Django 1.0 +
* jquery 1.26 +
* Autocomplete - jQuery plugin 1.1 [http://bassistance.de/jquery-plugins/jquery-plugin-autocomplete/]
* jquery.autocomplete.css (included with Autocomplete)
The Autocomplete jQuery plugin has now been merged into jQuery UI 1.8 and been improved (2010-06-23). I will migrate this package to use the jQuery UI version and release that as django_ajax_select 1.2, but as of this reading (you, reading this right now) you should download and use his 1.1 version.
==Installation==
`pip install django-ajax-selects`
or
`easy_install django-ajax-selects`
or
download or checkout the distribution
or install using buildout by adding `django-ajax-selects` to your `eggs`
on fedora:
su -c 'yum install django-ajax-selects'
in settings.py :
{{{
INSTALLED_APPS = (
...,
'ajax_select'
)
}}}
Make sure that these js/css files appear on your page:
* jquery-1.2.6.js or greater
* jquery.autocomplete.js
* jquery.autocomplete.css
* ajax_select.js (for pop up admin support)
* iconic.css (optional, or use this as a starting point)
I like to use django-compress:
{{{
COMPRESS_CSS = {
'all': {
'source_filenames': (
...
'shared/js/jqplugins/jquery.autocomplete.css',
),
'output_filename': 'css/all_compressed.css',
'extra_context': {
'media': 'screen,projection',
},
},
}
COMPRESS_JS = {
'all': {
'source_filenames': (
'shared/jquery_ui/jquery-1.2.6.js',
'shared/js/jqplugins/jquery.autocomplete.js',
),
'output_filename': 'js/all_compressed.js',
},
}
}}}
But it would be nice if js and css files could be included from any path, not just those in the MEDIA_ROOT. You will have to copy or symlink the included files to place them somewhere where they can be served.
in your `settings.py` define the channels in use on the site:
{{{
AJAX_LOOKUP_CHANNELS = {
# the simplest case, pass a DICT with the model and field to search against :
'track' : dict(model='music.track',search_field='title'),
# this generates a simple channel
# specifying the model Track in the music app, and searching against the 'title' field
# or write a custom search channel and specify that using a TUPLE
'contact' : ('peoplez.lookups', 'ContactLookup'),
# this specifies to look for the class `ContactLookup` in the `peoplez.lookups` module
}
}}}
Custom search channels can be written when you need to do a more complex search, check the user's permissions (if the lookup URL should even be accessible to them, and then to perhaps filter which items they are allowed to see), format the results differently or customize the sort order of the results. Search channel objects should implement the 4 methods shown in the following example.
`peoplez/lookups.py`
{{{
from peoplez.models import Contact
from django.db.models import Q
class ContactLookup(object):
def get_query(self,q,request):
""" return a query set. you also have access to request.user if needed """
return Contact.objects.filter(Q(name__istartswith=q) | Q(fname__istartswith=q) | Q(lname__istartswith=q) | Q(email__icontains=q))
def format_result(self,contact):
""" the search results display in the dropdown menu. may contain html and multiple-lines. will remove any | """
return u"%s %s %s (%s)" % (contact.fname, contact.lname,contact.name,contact.email)
def format_item(self,contact):
""" the display of a currently selected object in the area below the search box. html is OK """
return unicode(contact)
def get_objects(self,ids):
""" given a list of ids, return the objects ordered as you would like them on the admin page.
this is for displaying the currently selected items (in the case of a ManyToMany field)
"""
return Contact.objects.filter(pk__in=ids).order_by('name','lname')
}}}
HTML is fine in the result or item format. Newlines and pipe chars will be removed and everything will be escaped properly.
Example showing security:
{{{
from django.http import HttpResponseForbidden
class ContactLookup(object):
def get_query(self,q,request):
""" return a query set. you also have access to request.user if needed """
if not request.user.is_authenticated():
raise HttpResponseForbidden() # raising an exception, django will catch this and return an Http 403
# filtering only this user's contacts
return Contact.objects.filter(name__istartswith=q,created_by=request.user)
...
}}}
include the urls in your site's `urls.py`. This adds the lookup view and the pop up admin view.
{{{
(r'^ajax_select/', include('ajax_select.urls')),
}}}
==Example==
for an example model:
{{{
class ContactMailing(models.Model):
""" can mail to multiple contacts, has one author """
contacts = models.ManyToManyField(Contact,blank=True)
author = models.ForeignKey(Contact,blank=False)
...
}}}
in the `admin.py` for this app:
{{{
from ajax_select import make_ajax_form
class ContactMailingAdmin(Admin):
form = make_ajax_form(ContactMailing,dict(author='contact',contacts='contact'))
}}}
`make_ajax_form( model, fieldlist )` is a factory function which will insert the ajax powered form field inputs
so in this example the `author` field (`ForeignKey`) uses the 'contact' channel
and the `contacts` field (`ManyToMany`) also uses the 'contact' channel
If you need to write your own form class then specify that form for the admin as usual:
{{{
from forms import ContactMailingForm
class ContactMailingAdmin(admin.ModelAdmin):
form = ContactMailingForm
admin.site.register(ContactMailing,ContactMailingAdmin)
}}}
in `forms.py` for that app:
{{{
from ajax_select.fields import AutoCompleteSelectMultipleField, AutoCompleteSelectField
class ContactMailingForm(models.ModelForm):
# declare a field and specify the named channel that it uses
contacts = AutoCompleteSelectMultipleField('contact', required=False)
author = AutoCompleteSelectField('contact', required=False)
}}}
==Add another via popup==
Note that ajax_selects does not need to be in an admin. Popups will still use an admin view (the registered admin for the model being added), even if your form does not.
1. subclass `AjaxSelectAdmin` or include the `autoselect_fields_check_can_add` hook in your admin's `get_form()` [see AjaxSelectAdmin]
{{{
def get_form(self, request, obj=None, **kwargs):
form = super(AjaxSelectAdmin,self).get_form(request,obj,**kwargs)
autoselect_fields_check_can_add(form,self.model,request.user)
return form
}}}
2. Make sure that `js/ajax_select.js` is included in your admin's media or in your site's admin js stack.
`autoselect_fields_check_can_add(form,model,user)`
This checks if the user has permission to add the model,
delegating first to the channel if that implements `can_add(user,model)`
otherwise using django's standard user.has_perm check.
The pop up is served by a custom view that uses the model's registered admin
3. For this to work you must include ajax_select/urls.py in your root urls.py under this directory:
`(r'^ajax_select/', include('ajax_select.urls')),`
Once the related object is successfully added, the mischevious custom view hijacks the little javascript response and substitutes `didAddPopup(win,newId,newRepr)` which is in `ajax_select.js`
Integrating with Django's normal popup admin system is tricky for a number of reasons.
`ModelAdmin` creates default fields for each field on the model. Then for `ForeignKey` and `ManyToMany` fields it wraps the (default) form field's widget with a `RelatedFieldWidgetWrapper` that adds the magical green +. (Incidentally it adds this regardless of whether you have permission to add the model or not. This is a bug I need to file)
It then overwrites all of those with any explicitly declared fields. `AutoComplete` fields are declared fields on your form, so if there was a Select field with a wrapper, it gets overwritten by the `AutoCompleteSelect`. That doesn't matter anyway because `RelatedFieldWidgetWrapper` operates only with the default `SelectField` that it is expecting.
The green + pops open a window with a GET param: `_popup=1`. The `ModelAdmin` recognizes this, the template uses if statements to reduce the page's html a bit, and when the ModelAdmin saves, it returns a simple response with just some javascript that calls `dismissAddAnotherPopup(win, newId, newRepr)` which is a function in `RelatedObjects.js`. That looks for the form field, and if it is a `SelectField` as expected then it alters that accordingly. Then it shuts the pop up window.
==Using ajax selects in a `FormSet`==
There might be a better way to do this.
`forms.py`
{{{
from django.forms.models import modelformset_factory
from django.forms.models import BaseModelFormSet
from ajax_select.fields import AutoCompleteSelectMultipleField, AutoCompleteSelectField
from models import *
# create a superclass
class BaseTaskFormSet(BaseModelFormSet):
# that adds the field in, overwriting the previous default field
def add_fields(self, form, index):
super(BaseTaskFormSet, self).add_fields(form, index)
form.fields["project"] = AutoCompleteSelectField('project', required=False)
# pass in the base formset class to the factory
TaskFormSet = modelformset_factory(Task,fields=('name','project','area'),extra=0,formset=BaseTaskFormSet)
}}}
==customizing the html or javascript==
django's `select_template` is used to choose the template to render the widget's interface:
`autocompleteselect_{channel}.html` or `autocompleteselect.html`
So by writing a template `autocompleteselect_{channel}.html` you can customize the interface just for that channel.
==Handlers: On item added or removed==
Triggers are a great way to keep code clean and untangled. Two triggers/signals are sent: 'added' and 'killed'. These are sent to the p 'on deck' element. That is the area that surrounds the currently selected items. Its quite easy to bind functions to respond to these triggers.
Extend the template, implement the extra_script block and bind functions that will respond to the trigger:
multi select:
{{{
{% block extra_script %}
$("#{{html_id}}_on_deck").bind('added',function() {
id = $("#{{html_id}}").val();
alert('added id:' + id );
});
$("#{{html_id}}_on_deck").bind('killed',function() {
current = $("#{{html_id}}").val()
alert('removed, current is:' + current);
});
{% endblock %}
}}}
select:
{{{
{% block extra_script %}
$("#{{html_id}}_on_deck").bind('added',function() {
id = $("#{{html_id}}").val();
alert('added id:' + id );
});
$("#{{html_id}}_on_deck").bind('killed',function() {
alert('removed');
});
{% endblock %}
}}}
auto-complete text select
{{{
{% block extra_script %}
$('#{{ html_id }}').bind('added',function() {
entered = $('#{{ html_id }}').val();
alert( entered );
});
{% endblock %}
}}}
There is no remove as there is no kill/delete button. The user may clear the text themselves but there is no javascript involved. Its just a text field.
see:
http://docs.jquery.com/Events/trigger
==Help text==
If you are using AutoCompleteSelectMultiple outside of the admin then pass in `show_help_text=True`.
This is because the admin displays the widget's help text and the widget would also. But when used outside of the admin you need the help text. This is not the case for `AutoCompleteSelect`.
When defining a db ManyToMany field django will append 'Hold down "Control", or "Command" on a Mac, to select more than one.' regardless of what widget is actually used. http://code.djangoproject.com/ticket/12359
Thus you should always define the help text in your form field, and its usually nicer to tell people what fields will be searched against. Its not inherently obvious that the text field is "ajax powered" or what a brand of window cleaner has to do with filling out this dang form anyway.
==CSS==
See iconic.css for some example styling. autocomplete.js adds the .ac_loading class to the text field while the search is being served. You can style this with fashionable ajax spinning discs etc. A fashionable ajax spinning disc is included.
==Planned Improvements==
* Migrating to use the jQuery 1.8 version of autocomplete. This will integrate it with ThemeRoller and would slightly reduce the js codebase.
* including of media will be improved to use field/admin's Media but it would be preferable if that can be integrated with django-compress
* make it work within inline many to many fields (when the inlines themselves have lookups)
==License==
Copyright (c) 2009 Chris Sattinger
Dual licensed under the MIT and GPL licenses:
http://www.opensource.org/licenses/mit-license.php
http://www.gnu.org/licenses/gpl.html
==Changelog==
1.1.0
Changed AutoCompleteSelect to work like AutoCompleteSelectMultiple:
after the result is selected it is displayed below the text input and the text input is cleared.
a clickable span is added to remove the item
Simplified functions a bit, cleaned up code
Added blocks: script and extra_script
Added 'killed' and 'added' triggers/signals
Support for adding an item via a pop up (ie. the django admin green + sign)
1.1.4
Fixed python 2.4 compatiblity
Added LICENSE