/django-tomselect

Autocomplete widgets and views using TomSelect

Primary LanguagePythonMIT LicenseMIT

Tom Select for Django

Django autocomplete form fields and views using Tom Select.

This package provides a Django autocomplete field and view that can be used together to provide a user interface for selecting a model instance from a database table.

The package is adapted from the fantastic work of Philip Becker in mizdb-tomselect, with the goal of a more generalized solution for Django autocompletion and a focus on use of django templates, translations, customization, explicitness, and minimal use of custom JavaScript


Examples

The following examples show the Tom Select fields in action. The first two examples show the Tom Select fields with single selection, with and without the tabular display.

Tom Select With Single Select

Tom Select Tabular With Single Select

The next two examples show the Tom Select fields with multiple selection, with and without the tabular display.

Tom Select With Multiple Select

Tom Select Tabular With Multiple Select

Installation

Install:

pip install -U django-tomselect

Usage

Add to installed apps:

INSTALLED_APPS = [
    # ...
    "django_tomselect"
]

Add an autocomplete view for each model that you want to use with django-tomselect:

# views.py
from django_tomselect.views import AutocompleteView
from .models import City, Person


class CityAutocompleteView(AutocompleteView):
    model = City

class PersonAutocompleteView(AutocompleteView):
    model = Person
    search_lookups = [
        "full_name__icontains",
    ]

Configure endpoints for autocomplete requests:

# urls.py
from django.urls import path

from .views import CityAutocompleteView, PersonAutocompleteView

urlpatterns = [
    # ...
    path("autocomplete-person/", PersonAutocompleteView.as_view(), name="person_autocomplete"),
    path("autocomplete-city/", CityAutocompleteView.as_view(), name="city_autocomplete"),
]

To make the field display tabular results, create a PluginDropdownHeader configuration object:

from django_tomselect.configs import PluginDropdownHeader

person_plugin_dropdown_header = PluginDropdownHeader(
    # The column header label for the valueField column
    value_field_label="ID",
    # The column header label for the labelField column
    label_field_label="Full Name",
    extra_columns={
        "first_name": "First Name",
        "last_name": "Last Name",
    },
)

Use the fields in a form.

from django import forms

from django_tomselect.forms import TomSelectField
from .models import City, Person


class MyForm(forms.Form):
    city = TomSelectField(
        url="my_autocomplete_view",
        value_field="id",
        label_field="name",
    )

    # Display results in a table, with additional columns for fields
    # "first_name" and "last_name":
    person = TomSelectField(
        url="my_autocomplete_view",
        value_field="id",
        label_field="full_name",
        plugin_dropdown_header=person_plugin_dropdown_header,
    )

NOTE: Make sure to include bootstrap 4 or 5, and the form media (or manually add the tomselect css and js files) in your template:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Django Tom Select Demo</title>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
    {{ form.media }}
</head>
<body>
<div class="container">
    <form>
        {% csrf_token %}
        {{ form.as_div }}
        <button type="submit" class="btn btn-success">Save</button>
    </form>
</div>
</body>
</html>

Form Fields

The form fields pass attributes necessary to make autocomplete requests to the HTML element via the dataset property. The Tom Select element is then initialized from the attributes in the dataset property.

TomSelectField & TomSelectMultipleField

Base autocomplete fields for ModelChoiceField and ModelMultipleChoiceField. The arguments of TomSelectField & TomSelectMultipleField are:

Argument Default value Type Description
model required Model the model class that provides the choices
url "autocomplete" str URL pattern name of the autocomplete view
value_field f"{model._meta.pk.name}" str model field that provides the value of an option
label_field getattr(model, "name_field", "name") str model field that provides the label of an option
create_field "" str model field name used to create new objects with (see below)
listview_url "" str URL name of the list view for this model (see below)
create_url "" str URL name of the create view for this model(see below)
update_url "" str URL name of the update view for each instance of this model(see below)
filter_by () tuple a 2-tuple defining an additional filter (see below)
bootstrap_version 5 int (4 or 5) the bootstrap version to use, either 4 or 5, overriding the default in django settings
general_config GeneralConfig() GeneralConfig A GeneralConfig object or None
plugin_checkbox_options PluginCheckboxOptions() PluginCheckboxOptions A PluginCheckboxOptions object or None
plugin_dropdown_input PluginDropdownInput() PluginDropdownInput A PluginDropdownInput object or None
plugin_clear_button PluginClearButton() PluginClearButton A PluginClearButton object or None
plugin_remove_button PluginRemoveButton() PluginRemoveButton A PluginRemoveButton object or None
plugin_dropdown_header PluginDropdownHeader() PluginDropdownHeader A PluginDropdownHeader object or None
plugin_dropdown_footer PluginDropdownFooter() PluginDropdownFooter A PluginDropdownFooter object or None

