Implement MultipleChoiceBlock (rebase of #5592) (#5819)

* Implement MultipleChoiceBlock (squashed commits from #5592)

* Omit widget from frozen kwargs

* Rename get_callable_choices to indicate it is an internal method

* Add release notes for MultipleChoiceBlock
pull/5820/head
Matt Westcott 2020-02-10 22:24:49 +00:00 zatwierdzone przez GitHub
rodzic 2b797f4a2e
commit 4314f3d1a1
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
6 zmienionych plików z 386 dodań i 14 usunięć

Wyświetl plik

@ -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ś)

Wyświetl plik

@ -431,6 +431,7 @@ Contributors
* Martin Coote
* Simon Evans
* Arkadiusz Michał Ryś
* James O'Toole
Translators
===========

Wyświetl plik

@ -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ś)

Wyświetl plik

@ -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 <https://docs.djangoproject.com/en/stable/ref/validators/>`__).
``widget``
The form widget to render the field with (see `Django Widgets <https://docs.djangoproject.com/en/stable/ref/forms/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 <https://docs.djangoproject.com/en/stable/ref/validators/>`__).
``widget``
The form widget to render the field with (see `Django Widgets <https://docs.djangoproject.com/en/stable/ref/forms/widgets/>`__).
PageChooserBlock
~~~~~~~~~~~~~~~~

Wyświetl plik

@ -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 = {

Wyświetl plik

@ -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('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertInHTML('<option value="coffee" selected="selected">Coffee</option>', html)
def test_render_required_multiple_choice_block_with_default(self):
block = blocks.MultipleChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')], default='tea')
html = block.render_form('coffee', prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertInHTML('<option value="coffee" selected="selected">Coffee</option>', 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('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertInHTML('<option value="coffee" selected="selected">Coffee</option>', html)
def test_validate_required_multiple_choice_block(self):
block = blocks.MultipleChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')])
self.assertEqual(block.clean(['coffee']), ['coffee'])
with self.assertRaises(ValidationError):
block.clean(['whisky'])
with self.assertRaises(ValidationError):
block.clean('')
with self.assertRaises(ValidationError):
block.clean(None)
def test_render_non_required_multiple_choice_block(self):
block = blocks.MultipleChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')], required=False)
html = block.render_form('coffee', prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertInHTML('<option value="coffee" selected="selected">Coffee</option>', 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('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertInHTML('<option value="coffee" selected="selected">Coffee</option>', html)
def test_validate_non_required_multiple_choice_block(self):
block = blocks.MultipleChoiceBlock(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_multiple_choice_block_with_existing_blank_choice(self):
block = blocks.MultipleChoiceBlock(
choices=[('tea', 'Tea'), ('coffee', 'Coffee'), ('', 'No thanks')],
required=False)
html = block.render_form("", prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertNotIn('<option value="">%s</option>' % self.blank_choice_dash_label, html)
self.assertInHTML('<option value="" selected="selected">No thanks</option>', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertInHTML('<option value="coffee">Coffee</option>', 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('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertNotIn('<option value="">%s</option>' % self.blank_choice_dash_label, html)
self.assertInHTML('<option value="" selected="selected">No thanks</option>', html)
self.assertIn('<option value="tea">Tea</option>', html)
self.assertIn('<option value="coffee">Coffee</option>', html)
def test_named_groups_without_blank_option(self):
block = blocks.MultipleChoiceBlock(
choices=[
('Alcoholic', [
('gin', 'Gin'),
('whisky', 'Whisky'),
]),
('Non-alcoholic', [
('tea', 'Tea'),
('coffee', 'Coffee'),
]),
])
# test rendering with the blank option selected
html = block.render_form(None, prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<optgroup label="Alcoholic">', html)
self.assertIn('<option value="tea">Tea</option>', html)
# test rendering with a non-blank option selected
html = block.render_form('tea', prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertIn('<optgroup label="Alcoholic">', html)
self.assertInHTML('<option value="tea" selected="selected">Tea</option>', html)
def test_named_groups_with_blank_option(self):
block = blocks.MultipleChoiceBlock(
choices=[
('Alcoholic', [
('gin', 'Gin'),
('whisky', 'Whisky'),
]),
('Non-alcoholic', [
('tea', 'Tea'),
('coffee', 'Coffee'),
]),
('Not thirsty', [
('', 'No thanks')
]),
],
required=False)
# test rendering with the blank option selected
html = block.render_form(None, prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertNotIn('<option value="">%s</option>' % self.blank_choice_dash_label, html)
self.assertIn('<optgroup label="Alcoholic">', html)
self.assertIn('<option value="tea">Tea</option>', html)
# test rendering with a non-blank option selected
html = block.render_form('tea', prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertNotIn('<option value="">%s</option>' % self.blank_choice_dash_label, html)
self.assertNotInHTML('<option value="" selected="selected">%s</option>' % self.blank_choice_dash_label, html)
self.assertIn('<optgroup label="Alcoholic">', html)
self.assertInHTML('<option value="tea" selected="selected">Tea</option>', html)
def test_subclassing(self):
class BeverageMultipleChoiceBlock(blocks.MultipleChoiceBlock):
choices = [
('tea', 'Tea'),
('coffee', 'Coffee'),
]
block = BeverageMultipleChoiceBlock(required=False)
html = block.render_form('tea', prefix='beverage')
self.assertTagInHTML('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertInHTML('<option value="tea" selected="selected">Tea</option>', 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('<select multiple id="beverage" name="beverage" placeholder="">', html)
self.assertInHTML('<option value="tea" selected="selected">Tea</option>', html)
self.assertEqual(
block.deconstruct(),
(
'wagtail.core.blocks.MultipleChoiceBlock',
[],
{
'choices': callable_choices,
'required': False,
},
)
)
def test_render_with_validator(self):
choices = [
('tea', 'Tea'),
('coffee', 'Coffee'),
]
def validate_tea_is_selected(value):
raise ValidationError("You must select 'tea'")
block = blocks.MultipleChoiceBlock(choices=choices, validators=[validate_tea_is_selected])
with self.assertRaises(ValidationError):
block.clean('coffee')
class TestRawHTMLBlock(unittest.TestCase):
def test_get_default_with_fallback_value(self):
default_value = blocks.RawHTMLBlock().get_default()