diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 860c83d6e1..72a6c9e834 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -4,6 +4,8 @@ Changelog 2.9 (xx.xx.xxxx) - IN DEVELOPMENT ~~~~~~~~~~~~~~~~ + * Added `MultipleChoiceBlock` block type for StreamField (James O'Toole) + * ChoiceBlock now accepts a `widget` keyword argument (James O'Toole) * Reduced contrast of rich text toolbar (Jack Paine) * Support the rel attribute on custom ModelAdmin buttons (Andy Chosak) * Server-side page slug generation now respects `WAGTAIL_ALLOW_UNICODE_SLUGS` (Arkadiusz Michał Ryś) diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index aac09fc0b9..4b0ccf51a2 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -431,6 +431,7 @@ Contributors * Martin Coote * Simon Evans * Arkadiusz Michał Ryś +* James O'Toole Translators =========== diff --git a/docs/releases/2.9.rst b/docs/releases/2.9.rst index 3d9a1928d1..1717b7d107 100644 --- a/docs/releases/2.9.rst +++ b/docs/releases/2.9.rst @@ -14,6 +14,8 @@ What's new Other features ~~~~~~~~~~~~~~ + * Added :ref:`streamfield_multiplechoiceblock` block type for StreamField (James O'Toole) + * ChoiceBlock now accepts a ``widget`` keyword argument (James O'Toole) * Reduced contrast of rich text toolbar (Jack Paine) * Support the rel attribute on custom ModelAdmin buttons (Andy Chosak) * Server-side page slug generation now respects ``WAGTAIL_ALLOW_UNICODE_SLUGS`` (Arkadiusz Michał Ryś) diff --git a/docs/topics/streamfield.rst b/docs/topics/streamfield.rst index 870cc5e90a..904388a4c8 100644 --- a/docs/topics/streamfield.rst +++ b/docs/topics/streamfield.rst @@ -238,6 +238,9 @@ A dropdown select box for choosing from a list of choices. The following keyword ``validators`` A list of validation functions for the field (see `Django Validators `__). +``widget`` + The form widget to render the field with (see `Django Widgets `__). + ``ChoiceBlock`` can also be subclassed to produce a reusable block with the same list of choices everywhere it is used. For example, a block definition such as: .. code-block:: python @@ -264,6 +267,32 @@ could be rewritten as a subclass of ChoiceBlock: ``StreamField`` definitions can then refer to ``DrinksChoiceBlock()`` in place of the full ``ChoiceBlock`` definition. Note that this only works when ``choices`` is a fixed list, not a callable. + +.. _streamfield_multiplechoiceblock: + +MultipleChoiceBlock +~~~~~~~~~~~~~~~~~~~ + +``wagtail.core.blocks.MultipleChoiceBlock`` + +A multiple select box for choosing from a list of choices. The following keyword arguments are accepted: + +``choices`` + A list of choices, in any format accepted by Django's :attr:`~django.db.models.Field.choices` parameter for model fields, or a callable returning such a list. + +``required`` (default: True) + If true, the field cannot be left blank. + +``help_text`` + Help text to display alongside the field. + +``validators`` + A list of validation functions for the field (see `Django Validators `__). + +``widget`` + The form widget to render the field with (see `Django Widgets `__). + + PageChooserBlock ~~~~~~~~~~~~~~~~ diff --git a/wagtail/core/blocks/field_block.py b/wagtail/core/blocks/field_block.py index 6b6305d77b..ab1c8f185f 100644 --- a/wagtail/core/blocks/field_block.py +++ b/wagtail/core/blocks/field_block.py @@ -359,11 +359,16 @@ class IntegerBlock(FieldBlock): icon = "plus-inverse" -class ChoiceBlock(FieldBlock): - +class BaseChoiceBlock(FieldBlock): choices = () - def __init__(self, choices=None, default=None, required=True, help_text=None, validators=(), **kwargs): + def __init__( + self, choices=None, default=None, required=True, + help_text=None, widget=None, validators=(), **kwargs): + + self._required = required + self._default = default + if choices is None: # no choices specified, so pick up the choice defined at the class level choices = self.choices @@ -380,6 +385,8 @@ class ChoiceBlock(FieldBlock): choices_for_constructor = choices = list(choices) # keep a copy of all kwargs (including our normalised choices list) for deconstruct() + # Note: we omit the `widget` kwarg, as widgets do not provide a serialization method + # for migrations, and they are unlikely to be useful within the frozen ORM anyhow self._constructor_kwargs = kwargs.copy() self._constructor_kwargs['choices'] = choices_for_constructor if required is not True: @@ -392,18 +399,17 @@ class ChoiceBlock(FieldBlock): # than having separate code paths for static vs dynamic lists, we'll _always_ pass a callable # to ChoiceField to perform this step at render time. - # If we have a default choice and the field is required, we don't need to add a blank option. - callable_choices = self.get_callable_choices(choices, blank_choice=not(default and required)) - - self.field = forms.ChoiceField( + callable_choices = self._get_callable_choices(choices) + self.field = self.get_field( choices=callable_choices, required=required, help_text=help_text, validators=validators, + widget=widget, ) super().__init__(default=default, **kwargs) - def get_callable_choices(self, choices, blank_choice=True): + def _get_callable_choices(self, choices, blank_choice=True): """ Return a callable that we can pass into `forms.ChoiceField`, which will provide the choices list with the addition of a blank choice (if blank_choice=True and one does not @@ -442,6 +448,23 @@ class ChoiceBlock(FieldBlock): return local_choices return choices_callable + class Meta: + # No icon specified here, because that depends on the purpose that the + # block is being used for. Feel encouraged to specify an icon in your + # descendant block type + icon = "placeholder" + + +class ChoiceBlock(BaseChoiceBlock): + def get_field(self, **kwargs): + return forms.ChoiceField(**kwargs) + + def _get_callable_choices(self, choices, blank_choice=None): + # If we have a default choice and the field is required, we don't need to add a blank option. + if blank_choice is None: + blank_choice = not(self._default and self._required) + return super()._get_callable_choices(choices, blank_choice=blank_choice) + def deconstruct(self): """ Always deconstruct ChoiceBlock instances as if they were plain ChoiceBlocks with their @@ -465,11 +488,42 @@ class ChoiceBlock(FieldBlock): return [force_str(v)] return [] # Value was not found in the list of choices - class Meta: - # No icon specified here, because that depends on the purpose that the - # block is being used for. Feel encouraged to specify an icon in your - # descendant block type - icon = "placeholder" + +class MultipleChoiceBlock(BaseChoiceBlock): + def get_field(self, **kwargs): + return forms.MultipleChoiceField(**kwargs) + + def _get_callable_choices(self, choices, blank_choice=False): + """ Override to default blank choice to False + """ + return super()._get_callable_choices(choices, blank_choice=blank_choice) + + def deconstruct(self): + """ + Always deconstruct MultipleChoiceBlock instances as if they were plain + MultipleChoiceBlocks with their choice list passed in the constructor, + even if they are actually subclasses. This allows users to define + subclasses of MultipleChoiceBlock in their models.py, with specific choice + lists passed in, without references to those classes ending up frozen + into migrations. + """ + return ('wagtail.core.blocks.MultipleChoiceBlock', [], self._constructor_kwargs) + + def get_searchable_content(self, value): + # Return the display value as the searchable value + content = [] + text_value = force_str(value) + for k, v in self.field.choices: + if isinstance(v, (list, tuple)): + # This is an optgroup, so look inside the group for options + for k2, v2 in v: + if value == k2 or text_value == force_str(k2): + content.append(force_str(k)) + content.append(force_str(v2)) + else: + if value == k or text_value == force_str(k): + content.append(force_str(v)) + return content class RichTextBlock(FieldBlock): @@ -706,7 +760,7 @@ class PageChooserBlock(ChooserBlock): block_classes = [ FieldBlock, CharBlock, URLBlock, RichTextBlock, RawHTMLBlock, ChooserBlock, PageChooserBlock, TextBlock, BooleanBlock, DateBlock, TimeBlock, - DateTimeBlock, ChoiceBlock, EmailBlock, IntegerBlock, FloatBlock, + DateTimeBlock, ChoiceBlock, MultipleChoiceBlock, EmailBlock, IntegerBlock, FloatBlock, DecimalBlock, RegexBlock, BlockQuoteBlock ] DECONSTRUCT_ALIASES = { diff --git a/wagtail/core/tests/test_blocks.py b/wagtail/core/tests/test_blocks.py index 4b89f1e353..b7a6cdf5e2 100644 --- a/wagtail/core/tests/test_blocks.py +++ b/wagtail/core/tests/test_blocks.py @@ -848,6 +848,290 @@ class TestChoiceBlock(WagtailTestUtils, SimpleTestCase): block.clean('coffee') +class TestMultipleChoiceBlock(WagtailTestUtils, SimpleTestCase): + 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_multiple_choice_block(self): + block = blocks.MultipleChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')]) + html = block.render_form('coffee', prefix='beverage') + self.assertTagInHTML('', html) + self.assertIn('', html) + self.assertInHTML('', html) + + def test_render_required_multiple_choice_block_with_callable_choices(self): + def callable_choices(): + return [('tea', 'Tea'), ('coffee', 'Coffee')] + + block = blocks.MultipleChoiceBlock(choices=callable_choices) + html = block.render_form('coffee', prefix='beverage') + self.assertTagInHTML('', html) + self.assertIn('', html) + self.assertInHTML('', html) + + def test_render_non_required_multiple_choice_block_with_callable_choices(self): + def callable_choices(): + return [('tea', 'Tea'), ('coffee', 'Coffee')] + + block = blocks.MultipleChoiceBlock(choices=callable_choices, required=False) + html = block.render_form('coffee', prefix='beverage') + self.assertTagInHTML('', html) + self.assertNotIn('' % self.blank_choice_dash_label, html) + self.assertInHTML('', html) + self.assertIn('', html) + self.assertInHTML('', html) + + def test_render_multiple_choice_block_with_existing_blank_choice_and_with_callable_choices(self): + def callable_choices(): + return [('tea', 'Tea'), ('coffee', 'Coffee'), ('', 'No thanks')] + + block = blocks.MultipleChoiceBlock( + choices=callable_choices, + required=False) + html = block.render_form("", prefix='beverage') + self.assertTagInHTML('', html) + self.assertIn('', html) + self.assertIn('', html) + + # test rendering with a non-blank option selected + html = block.render_form('tea', prefix='beverage') + self.assertTagInHTML('', html) + self.assertNotIn('' % 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.assertTagInHTML('', html) + self.assertInHTML('', html) + + # subclasses of ChoiceBlock should deconstruct to a basic ChoiceBlock for migrations + self.assertEqual( + block.deconstruct(), + ( + 'wagtail.core.blocks.MultipleChoiceBlock', + [], + { + 'choices': [('tea', 'Tea'), ('coffee', 'Coffee')], + 'required': False, + }, + ) + ) + + def test_searchable_content(self): + block = blocks.MultipleChoiceBlock(choices=[ + ('choice-1', "Choice 1"), + ('choice-2', "Choice 2"), + ]) + self.assertEqual(block.get_searchable_content("choice-1"), + ["Choice 1"]) + + def test_searchable_content_with_callable_choices(self): + def callable_choices(): + return [ + ('choice-1', "Choice 1"), + ('choice-2', "Choice 2"), + ] + + block = blocks.MultipleChoiceBlock(choices=callable_choices) + self.assertEqual(block.get_searchable_content("choice-1"), + ["Choice 1"]) + + def test_optgroup_searchable_content(self): + block = blocks.MultipleChoiceBlock(choices=[ + ('Section 1', [ + ('1-1', "Block 1"), + ('1-2', "Block 2"), + ]), + ('Section 2', [ + ('2-1', "Block 1"), + ('2-2', "Block 2"), + ]), + ]) + self.assertEqual(block.get_searchable_content("2-2"), + ["Section 2", "Block 2"]) + + def test_invalid_searchable_content(self): + block = blocks.MultipleChoiceBlock(choices=[ + ('one', 'One'), + ('two', 'Two'), + ]) + self.assertEqual(block.get_searchable_content('three'), []) + + def test_searchable_content_with_lazy_translation(self): + block = blocks.MultipleChoiceBlock(choices=[ + ('choice-1', __("Choice 1")), + ('choice-2', __("Choice 2")), + ]) + result = block.get_searchable_content("choice-1") + # result must survive JSON (de)serialisation, which is not the case for + # lazy translation objects + result = json.loads(json.dumps(result)) + self.assertEqual(result, ["Choice 1"]) + + def test_optgroup_searchable_content_with_lazy_translation(self): + block = blocks.MultipleChoiceBlock(choices=[ + (__('Section 1'), [ + ('1-1', __("Block 1")), + ('1-2', __("Block 2")), + ]), + (__('Section 2'), [ + ('2-1', __("Block 1")), + ('2-2', __("Block 2")), + ]), + ]) + result = block.get_searchable_content("2-2") + # result must survive JSON (de)serialisation, which is not the case for + # lazy translation objects + result = json.loads(json.dumps(result)) + self.assertEqual(result, ["Section 2", "Block 2"]) + + def test_deconstruct_with_callable_choices(self): + def callable_choices(): + return [ + ('tea', 'Tea'), + ('coffee', 'Coffee'), + ] + + block = blocks.MultipleChoiceBlock(choices=callable_choices, required=False) + html = block.render_form('tea', prefix='beverage') + + self.assertTagInHTML('