diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index faa826ed6c..85a1094d6f 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -60,7 +60,7 @@ def get_lookup(channel): try: lookup_label = settings.AJAX_LOOKUP_CHANNELS[channel] except (KeyError, AttributeError): - raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %s" % channel) + raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %r" % channel) if isinstance(lookup_label,dict): # 'channel' : dict(model='app.model', search_field='title' ) diff --git a/ajax_select/admin.py b/ajax_select/admin.py new file mode 100644 index 0000000000..aa7e6a9636 --- /dev/null +++ b/ajax_select/admin.py @@ -0,0 +1,15 @@ + + +from ajax_select.fields import autoselect_fields_check_can_add +from django.contrib import admin + +class AjaxSelectAdmin(admin.ModelAdmin): + + """ in order to get + popup functions subclass this or do the same hook inside of your get_form """ + + 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 + diff --git a/ajax_select/docs.txt b/ajax_select/docs.txt index 0f7dc4fa73..519ffc96af 100644 --- a/ajax_select/docs.txt +++ b/ajax_select/docs.txt @@ -5,10 +5,11 @@ Ajax selects will work in any normal form as well as in the admin. User experience: -The user is presented with a text field. They type a few characters 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. +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. +A single view services all of the ajax search requests, delegating the searches to named 'channels'. -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. This channel can be used for both AutoCompleteSelect ( foreign key, single item ) and AutoCompleteSelectMultiple (many to many) fields. @@ -27,7 +28,7 @@ Custom search channels can be written when you need to do a more complex search, ==Installation== -install as a normal django app +in settings.py : {{{ INSTALLED_APPS = ( @@ -141,7 +142,7 @@ class ContactLookup(object): include the lookup url in your site's `urls.py` {{{ - (r'^ajax/', include('ajax_select.urls')), + (r'^ajax_select/', include('ajax_select.urls')), }}} @@ -196,6 +197,46 @@ class ContactMailingForm(models.ModelForm): }}} + +==Add another via popup== + +Note that ajax_selects does not need to be in an admin. Popups will use an admin view, 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. Include js/ajax_select.js 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 + +Once the related object is successfully added, the mischevious view hijacks the little javascript response and substitutes a different javascript function. That function 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. + +tl/dr: there's no clean hack + + + ==Using ajax selects in a FormSet== There might be a better way to do this. @@ -229,33 +270,90 @@ django's `select_template` is used to choose the template to render the widget's So by writing a template `autocompleteselect_{channel}.html` you can customize the interface just for that channel. -There is one block 'help' that allows you to inherit from the main widget template. -=On item removed= +=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: -For AutoCompleteSelectMultiple when you remove an item (by clicking the X) a "killed" trigger/signal/dispatch/handler is fired on the P element that holds the selected ("on-deck") items. +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: {{{ -$("#{{html_id}}_on_deck").trigger("killed"); // run killed() on {{html_id}}_on_deck if it is defined +{% 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 %} }}} -this is the element that receives the trigger: +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. + + see: http://docs.jquery.com/Events/trigger +Help text + +Django has some historical confusions baked in. The help text is in the db field, and it obviously shouldn't have been. The form field passes it to the widget ... who sadly has nothing to do with displaying help. + +Displaying the help text in the widget body results in it being shown twice, as the admin also displays the help text. But only for AutoCompleteSelectMultiple ?! AutoCompleteSelect shows it only once. I would prefer to keep it consistent. + +I have commented out the hard-coded help text from the widgets, but there is a help_text block if you are extending the templates. + + +==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. + ==Planned Improvements== - * integration with (+) add item via popup in django-admin * 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 - * ajax niceness ("searching...") - * help_text is still not showing - * let channel customize the interface's help text + + * make it work within inline many to many fields (when the inlines themselves have lookups) +Changelog + +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 +Added blocks: script and extra_script +Added 'killed' and 'added' triggers/signals + +1.1.0 + + adding + pop up functionality + fixing several bugs with escaping and with multiple fields on a page + more compact and cleaner code diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 3d94d1467e..b081769458 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -1,49 +1,56 @@ +from ajax_select import get_lookup from django import forms -from django.template.loader import render_to_string -from django.utils.safestring import mark_safe from django.core.urlresolvers import reverse -from ajax_select import get_lookup from django.forms.util import flatatt from django.template.defaultfilters import escapejs - +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext as _ +from globalapp.debug import dbug +from django.contrib.contenttypes.models import ContentType +from django.conf import settings class AutoCompleteSelectWidget(forms.widgets.TextInput): - """ widget to select a model """ - - html_id = '' + """ widget to select a model """ + + add_link = None + def __init__(self, channel, help_text='', *args, **kw): super(forms.widgets.TextInput, self).__init__(*args, **kw) - # url for Datasource self.channel = channel self.help_text = help_text def render(self, name, value, attrs=None): - if value == None: - value = '' - html_id = attrs.get('pk', name) - self.html_id = html_id + + value = value or '' + final_attrs = self.build_attrs(attrs, name=name) + self.html_id = final_attrs.pop('pk', name) lookup = get_lookup(self.channel) if value: - current_name = lookup.get_objects([value])[0] + current_result = mark_safe(lookup.format_result( lookup.get_objects([value])[0] )) else: - current_name = '' - lookup_url = reverse('ajax_lookup',kwargs={'channel':self.channel}) - vars = dict( - name=name, - html_id=html_id, - lookup_url=lookup_url, - current_id=value, - current_name=current_name, - help_text=self.help_text, - extra_attrs=mark_safe(flatatt(self.attrs)) - ) - return mark_safe(render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'),vars)) + current_result = '' + + context = { + 'name': name, + 'html_id' : self.html_id, + 'lookup_url': reverse('ajax_lookup',kwargs={'channel':self.channel}), + 'current_id': value, + 'current_result': current_result, + 'help_text': self.help_text, + 'extra_attrs': mark_safe(flatatt(final_attrs)), + 'func_slug': self.html_id.replace("-",""), + 'add_link' : self.add_link, + 'admin_media_prefix' : settings.ADMIN_MEDIA_PREFIX + } + + return mark_safe(render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'),context)) def value_from_datadict(self, data, files, name): @@ -56,18 +63,17 @@ def value_from_datadict(self, data, files, name): class AutoCompleteSelectField(forms.fields.CharField): + """ form field to select a model for a ForeignKey db field """ + channel = None def __init__(self, channel, *args, **kwargs): self.channel = channel widget = kwargs.get("widget", False) if not widget or not isinstance(widget, AutoCompleteSelectWidget): - kwargs["widget"] = AutoCompleteSelectWidget(channel=channel,help_text=kwargs.get('help_text','')) - - super(AutoCompleteSelectField, self).__init__( - max_length=255, - *args, **kwargs) + kwargs["widget"] = AutoCompleteSelectWidget(channel=channel,help_text=kwargs.get('help_text',_('Enter text to search.'))) + super(AutoCompleteSelectField, self).__init__(max_length=255,*args, **kwargs) def clean(self, value): if value: @@ -76,7 +82,7 @@ def clean(self, value): if len(objs) != 1: # someone else might have deleted it while you were editing # or your channel is faulty - # out of the scope of this app to do anything more than tell you it doesn't exist + # out of the scope of this field to do anything more than tell you it doesn't exist raise forms.ValidationError(u"The selected item does not exist.") return objs[0] else: @@ -84,44 +90,44 @@ def clean(self, value): raise forms.ValidationError(self.error_messages['required']) return None - + def check_can_add(self,user,model): + _check_can_add(self,user,model) class AutoCompleteSelectMultipleWidget(forms.widgets.SelectMultiple): """ widget to select multiple models """ - - html_id = '' - + + add_link = None + def __init__(self, channel, help_text='', - *args, **kw): - super(AutoCompleteSelectMultipleWidget, self).__init__(*args, **kw) + *args, **kwargs): + super(AutoCompleteSelectMultipleWidget, self).__init__(*args, **kwargs) self.channel = channel self.help_text = help_text def render(self, name, value, attrs=None): - if value == None: + + if value is None: value = [] - html_id = attrs.get('pk', name) - self.html_id = html_id + final_attrs = self.build_attrs(attrs, name=name) + self.html_id = final_attrs.pop('pk', name) lookup = get_lookup(self.channel) - lookup_url = reverse('ajax_lookup',kwargs={'channel':self.channel}) - current_name = ""# the text field starts empty - # value = [3002L, 1194L] + current_name = "" # the text field starts empty + # eg. value = [3002L, 1194L] if value: - current_ids = "|" + "|".join( str(pk) for pk in value ) + "|" # pk|pk of current + current_ids = "|" + "|".join( str(pk) for pk in value ) + "|" # |pk|pk| of current else: current_ids = "|" objects = lookup.get_objects(value) # text repr of currently selected items - current_repr = [] current_repr_json = [] for obj in objects: repr = lookup.format_item(obj) @@ -129,41 +135,47 @@ def render(self, name, value, attrs=None): current_reprs = mark_safe("new Array(%s)" % ",".join(current_repr_json)) - vars = dict(name=name, - html_id=html_id, - lookup_url=lookup_url, - current=value, - current_name=current_name, - current_ids=current_ids, - current_reprs=current_reprs, - help_text=self.help_text - ) - return mark_safe(render_to_string(('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'),vars)) - - + context = { + 'name':name, + 'html_id':self.html_id, + 'lookup_url':reverse('ajax_lookup',kwargs={'channel':self.channel}), + 'current':value, + 'current_name':current_name, + 'current_ids':current_ids, + 'current_reprs':current_reprs, + 'help_text':self.help_text, + 'extra_attrs': mark_safe(flatatt(final_attrs)), + 'func_slug': self.html_id.replace("-",""), + 'add_link' : self.add_link, + 'admin_media_prefix' : settings.ADMIN_MEDIA_PREFIX + } + return mark_safe(render_to_string(('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'),context)) def value_from_datadict(self, data, files, name): - #eg u'members': [u'|229|4688|190|'] + # eg. u'members': [u'|229|4688|190|'] return [long(val) for val in data.get(name,'').split('|') if val] class AutoCompleteSelectMultipleField(forms.fields.CharField): + """ form field to select multiple models for a ManyToMany db field """ channel = None def __init__(self, channel, *args, **kwargs): self.channel = channel - kwargs['widget'] = AutoCompleteSelectMultipleWidget(channel=channel,help_text=kwargs.get('help_text','')) + kwargs['widget'] = AutoCompleteSelectMultipleWidget(channel=channel,help_text=kwargs.get('help_text',_('Enter text to search.'))) super(AutoCompleteSelectMultipleField, self).__init__(*args, **kwargs) def clean(self, value): if not value and self.required: raise forms.ValidationError(self.error_messages['required']) return value # a list of IDs from widget value_from_datadict - # should: check that none of the objects have been deleted by somebody else while we were editing + + def check_can_add(self,user,model): + _check_can_add(self,user,model) class AutoCompleteWidget(forms.TextInput): @@ -182,13 +194,10 @@ def __init__(self, channel, *args, **kwargs): super(AutoCompleteWidget, self).__init__(*args, **kwargs) def render(self, name, value, attrs=None): - if attrs is not None: - html_id = attrs.get('pk', name) - else: - html_id = name - self.html_id = html_id value = value or '' + final_attrs = self.build_attrs(attrs, name=name) + self.html_id = final_attrs.pop('pk', name) context = { 'current_name': value, @@ -197,6 +206,8 @@ def render(self, name, value, attrs=None): 'html_id': self.html_id, 'lookup_url': reverse('ajax_lookup', args=[self.channel]), 'name': name, + 'extra_attrs':mark_safe(flatatt(final_attrs)), + 'func_slug': self.html_id.replace("-","") } templates = ('autocomplete_%s.html' % self.channel, @@ -213,12 +224,34 @@ class AutoCompleteField(forms.CharField): def __init__(self, channel, *args, **kwargs): self.channel = channel - widget = AutoCompleteWidget(channel, - help_text=kwargs.get('help_text', '')) + widget = AutoCompleteWidget(channel,help_text=kwargs.get('help_text', _('Enter text to search.'))) - defaults = {'max_length': 255, - 'widget': widget} + defaults = {'max_length': 255,'widget': widget} defaults.update(kwargs) super(AutoCompleteField, self).__init__(*args, **defaults) + + + + +def _check_can_add(self,user,model): + """ check if the user can add the model, deferring first to the channel if it implements can_add() \ + else using django's default perm check. \ + if it can add, then enable the widget to show the + link """ + lookup = get_lookup(self.channel) + try: + can_add = lookup.can_add(user,model) + except AttributeError: + ctype = ContentType.objects.get_for_model(model) + can_add = user.has_perm("%s.view_%s" % (ctype.app_label,ctype.model)) + if can_add: + self.widget.add_link = reverse('add_popup',kwargs={'app_label':model._meta.app_label,'model':model._meta.object_name.lower()}) + +def autoselect_fields_check_can_add(form,model,user): + """ check the form's fields for any autoselect fields and enable their widgets with + sign add links if permissions allow""" + for name,form_field in form.declared_fields.iteritems(): + if isinstance(form_field,(AutoCompleteSelectMultipleField,AutoCompleteSelectField)): + db_field = model._meta.get_field_by_name(name)[0] + form_field.check_can_add(user,db_field.rel.to) + diff --git a/ajax_select/iconic.css b/ajax_select/iconic.css index c08a208834..3c96488ded 100644 --- a/ajax_select/iconic.css +++ b/ajax_select/iconic.css @@ -21,3 +21,28 @@ cursor: pointer; } +input.ac_loading { + background: #FFF url('../images/loading-indicator.gif') no-repeat; + background-position: right; +} + + +/* change the X to an image */ +.results_on_deck .iconic, .results_on_deck .iconic:hover { + float: left; + background: url(../shared/images/Trashcan.gif) no-repeat; + color: transparent; + border: 0; +} + +/* specific to a site I worked on. the formatted results were tables. I sized them and floated them left, next to the icon */ +.results_on_deck div table { + float: left; + width: 300px; + border: 0; +} +/* and each div in the result clears to start a new row */ +.results_on_deck > div { + clear: both; +} + diff --git a/ajax_select/js/ajax_select.js b/ajax_select/js/ajax_select.js new file mode 100644 index 0000000000..759e19ec64 --- /dev/null +++ b/ajax_select/js/ajax_select.js @@ -0,0 +1,9 @@ + +/* requires RelatedObjects.js */ + +function didAddPopup(win,newId,newRepr) { + var name = windowname_to_id(win.name); + $("#"+name).trigger('didAddPopup',[html_unescape(newId),html_unescape(newRepr)]); + win.close(); +} + diff --git a/ajax_select/loading-indicator.gif b/ajax_select/loading-indicator.gif new file mode 100644 index 0000000000..085ccaecaf Binary files /dev/null and b/ajax_select/loading-indicator.gif differ diff --git a/ajax_select/models.py b/ajax_select/models.py new file mode 100644 index 0000000000..f30eb69f5a --- /dev/null +++ b/ajax_select/models.py @@ -0,0 +1 @@ +# blank file so django recognizes the app \ No newline at end of file diff --git a/ajax_select/templates/autocomplete.html b/ajax_select/templates/autocomplete.html index 2d1eac61b0..2f87f58996 100644 --- a/ajax_select/templates/autocomplete.html +++ b/ajax_select/templates/autocomplete.html @@ -1,23 +1,18 @@ {% load i18n %} - -{% block help_text %}

