From 7ec2a21be15af5b2c7513482c3bcfdd1e12782ed Mon Sep 17 00:00:00 2001
From: Claude Paroz
Date: Sat, 23 Feb 2013 09:45:56 +0100
Subject: [PATCH] Fixed #19686 -- Added HTML5 number input type
Thanks Simon Charette for his help on the patch. Refs #16630.
---
django/forms/fields.py | 45 ++++++++++----
django/forms/widgets.py | 6 +-
docs/ref/forms/fields.txt | 9 ++-
docs/ref/forms/widgets.txt | 13 ++++
docs/releases/1.6.txt | 13 ++--
docs/topics/forms/formsets.txt | 6 +-
tests/modeltests/model_forms/tests.py | 4 +-
tests/modeltests/model_formsets/tests.py | 17 ++---
tests/regressiontests/forms/tests/fields.py | 33 +++++++++-
tests/regressiontests/forms/tests/forms.py | 6 +-
tests/regressiontests/forms/tests/formsets.py | 62 +++++++++----------
tests/regressiontests/i18n/tests.py | 1 +
12 files changed, 148 insertions(+), 67 deletions(-)
diff --git a/django/forms/fields.py b/django/forms/fields.py
index c547d1456c..621d3801f2 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -19,7 +19,7 @@ from django.core import validators
from django.core.exceptions import ValidationError
from django.forms.util import ErrorList, from_current_timezone, to_current_timezone
from django.forms.widgets import (
- TextInput, PasswordInput, EmailInput, URLInput, HiddenInput,
+ TextInput, NumberInput, EmailInput, URLInput, HiddenInput,
MultipleHiddenInput, ClearableFileInput, CheckboxInput, Select,
NullBooleanSelect, SelectMultiple, DateInput, DateTimeInput, TimeInput,
SplitDateTimeWidget, SplitHiddenDateTimeWidget, FILE_INPUT_CONTRADICTION
@@ -234,6 +234,7 @@ class IntegerField(Field):
def __init__(self, max_value=None, min_value=None, *args, **kwargs):
self.max_value, self.min_value = max_value, min_value
+ kwargs.setdefault('widget', NumberInput if not kwargs.get('localize') else self.widget)
super(IntegerField, self).__init__(*args, **kwargs)
if max_value is not None:
@@ -257,6 +258,16 @@ class IntegerField(Field):
raise ValidationError(self.error_messages['invalid'])
return value
+ def widget_attrs(self, widget):
+ attrs = super(IntegerField, self).widget_attrs(widget)
+ if isinstance(widget, NumberInput):
+ if self.min_value is not None:
+ attrs['min'] = self.min_value
+ if self.max_value is not None:
+ attrs['max'] = self.max_value
+ return attrs
+
+
class FloatField(IntegerField):
default_error_messages = {
'invalid': _('Enter a number.'),
@@ -278,25 +289,24 @@ class FloatField(IntegerField):
raise ValidationError(self.error_messages['invalid'])
return value
-class DecimalField(Field):
+ def widget_attrs(self, widget):
+ attrs = super(FloatField, self).widget_attrs(widget)
+ if isinstance(widget, NumberInput):
+ attrs.setdefault('step', 'any')
+ return attrs
+
+
+class DecimalField(IntegerField):
default_error_messages = {
'invalid': _('Enter a number.'),
- 'max_value': _('Ensure this value is less than or equal to %(limit_value)s.'),
- 'min_value': _('Ensure this value is greater than or equal to %(limit_value)s.'),
'max_digits': _('Ensure that there are no more than %s digits in total.'),
'max_decimal_places': _('Ensure that there are no more than %s decimal places.'),
'max_whole_digits': _('Ensure that there are no more than %s digits before the decimal point.')
}
def __init__(self, max_value=None, min_value=None, max_digits=None, decimal_places=None, *args, **kwargs):
- self.max_value, self.min_value = max_value, min_value
self.max_digits, self.decimal_places = max_digits, decimal_places
- Field.__init__(self, *args, **kwargs)
-
- if max_value is not None:
- self.validators.append(validators.MaxValueValidator(max_value))
- if min_value is not None:
- self.validators.append(validators.MinValueValidator(min_value))
+ super(DecimalField, self).__init__(max_value, min_value, *args, **kwargs)
def to_python(self, value):
"""
@@ -345,6 +355,19 @@ class DecimalField(Field):
raise ValidationError(self.error_messages['max_whole_digits'] % (self.max_digits - self.decimal_places))
return value
+ def widget_attrs(self, widget):
+ attrs = super(DecimalField, self).widget_attrs(widget)
+ if isinstance(widget, NumberInput):
+ if self.max_digits is not None:
+ max_length = self.max_digits + 1 # for the sign
+ if self.decimal_places is None or self.decimal_places > 0:
+ max_length += 1 # for the dot
+ attrs['maxlength'] = max_length
+ if self.decimal_places:
+ attrs['step'] = '0.%s1' % ('0' * (self.decimal_places-1))
+ return attrs
+
+
class BaseTemporalField(Field):
def __init__(self, input_formats=None, *args, **kwargs):
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index e906ed5bc6..026e8dc36a 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -23,7 +23,7 @@ from django.utils import datetime_safe, formats, six
__all__ = (
'Media', 'MediaDefiningClass', 'Widget', 'TextInput',
- 'EmailInput', 'URLInput', 'PasswordInput',
+ 'EmailInput', 'URLInput', 'NumberInput', 'PasswordInput',
'HiddenInput', 'MultipleHiddenInput', 'ClearableFileInput',
'FileInput', 'DateInput', 'DateTimeInput', 'TimeInput', 'Textarea', 'CheckboxInput',
'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect',
@@ -252,6 +252,10 @@ class TextInput(Input):
super(TextInput, self).__init__(attrs)
+class NumberInput(TextInput):
+ input_type = 'number'
+
+
class EmailInput(TextInput):
input_type = 'email'
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index 2e4e779f0c..85650adcf4 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -454,7 +454,8 @@ For each field, we describe the default widget used if you don't specify
.. class:: DecimalField(**kwargs)
- * Default widget: :class:`TextInput`
+ * Default widget: :class:`NumberInput` when :attr:`Field.localize` is
+ ``False``, else :class:`TextInput`.
* Empty value: ``None``
* Normalizes to: A Python ``decimal``.
* Validates that the given value is a decimal. Leading and trailing
@@ -580,7 +581,8 @@ For each field, we describe the default widget used if you don't specify
.. class:: FloatField(**kwargs)
- * Default widget: :class:`TextInput`
+ * Default widget: :class:`NumberInput` when :attr:`Field.localize` is
+ ``False``, else :class:`TextInput`.
* Empty value: ``None``
* Normalizes to: A Python float.
* Validates that the given value is an float. Leading and trailing
@@ -621,7 +623,8 @@ For each field, we describe the default widget used if you don't specify
.. class:: IntegerField(**kwargs)
- * Default widget: :class:`TextInput`
+ * Default widget: :class:`NumberInput` when :attr:`Field.localize` is
+ ``False``, else :class:`TextInput`.
* Empty value: ``None``
* Normalizes to: A Python integer or long integer.
* Validates that the given value is an integer. Leading and trailing
diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt
index 970901a9ae..7218e41082 100644
--- a/docs/ref/forms/widgets.txt
+++ b/docs/ref/forms/widgets.txt
@@ -389,6 +389,19 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
Text input: ````
+``NumberInput``
+~~~~~~~~~~~~~~~
+
+.. class:: NumberInput
+
+ .. versionadded:: 1.6
+
+ Text input: ````
+
+ Beware that not all browsers support entering localized numbers in
+ ``number`` input types. Django itself avoids using them for fields having
+ their :attr:`~django.forms.Field.localize` property to ``True``.
+
``EmailInput``
~~~~~~~~~~~~~~
diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt
index d7b1547a91..67c032a362 100644
--- a/docs/releases/1.6.txt
+++ b/docs/releases/1.6.txt
@@ -60,9 +60,13 @@ Minor features
* In addition to :lookup:`year`, :lookup:`month` and :lookup:`day`, the ORM
now supports :lookup:`hour`, :lookup:`minute` and :lookup:`second` lookups.
-* The default widgets for :class:`~django.forms.EmailField` and
- :class:`~django.forms.URLField` use the new type attributes available in
- HTML5 (type='email', type='url').
+* The default widgets for :class:`~django.forms.EmailField`,
+ :class:`~django.forms.URLField`, :class:`~django.forms.IntegerField`,
+ :class:`~django.forms.FloatField` and :class:`~django.forms.DecimalField` use
+ the new type attributes available in HTML5 (type='email', type='url',
+ type='number'). Note that due to erratic support of the ``number`` input type
+ with localized numbers in current browsers, Django only uses it when numeric
+ fields are not localized.
* The ``number`` argument for :ref:`lazy plural translations
` can be provided at translation time rather than
@@ -122,7 +126,8 @@ Backwards incompatible changes in 1.6
* If your CSS/Javascript code used to access HTML input widgets by type, you
should review it as ``type='text'`` widgets might be now output as
- ``type='email'`` or ``type='url'`` depending on their corresponding field type.
+ ``type='email'``, ``type='url'`` or ``type='number'`` depending on their
+ corresponding field type.
* Extraction of translatable literals from templates with the
:djadmin:`makemessages` command now correctly detects i18n constructs when
diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt
index d2d102b5d6..2534947dd3 100644
--- a/docs/topics/forms/formsets.txt
+++ b/docs/topics/forms/formsets.txt
@@ -273,13 +273,13 @@ Lets you create a formset with the ability to order::
... print(form.as_table())
-
+
-
+
-
+
This adds an additional field to each form. This new field is named ``ORDER``
and is an ``forms.IntegerField``. For the forms that came from the initial
diff --git a/tests/modeltests/model_forms/tests.py b/tests/modeltests/model_forms/tests.py
index 6ecd1278dd..fb400375b8 100644
--- a/tests/modeltests/model_forms/tests.py
+++ b/tests/modeltests/model_forms/tests.py
@@ -1133,7 +1133,7 @@ class OldFormForXTests(TestCase):
''' % (w_woodward.pk, w_bernstein.pk, bw.pk, w_royko.pk))
def test_file_field(self):
# Test conditions when files is either not given or empty.
diff --git a/tests/modeltests/model_formsets/tests.py b/tests/modeltests/model_formsets/tests.py
index 50ee3c73fb..037163b741 100644
--- a/tests/modeltests/model_formsets/tests.py
+++ b/tests/modeltests/model_formsets/tests.py
@@ -392,7 +392,7 @@ class ModelFormsetTest(TestCase):
self.assertEqual(len(formset.forms), 1)
self.assertHTMLEqual(formset.forms[0].as_p(),
'
\n'
- '
')
+ '
')
data = {
'form-TOTAL_FORMS': '1', # the number of forms rendered
@@ -415,10 +415,10 @@ class ModelFormsetTest(TestCase):
self.assertEqual(len(formset.forms), 2)
self.assertHTMLEqual(formset.forms[0].as_p(),
'
')
data = {
'form-TOTAL_FORMS': '2', # the number of forms rendered
@@ -551,6 +551,7 @@ class ModelFormsetTest(TestCase):
def test_inline_formsets_with_custom_pk(self):
# Test inline formsets where the inline-edited object has a custom
# primary key that is not the fk to the parent object.
+ self.maxDiff = 1024
AuthorBooksFormSet2 = inlineformset_factory(Author, BookWithCustomPK, can_delete=False, extra=1)
author = Author.objects.create(pk=1, name='Charles Baudelaire')
@@ -558,7 +559,7 @@ class ModelFormsetTest(TestCase):
formset = AuthorBooksFormSet2(instance=author)
self.assertEqual(len(formset.forms), 1)
self.assertHTMLEqual(formset.forms[0].as_p(),
- '
\n'
+ '
\n'
'
')
data = {
@@ -806,7 +807,7 @@ class ModelFormsetTest(TestCase):
'\n'
'\n'
'\n'
- '
'
% owner1.auto_id)
data = {
@@ -837,7 +838,7 @@ class ModelFormsetTest(TestCase):
formset = FormSet(instance=owner1)
self.assertEqual(len(formset.forms), 1)
self.assertHTMLEqual(formset.forms[0].as_p(),
- '
'
+ '
'
% owner1.auto_id)
data = {
@@ -993,7 +994,7 @@ class ModelFormsetTest(TestCase):
result = re.sub(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?', '__DATETIME__', result)
self.assertHTMLEqual(result,
'
\n'
- '
'
+ '
'
% person.id)
# test for validation with callable defaults. Validations rely on hidden fields
diff --git a/tests/regressiontests/forms/tests/fields.py b/tests/regressiontests/forms/tests/fields.py
index fc7fc70da4..3fe2cd2ace 100644
--- a/tests/regressiontests/forms/tests/fields.py
+++ b/tests/regressiontests/forms/tests/fields.py
@@ -35,7 +35,6 @@ from decimal import Decimal
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import *
from django.test import SimpleTestCase
-from django.utils import formats
from django.utils import six
from django.utils._os import upath
@@ -131,6 +130,7 @@ class FieldsTests(SimpleTestCase):
def test_integerfield_1(self):
f = IntegerField()
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None)
self.assertEqual(1, f.clean('1'))
@@ -165,6 +165,7 @@ class FieldsTests(SimpleTestCase):
def test_integerfield_3(self):
f = IntegerField(max_value=10)
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None)
self.assertEqual(1, f.clean(1))
self.assertEqual(10, f.clean(10))
@@ -176,6 +177,7 @@ class FieldsTests(SimpleTestCase):
def test_integerfield_4(self):
f = IntegerField(min_value=10)
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None)
self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 10.'", f.clean, 1)
self.assertEqual(10, f.clean(10))
@@ -187,6 +189,7 @@ class FieldsTests(SimpleTestCase):
def test_integerfield_5(self):
f = IntegerField(min_value=10, max_value=20)
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None)
self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 10.'", f.clean, 1)
self.assertEqual(10, f.clean(10))
@@ -198,10 +201,19 @@ class FieldsTests(SimpleTestCase):
self.assertEqual(f.max_value, 20)
self.assertEqual(f.min_value, 10)
+ def test_integerfield_localized(self):
+ """
+ Make sure localized IntegerField's widget renders to a text input with
+ no number input specific attributes.
+ """
+ f1 = IntegerField(localize=True)
+ self.assertWidgetRendersTo(f1, '')
+
# FloatField ##################################################################
def test_floatfield_1(self):
f = FloatField()
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None)
self.assertEqual(1.0, f.clean('1'))
@@ -228,6 +240,7 @@ class FieldsTests(SimpleTestCase):
def test_floatfield_3(self):
f = FloatField(max_value=1.5, min_value=0.5)
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'Ensure this value is less than or equal to 1.5.'", f.clean, '1.6')
self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 0.5.'", f.clean, '0.4')
self.assertEqual(1.5, f.clean('1.5'))
@@ -235,10 +248,19 @@ class FieldsTests(SimpleTestCase):
self.assertEqual(f.max_value, 1.5)
self.assertEqual(f.min_value, 0.5)
+ def test_floatfield_localized(self):
+ """
+ Make sure localized FloatField's widget renders to a text input with
+ no number input specific attributes.
+ """
+ f = FloatField(localize=True)
+ self.assertWidgetRendersTo(f, '')
+
# DecimalField ################################################################
def test_decimalfield_1(self):
f = DecimalField(max_digits=4, decimal_places=2)
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '')
self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None)
self.assertEqual(f.clean('1'), Decimal("1"))
@@ -284,6 +306,7 @@ class FieldsTests(SimpleTestCase):
def test_decimalfield_3(self):
f = DecimalField(max_digits=4, decimal_places=2, max_value=Decimal('1.5'), min_value=Decimal('0.5'))
+ self.assertWidgetRendersTo(f, '')
self.assertRaisesMessage(ValidationError, "'Ensure this value is less than or equal to 1.5.'", f.clean, '1.6')
self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 0.5.'", f.clean, '0.4')
self.assertEqual(f.clean('1.5'), Decimal("1.5"))
@@ -315,6 +338,14 @@ class FieldsTests(SimpleTestCase):
self.assertEqual(f.clean('.01'), Decimal(".01"))
self.assertRaisesMessage(ValidationError, "'Ensure that there are no more than 0 digits before the decimal point.'", f.clean, '1.1')
+ def test_decimalfield_localized(self):
+ """
+ Make sure localized DecimalField's widget renders to a text input with
+ no number input specific attributes.
+ """
+ f = DecimalField(localize=True)
+ self.assertWidgetRendersTo(f, '')
+
# DateField ###################################################################
def test_datefield_1(self):
diff --git a/tests/regressiontests/forms/tests/forms.py b/tests/regressiontests/forms/tests/forms.py
index f2fa78e229..f856e30d33 100644
--- a/tests/regressiontests/forms/tests/forms.py
+++ b/tests/regressiontests/forms/tests/forms.py
@@ -1740,7 +1740,7 @@ class FormsTestCase(TestCase):
-
This field is required.
""")
+
This field is required.
""")
self.assertHTMLEqual(p.as_p(), """
This field is required.
@@ -1751,7 +1751,7 @@ class FormsTestCase(TestCase):
This field is required.
-
""")
+
""")
self.assertHTMLEqual(p.as_table(), """
This field is required.
-
This field is required.
""")
+
This field is required.
""")
def test_label_split_datetime_not_displayed(self):
class EventForm(Form):
diff --git a/tests/regressiontests/forms/tests/formsets.py b/tests/regressiontests/forms/tests/formsets.py
index 573a8f6a6d..2bef0c5c33 100644
--- a/tests/regressiontests/forms/tests/formsets.py
+++ b/tests/regressiontests/forms/tests/formsets.py
@@ -53,7 +53,7 @@ class FormsFormsetTestCase(TestCase):
formset = ChoiceFormSet(auto_id=False, prefix='choices')
self.assertHTMLEqual(str(formset), """
Choice:
-
Votes:
""")
+
Votes:
""")
# On thing to note is that there needs to be a special value in the data. This
# value tells the FormSet how many forms were displayed so it can tell how
@@ -137,9 +137,9 @@ class FormsFormsetTestCase(TestCase):
form_output.append(form.as_ul())
self.assertHTMLEqual('\n'.join(form_output), """
Choice:
-
Votes:
+
Votes:
Choice:
-
Votes:
""")
+
Votes:
""")
# Let's simulate what would happen if we submitted this form.
@@ -210,11 +210,11 @@ class FormsFormsetTestCase(TestCase):
form_output.append(form.as_ul())
self.assertHTMLEqual('\n'.join(form_output), """
Choice:
-
Votes:
+
Votes:
Choice:
-
Votes:
+
Votes:
Choice:
-
Votes:
""")
+
Votes:
""")
# Since we displayed every form as blank, we will also accept them back as blank.
# This may seem a little strange, but later we will show how to require a minimum
@@ -301,19 +301,19 @@ class FormsFormsetTestCase(TestCase):
form_output.append(form.as_ul())
self.assertHTMLEqual('\n'.join(form_output), """
Choice:
-
Votes:
+
Votes:
Choice:
-
Votes:
+
Votes:
Choice:
-
Votes:
+
Votes:
Choice:
-
Votes:
""")
+
Votes:
""")
# Make sure retrieving an empty form works, and it shows up in the form list
self.assertTrue(formset.empty_form.empty_permitted)
self.assertHTMLEqual(formset.empty_form.as_ul(), """
Choice:
-
Votes:
""")
+
Votes:
""")
def test_formset_with_deletion(self):
# FormSets with deletion ######################################################
@@ -331,13 +331,13 @@ class FormsFormsetTestCase(TestCase):
form_output.append(form.as_ul())
self.assertHTMLEqual('\n'.join(form_output), """
Choice:
-
Votes:
+
Votes:
Delete:
Choice:
-
Votes:
+
Votes:
Delete:
Choice:
-
Votes:
+
Votes:
Delete:
""")
# To delete something, we just need to set that form's special delete field to
@@ -428,14 +428,14 @@ class FormsFormsetTestCase(TestCase):
form_output.append(form.as_ul())
self.assertHTMLEqual('\n'.join(form_output), """
Choice:
-
Votes:
-
Order:
+
Votes:
+
Order:
Choice:
-
Votes:
-
Order:
+
Votes:
+
Order:
Choice:
-
Votes:
-
Order:
""")
+
Votes:
+
Order:
""")
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
@@ -539,20 +539,20 @@ class FormsFormsetTestCase(TestCase):
form_output.append(form.as_ul())
self.assertHTMLEqual('\n'.join(form_output), """
Choice:
-
Votes:
-
Order:
+
Votes:
+
Order:
Delete:
Choice:
-
Votes:
-
Order:
+
Votes:
+
Order:
Delete:
Choice:
-
Votes:
-
Order:
+
Votes:
+
Order:
Delete:
Choice:
-
Votes:
-
Order:
+
Votes:
+
Order:
Delete:
""")
# Let's delete Fergie, and put The Decemberists ahead of Calexico.
@@ -956,19 +956,19 @@ class FormsetAsFooTests(TestCase):
formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
self.assertHTMLEqual(formset.as_table(),"""