Django form with choices but also with freetext option?

Solution 1:

I would recommend a custom Widget approach, HTML5 allows you to have a free text input with a dropdown list which would work as a pick-one-or-write-other type of field, this is how I made it:

fields.py

from django import forms

class ListTextWidget(forms.TextInput):
    def __init__(self, data_list, name, *args, **kwargs):
        super(ListTextWidget, self).__init__(*args, **kwargs)
        self._name = name
        self._list = data_list
        self.attrs.update({'list':'list__%s' % self._name})

    def render(self, name, value, attrs=None, renderer=None):
        text_html = super(ListTextWidget, self).render(name, value, attrs=attrs)
        data_list = '<datalist id="list__%s">' % self._name
        for item in self._list:
            data_list += '<option value="%s">' % item
        data_list += '</datalist>'

        return (text_html + data_list)

forms.py

from django import forms
from myapp.fields import ListTextWidget

class FormForm(forms.Form):
   char_field_with_list = forms.CharField(required=True)

   def __init__(self, *args, **kwargs):
      _country_list = kwargs.pop('data_list', None)
      super(FormForm, self).__init__(*args, **kwargs)

    # the "name" parameter will allow you to use the same widget more than once in the same
    # form, not setting this parameter differently will cuse all inputs display the
    # same list.
       self.fields['char_field_with_list'].widget = ListTextWidget(data_list=_country_list, name='country-list')

views.py

from myapp.forms import FormForm

def country_form(request):
    # instead of hardcoding a list you could make a query of a model, as long as
    # it has a __str__() method you should be able to display it.
    country_list = ('Mexico', 'USA', 'China', 'France')
    form = FormForm(data_list=country_list)

    return render(request, 'my_app/country-form.html', {
        'form': form
    })

Solution 2:

I know I’m a bit late to the party but there is another solution which I have recently used.

I have used the Input widget of django-floppyforms with a datalist argument. This generates an HTML5 <datalist> element for which your browser automatically creates a list of suggestions (see also this SO answer).

Here’s what a model form could then simply look like:

class MyProjectForm(ModelForm):
    class Meta:
        model = MyProject
        fields = "__all__" 
        widgets = {
            'name': floppyforms.widgets.Input(datalist=_get_all_proj_names())
        }

Solution 3:

Edit: updated to make it work with UpdateView as well

So what I was looking for appears to be

utils.py:

from django.core.exceptions import ValidationError
from django import forms


class OptionalChoiceWidget(forms.MultiWidget):
    def decompress(self,value):
        #this might need to be tweaked if the name of a choice != value of a choice
        if value: #indicates we have a updating object versus new one
            if value in [x[0] for x in self.widgets[0].choices]:
                 return [value,""] # make it set the pulldown to choice
            else:
                 return ["",value] # keep pulldown to blank, set freetext
        return ["",""] # default for new object

class OptionalChoiceField(forms.MultiValueField):
    def __init__(self, choices, max_length=80, *args, **kwargs):
        """ sets the two fields as not required but will enforce that (at least) one is set in compress """
        fields = (forms.ChoiceField(choices=choices,required=False),
                  forms.CharField(required=False))
        self.widget = OptionalChoiceWidget(widgets=[f.widget for f in fields])
        super(OptionalChoiceField,self).__init__(required=False,fields=fields,*args,**kwargs)
    def compress(self,data_list):
        """ return the choicefield value if selected or charfield value (if both empty, will throw exception """
        if not data_list:
            raise ValidationError('Need to select choice or enter text for this field')
        return data_list[0] or data_list[1]

Example use

(forms.py)

from .utils import OptionalChoiceField
from django import forms
from .models import Dummy

class DemoForm(forms.ModelForm):
    name = OptionalChoiceField(choices=(("","-----"),("1","1"),("2","2")))
    value = forms.CharField(max_length=100)
    class Meta:
        model = Dummy

(Sample dummy model.py:)

from django.db import models
from django.core.urlresolvers import reverse

class Dummy(models.Model):
    name = models.CharField(max_length=80)
    value = models.CharField(max_length=100)
    def get_absolute_url(self):
        return reverse('dummy-detail', kwargs={'pk': self.pk})

(Sample dummy views.py:)

from .forms import DemoForm
from .models import Dummy
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, UpdateView


class DemoCreateView(CreateView):
    form_class = DemoForm
    model = Dummy

class DemoUpdateView(UpdateView):
    form_class = DemoForm
    model = Dummy


class DemoDetailView(DetailView):
    model = Dummy

Solution 4:

I had the similar requirement as OP but with the base field being a DecimalField. So the user could enter a valid floating point number or select from a list of optional choices.

I liked Austin Fox's answer in that it follows the django framework better than Viktor eXe's answer. Inheriting from the ChoiceField object allows the field to manage an array of option widgets. So it would be tempting to try;

