Introduction to Comboboxes

This unit uses the lino_book.projects.combo project to show how to define dynamic lists of choices for a combobox field in a Lino application.

All related fields and choice lists are rendered in lino using a combo box. This component allows for a selection of a single choice from a dropdown. It also supports tying of a query to filter the choices.

Filtering results

Filtering is done via setting Model.quick_search_fields, or by overriding Model.quick_search_filter().

Context-sensitive Comboboxes

More examples and info can be seen here Chooser examples

The challenge is old: when you have two fields on a model (here country and city on the Person model) and you want the choices for one of these field (here city) to depend on the value of the other field (here country), then you need a hack because Django has no built-in API for producing this behaviour.

The Lino solution is you simply define the following function on the Person model:

@dd.chooser()
def city_choices(cls, country):
    return rt.models.combo.City.objects.filter(country=country)

Lino finds all choosers at startup that are decorated with the dd.chooser decorator (which turns it into a "chooser" object) and has a name of the form.

Lino matches it to the field using the fieldname in`<fieldname>_choices``. Lino matches the context related fields by positional argument named the same as other fields. ar is also a valid argument name for the chooser. The value will be the action request used in the API call. The request object can be used to

Then Lino then does the dirty work of generating appropriate JavaScript and HTML code and the views which respond to the AJAX calls.

Learning Comboboxes

When the model also defines a method create_<fieldname>_choice, then the chooser will become "learning": the ComboBox will be told to accept also new values, and the server will handle these cases accordingly.

In the example application you can create new cities by simply typing them into the combobox.

class Person(dd.Model):
    ...
    def create_city_choice(self, text):
        """
        Called when an unknown city name was given.
        Try to auto-create it.
        """
        if self.country is not None:
            return rt.models.countries.Place.lookup_or_create(
                'name', text, country=self.country)

        raise ValidationError(
            "Cannot auto-create city %r if country is empty", text)

Other files

Here is the models.py file :

from django.db import models
from django.core.exceptions import ValidationError
from lino.api import dd, rt

class Country(dd.Model):
    
    class Meta(object):
        verbose_name = "Country"
        verbose_name_plural = "Countries"

    name = models.CharField(max_length=30)

    def __str__(self):
        return self.name

class City(dd.Model):
    
    class Meta(object):
        verbose_name_plural = "Cities"
        
    country = dd.ForeignKey(Country, on_delete=models.CASCADE)
    name = models.CharField(max_length=30)

    def __str__(self):
        return self.name

class Person(dd.Model):
    
    class Meta(object):
        verbose_name_plural = "Persons"
        
    name = models.CharField(max_length=100)
    birthdate = models.DateField(null=True, blank=True)
    country = dd.ForeignKey(Country, on_delete=models.SET_NULL, null=True)
    city = dd.ForeignKey(City, on_delete=models.SET_NULL, null=True)

    @dd.chooser()
    def city_choices(cls, country):
        return City.objects.filter(country=country)

    def create_city_choice(self, text):
        """
        Called when an unknown city name was given.
        """
        if self.country is None:
            raise ValidationError(
                "Cannot auto-create city %r if country is empty", text)
        return City.lookup_or_create(
            'name', text, country=self.country)

    def __str__(self):
        return self.name
    

Here are the other files used in this unit.

The desktop.py file specifies a table for every model:

from lino.api import dd
from .models import Person, City, Country

class Persons(dd.Table):
    model = Person
    detail_layout = dd.DetailLayout("""
    name
    country
    city
    """, window_size=(50, 'auto'))

    insert_layout = """
    name
    country
    city
    """


class Cities(dd.Table):
    model = City

class Countries(dd.Table):
    model = Country

The __init__.py file specifies how the tables are organized in the main menu:

from lino.api import ad, _


class Plugin(ad.Plugin):
    verbose_name = _("Combo")

    def setup_main_menu(self, site, profile, m):
        m = m.add_menu(self.app_label, self.verbose_name)
        m.add_action('combo.Persons')
        m.add_action('combo.Countries')
        m.add_action('combo.Cities')

    

Here is the project's settings.py file :

from lino.projects.std.settings import *
SITE = Site(globals(), 'lino_book.projects.combo')
SITE.demo_fixtures = ['demo']

DEBUG = True

And finally the fixtures/demo.py file contains the data we use to fill our database:

from lino.api import rt


def objects():
    Country = rt.models.combo.Country
    City = rt.models.combo.City
    
    be = Country(name="Belgium")
    yield be
    ee = Country(name="Estonia")
    yield ee
    
    yield City(name="Eupen", country=be)
    yield City(name="Brussels", country=be)
    yield City(name="Gent", country=be)
    yield City(name="Raeren", country=be)
    yield City(name="Namur", country=be)
    
    yield City(name="Tallinn", country=ee)
    yield City(name="Tartu", country=ee)
    yield City(name="Vigala", country=ee)

Exercise

The files we are going to use in this tutorial are already on your hard disk in the lino_book.projects.combo package.

Start your development server and your browser, and have a look at the application:

$ go combo
$ python manage.py runserver

Explore the application and try to extend it: change things in the code and see what happens.

Discussion

This is inspired by Vitor Freitas' blog post How to Implement Dependent/Chained Dropdown List with Django.

Doctests

The remaining samples are here in order to test the project.

>>> from lino import startup
>>> startup('lino_book.projects.combo.settings')
>>> from lino.api.doctest import *
>>> rt.show('combo.Cities')
==== ========= ==========
 ID   Country   name
---- --------- ----------
 1    Belgium   Eupen
 2    Belgium   Brussels
 3    Belgium   Gent
 4    Belgium   Raeren
 5    Belgium   Namur
 6    Estonia   Tallinn
 7    Estonia   Tartu
 8    Estonia   Vigala
==== ========= ==========