├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── better_filter_widget ├── __init__.py ├── models.py ├── static │ ├── css │ │ └── better-filter-widget.css │ └── js │ │ └── better-filter-widget.js └── widgets.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Exotic Objects 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include better_filter_widget * 4 | global-exclude *pyc 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-better-filter-widget 2 | =========================== 3 | 4 | A better filter widget for foreign key relationships that also works on mobile devices. This was initially developed as a drop-in replacement for admin forms. It will work in a normal form but you'll have to include some css/js dependencies. I'll write about how to do that soon. 5 | 6 | 7 | 8 | # About 9 | Django's [horizontal and vertical filter widget](http://i.imgur.com/RBgrm.png) is ugly, confusing to new users and completely broken on mobile devices. **django-better-filter-widget** is not magic and does not do real-time lookups like **django-selectable**. It's simply a nicer UI for filtering a list of things that, most importantly, actual works on mobile devices. 10 | 11 | 12 | # Installation 13 | 14 | Note: This project is brand spanking new and is still being tested. It was developed using django 1.6. It was tested in modern desktop and mobile browsers. 15 | 16 | **Install with pip** 17 | 18 | `pip install django-better-filter-widget` 19 | 20 | **settings.py** 21 | 22 | Add 'better_filter_widget' to INSTALLED_APPS in your settings.py 23 | 24 | **admin.py** 25 | 26 | Specify BetterFilterWidget as the widget for your field: 27 | 28 | from django import forms 29 | from django.contrib import admin 30 | from better_filter_widget import BetterFilterWidget 31 | 32 | class MyModelForm(forms.ModelForm): 33 | 34 | class Meta(object): 35 | model = MyModel 36 | widgets = { 37 | 'my_field': BetterFilterWidget(), 38 | } 39 | 40 | 41 | class MyModelAdmin(admin.ModelAdmin): 42 | 43 | form = MyModelForm 44 | 45 | admin.site.register(MyModel, MyModelAdmin) 46 | 47 | 48 | # License 49 | 50 | The MIT License (MIT) 51 | 52 | Copyright (c) 2014 Exotic Objects 53 | 54 | Permission is hereby granted, free of charge, to any person obtaining a copy 55 | of this software and associated documentation files (the "Software"), to deal 56 | in the Software without restriction, including without limitation the rights 57 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 58 | copies of the Software, and to permit persons to whom the Software is 59 | furnished to do so, subject to the following conditions: 60 | 61 | The above copyright notice and this permission notice shall be included in all 62 | copies or substantial portions of the Software. 63 | 64 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 65 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 66 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 67 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 68 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 69 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 70 | SOFTWARE. 71 | 72 | -------------------------------------------------------------------------------- /better_filter_widget/__init__.py: -------------------------------------------------------------------------------- 1 | from .widgets import BetterFilterWidget -------------------------------------------------------------------------------- /better_filter_widget/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExoticObjects/django-better-filter-widget/a9f81df0493c1ba1422fd6f8552e3be227f69e14/better_filter_widget/models.py -------------------------------------------------------------------------------- /better_filter_widget/static/css/better-filter-widget.css: -------------------------------------------------------------------------------- 1 | .bfw { font-size: 14px; float: left; } 2 | .bfw * { box-sizing: border-box; } 3 | .bfw .title { float: left; width: 220px; font-size: 1.1em; color: #888; font-weight: bold; text-transform: capitalize; } 4 | .bfw input.item-filter { display:block; width: 200px; font-size: 1.5em; height: 40px; padding: 0 10px; background: #eee; border-radius: 0; margin-bottom: 0; } 5 | .bfw .item-list { overflow: auto; float: left; width: 200px; height: 185px; font-size: 1.5em; border: 1px solid #ccc; } 6 | .bfw .selected-items { position: relative; top: -40px; height: 225px;} 7 | .bfw .item-list { float: left; font-size: 1.5em; margin-right: 20px;} 8 | .bfw .item-list .item { display: block; padding: 5px 10px; border-bottom: 1px solid #ddd; cursor: pointer; } 9 | .bfw .item-list .item.selected { display: none; } 10 | .bfw .item-list.available-items .item:active { background: lightgreen; } 11 | .bfw .item-list .glyphicon { float: right; font-size: .9em; line-height: 1.5em; } 12 | .bfw .action-icon { float: right; border-radius: 30px; display: inline-block; width: 20px; height: 20px; color: white; text-align: center; } 13 | .bfw .action-icon-plus { color: lightgreen; } 14 | .bfw .action-icon-minus { color: pink; } 15 | .bfw .item-list.selected-items { height: auto; overflow: visible; } 16 | .bfw .item-list.selected-items .item:active { background: pink; color: white; } 17 | .bf-toast { position: fixed; z-index:10000; top: 5%; left: 45%; text-align: center; background: rgba(0,0,0,.8); border: 1px solid #888; color: white; padding: 1em; border-radius: 3px; display: none; } -------------------------------------------------------------------------------- /better_filter_widget/static/js/better-filter-widget.js: -------------------------------------------------------------------------------- 1 | function BetterFilterWidget(field_name){ 2 | 3 | function updateSelectedDisplay(){ 4 | // BFWTimer.start(arguments.callee.name); 5 | selected_items.html(''); 6 | orig_input.find('option[selected]').each(function(i, opt){ 7 | opt = $(opt); 8 | var item = $('
-'+opt.text()+'
'); 9 | selected_items.append( item ); 10 | item.click(deselectItem); 11 | }); 12 | // BFWTimer.report(arguments.callee.name); 13 | } 14 | function deselectItem(){ 15 | var item = $(this); 16 | var id = item.data('id'); 17 | toast('Removed '+ item.text()); 18 | orig_input.find('option[value='+id+']').removeAttr('selected'); 19 | available_items.find('[data-id='+id+']').removeClass('selected'); 20 | item.remove(); 21 | updateSelectedDisplay(); 22 | } 23 | function selectItem(){ 24 | // BFWTimer.start(arguments.callee.name); 25 | var selected_item = $(this); 26 | var selected_id = selected_item.data('id'); 27 | selected_item.addClass('selected'); 28 | // if (had_focus) filter_input.focus(); // to bring keyboard back on mobile 29 | // select item in the hidden input 30 | orig_input.find('option[value='+selected_id+']').attr('selected','selected'); 31 | updateSelectedDisplay(); 32 | toast('Added '+ selected_item.text()); 33 | // BFWTimer.report(arguments.callee.name); 34 | } 35 | function toast(msg){ 36 | // Useful for mobile where UI feedback is not great 37 | $('#bf-toast').html(msg).finish().fadeIn(100).delay(2000).fadeOut(400); 38 | } 39 | $ = window.$ || django.jQuery; 40 | var bfw_wrap; 41 | var orig_input = $('#id_'+field_name); 42 | var available_items = $('
'); 43 | var selected_items = $('
'); 44 | var filter_input = $(''); 45 | var item_count = 0; 46 | // Layout 47 | // Hide built-in widget stuff 48 | orig_input.parent().children().hide(); 49 | // but show label 50 | $('label[for=id_'+field_name+']').show(); 51 | bfw_wrap = $('
'); 52 | orig_input.parent().append(bfw_wrap); 53 | bfw_wrap.addClass('bfw'); 54 | bfw_wrap.append( '
Available '+field_name+'
Selected '+field_name+'
'); 55 | bfw_wrap.append( filter_input ); 56 | bfw_wrap.append( available_items ); 57 | bfw_wrap.append( selected_items ); 58 | if ($('.bf-toast').length===0) 59 | $('body').append( '
' ); 60 | 61 | // Recreate available list 62 | orig_input.find('option').each(function(i, opt){ 63 | opt = $(opt); 64 | var item = $('
+'+opt.text()+'
'); 65 | if (opt.is(':selected')) item.addClass('selected'); 66 | available_items.append(item); 67 | item.click(selectItem); 68 | item_count++; 69 | }); 70 | 71 | // update filter 72 | var last_filter; 73 | var search_timeout; 74 | filter_input.keyup(function(){ 75 | var filter = filter_input.val().toLowerCase(); 76 | var sel = '.item'; 77 | var match_count = 0; 78 | 79 | if (filter==last_filter) return; 80 | 81 | // If this filter is just more specific version of last_filter, then we can just search what's visible. 82 | if (filter.indexOf(last_filter)===0) sel += ':visible'; 83 | last_filter = filter; 84 | var items = available_items.find(sel); 85 | 86 | // If more than 3000 items, wait longer before searching 87 | var delay = item_count > 3000 ? (filter.length==1 ? 300: 100) : 1; 88 | clearTimeout(search_timeout); 89 | search_timeout = setTimeout(function(){ 90 | // var t = window.performance.now(); 91 | items.each(function(i, opt){ 92 | opt = $(opt); 93 | var match = !opt.hasClass('selected') && opt.text().toLowerCase().indexOf( filter ) > -1; 94 | opt.attr('style','display:' + (match ? 'block' : 'none') ); 95 | match_count += match ? 1 : 0; 96 | // if (items.length==i+1) console.log('filter', filter, match_count, window.performance.now()-t ); 97 | }); 98 | }, delay); 99 | // ^-- first letter is most heavy search and user typically types more than 1 char before stopping. 100 | // So, increasing timeout here on first search which increases its chance of getting cancelled. 101 | }); 102 | 103 | // init 104 | updateSelectedDisplay(); 105 | } 106 | var BFWTimer = { 107 | _start: {}, 108 | start: function(name){ 109 | BFWTimer._start[name] = BFWTimer.precise_now(); 110 | }, 111 | report: function(name){ 112 | console.log(name + ": " + String( (BFWTimer.precise_now()-BFWTimer._start[name])/1000 ) +' secs'); 113 | }, 114 | precise_now: function(){ 115 | return window.performance.now ? window.performance.now() : (new Date()).getTime(); 116 | } 117 | }; -------------------------------------------------------------------------------- /better_filter_widget/widgets.py: -------------------------------------------------------------------------------- 1 | from django.utils.safestring import mark_safe 2 | from django.contrib.admin.widgets import FilteredSelectMultiple 3 | from django import forms 4 | 5 | class BetterFilterWidget(forms.SelectMultiple): 6 | class Media: 7 | extend = False 8 | css = { 9 | 'all': ('css/better-filter-widget.css',) 10 | } 11 | js = ('js/better-filter-widget.js', ) 12 | 13 | def render(self, name, value, attrs=None, choices=()): 14 | output = super(BetterFilterWidget, self).render(name, value, attrs, choices) 15 | output += u''' 16 | 23 | ''' % name 24 | return mark_safe(output) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name='django-better-filter-widget', 7 | version='0.4.2', 8 | author='Exotic Objects LLC', 9 | author_email='git@extc.co', 10 | license='MIT', 11 | url='https://github.com/ExoticObjects/django-better-filter-widget', 12 | include_package_data=True, 13 | long_description=open('README.md').read(), 14 | description='A better filter widget for foreign key relationships', 15 | packages=find_packages(), 16 | ) 17 | --------------------------------------------------------------------------------