diff --git a/wagtail/wagtailcore/blocks/field_block.py b/wagtail/wagtailcore/blocks/field_block.py index 586d03fae..be2759ab5 100644 --- a/wagtail/wagtailcore/blocks/field_block.py +++ b/wagtail/wagtailcore/blocks/field_block.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import datetime from django import forms +from django.db.models.fields import BLANK_CHOICE_DASH from django.template.loader import render_to_string from django.utils.encoding import force_text from django.utils.dateparse import parse_date, parse_time, parse_datetime @@ -142,6 +143,56 @@ class DateTimeBlock(FieldBlock): return parse_datetime(value) +class ChoiceBlock(FieldBlock): + choices = () + + def __init__(self, choices=None, required=True, help_text=None, **kwargs): + if choices is None: + # no choices specified, so pick up the choice list defined at the class level + choices = list(self.choices) + else: + choices = list(choices) + + # keep a copy of all kwargs (including our normalised choices list) for deconstruct() + self._constructor_kwargs = kwargs.copy() + self._constructor_kwargs['choices'] = choices + if required is not True: + self._constructor_kwargs['required'] = required + if help_text is not None: + self._constructor_kwargs['help_text'] = help_text + + # If choices does not already contain a blank option, insert one + # (to match Django's own behaviour for modelfields: https://github.com/django/django/blob/1.7.5/django/db/models/fields/__init__.py#L732-744) + has_blank_choice = False + for v1, v2 in choices: + if isinstance(v2, (list, tuple)): + # this is a named group, and v2 is the value list + has_blank_choice = any([value in ('', None) for value, label in v2]) + if has_blank_choice: + break + else: + # this is an individual choice; v1 is the value + if v1 in ('', None): + has_blank_choice = True + break + + if not has_blank_choice: + choices = BLANK_CHOICE_DASH + choices + + self.field = forms.ChoiceField(choices=choices, required=required, help_text=help_text) + super(ChoiceBlock, self).__init__(**kwargs) + + def deconstruct(self): + """ + Always deconstruct ChoiceBlock instances as if they were plain ChoiceBlocks with their + choice list passed in the constructor, even if they are actually subclasses. This allows + users to define subclasses of ChoiceBlock in their models.py, with specific choice lists + passed in, without references to those classes ending up frozen into migrations. + """ + return ('wagtail.wagtailcore.blocks.ChoiceBlock', [], self._constructor_kwargs) + + + class RichTextBlock(FieldBlock): @cached_property def field(self): @@ -228,7 +279,7 @@ class PageChooserBlock(ChooserBlock): # rather than wagtailcore.blocks.field.FooBlock block_classes = [ FieldBlock, CharBlock, URLBlock, RichTextBlock, RawHTMLBlock, ChooserBlock, PageChooserBlock, - BooleanBlock, DateBlock, TimeBlock, DateTimeBlock, + BooleanBlock, DateBlock, TimeBlock, DateTimeBlock, ChoiceBlock, ] DECONSTRUCT_ALIASES = { cls: 'wagtail.wagtailcore.blocks.%s' % cls.__name__ diff --git a/wagtail/wagtailcore/tests/test_blocks.py b/wagtail/wagtailcore/tests/test_blocks.py index 17bf1b81e..35b13f1df 100644 --- a/wagtail/wagtailcore/tests/test_blocks.py +++ b/wagtail/wagtailcore/tests/test_blocks.py @@ -82,6 +82,150 @@ class TestFieldBlock(unittest.TestCase): self.assertEqual(content, ["Choice 1"]) +class TestChoiceBlock(unittest.TestCase): + def setUp(self): + from django.db.models.fields import BLANK_CHOICE_DASH + self.blank_choice_dash_label = BLANK_CHOICE_DASH[0][1] + + def test_render_required_choice_block(self): + block = blocks.ChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')]) + html = block.render_form('coffee', prefix='beverage') + self.assertIn('', html) + self.assertIn('' % self.blank_choice_dash_label, html) + self.assertIn('', html) + self.assertIn('', html) + + def test_validate_non_required_choice_block(self): + block = blocks.ChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')], required=False) + self.assertEqual(block.clean('coffee'), 'coffee') + + with self.assertRaises(ValidationError): + block.clean('whisky') + + self.assertEqual(block.clean(''), '') + self.assertEqual(block.clean(None), '') + + def test_render_choice_block_with_existing_blank_choice(self): + block = blocks.ChoiceBlock( + choices=[('tea', 'Tea'), ('coffee', 'Coffee'), ('', 'No thanks')], + required=False) + html = block.render_form(None, prefix='beverage') + self.assertIn('', html) + self.assertIn('' % self.blank_choice_dash_label, html) + self.assertIn('', html) + self.assertIn('', html) + + # test rendering with a non-blank option selected + html = block.render_form('tea', prefix='beverage') + self.assertIn('', html) + self.assertNotIn('' % self.blank_choice_dash_label, html) + self.assertNotIn('' % self.blank_choice_dash_label, html) + self.assertIn('', html) + self.assertIn('', html) + self.assertIn('', html) + + # test rendering with a non-blank option selected + html = block.render_form('tea', prefix='beverage') + self.assertIn('', html) + self.assertIn('', html) + + # subclasses of ChoiceBlock should deconstruct to a basic ChoiceBlock for migrations + self.assertEqual( + block.deconstruct(), + ( + 'wagtail.wagtailcore.blocks.ChoiceBlock', + [], + { + 'choices': [('tea', 'Tea'), ('coffee', 'Coffee')], + 'required': False, + }, + ) + ) + + class TestMeta(unittest.TestCase): def test_set_template_with_meta(self): class HeadingBlock(blocks.CharBlock):