Configuration Objects

The TomSelect fields can be configured be passing in instances of the following classes from django_tomselect.configs:

Class Description
django_tomselect.configs.GeneralConfig Base class for all configuration objects.
django_tomselect.configs.PluginCheckboxOptions Configures Tom Select to display results with checkboxes.
django_tomselect.configs.PluginDropdownInput Configures the Tom Select dropdown to display an input field for searching and displaying selected results.
django_tomselect.configs.PluginClearButton Configures Tom Select to display a button to clear all selected values.
django_tomselect.configs.PluginRemoveButton Configures Tom Select to display a button to clear a single selected value.
django_tomselect.configs.PluginDropdownHeader Configures the Tom Select dropdown to display results in a table.
django_tomselect.configs.PluginDropdownFooter Configures the footer to be displayed in the Tom Select dropdown.

GeneralConfig

Available arguments:

Argument Default value Type Description
close_after_select None bool or None After a selection is made, the dropdown will remain open if in a multi-selection control or will close in a single-selection control. Setting to True will force the dropdown to close after selections are made. Setting to False will keep the dropdown open after selections are made.
hide_placeholder None bool or None If True, the placeholder will be hidden when the control has one or more items (selected options) and is not focused. This defaults to False when in a multi-selection control, and to True otherwise.
highlight True bool Toggles match highlighting within the dropdown menu when a search term is entered
load_throttle 300 int The number of milliseconds to wait before requesting options from the server or None. If None, throttling is disabled. Useful when loading options dynamically while the user types a search / filter expression.
loading_class "loading" str The class name added to the wrapper element while awaiting the fulfillment of load requests.
max_items 50 int The max number of items the user can select. A value of 1 makes the control mono-selection, None allows an unlimited number of items. TomSelectField is limited to a value of 1, regardless of the provided config value.
max_options 50 int or None The max number of options to display in the dropdown. Set to None for an unlimited number of options.
open_on_focus True bool Show the dropdown immediately when the control receives focus.
placeholder "Select a value" str or None The placeholder of the field. Defaults to input widget's placeholder, unless this one is specified.
preload "focus" bool or "focus" If True, the load function will be called upon control initialization (with an empty search). Alternatively it can be set to "focus" to call the load function when control receives focus.
create False bool Determines if the user is allowed to create new items that aren't in the initial list of options.
create_filter None str or None Specifies a RegExp or a string containing a regular expression that the current search filter must match to be allowed to be created. May also be a predicate function provided as a string that takes the filter text and returns whether it is allowed.
create_with_htmx False bool Reserved for future use.
minimum_query_length 2 int the minimum number of characters to enter before displaying results

PluginCheckboxOptions

Available arguments: None

PluginDropdownInput

Available arguments: None

PluginClearButton

Overridable template: django_tomselect/render/clear_button.html

Available arguments:

Argument Default value Description
title "Clear Selections" A string to use as the title of the clear button.
class_name "clear-button" A string to use as the class name of the clear button.

PluginRemoveButton

Available arguments:

Argument Default value Description
title "Remove this item" A string to use as the title of the remove button.
label "×" A string to use as the label of the remove button.
class_name "remove" A string to use as the class name of the clear button.

PluginDropdownHeader

Overridable template: django_tomselect/render/dropdown_header.html

Adding this configuration object displays the results in tabular form. A table header will be added to the dropdown. By default, the table contains two columns: one column for the choice value (commonly the "ID" of the option) and one column for the choice label (the human-readable part of the choice).

Available arguments:

Argument Default value Description
extra_columns {} a dict mapping for additional columns to be displayed, where the key is the model field name and the value is the column label
header_class "container-fluid bg-primary text-bg-primary pt-1 pb-1 mb-2 dropdown-header" the classes to use for the header container
title_row_class "row" the classes to use for the title row
label_class "form-label" the classes to use for the label column
value_field_label f"{value_field.title()}" table header for the value column
label_field_label f"{model._meta.verbose_name}" table header for the label column
show_value_field False show the value field column (typically id)

Adding more columns to the fields

To add more columns, pass a dictionary mapping field names to column labels as extra_columns to the PluginDropdownHeader's arguments.

from django import forms
from django_tomselect.configs import PluginDropdownHeader
from django_tomselect.forms import TomSelectField
from .models import Person


person_plugin_dropdown_header = PluginDropdownHeader(
    extra_columns={
        # field_name: column_label
        "first_name": "First Name",
        "last_name": "Last Name",
    },
)

class MyForm(forms.Form):
    person = TomSelectField(
        url="my_autocomplete_view",
        value_field="id",
        label_field="full_name",
        plugin_dropdown_header=person_plugin_dropdown_header,
    )

