Imported Upstream version 1.2.5
SVN-Git Migration
8 years ago
14 | 14 | [Note: screen shots are from the older version. Styling has changed slightly] |
15 | 15 | |
16 | 16 | 1. The user types a search term into the text field |
17 | 2. An ajax request is sent to the server. | |
18 | 3. The dropdown menu is populated with results. | |
17 | 2. An ajax request is sent to the server. | |
18 | 3. The dropdown menu is populated with results. | |
19 | 19 | 4. User selects by clicking or using arrow keys |
20 | 20 | 5. Selected result displays in the "deck" area directly below the input field. |
21 | 21 | 6. User can click trashcan icon to remove a selected item |
46 | 46 | `easy_install django-ajax-selects` |
47 | 47 | or |
48 | 48 | download or checkout the distribution |
49 | or | |
49 | or | |
50 | 50 | install using buildout by adding `django-ajax-selects` to your `eggs` |
51 | 51 | |
52 | 52 | on fedora: |
156 | 156 | |
157 | 157 | + channel_name : {'model': 'app.modelname', 'search_field': 'name_of_field_to_search' } |
158 | 158 | > This will create a channel automatically |
159 | ||
159 | ||
160 | 160 | chanel_name : ( 'app.lookups', 'YourLookup' ) |
161 | 161 | This points to a custom Lookup channel name YourLookup in app/lookups.py |
162 | 162 | |
163 | 163 | AJAX_LOOKUP_CHANNELS = { |
164 | 164 | # channel : dict with settings to create a channel |
165 | 165 | 'person' : {'model':'example.person', 'search_field':'name'}, |
166 | ||
166 | ||
167 | 167 | # channel: ( module.where_lookup_is, ClassNameOfLookup ) |
168 | 168 | 'song' : ('example.lookups', 'SongLookup'), |
169 | 169 | } |
170 | ||
170 | ||
171 | 171 | #### AJAX_SELECT_BOOTSTRAP |
172 | 172 | |
173 | 173 | Sets if it should automatically include jQuery/jQueryUI/theme. On large formsets this will cause it to check each time but it will only jQuery the first time. |
185 | 185 | |
186 | 186 | This controls if and how these: |
187 | 187 | |
188 | ajax_select/static/js/ajax_select.js | |
189 | ajax_select/static/css/ajax_select.css | |
188 | ajax_select/static/js/ajax_select.js | |
189 | ajax_select/static/css/ajax_select.css | |
190 | 190 | |
191 | 191 | are included inline in the html with each form field. |
192 | 192 | |
270 | 270 | |
271 | 271 | The model class this channel searches |
272 | 272 | |
273 | ###### plugin_options [property, default={}] | |
274 | ||
275 | Set any options for the jQuery plugin. This includes: | |
276 | ||
277 | + minLength | |
278 | + autoFocus | |
279 | + disabled | |
280 | + position | |
281 | + source - setting this would overide the normal ajax URL. could be used to add URL query params | |
282 | ||
283 | See http://docs.jquery.com/UI/Autocomplete#options | |
284 | ||
285 | The field or widget may also specify plugin_options that will overwrite those specified by the channel. | |
286 | ||
273 | 287 | ###### min_length [property, default=1] |
288 | ||
289 | This is a jQuery plugin option. It is preferred to set this in the plugin_options dict, but this older style attribute will still be honored. | |
274 | 290 | |
275 | 291 | Minimum query length to return a result. Large datasets can choke if they search too often with small queries. |
276 | 292 | Better to demand at least 2 or 3 characters. |
280 | 296 | |
281 | 297 | Name of the field for the query to search with icontains. This is used only in the default get_query implementation. |
282 | 298 | Usually better to just implement your own get_query |
283 | ||
299 | ||
284 | 300 | ###### get_query(self,q,request) |
285 | 301 | |
286 | 302 | return a query set searching for the query string q, ordering as appropriate. |
342 | 358 | + *fieldlist*: a dict of {fieldname : channel_name, ... } |
343 | 359 | + *superclass*: [default ModelForm] Substitute a different superclass for the constructed Form class. |
344 | 360 | + *show_help_text*: [default False] |
345 | Leave blank [False] if using this form in a standard Admin. | |
361 | Leave blank [False] if using this form in a standard Admin. | |
346 | 362 | Set it True for InlineAdmin classes or if making a form for use outside of the Admin. |
347 | 363 | |
348 | 364 | ######Example |
350 | 366 | from ajax_select import make_ajax_form |
351 | 367 | from ajax_select.admin import AjaxSelectAdmin |
352 | 368 | from yourapp.models import YourModel |
353 | ||
369 | ||
354 | 370 | class YourModelAdmin(AjaxSelectAdmin): |
355 | 371 | # create an ajax form class using the factory function |
356 | 372 | # model,fieldlist, [form superclass] |
357 | 373 | form = make_ajax_form(Label,{'owner':'person'}) |
358 | ||
374 | ||
359 | 375 | admin.site.register(YourModel,YourModelAdmin) |
360 | 376 | |
361 | 377 | You may use AjaxSelectAdmin as a mixin class and multiple inherit if you have another Admin class that you would like to use. You may also just add the hook into your own Admin class: |
371 | 387 | forms.py |
372 | 388 | -------- |
373 | 389 | |
374 | subclass ModelForm just as usual. You may add ajax fields using the helper or directly. | |
390 | subclass ModelForm just as usual. You may add ajax fields using the helper or directly. | |
375 | 391 | |
376 | 392 | #### make_ajax_field(model,model_fieldname,channel,show_help_text = False,**kwargs) |
377 | 393 | |
389 | 405 | # do not show any help at all |
390 | 406 | help_text=None |
391 | 407 | |
408 | plugin_options - directly specify jQuery plugin options. see Lookup plugin_options above | |
409 | ||
410 | ||
392 | 411 | #####Example |
393 | 412 | |
394 | 413 | from ajax_select import make_ajax_field |
404 | 423 | |
405 | 424 | |
406 | 425 | from ajax_select.fields import AutoCompleteSelectField |
407 | ||
426 | ||
408 | 427 | class ReleaseForm(ModelForm): |
409 | ||
428 | ||
410 | 429 | group = AutoCompleteSelectField('group', required=False, help_text=None) |
411 | 430 | |
431 | #### Setting plugin options | |
432 | ||
433 | from ajax_select.fields import AutoCompleteSelectField | |
434 | ||
435 | class ReleaseForm(ModelForm): | |
436 | ||
437 | group = AutoCompleteSelectField('group', required=False, help_text=None,plugin_options = {'autoFocus':True,'minLength':4}) | |
412 | 438 | |
413 | 439 | #### Using ajax selects in a `FormSet` |
414 | 440 | |
438 | 464 | templates/ |
439 | 465 | ---------- |
440 | 466 | |
441 | Each form field widget is rendered using a template. You may write a custom template per channel and extend the base template in order to implement these blocks: | |
467 | Each form field widget is rendered using a template. You may write a custom template per channel and extend the base template in order to implement these blocks: | |
442 | 468 | |
443 | 469 | {% block extra_script %}{% endblock %} |
444 | 470 | {% block help %}{% endblock %} |
522 | 548 | }); |
523 | 549 | {% endblock %} |
524 | 550 | |
525 | There is no remove as there is no kill/delete button in a simple auto-complete. | |
551 | There is no remove as there is no kill/delete button in a simple auto-complete. | |
526 | 552 | The user may clear the text themselves but there is no javascript involved. Its just a text field. |
527 | 553 | |
528 | 554 |
0 | 0 | """JQuery-Ajax Autocomplete fields for Django Forms""" |
1 | __version__ = "1.2.4" | |
1 | __version__ = "1.2.5" | |
2 | 2 | __author__ = "crucialfelix" |
3 | 3 | __contact__ = "crucialfelix@gmail.com" |
4 | __homepage__ = "http://code.google.com/p/django-ajax-selects/" | |
4 | __homepage__ = "https://github.com/crucialfelix/django-ajax-selects/" | |
5 | 5 | |
6 | 6 | from django.conf import settings |
7 | 7 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied |
15 | 15 | class LookupChannel(object): |
16 | 16 | |
17 | 17 | """Subclass this, setting model and overiding the methods below to taste""" |
18 | ||
18 | ||
19 | 19 | model = None |
20 | plugin_options = {} | |
20 | 21 | min_length = 1 |
21 | ||
22 | ||
22 | 23 | def get_query(self,q,request): |
23 | """ return a query set searching for the query string q | |
24 | """ return a query set searching for the query string q | |
24 | 25 | either implement this method yourself or set the search_field |
25 | 26 | in the LookupChannel class definition |
26 | 27 | """ |
50 | 51 | return [things[aid] for aid in ids if things.has_key(aid)] |
51 | 52 | |
52 | 53 | def can_add(self,user,argmodel): |
53 | """ Check if the user has permission to add | |
54 | """ Check if the user has permission to add | |
54 | 55 | one of these models. This enables the green popup + |
55 | 56 | Default is the standard django permission check |
56 | 57 | """ |
70 | 71 | |
71 | 72 | def make_ajax_form(model,fieldlist,superclass=ModelForm,show_help_text=False,**kwargs): |
72 | 73 | """ Creates a ModelForm subclass with autocomplete fields |
73 | ||
74 | ||
74 | 75 | usage: |
75 | 76 | class YourModelAdmin(Admin): |
76 | 77 | ... |
77 | 78 | form = make_ajax_form(YourModel,{'contacts':'contact','author':'contact'}) |
78 | 79 | |
79 | where | |
80 | where | |
80 | 81 | 'contacts' is a ManyToManyField specifying to use the lookup channel 'contact' |
81 | 82 | and |
82 | 83 | 'author' is a ForeignKeyField specifying here to also use the lookup channel 'contact' |
84 | 85 | # will support previous arg name for several versions before deprecating |
85 | 86 | if 'show_m2m_help' in kwargs: |
86 | 87 | show_help_text = kwargs.pop('show_m2m_help') |
87 | ||
88 | ||
88 | 89 | class TheForm(superclass): |
89 | ||
90 | ||
90 | 91 | class Meta: |
91 | 92 | pass |
92 | 93 | setattr(Meta, 'model', model) |
105 | 106 | """ Makes a single autocomplete field for use in a Form |
106 | 107 | |
107 | 108 | optional args: |
108 | help_text - default is the model db field's help_text. | |
109 | help_text - default is the model db field's help_text. | |
109 | 110 | None will disable all help text |
110 | 111 | label - default is the model db field's verbose name |
111 | 112 | required - default is the model db field's (not) blank |
112 | ||
113 | show_help_text - | |
113 | ||
114 | show_help_text - | |
114 | 115 | Django will show help text below the widget, but not for ManyToMany inside of admin inlines |
115 | 116 | This setting will show the help text inside the widget itself. |
116 | 117 | """ |
123 | 124 | AutoCompleteSelectField |
124 | 125 | |
125 | 126 | field = model._meta.get_field(model_fieldname) |
126 | if kwargs.has_key('label'): | |
127 | label = kwargs.pop('label') | |
128 | else: | |
129 | label = _(capfirst(unicode(field.verbose_name))) | |
130 | ||
131 | if kwargs.has_key('help_text'): | |
132 | help_text = kwargs.pop('help_text') | |
133 | else: | |
134 | if isinstance(field.help_text,basestring) and field.help_text: | |
135 | help_text = _(field.help_text) | |
136 | else: | |
137 | help_text = field.help_text | |
138 | if kwargs.has_key('required'): | |
139 | required = kwargs.pop('required') | |
140 | else: | |
141 | required = not field.blank | |
127 | if not kwargs.has_key('label'): | |
128 | kwargs['label'] = _(capfirst(unicode(field.verbose_name))) | |
129 | ||
130 | if not kwargs.has_key('help_text') and field.help_text: | |
131 | kwargs['help_text'] = field.help_text | |
132 | if not kwargs.has_key('required'): | |
133 | kwargs['required'] = not field.blank | |
142 | 134 | |
143 | 135 | kwargs['show_help_text'] = show_help_text |
144 | 136 | if isinstance(field,ManyToManyField): |
145 | 137 | f = AutoCompleteSelectMultipleField( |
146 | 138 | channel, |
147 | required=required, | |
148 | help_text=help_text, | |
149 | label=label, | |
150 | 139 | **kwargs |
151 | 140 | ) |
152 | 141 | elif isinstance(field,ForeignKey): |
153 | 142 | f = AutoCompleteSelectField( |
154 | 143 | channel, |
155 | required=required, | |
156 | help_text=help_text, | |
157 | label=label, | |
158 | 144 | **kwargs |
159 | 145 | ) |
160 | 146 | else: |
161 | 147 | f = AutoCompleteField( |
162 | 148 | channel, |
163 | required=required, | |
164 | help_text=help_text, | |
165 | label=label, | |
166 | 149 | **kwargs |
167 | 150 | ) |
168 | 151 | return f |
196 | 179 | lambda self,obj: unicode(obj))) |
197 | 180 | if not hasattr(lookup_class,'format_item_display'): |
198 | 181 | setattr(lookup_class, 'format_item_display', |
199 | getattr(lookup_class,'format_item', | |
182 | getattr(lookup_class,'format_item', | |
200 | 183 | lambda self,obj: unicode(obj))) |
201 | 184 | if not hasattr(lookup_class,'get_result'): |
202 | 185 | setattr(lookup_class, 'get_result', |
203 | getattr(lookup_class,'format_result', | |
186 | getattr(lookup_class,'format_result', | |
204 | 187 | lambda self,obj: unicode(obj))) |
205 | 188 | |
206 | 189 | return lookup_class() |
209 | 192 | def make_channel(app_model,arg_search_field): |
210 | 193 | """ used in get_lookup |
211 | 194 | app_model : app_name.model_name |
212 | search_field : the field to search against and to display in search results | |
195 | search_field : the field to search against and to display in search results | |
213 | 196 | """ |
214 | 197 | from django.db import models |
215 | 198 | app_label, model_name = app_model.split(".") |
216 | 199 | themodel = models.get_model(app_label, model_name) |
217 | ||
200 | ||
218 | 201 | class MadeLookupChannel(LookupChannel): |
219 | ||
202 | ||
220 | 203 | model = themodel |
221 | 204 | search_field = arg_search_field |
222 | ||
205 | ||
223 | 206 | return MadeLookupChannel() |
224 | 207 | |
225 | 208 |
9 | 9 | |
10 | 10 | def get_form(self, request, obj=None, **kwargs): |
11 | 11 | form = super(AjaxSelectAdmin,self).get_form(request,obj,**kwargs) |
12 | ||
12 | ||
13 | 13 | autoselect_fields_check_can_add(form,self.model,request.user) |
14 | 14 | return form |
15 | 15 |
8 | 8 | from django.template.loader import render_to_string |
9 | 9 | from django.utils.safestring import mark_safe |
10 | 10 | from django.utils.translation import ugettext as _ |
11 | from django.conf import settings | |
12 | from django.utils import simplejson | |
11 | 13 | import os |
12 | 14 | |
13 | 15 | |
16 | as_default_help = u'Enter text to search.' | |
14 | 17 | |
15 | 18 | #################################################################################### |
16 | 19 | |
22 | 25 | |
23 | 26 | def __init__(self, |
24 | 27 | channel, |
25 | help_text='', | |
26 | show_help_text=False, | |
27 | *args, **kw): | |
28 | super(forms.widgets.TextInput, self).__init__(*args, **kw) | |
28 | help_text = u'', | |
29 | show_help_text = True, | |
30 | plugin_options = {}, | |
31 | *args, **kwargs): | |
32 | self.plugin_options = plugin_options | |
33 | super(forms.widgets.TextInput, self).__init__(*args, **kwargs) | |
29 | 34 | self.channel = channel |
30 | 35 | self.help_text = help_text |
31 | 36 | self.show_help_text = show_help_text |
36 | 41 | final_attrs = self.build_attrs(attrs) |
37 | 42 | self.html_id = final_attrs.pop('id', name) |
38 | 43 | |
44 | current_repr = '' | |
45 | initial = None | |
39 | 46 | lookup = get_lookup(self.channel) |
40 | 47 | if value: |
41 | 48 | objs = lookup.get_objects([value]) |
43 | 50 | obj = objs[0] |
44 | 51 | except IndexError: |
45 | 52 | raise Exception("%s cannot find object:%s" % (lookup, value)) |
46 | display = lookup.format_item_display(obj) | |
47 | current_repr = mark_safe( """new Array("%s",%s)""" % (escapejs(display),obj.pk) ) | |
48 | else: | |
49 | current_repr = 'null' | |
53 | current_repr = lookup.format_item_display(obj) | |
54 | initial = [current_repr,obj.pk] | |
50 | 55 | |
51 | 56 | if self.show_help_text: |
52 | 57 | help_text = self.help_text |
53 | 58 | else: |
54 | help_text = '' | |
59 | help_text = u'' | |
55 | 60 | |
56 | 61 | context = { |
57 | 'name': name, | |
58 | 'html_id' : self.html_id, | |
59 | 'min_length': getattr(lookup, 'min_length', 1), | |
60 | 'lookup_url': reverse('ajax_lookup',kwargs={'channel':self.channel}), | |
61 | 'current_id': value, | |
62 | 'current_repr': current_repr, | |
63 | 'help_text': help_text, | |
64 | 'extra_attrs': mark_safe(flatatt(final_attrs)), | |
65 | 'func_slug': self.html_id.replace("-",""), | |
66 | 'add_link' : self.add_link, | |
67 | } | |
62 | 'name': name, | |
63 | 'html_id': self.html_id, | |
64 | 'current_id': value, | |
65 | 'current_repr': current_repr, | |
66 | 'help_text': help_text, | |
67 | 'extra_attrs': mark_safe(flatatt(final_attrs)), | |
68 | 'func_slug': self.html_id.replace("-",""), | |
69 | 'add_link': self.add_link, | |
70 | } | |
71 | context.update(plugin_options(lookup,self.channel,self.plugin_options,initial)) | |
68 | 72 | context.update(bootstrap()) |
69 | ||
73 | ||
70 | 74 | return mark_safe(render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'),context)) |
71 | 75 | |
72 | 76 | def value_from_datadict(self, data, files, name): |
91 | 95 | def __init__(self, channel, *args, **kwargs): |
92 | 96 | self.channel = channel |
93 | 97 | widget = kwargs.get("widget", False) |
94 | ||
98 | ||
95 | 99 | if not widget or not isinstance(widget, AutoCompleteSelectWidget): |
96 | help_text = kwargs.get('help_text',_('Enter text to search.')) | |
97 | show_help_text = kwargs.pop('show_help_text',False) | |
98 | kwargs["widget"] = AutoCompleteSelectWidget(channel=channel,help_text=help_text,show_help_text=show_help_text) | |
100 | widget_kwargs = dict( | |
101 | channel = channel, | |
102 | help_text = kwargs.get('help_text',_(as_default_help)), | |
103 | show_help_text = kwargs.pop('show_help_text',True), | |
104 | plugin_options = kwargs.pop('plugin_options',{}) | |
105 | ) | |
106 | kwargs["widget"] = AutoCompleteSelectWidget(**widget_kwargs) | |
99 | 107 | super(AutoCompleteSelectField, self).__init__(max_length=255,*args, **kwargs) |
100 | 108 | |
101 | 109 | def clean(self, value): |
129 | 137 | def __init__(self, |
130 | 138 | channel, |
131 | 139 | help_text='', |
132 | show_help_text=False, | |
140 | show_help_text=True, | |
141 | plugin_options = {}, | |
133 | 142 | *args, **kwargs): |
134 | 143 | super(AutoCompleteSelectMultipleWidget, self).__init__(*args, **kwargs) |
135 | 144 | self.channel = channel |
136 | ||
137 | self.help_text = help_text or _('Enter text to search.') | |
145 | ||
146 | self.help_text = help_text | |
138 | 147 | self.show_help_text = show_help_text |
148 | self.plugin_options = plugin_options | |
139 | 149 | |
140 | 150 | def render(self, name, value, attrs=None): |
141 | 151 | |
156 | 166 | objects = lookup.get_objects(value) |
157 | 167 | |
158 | 168 | # text repr of currently selected items |
159 | current_repr_json = [] | |
169 | initial = [] | |
160 | 170 | for obj in objects: |
161 | 171 | display = lookup.format_item_display(obj) |
162 | current_repr_json.append( """new Array("%s",%s)""" % (escapejs(display),obj.pk) ) | |
163 | current_reprs = mark_safe("new Array(%s)" % ",".join(current_repr_json)) | |
164 | ||
172 | initial.append([display,obj.pk]) | |
173 | ||
165 | 174 | if self.show_help_text: |
166 | 175 | help_text = self.help_text |
167 | 176 | else: |
168 | help_text = '' | |
169 | ||
177 | help_text = u'' | |
178 | ||
170 | 179 | context = { |
171 | 180 | 'name':name, |
172 | 181 | 'html_id':self.html_id, |
173 | 'min_length': getattr(lookup, 'min_length', 1), | |
174 | 'lookup_url':reverse('ajax_lookup',kwargs={'channel':self.channel}), | |
175 | 182 | 'current':value, |
176 | 183 | 'current_ids':current_ids, |
177 | 'current_reprs': current_reprs, | |
184 | 'current_reprs':mark_safe(simplejson.dumps(initial)), | |
178 | 185 | 'help_text':help_text, |
179 | 186 | 'extra_attrs': mark_safe(flatatt(final_attrs)), |
180 | 187 | 'func_slug': self.html_id.replace("-",""), |
181 | 188 | 'add_link' : self.add_link, |
182 | 189 | } |
190 | context.update(plugin_options(lookup,self.channel,self.plugin_options,initial)) | |
183 | 191 | context.update(bootstrap()) |
184 | 192 | |
185 | 193 | return mark_safe(render_to_string(('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'),context)) |
202 | 210 | def __init__(self, channel, *args, **kwargs): |
203 | 211 | self.channel = channel |
204 | 212 | |
205 | as_default_help = u'Enter text to search.' | |
206 | 213 | help_text = kwargs.get('help_text') |
214 | show_help_text = kwargs.pop('show_help_text',False) | |
215 | ||
207 | 216 | if not (help_text is None): |
208 | try: | |
209 | en_help = help_text.translate('en') | |
210 | except AttributeError: | |
211 | pass | |
212 | else: | |
213 | # monkey patch the django default help text to the ajax selects default help text | |
214 | django_default_help = u'Hold down "Control", or "Command" on a Mac, to select more than one.' | |
215 | if django_default_help in en_help: | |
216 | en_help = en_help.replace(django_default_help,'').strip() | |
217 | # '' will cause translation to fail | |
218 | # should be u'' | |
219 | if type(help_text) == str: | |
220 | help_text = unicode(help_text) | |
221 | # django admin appends "Hold down "Control",..." to the help text | |
222 | # regardless of which widget is used. so even when you specify an explicit help text it appends this other default text onto the end. | |
223 | # This monkey patches the help text to remove that | |
224 | if help_text != u'': | |
225 | if type(help_text) != unicode: | |
226 | # ideally this could check request.LANGUAGE_CODE | |
227 | translated = help_text.translate(settings.LANGUAGE_CODE) | |
228 | else: | |
229 | translated = help_text | |
230 | django_default_help = _(u'Hold down "Control", or "Command" on a Mac, to select more than one.').translate(settings.LANGUAGE_CODE) | |
231 | if django_default_help in translated: | |
232 | cleaned_help = translated.replace(django_default_help,'').strip() | |
217 | 233 | # probably will not show up in translations |
218 | if en_help: | |
219 | help_text = _(en_help) | |
234 | if cleaned_help: | |
235 | help_text = cleaned_help | |
220 | 236 | else: |
221 | help_text = _(as_default_help) | |
237 | help_text = u"" | |
238 | show_help_text = False | |
222 | 239 | else: |
223 | 240 | help_text = _(as_default_help) |
224 | 241 | |
225 | # admin will also show help text, so by default do not show it in widget | |
226 | # if using in a normal form then set to True so the widget shows help | |
227 | show_help_text = kwargs.pop('show_help_text',False) | |
228 | ||
229 | kwargs['widget'] = AutoCompleteSelectMultipleWidget(channel=channel,help_text=help_text,show_help_text=show_help_text) | |
242 | # django admin will also show help text outside of the display | |
243 | # area of the widget. this results in duplicated help. | |
244 | # it should just let the widget do the rendering | |
245 | # so by default do not show it in widget | |
246 | # if using in a normal form then set to True when creating the field | |
247 | widget_kwargs = { | |
248 | 'channel': channel, | |
249 | 'help_text': help_text, | |
250 | 'show_help_text': show_help_text, | |
251 | 'plugin_options': kwargs.pop('plugin_options',{}) | |
252 | } | |
253 | kwargs['widget'] = AutoCompleteSelectMultipleWidget(**widget_kwargs) | |
230 | 254 | kwargs['help_text'] = help_text |
231 | ||
255 | ||
232 | 256 | super(AutoCompleteSelectMultipleField, self).__init__(*args, **kwargs) |
233 | 257 | |
234 | 258 | def clean(self, value): |
255 | 279 | def __init__(self, channel, *args, **kwargs): |
256 | 280 | self.channel = channel |
257 | 281 | self.help_text = kwargs.pop('help_text', '') |
258 | self.show_help_text = kwargs.pop('show_help_text',False) | |
259 | ||
282 | self.show_help_text = kwargs.pop('show_help_text',True) | |
283 | self.plugin_options = kwargs.pop('plugin_options',{}) | |
284 | ||
260 | 285 | super(AutoCompleteWidget, self).__init__(*args, **kwargs) |
261 | 286 | |
262 | 287 | def render(self, name, value, attrs=None): |
263 | 288 | |
264 | value = value or '' | |
265 | ||
289 | initial = value or '' | |
290 | ||
266 | 291 | final_attrs = self.build_attrs(attrs) |
267 | 292 | self.html_id = final_attrs.pop('id', name) |
268 | 293 | |
270 | 295 | if self.show_help_text: |
271 | 296 | help_text = self.help_text |
272 | 297 | else: |
273 | help_text = '' | |
298 | help_text = u'' | |
299 | ||
274 | 300 | context = { |
275 | 'current_repr': value, | |
276 | 'current_id': value, | |
301 | 'current_repr': initial, | |
302 | 'current_id': initial, | |
277 | 303 | 'help_text': help_text, |
278 | 304 | 'html_id': self.html_id, |
279 | 'min_length': getattr(lookup, 'min_length', 1), | |
280 | 'lookup_url': reverse('ajax_lookup', args=[self.channel]), | |
281 | 305 | 'name': name, |
282 | 'extra_attrs':mark_safe(flatatt(final_attrs)), | |
306 | 'extra_attrs': mark_safe(flatatt(final_attrs)), | |
283 | 307 | 'func_slug': self.html_id.replace("-",""), |
284 | 308 | } |
309 | context.update(plugin_options(lookup,self.channel,self.plugin_options,initial)) | |
285 | 310 | context.update(bootstrap()) |
286 | 311 | |
287 | 312 | templates = ('autocomplete_%s.html' % self.channel, |
299 | 324 | def __init__(self, channel, *args, **kwargs): |
300 | 325 | self.channel = channel |
301 | 326 | |
302 | widget_kwargs = dict(help_text=kwargs.get('help_text', _('Enter text to search.'))) | |
303 | widget_kwargs['show_help_text'] = kwargs.pop('show_help_text',False) | |
327 | widget_kwargs = dict( | |
328 | help_text = kwargs.get('help_text', _(as_default_help)), | |
329 | show_help_text = kwargs.pop('show_help_text',True), | |
330 | plugin_options = kwargs.pop('plugin_options',{}) | |
331 | ) | |
304 | 332 | if 'attrs' in kwargs: |
305 | 333 | widget_kwargs['attrs'] = kwargs.pop('attrs') |
306 | 334 | widget = AutoCompleteWidget(channel,**widget_kwargs) |
314 | 342 | #################################################################################### |
315 | 343 | |
316 | 344 | def _check_can_add(self,user,model): |
317 | """ check if the user can add the model, deferring first to | |
345 | """ check if the user can add the model, deferring first to | |
318 | 346 | the channel if it implements can_add() |
319 | 347 | else using django's default perm check. |
320 | if it can add, then enable the widget to show the + link | |
348 | if it can add, then enable the widget to show the + link | |
321 | 349 | """ |
322 | 350 | lookup = get_lookup(self.channel) |
323 | 351 | if hasattr(lookup,'can_add'): |
337 | 365 | db_field = model._meta.get_field_by_name(name)[0] |
338 | 366 | form_field.check_can_add(user,db_field.rel.to) |
339 | 367 | |
368 | def plugin_options(channel,channel_name,widget_plugin_options,initial): | |
369 | """ Make a JSON dumped dict of all options for the jquery ui plugin itself """ | |
370 | po = {} | |
371 | if initial: | |
372 | po['initial'] = initial | |
373 | po.update(getattr(channel,'plugin_options',{})) | |
374 | po.update(widget_plugin_options) | |
375 | if not po.get('min_length'): | |
376 | # backward compatibility: honor the channel's min_length attribute | |
377 | # will deprecate that some day and prefer to use plugin_options | |
378 | po['min_length'] = getattr(channel, 'min_length', 1) | |
379 | if not po.get('source'): | |
380 | po['source'] = reverse('ajax_lookup',kwargs={'channel':channel_name}) | |
381 | return { | |
382 | 'plugin_options': mark_safe(simplejson.dumps(po)), | |
383 | # continue to support any custom templates that still expect these | |
384 | 'lookup_url': po['source'], | |
385 | 'min_length': po['min_length'] | |
386 | } | |
387 | ||
340 | 388 | |
341 | 389 | def bootstrap(): |
342 | 390 | b = {} |
0 | ||
1 | 0 | .results_on_deck .ui-icon-trash { |
2 | 1 | float: left; |
3 | 2 | cursor: pointer; |
31 | 30 | */ |
32 | 31 | margin: 0; |
33 | 32 | padding: 0; |
33 | position: absolute; | |
34 | 34 | } |
35 | 35 | ul.ui-autocomplete li { |
36 | 36 | list-style-type: none; |
21 | 21 | return this.each(function() { |
22 | 22 | var id = this.id; |
23 | 23 | var $this = $(this); |
24 | ||
24 | ||
25 | 25 | var $text = $("#"+id+"_text"); |
26 | 26 | var $deck = $("#"+id+"_on_deck"); |
27 | 27 | |
56 | 56 | $this.val(''); |
57 | 57 | $deck.children().fadeOut(1.0).remove(); |
58 | 58 | } |
59 | ||
59 | ||
60 | 60 | options.select = receiveResult; |
61 | 61 | $text.autocomplete(options); |
62 | 62 | $text.autocompletehtml(); |
63 | ||
63 | ||
64 | 64 | if (options.initial) { |
65 | 65 | its = options.initial; |
66 | 66 | addKiller(its[0], its[1]); |
84 | 84 | function receiveResult(event, ui) { |
85 | 85 | pk = ui.item.pk; |
86 | 86 | prev = $this.val(); |
87 | ||
87 | ||
88 | 88 | if (prev.indexOf("|"+pk+"|") == -1) { |
89 | 89 | $this.val((prev ? prev : "|") + pk + "|"); |
90 | 90 | addKiller(ui.item.repr, pk); |
114 | 114 | options.select = receiveResult; |
115 | 115 | $text.autocomplete(options); |
116 | 116 | $text.autocompletehtml(); |
117 | ||
117 | ||
118 | 118 | if (options.initial) { |
119 | 119 | $.each(options.initial, function(i, its) { |
120 | 120 | addKiller(its[0], its[1]); |
127 | 127 | }); |
128 | 128 | }); |
129 | 129 | }; |
130 | })(jQuery); | |
131 | 130 | |
132 | function addAutoComplete(prefix_id, callback/*(html_id)*/) { | |
133 | /* detects inline forms and converts the html_id if needed */ | |
134 | var prefix = 0; | |
135 | var html_id = prefix_id; | |
136 | if(html_id.indexOf("__prefix__") != -1) { | |
137 | // Some dirty loop to find the appropriate element to apply the callback to | |
138 | while (jQuery('#'+html_id).length) { | |
139 | html_id = prefix_id.replace(/__prefix__/, prefix++); | |
140 | } | |
141 | html_id = prefix_id.replace(/__prefix__/, prefix-2); | |
142 | // Ignore the first call to this function, the one that is triggered when | |
143 | // page is loaded just because the "empty" form is there. | |
144 | if (jQuery("#"+html_id+", #"+html_id+"_text").hasClass("ui-autocomplete-input")) | |
145 | return; | |
131 | window.addAutoComplete = function (prefix_id, callback ) { /*(html_id)*/ | |
132 | /* detects inline forms and converts the html_id if needed */ | |
133 | var prefix = 0; | |
134 | var html_id = prefix_id; | |
135 | if(html_id.indexOf("__prefix__") != -1) { | |
136 | // Some dirty loop to find the appropriate element to apply the callback to | |
137 | while ($('#'+html_id).length) { | |
138 | html_id = prefix_id.replace(/__prefix__/, prefix++); | |
146 | 139 | } |
147 | callback(html_id); | |
140 | html_id = prefix_id.replace(/__prefix__/, prefix-2); | |
141 | // Ignore the first call to this function, the one that is triggered when | |
142 | // page is loaded just because the "empty" form is there. | |
143 | if ($("#"+html_id+", #"+html_id+"_text").hasClass("ui-autocomplete-input")) | |
144 | return; | |
148 | 145 | } |
146 | callback(html_id); | |
147 | } | |
149 | 148 | /* the popup handler |
150 | 149 | requires RelatedObjects.js which is part of the django admin js |
151 | 150 | so if using outside of the admin then you would need to include that manually */ |
152 | function didAddPopup(win,newId,newRepr) { | |
153 | var name = windowname_to_id(win.name); | |
154 | jQuery("#"+name).trigger('didAddPopup',[html_unescape(newId),html_unescape(newRepr)]); | |
155 | win.close(); | |
156 | } | |
157 | }⏎ | |
151 | window.didAddPopup = function (win,newId,newRepr) { | |
152 | var name = windowname_to_id(win.name); | |
153 | $("#"+name).trigger('didAddPopup',[html_unescape(newId),html_unescape(newRepr)]); | |
154 | win.close(); | |
155 | } | |
156 | ||
157 | })(jQuery); | |
158 | ||
159 | } |
7 | 7 | } |
8 | 8 | if(typeof jQuery == 'undefined' || (typeof jQuery.ui == 'undefined')) { |
9 | 9 | document.write('<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jqueryui/1.8.16/jquery-ui.min.js"><\/script>'); |
10 | document.write('<link type="text/css" rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.7.2/themes/smoothness/jquery-ui.css" />'); | |
10 | document.write('<link type="text/css" rel="stylesheet" href="//ajax.googleapis.com/ajax/libs/jqueryui/1.7.2/themes/smoothness/jquery-ui.css" />'); | |
11 | 11 | } |
12 | 12 | //]]> |
13 | 13 | </script> |
3 | 3 | jQuery(document).ready(function($){ |
4 | 4 | {% block script %} |
5 | 5 | addAutoComplete("{{html_id}}", function(html_id) { |
6 | $("#"+html_id).autocomplete({ | |
7 | minLength: {{min_length}}, | |
8 | source: '{{lookup_url}}', | |
9 | initial: '{{current_repr|escapejs}}', | |
10 | select: function(event, ui) { | |
11 | $("#"+html_id).val(ui.item.value); | |
12 | $("#"+html_id).trigger("added"); | |
6 | var opts = {{ plugin_options }}; | |
7 | opts['select'] = | |
8 | function(event, ui) { | |
9 | $("#"+html_id).val(ui.item.value).trigger("added"); | |
13 | 10 | return false; |
14 | 11 | } |
15 | }).autocompletehtml(); | |
12 | $("#"+html_id).autocomplete(opts).autocompletehtml(); | |
16 | 13 | }); |
17 | 14 | {% block extra_script %}{% endblock %} |
18 | 15 | {% endblock %} |
20 | 17 | //]]> |
21 | 18 | </script> |
22 | 19 | {% block help %}{% if help_text %}<p class="help">{{ help_text }}</p>{% endif %}{% endblock %} |
23 | {{ inline }}⏎ | |
20 | {{ inline }} |
4 | 4 | <a href="{{ add_link }}" class="add-another addlink" id="add_{{ html_id }}" onclick="return showAddAnotherPopup(this);"> add</a> |
5 | 5 | {% endif %} |
6 | 6 | <input type="hidden" name="{{name}}" id="{{html_id}}" value="{{current_id}}" /> |
7 | <div id="{{html_id}}_on_deck" class="results_on_deck"><div>{{current_result|safe}}</div></div> | |
7 | <div id="{{html_id}}_on_deck" class="results_on_deck"><div>{{current_repr|safe}}</div></div> | |
8 | 8 | <script type="text/javascript">//<![CDATA[ |
9 | 9 | jQuery(document).ready(function($){ |
10 | 10 | addAutoComplete("{{html_id}}", function(html_id) { |
11 | $("#"+html_id).autocompleteselect({ | |
12 | minLength: {{min_length}}, | |
13 | source: '{{lookup_url}}', | |
14 | initial: {{current_repr}} | |
15 | }); | |
11 | $("#"+html_id).autocompleteselect({{ plugin_options }}); | |
16 | 12 | }); |
17 | 13 | {% block extra_script %}{% endblock %} |
18 | 14 | });//]]> |
19 | 15 | </script> |
20 | 16 | {% block help %}{% if help_text %}<p class="help">{{help_text}}</p>{% endif %}{% endblock %} |
21 | 17 | </span> |
22 | {{ inline }}⏎ | |
18 | {{ inline }} |
7 | 7 | <script type="text/javascript">//<![CDATA[ |
8 | 8 | jQuery(document).ready(function($){ |
9 | 9 | addAutoComplete("{{html_id}}", function(html_id) { |
10 | $("#"+html_id).autocompleteselectmultiple({ | |
11 | minLength: {{min_length}}, | |
12 | source: '{{lookup_url}}', | |
13 | initial: {{current_reprs}} | |
14 | }); | |
10 | $("#"+html_id).autocompleteselectmultiple({{plugin_options}}); | |
15 | 11 | }); |
16 | 12 | {% block extra_script %}{% endblock %} |
17 | 13 | }); |
19 | 15 | </script> |
20 | 16 | {# django admin adds the help text. this is for use outside of the admin #} |
21 | 17 | {% block help %}{% if help_text %}<p class="help">{{help_text}}</p>{% endif %}{% endblock %} |
22 | {{ inline }}⏎ | |
18 | {{ inline }} |
48 | 48 | make sure that you have added ajax_select.urls to your urls.py: |
49 | 49 | (r'^ajax_select/', include('ajax_select.urls')), |
50 | 50 | this URL is expected in the code below, so it won't work under a different path |
51 | ||
51 | ||
52 | 52 | this view then hijacks the result that the django admin returns |
53 | and instead of calling django's dismissAddAnontherPopup(win,newId,newRepr) | |
53 | and instead of calling django's dismissAddAnontherPopup(win,newId,newRepr) | |
54 | 54 | it calls didAddPopup(win,newId,newRepr) which was added inline with bootstrap.html |
55 | 55 | """ |
56 | 56 | themodel = models.get_model(app_label, model) |
57 | 57 | admin = site._registry[themodel] |
58 | 58 | |
59 | 59 | # TODO : should detect where we really are |
60 | admin.admin_site.root_path = "/ajax_select/" | |
60 | admin.admin_site.root_path = "/ajax_select/" | |
61 | 61 | |
62 | 62 | response = admin.add_view(request,request.path) |
63 | 63 | if request.method == 'POST': |
9 | 9 | class PersonAdmin(admin.ModelAdmin): |
10 | 10 | |
11 | 11 | pass |
12 | ||
12 | ||
13 | 13 | admin.site.register(Person,PersonAdmin) |
14 | 14 | |
15 | 15 | |
16 | 16 | |
17 | 17 | class LabelAdmin(AjaxSelectAdmin): |
18 | """ to get + popup buttons, subclass AjaxSelectAdmin | |
19 | ||
18 | """ to get + popup buttons, subclass AjaxSelectAdmin | |
19 | ||
20 | 20 | multi-inheritance is also possible if you have an Admin class you want to inherit from: |
21 | ||
21 | ||
22 | 22 | class PersonAdmin(YourAdminSuperclass,AjaxSelectAdmin): |
23 | ||
23 | ||
24 | 24 | this acts as a MixIn to add the relevant methods |
25 | 25 | """ |
26 | 26 | # this shows a ForeignKey field |
28 | 28 | # create an ajax form class using the factory function |
29 | 29 | # model,fieldlist, [form superclass] |
30 | 30 | form = make_ajax_form(Label,{'owner':'person'}) |
31 | ||
31 | ||
32 | 32 | admin.site.register(Label,LabelAdmin) |
33 | 33 | |
34 | 34 | |
65 | 65 | model = Book |
66 | 66 | form = make_ajax_form(Book,{'about_group':'group','mentions_persons':'person'},show_help_text=True) |
67 | 67 | extra = 2 |
68 | ||
68 | ||
69 | 69 | # + check add still not working |
70 | 70 | # no + appearing |
71 | 71 | # def get_formset(self, request, obj=None, **kwargs): |
78 | 78 | inlines = [ |
79 | 79 | BookInline, |
80 | 80 | ] |
81 | ||
81 | ||
82 | 82 | admin.site.register(Author, AuthorAdmin) |
83 | 83 | |
84 | 84 |
11 | 11 | model = Release |
12 | 12 | |
13 | 13 | # args: this model, fieldname on this model, lookup_channel_name |
14 | group = make_ajax_field(Release,'group','group') | |
15 | ||
16 | # no help text at all | |
14 | group = make_ajax_field(Release,'group','group',show_help_text=True) | |
15 | ||
17 | 16 | label = make_ajax_field(Release,'label','label',help_text="Search for label by name") |
18 | ||
17 | ||
19 | 18 | # any extra kwargs are passed onto the field, so you may pass a custom help_text here |
20 | songs = make_ajax_field(Release,'songs','song',help_text=u"Search for song by title") | |
19 | #songs = make_ajax_field(Release,'songs','song',help_text=u"Search for song by title") | |
20 | ||
21 | # testing bug with no help text supplied | |
22 | songs = make_ajax_field(Release,'songs','song',help_text="",show_help_text=True) | |
21 | 23 | |
22 | 24 | # these are from a fixed array defined in lookups.py |
23 | 25 | title = make_ajax_field(Release,'title','cliche',help_text=u"Autocomplete will suggest clichés about cats.") |
1 | 1 | # creates a virtualenv and installs a django here |
2 | 2 | virtualenv AJAXSELECTS |
3 | 3 | source AJAXSELECTS/bin/activate |
4 | easy_install django | |
4 | pip install django | |
5 | 5 | |
6 | 6 | # put ajax selects in the path |
7 | 7 | ln -s ../ajax_select/ ./ajax_select |
0 | ||
1 | 0 | |
2 | 1 | from django.db.models import Q |
3 | 2 | from django.utils.html import escape |
35 | 34 | |
36 | 35 | def get_result(self,obj): |
37 | 36 | return unicode(obj) |
38 | ||
37 | ||
39 | 38 | def format_match(self,obj): |
40 | 39 | return self.format_item_display(obj) |
41 | 40 | |
59 | 58 | |
60 | 59 | def get_result(self,obj): |
61 | 60 | return unicode(obj.title) |
62 | ||
61 | ||
63 | 62 | def format_match(self,obj): |
64 | 63 | return self.format_item_display(obj) |
65 | 64 |
0 | # -*- coding: utf8 -*- | |
0 | 1 | |
1 | 2 | from django.db import models |
2 | 3 | |
53 | 54 | title = models.CharField(max_length=100) |
54 | 55 | catalog = models.CharField(blank=True, max_length=100) |
55 | 56 | |
56 | group = models.ForeignKey(Group,blank=True,null=True) | |
57 | group = models.ForeignKey(Group,blank=True,null=True,verbose_name=u"Русский текст") | |
57 | 58 | label = models.ForeignKey(Label,blank=False,null=False) |
58 | 59 | songs = models.ManyToManyField(Song,blank=True) |
59 | 60 | |
70 | 71 | title = models.CharField(max_length=100) |
71 | 72 | about_group = models.ForeignKey(Group) |
72 | 73 | mentions_persons = models.ManyToManyField(Person) |
73 | ||
74 |
8 | 8 | 'django.contrib.sites', |
9 | 9 | 'django.contrib.admin', |
10 | 10 | 'example', |
11 | ||
11 | ||
12 | 12 | #################################### |
13 | 13 | 'ajax_select', # <- add the app |
14 | 14 | #################################### |
22 | 22 | AJAX_LOOKUP_CHANNELS = { |
23 | 23 | # simplest way, automatically construct a search channel by passing a dictionary |
24 | 24 | 'label' : {'model':'example.label', 'search_field':'name'}, |
25 | ||
25 | ||
26 | 26 | # Custom channels are specified with a tuple |
27 | 27 | # channel: ( module.where_lookup_is, ClassNameOfLookup ) |
28 | 28 | 'person' : ('example.lookups', 'PersonLookup'), |
99 | 99 | # http://www.i18nguy.com/unicode/language-identifiers.html |
100 | 100 | LANGUAGE_CODE = 'en-us' |
101 | 101 | |
102 | # for testing translations | |
103 | # LANGUAGE_CODE = 'de-at' | |
104 | ||
102 | 105 | SITE_ID = 1 |
103 | 106 | |
104 | 107 | # If you set this to False, Django will make some optimizations so as not |
105 | 108 | # to load the internationalization machinery. |
106 | USE_I18N = False | |
109 | USE_I18N = True | |
110 | ||
107 | 111 | |
108 | 112 | # Absolute path to the directory that holds media. |
109 | 113 | # Example: "/home/media/media.lawrence.com/" |
117 | 121 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a |
118 | 122 | # trailing slash. |
119 | 123 | # Examples: "http://foo.com/media/", "/media/". |
120 | ADMIN_MEDIA_PREFIX = '/media/' | |
124 | STATIC_URL = '/media/' | |
121 | 125 | |
122 | 126 | # Make this unique, and don't share it with nobody. |
123 | 127 | SECRET_KEY = '=9fhrrwrazha6r_m)r#+in*@n@i322ubzy4r+zz%wz$+y(=qpb' |
124 | 128 | |
125 | # List of callables that know how to import templates from various sources. | |
126 | TEMPLATE_LOADERS = ( | |
127 | 'django.template.loaders.filesystem.load_template_source', | |
128 | 'django.template.loaders.app_directories.load_template_source', | |
129 | # 'django.template.loaders.eggs.load_template_source', | |
130 | ) | |
131 | ||
132 | MIDDLEWARE_CLASSES = ( | |
133 | 'django.middleware.common.CommonMiddleware', | |
134 | 'django.contrib.sessions.middleware.SessionMiddleware', | |
135 | 'django.contrib.auth.middleware.AuthenticationMiddleware', | |
136 | ) | |
137 | 129 | |
138 | 130 | ROOT_URLCONF = 'example.urls' |
139 | 131 |
14 | 14 | label="Favorite Cliché", |
15 | 15 | attrs={'size': 100} |
16 | 16 | ) |
17 | ||
17 | ||
18 | 18 | def search_form(request): |
19 | ||
19 | ||
20 | 20 | dd = {} |
21 | 21 | if 'q' in request.GET: |
22 | 22 | dd['entered'] = request.GET.get('q') |
24 | 24 | form = SearchForm(initial=initial) |
25 | 25 | dd['form'] = form |
26 | 26 | return render_to_response('search_form.html',dd,context_instance=RequestContext(request)) |
27 | ⏎ |
0 | 0 | #!/usr/bin/env python |
1 | 1 | |
2 | from distutils.core import setup | |
2 | try: | |
3 | from setuptools import setup | |
4 | except ImportError: | |
5 | from ez_setup import use_setuptools | |
6 | use_setuptools() | |
7 | from setuptools import setup | |
3 | 8 | |
4 | 9 | setup(name='django-ajax-selects', |
5 | version='1.2.4', | |
10 | version='1.2.5', | |
6 | 11 | description='jQuery-powered auto-complete fields for editing ForeignKey, ManyToManyField and CharField', |
7 | 12 | author='crucialfelix', |
8 | 13 | author_email='crucialfelix@gmail.com', |
9 | url='http://code.google.com/p/django-ajax-selects/', | |
14 | url='https://github.com/crucialfelix/django-ajax-selects/', | |
10 | 15 | packages=['ajax_select', ], |
11 | 16 | package_data={'ajax_select': ['*.py','*.txt','static/css/*','static/images/*','static/js/*','templates/*.html', 'templates/ajax_select/*.html']}, |
12 | 17 | classifiers = [ |
25 | 30 | Enables editing of `ForeignKey`, `ManyToManyField` and `CharField` using jQuery UI AutoComplete. |
26 | 31 | |
27 | 32 | 1. The user types a search term into the text field |
28 | 2. An ajax request is sent to the server. | |
29 | 3. The dropdown menu is populated with results. | |
33 | 2. An ajax request is sent to the server. | |
34 | 3. The dropdown menu is populated with results. | |
30 | 35 | 4. User selects by clicking or using arrow keys |
31 | 36 | 5. Selected result displays in the "deck" area directly below the input field. |
32 | 37 | 6. User can click trashcan icon to remove a selected item |