Django form with choices but also with freetext option?
Asked Answered
W

9

41

What I'm looking for: A single widget that gives the user a drop down list of choices but then also has a text input box underneath for the user to enter a new value.

The backend model would have a set of default choices (but wouldn't use the choices keyword on the model). I know I can (and I have) implemented this by having the form have both a ChoicesField and CharField and have code use the CharField if ChoicesField is left at the default, but this feels "un-django" like.

Is there a way (either using Django-builtins or a Django plugin) to define something like ChoiceEntryField (modeled after the GtkComboboxEntry which IIRC does this) for a form?

In case anyone finds this, note that there is a similar question on how to best do what I was looking for from a UX perspective at https://ux.stackexchange.com/questions/85980/is-there-a-ux-pattern-for-drop-down-preferred-but-free-text-allowed

Wildawildcat answered 16/7, 2014 at 14:21 Comment(1)
Five years on, was about to ask the same question. StackOverflow found this answer and it's an absolute gem. Thanks to all who made it so!Grill
C
46

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
    })
Custard answered 25/9, 2015 at 22:36 Comment(8)
Interesting. Hope you don't mind, but I made a few small edits (feel free to revert if you do). Out of curiosity, is there a way to make it show all the options (What I get is that if I type a letter, it shows everything that matches that; I rather be able to get a full list of all possible inputs)Wildawildcat
(Hmm... actually, the HTML in general is a little wonky, which might be because of my overly quick and dirty testing... what is the behavior I should be seeing? Drop down right next to the text box on click or on type or something else?Wildawildcat
Unfortunately the idea behind this input is a search field, I don't think we can make it display all options when you type a letter :/Custard
I still like it for some use cases ... so have a checkmarkWildawildcat
I liked this too, and am using it. But I just noticed different behavior between browsers. In Safari, I see a text box. I enter a letter, and then I can choose from the values in a drop down that appears. That works OK. In Chrome, I see an arrow in the text box, which when clicked shows me a very ugly, raw version of the list of tuples (the choices from my model field) that is the data_list. Not nice. But I think if I can switch to just a flat list like in the example here, the Chrome view would essentially be an editable drop down.Pneumectomy
Actually that was the intention, I used it in a project where we needed a drop-down but the user could insert new data. For projects who require a lot of capturing data the user end up preferring to type the first letters to see if the option exists and finish typing of it doesn't.Custard
I think some of the browser issues with datalists motivated this alternative Awesomplete. Also, I've posted a Python 3 Gist based on this answer which uses tuple lists, as per Django choicesDemurral
What if I already have these form = FormForm(request.POST or None, request.FILES or None) so Where do I need to add This form = FormForm(data_list=country_list) ?Beanie
P
11

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())
        }
Puppet answered 27/4, 2016 at 12:53 Comment(2)
Impressive. I've been looking for django-floppyform since ages.Alcove
django-floppyforms appears to be abandoned and does not seem to work out-of-the-box with Django ≥ 1.11. I ended up basing my solution on the answer from Viktor eXe above.Psychological
W
5

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
Wildawildcat answered 16/7, 2014 at 14:21 Comment(7)
Note that you don't need to community wiki your own answer to your own question. It's allowed and recommended to post self answersStarknaked
Thank you, @jamylak. This works great for me with CreateView. But with UpdateView, the widget does not get initialized with the existing value. It's not bound to the instance, and I can't figure out how to manually set the value of the ChoiceField and CharField. Do you by any chance have any guidance?Pneumectomy
@Pneumectomy This answer was not from meStarknaked
Sorry! My bad. My comment was meant for @Foon.Pneumectomy
@Pneumectomy thought I replied yesterday but apparently that comment got lost... looking at this now, but just in case I don't reply... I did try Victor eXe's answer; there's some oddness to the HTML rendering in my test which may or may not be my fault, but his did handle Create and UpdateViews so you might want to look at that.Wildawildcat
@Pneumectomy I updated my code to hopefully support DemoUpdateView as well; if this still doesn't work, you might need to open another question (suggest linking to this one and possibly adding it as a comment so I can see it) just so you can show what you're doing, as this is working for me nowWildawildcat
@Wildawildcat for now I'm using Viktor eXe's approach, but I see now how your approach here would work for updates too. Thanks for helping me along!Pneumectomy
P
3

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__'
Priestess answered 4/9, 2018 at 0:57 Comment(0)
C
2