Important: that means that the result visible to Tom Select must have an attribute or property with that name or the field's contents will remain empty. The results for Tom Select are created by the view calling values() on the result queryset, so you must make sure that the attribute name is available on the view's root queryset as either a model field or as an annotation.

PluginDropdownFooter

Overridable template: django_tomselect/render/dropdown_footer.html

Adding this configuration object includes a footer below the results dropdown. By default, the footer contains a link to the list view of the model, if the listview_url argument is provided to the field.

Available arguments:

Argument Default value Description
extra_columns {} a dict mapping for additional columns to be displayed, where the key is the model field name and the value is the column label
header_class "container-fluid bg-primary text-bg-primary pt-1 pb-1 mb-2 dropdown-header" the classes to use for the header container
title_row_class "row" the classes to use for the title row
label_class "form-label" the classes to use for the label column
value_field_label f"{value_field.title()}" table header for the value column
label_field_label f"{model._meta.verbose_name}" table header for the label column
show_value_field False show the value field column (typically id)

Settings

Setting Default value Type Description
TOMSELECT_BOOTSTRAP_VERSION 5 int (4 or 5) The bootstrap version to use. Either 4 or 5. Defaults to 5. This sets the project-wide default for the bootstrap_version argument of the fields.

You can overwrite the default for a specific field by passing the bootstrap_version argument to the field.

TOMSELECT_PROXY_REQUEST "django_tomselect.utils.DefaultProxyRequest" DefaultProxyRequest or subclass Either a direct reference to a DefaultProxyRequest subclass or the path to the DefaultProxyRequest subclass to use. See below.
DJANGO_TOMSELECT_MINIFIED django_tomselect.app_settings.currently_in_production_mode() Boolean or callable Either a boolean or a callable that returns a boolean to determine whether to use minified static files.
DJANGO_TOMSELECT_GENERAL_CONFIG GeneralConfig() Either a GeneralConfig object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.
DJANGO_TOMSELECT_PLUGIN_CLEAR_BUTTON PluginClearButton() Either a PluginClearButton object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.
DJANGO_TOMSELECT_PLUGIN_REMOVE_BUTTON PluginRemoveButton() Either a PluginRemoveButton object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.
DJANGO_TOMSELECT_PLUGIN_DROPDOWN_INPUT PluginDropdownInput() Either a PluginDropdownInput object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.
DJANGO_TOMSELECT_PLUGIN_DROPDOWN_HEADER PluginDropdownHeader() Either a PluginDropdownHeader object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.
DJANGO_TOMSELECT_PLUGIN_DROPDOWN_FOOTER PluginDropdownFooter() Either a PluginDropdownFooter object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.
DJANGO_TOMSELECT_PLUGIN_CHECKBOX_OPTIONS PluginCheckboxOptions() Either a PluginCheckboxOptions object or None Sets the default for this configuration in all forms (can be overridden per-form). If not provided in settings, forms default to use the version provided in django_tomselect.configs.

Note: The DefaultProxyRequest class is used to obtain the model details for the autocomplete. In order to simplify the process of creating a custom autocomplete view, django-tomselect provides a DefaultProxyRequest class that can be used to obtain the model details from the queryset and the request. In most cases, you will not need to use this class directly.


Function & Features

Modifying the initial QuerySet

If you want to modify all autocomplete queries for a subclassed AutocompleteView, you can use super() with the get_queryset() method.

from django_tomselect.views import AutocompleteView


class MyAutocompleteView(AutocompleteView):
    model = MyModel
    search_lookups = [
        "name__icontains",
    ]
    
    def get_queryset(self):
        """Toy example of filtering all queries in this view to id values less than 10"""
        queryset = super().get_queryset()
        queryset.filter(id__lt=10)
        return queryset

Searching

The AutocompleteView filters the result QuerySet against search_lookups. The default value for the lookup is []. Overwrite the AutocompleteView.search method to modify the search process.

from django_tomselect.views import AutocompleteView


class MyAutocompleteView(AutocompleteView):
    model = MyModel
    search_lookups = [
        "name__icontains",
    ]
    
    def search(self, queryset, q):
        # Filter using your own queryset method:
        return queryset.search(q)

Option creation

Important: This is a work in progress. The API may change in the future.

To enable option creation in the dropdown, pass the URL name of the create view of the given model to the field. This will add an 'Add' option to the bottom of the dropdown.

List View link

The dropdown will include a link to the list view of the given model if you pass in the URL pattern name of the list view.

# urls.py
from django.urls import path
from django_tomselect.views import AutocompleteView
from django_tomselect.forms import TomSelectField
from .models import City
from .views import CityListView

urlpatterns = [
    # ...
    path("autocomplete/", AutocompleteView.as_view(), name="my_autocomplete_view"),
    path("city/list/", CityListView.as_view(), name="city_listview"),
]

# forms.py
city = TomSelectField(
    url="my_autocomplete_view",
    value_field="id",
    label_field="name",
    listview_url="city_listview",
)