{% if help_text %}{{ help_text }}{% else %}{% trans 'Enter text to search.' %}{% endif %}

{% endblock %} - + +{% block help %}{# {% if help_text %}

{{ help_text }}

{% endif %} #}{% endblock %} diff --git a/ajax_select/templates/autocompleteselect.html b/ajax_select/templates/autocompleteselect.html index c20eb1d198..90c02daa48 100644 --- a/ajax_select/templates/autocompleteselect.html +++ b/ajax_select/templates/autocompleteselect.html @@ -1,28 +1,57 @@ {% load i18n %} - -{% block help %}

{% trans 'Enter text to search.' %}{{help_text}}

{% endblock %} + +{% if add_link %} + Add Another +{% endif %} +{% block help %}{# {% if help_text %}

{{help_text}}

{% endif %} #}{% endblock %} +
{{current_result|safe}}
+ \ No newline at end of file +{% block extra_script %}{% endblock %} +{% endblock %}}); + + diff --git a/ajax_select/templates/autocompleteselectmultiple.html b/ajax_select/templates/autocompleteselectmultiple.html index 37f25566b0..a89191719c 100644 --- a/ajax_select/templates/autocompleteselectmultiple.html +++ b/ajax_select/templates/autocompleteselectmultiple.html @@ -1,52 +1,60 @@ {% load i18n %} - -{% block help %}