class CustomField(Decimal, ChoiceField): # MRO Decimal->Integer->ChoiceField->Field
    ...
class CustomWidget(NumberInput, Select):

But the assumption is that the field must contain something that appears in the choices list. There is a handy valid_value method that you can override to allow any value, but there is a bigger problem - binding to a decimal model field.

Fundamentally, all the ChoiceField objects manage lists of values and then have an index or multiple selection indices that represents the selection. So bound data will appear in the widget as;

[some_data] or [''] empty value

Hence Austin Fox overriding the format_value method to return back to a base Input class method version. Works for charfield but not for Decimal or Float fields because we lose all the special formatting in the number widget.

So my solution was to inherit directly from Decimal field but adding only the choice property (lifted from django CoiceField)....

First the custom widgets;

class ComboBoxWidget(Input):
"""
Abstract class
"""
input_type = None  # must assigned by subclass
template_name = "datalist.html"
option_template_name = "datalist_option.html"

def __init__(self, attrs=None, choices=()):
    super(ComboBoxWidget, self).__init__(attrs)
    # choices can be any iterable, but we may need to render this widget
    # multiple times. Thus, collapse it into a list so it can be consumed
    # more than once.
    self.choices = list(choices)

def __deepcopy__(self, memo):
    obj = copy.copy(self)
    obj.attrs = self.attrs.copy()
    obj.choices = copy.copy(self.choices)
    memo[id(self)] = obj
    return obj

def optgroups(self, name):
    """Return a list of optgroups for this widget."""
    groups = []

    for index, (option_value, option_label) in enumerate(self.choices):
        if option_value is None:
            option_value = ''

        subgroup = []
        if isinstance(option_label, (list, tuple)):
            group_name = option_value
            subindex = 0
            choices = option_label
        else:
            group_name = None
            subindex = None
            choices = [(option_value, option_label)]
        groups.append((group_name, subgroup, index))

        for subvalue, sublabel in choices:
            subgroup.append(self.create_option(
                name, subvalue
            ))
            if subindex is not None:
                subindex += 1
    return groups

def create_option(self, name, value):
    return {
        'name': name,
        'value': value,
        'template_name': self.option_template_name,
    }

def get_context(self, name, value, attrs):
    context = super(ComboBoxWidget, self).get_context(name, value, attrs)
    context['widget']['optgroups'] = self.optgroups(name)
    context['wrap_label'] = True
    return context


class NumberComboBoxWidget(ComboBoxWidget):
    input_type = 'number'


class TextComboBoxWidget(ComboBoxWidget):
    input_type = 'text'

The Custom Field Class

class OptionsField(forms.Field):
def __init__(self, choices=(), **kwargs):
    super(OptionsField, self).__init__(**kwargs)
    self.choices = list(choices)

def _get_choices(self):
    return self._choices

def _set_choices(self, value):
    """
    Assign choices to widget
    """
    value = list(value)
    self._choices = self.widget.choices = value

choices = property(_get_choices, _set_choices)


class DecimalOptionsField(forms.DecimalField, OptionsField):
widget = NumberComboBoxWidget

def __init__(self, choices=(), max_value=None, min_value=None, max_digits=None, decimal_places=None, **kwargs):
    super(DecimalOptionsField, self).__init__(choices=choices, max_value=max_value, min_value=min_value,
                                               max_digits=max_digits, decimal_places=decimal_places, **kwargs)


class CharOptionsField(forms.CharField, OptionsField):
widget = TextComboBoxWidget

def __init__(self, choices=(), max_length=None, min_length=None, strip=True, empty_value='', **kwargs):
    super(CharOptionsField, self).__init__(choices=choices, max_length=max_length, min_length=min_length,
                                           strip=strip, empty_value=empty_value, **kwargs)

The html templates

datalist.html

<input list="{{ widget.name }}_list" type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %}{% include "django/forms/widgets/attrs.html" %} />
<datalist id="{{ widget.name }}_list">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
</optgroup>{% endif %}{% endfor %}
</datalist>

datalist_option.html

<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>

An example of use. Note that the second element of the choice tuple is not needed by the HTML datalist option tag, so I leave them as None. Also the first tuple value can be text or native decimal - you can see how the widget handles them.

class FrequencyDataForm(ModelForm):
frequency_measurement = DecimalOptionsField(
    choices=(
        ('Low Freq', (
            ('11.11', None),
            ('22.22', None),
            (33.33, None),
            ),
         ),
        ('High Freq', (
            ('66.0E+06', None),
            (1.2E+09, None),
            ('2.4e+09', None)
            ),
         )
    ),
    required=False,
    max_digits=15,
    decimal_places=3,
)

class Meta:
    model = FrequencyData
    fields = '__all__'