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('