{% trans 'Enter text to search.' %}{{help_text}}

{% endblock %} + +{% if add_link %} + Add Another +{% endif %} +{% block help %}{# {% if help_text %}

{{help_text}}

{% endif %} #}{% endblock %}

\ No newline at end of file diff --git a/ajax_select/urls.py b/ajax_select/urls.py index 9b28453a54..5bfd619ad7 100644 --- a/ajax_select/urls.py +++ b/ajax_select/urls.py @@ -1,10 +1,15 @@ -from django.conf.urls.defaults import * +from django.conf.urls.defaults import patterns, url + urlpatterns = patterns('', url(r'^ajax_lookup/(?P[-\w]+)$', 'ajax_select.views.ajax_lookup', name = 'ajax_lookup' ), + url(r'^add_popup/(?P\w+)/(?P\w+)$', + 'ajax_select.views.add_popup', + name = 'add_popup' + ) ) diff --git a/ajax_select/views.py b/ajax_select/views.py index e0f44a75cb..69be044a12 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -1,7 +1,9 @@ -from django.http import HttpResponse -from django.conf import settings from ajax_select import get_lookup +from django.contrib.admin import site +from django.db import models +from django.http import HttpResponse + def ajax_lookup(request,channel): """ this view supplies results for both foreign keys and many to many fields """ @@ -27,7 +29,24 @@ def ajax_lookup(request,channel): results = [] for item in instances: - results.append(u"%s|%s|%s\n" % (item.pk,lookup_channel.format_item(item),lookup_channel.format_result(item))) + itemf = lookup_channel.format_item(item) + itemf = itemf.replace("\n","").replace("|","¦") + resultf = lookup_channel.format_result(item) + resultf = resultf.replace("\n","").replace("|","¦") + results.append( "|".join((unicode(item.pk),itemf,resultf)) ) return HttpResponse("\n".join(results)) +def add_popup(request,app_label,model): + """ present an admin site add view, hijacking the result if its the dismissAddAnotherPopup js and returning didAddPopup """ + themodel = models.get_model(app_label, model) + admin = site._registry[themodel] + + admin.admin_site.root_path = "/ajax_select/" # warning: your URL should be configured here. I should be able to auto-figure this out but ... + + response = admin.add_view(request,request.path) + if request.method == 'POST': + if response.content.startswith('