Dependent Filtering

Important: This is a work in progress. The API may change in the future.

Use the filter_by argument to restrict the available options of one TomSelectField to the value selected in another form field. The parameter must be a 2-tuple: (field_this_field_is_dependent_on, django_field_lookup).

# models.py
from django import forms
from django.db import models
from django_tomselect.forms import TomSelectField


class Person(models.Model):
    name = models.CharField(max_length=50)
    city = models.ForeignKey("City", on_delete=models.SET_NULL, blank=True, null=True)


class City(models.Model):
    name = models.CharField(max_length=50)
    is_capitol = models.BooleanField(default=False)


# forms.py
class PersonsFromCapitolsForm(forms.Form):
    capitol = forms.ModelChoiceField(queryset=City.objects.filter(is_capitol=True))
    person = TomSelectField(
        url="my_autocomplete_view",
        value_field="id",
        label_field="name",
        filter_by=("capitol", "city_id"),
    )

In this example, the options for the Person QuerySet are dependent on the city_id for the currently selected capitol formfield value.
NOTE: When using filter_by, the declaring element now requires that the other field provides a value, since its choices are dependent on the other field. If the other field does not have a value, the search will not return any results.

Advanced Topics

Manually Initializing Tom Select Fields

If a form is added dynamically after the page loads (e.g.: with htmx), the new form fields will not be initialized as django-tomselect fields. In order to manually initialize them, dispatch a triggerTomSelect event, providing the id of the form field as a value in detail as follows.

<script>
  window.dispatchEvent(new CustomEvent('triggerTomSelect', {
    detail: {
        elemID: 'id_tomselect_tabular'
    }
  }));
</script>

Using Annotated QuerySets in Autocomplete view

If you want to use annotations in your QuerySet, you must overide your form's clean_fieldname method to remove the annotation before saving the form.

Assuming you have a model with a foreign key to a City model, and you want to annotate the City name onto the QuerySet in the AutoComplete view, you would do the following:

from django import forms
from django_tomselect.views import AutocompleteView


class MyAutocompleteView(AutocompleteView):
    model = MyModel
    search_lookups = [
        "name__icontains",
    ]
    
    def get_queryset(self):
        """Toy example of annotating the city name onto the queryset"""
        queryset = super().get_queryset()
        queryset = queryset.annotate(city_name=F("city__name"))
        return queryset

Then, in your ModelForm, you would override the clean_fieldname method to remove the annotation. Here is an example using TomSelectField for a single selection field, which should return a single model instance (or None):

from django import forms
from django_tomselect.forms import TomSelectField
from .models import City, Person


class MyForm(forms.ModelForm):
    city = TomSelectField(
        url="my_autocomplete_view",
        value_field="id",
        label_field="name",
    )

    def clean_city(self):
        city = self.cleaned_data.get("city")
        try:
            city = City.objects.get(pk=data.get("pkid"))
            return city
        except City.DoesNotExist:
            logger.error(f"Error cleaning city: {e}")
            return None

And an example using TomSelectMultipleField for a multi-selection field, which should return a QuerySet:

from django import forms
from django_tomselect.forms import TomSelectMultipleField
from .models import City, Person


class MyForm(forms.ModelForm):
    cities = TomSelectMultipleField(
        url="my_autocomplete_view",
        value_field="id",
        label_field="name",
    )

    def clean_cities(self):
        cities = self.cleaned_data.get("cities")
        try:
            city_id_list = list(cities.values_list("city_id", flat=True))
            cities_qs = City.objects.filter(id__in=city_id_list)
            return cities_qs
        except AttributeError as e:
            logger.error(f"Error cleaning cities: {e}")
            return City.objects.none()

Customizing Templates

All templates are located in django_tomselect/templates/django_tomselect/.

The base template for the widgets is select.html. It contains the HTML structure for the custom widget and form field.

Additionally, each of the Render Templates has its own template.

The templates are rendered with the following context:

Variable Description
ToDo ToDo

You can override templates by creating a template with the same name in your project's templates/django_tomselect/ directory.

Translations

There are a handful of strings in the default templates that are marked for translation.

Before a new language can be added, the LANGUAGES setting must be updated with the new language code.

LANGUAGES = (
    ('en', _('English')),
    ('es', _('Spanish')),
    ('de', _('Deutsch')),
)

To update translations, from the django_tomselect directory run python ../../manage.py maketmessages -a. This will update the .po files in django_tomselect/locale/ directory. Once translations files have been updated, compile them with python ../../manage.py compilemessages.

Development & Demo

python3 -m venv venv
source venv/bin/activate
make init

Then see the demo for a preview: python demo/manage.py runserver

Run tests with make test or make tox. To install required browsers for playwright: playwright install. See the makefile for other commands.