Would the input type be identical in both the choice and text fields? If so, I would make a single CharField (or Textfield) in the class and have some front end javascript/jquery take care of what data will be passed by applying a "if no information in dropdown, use data in textfield" clause.

I made a jsFiddle to demonstrate how you can do this on the frontend.

HTML:

<div class="formarea">

<select id="dropdown1">
<option value="One">"One"</option>
<option value="Two">"Two"</option>
<option value="Three">or just write your own</option>
</select>

<form><input id="txtbox" type="text"></input></form>
    <input id="inputbutton" type="submit" value="Submit"></input>

</div>

JS:

var txt = document.getElementById('txtbox');
var btn = document.getElementById('inputbutton');
txt.disabled=true;

$(document).ready(function() {
    $('#dropdown1').change(function() {
        if($(this).val() == "Three"){
            document.getElementById('txtbox').disabled=false;
        }
        else{
            document.getElementById('txtbox').disabled=true;
        }
    });
});

btn.onclick = function () { 
    if((txt).disabled){
        alert('input is: ' + $('#dropdown1').val());
    }
    else{
        alert('input is: ' + $(txt).val());
    }
};

you can then, on submit, specify which value will be passed to your view.

Cowl answered 17/7, 2014 at 6:0 Comment(1)
Yes as far as the input type being the same. This might be helpful for someone else, but I was looking to find a way to make django do all the work versus having to embed javascript / custom html.Wildawildcat
S
2

I know this is old but thought this might be useful to others. The following achieves a similar outcome to Viktor eXe's answer but works for models, querysets, and foreign keys with django native methods.

In forms.py subclass forms.Select and forms.ModelChoiceField:

from django import forms

class ListTextWidget(forms.Select):
    template_name = 'listtxt.html'

    def format_value(self, value):
        # Copied from forms.Input - makes sure value is rendered properly
        if value == '' or value is None:
            return ''
        if self.is_localized:
            return formats.localize_input(value)
        return str(value)

class ChoiceTxtField(forms.ModelChoiceField):
    widget=ListTextWidget()

Then create the listtxt.html in templates:

<input list="{{ widget.name }}"
    {% if widget.value != None %} name="{{ widget.name }}" value="{{ widget.value|stringformat:'s' }}"{% endif %}
    {% include "django/forms/widgets/attrs.html" %}>

<datalist id="{{ widget.name }}">
    {% 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>

And now you can use either the widget or field in your forms.py:

from .fields import *
from django import forms

Class ListTxtForm(forms.Form):
    field = ChoiceTxtField(queryset=YourModel.objects.all())  # Using new field
    field2 = ModelChoiceField(queryset=YourModel.objects.all(),
                              widget=ListTextWidget())  # Using Widget

The widget and field also work in form.ModelForm Forms and will accept attributes.

Selfdeception answered 20/7, 2018 at 1:4 Comment(4)
in my case, i use foreign key. how can i show foreignkey name , not pk number value? i try this https://mcmap.net/q/392900/-get-form-values-foreign-key-field-in-django, but this choise, in your case is querysetApterygial
@Apterygial you still use queryset as shown in the answer. YourModel will be the Model your foreign key points to.Selfdeception
can you help with your example? https://mcmap.net/q/392901/-when-editing-a-model-in-django-it-outputs-pk-foreignkey-and-not-the-desired-field/9653855Apterygial
@Apterygial - The answer form SLDem to that question should do it (repr). The other option is to use str in YourModel classSelfdeception
G
2

5 years late to this party, but @Foon 's self-answer OptionalChoiceWidget was exactly what I was looking for and hopefully other people thinking of asking the same question will be directed here by StackOverflow's answer-finding algorithms as I was.

I wanted the text input box to disappear if an answer was selected from the options pull-down, and that's easy to accomplish. Since it may be useful to others:

{% block onready_js %}
{{block.super}}

/* hide the text input box if an answer for "name" is selected via the pull-down */
$('#id_name_0').click( function(){
  if ($(this).find('option:selected').val() === "") {
     $('#id_name_1').show(); }
  else {
     $('#id_name_1').hide(); $('#id_name_1').val(""); }
});
$('#id_name_0').trigger("click"); /* sets initial state right */

{% endblock %} 

Anybody wondering about the onready_js block, I have (in my base.html template that everything else inherits)

<script type="text/javascript"> $(document).ready(  function() {
{% block onready_js %}{% endblock onready_js %}   
   }); 
</script>

Beats me why not everybody does little bits of JQuery this way!

Grill answered 10/10, 2019 at 11:17 Comment(0)
E
0

Here's how i solved this problem. I retrieve choices from passed to template form object and fill datalist manually:

{% for field in form %}
  <div class="form-group">
    {{ field.label_tag }}
    <input list="options" name="test-field"  required="" class="form-control" id="test-field-add">
    <datalist id="options">
      {% for option in field.subwidgets %}
        <option value="{{ option.choice_label }}"/>
      {% endfor %}
    </datalist>
   </div>
{% endfor %}
Epistasis answered 13/4, 2018 at 13:14 Comment(0)
N
0

Here's usage of the reusable Model Field level DataListCharField. I modified @Viktor eXe & @Ryan Skene's ListTextWidget.

# 'models.py'
from my_custom_fields import DatalistCharField

class MyModel(models.Model):
    class MyChoices(models.TextChoices):
        ans1 = 'ans1', 'ans1'
        ans2 = 'ans2', 'ans2'

    my_model_field = DatalistCharField('label of this field', datalist=MyChoices.choices, max_length=30)

DatalistCharField(custom) defined at 'my_custom_fields.py'. it is easy to read from bottom to up.

(DatalistCharField > ListTextField > ListTextWidget)

((models.Field > forms.Field > forms.Widget) structure)

# 'my_custom_fields.py'
class ListTextWidget(forms.TextInput):  # form widget
    def __init__(self, *args, **kwargs):
        self.datalist = None
        super(ListTextWidget, self).__init__(*args, **kwargs)

    def render(self, name, value, attrs=None, renderer=None):
        default_attrs = {'list': 'list__%s' % name}
        default_attrs.update(attrs)
        text_html = super(ListTextWidget, self).render(name, value, attrs=default_attrs)  # TextInput rendered

        data_list = '<datalist id="list__%s">' % name  # append <datalist> under the <input> elem.
        if self.datalist:
            for _, value in self.datalist:
                data_list += '<option value="%s">' % value
        else:
            data_list += '<option value="%s">' % 'no'  # default datalist option
        data_list += '</datalist>'
        return text_html + data_list


class ListTextField(forms.CharField):  # form field
    widget = ListTextWidget

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


class DatalistCharField(models.CharField):  # model field
    def __init__(self, *args, **kwargs):
        self.datalist = kwargs.pop('datalist', None)  # custom parameters should be poped here to bypass super().__init__() or it will raise an error of wrong parameter
        super().__init__(*args, **kwargs)

    def formfield(self, **kwargs):
        defaults = {'form_class': ListTextField, 'datalist': self.datalist}  # bypassed custom parameters arrived here
        defaults.update(**kwargs)
        return super().formfield(**defaults)
Nous answered 3/10, 2020 at 5:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.