diff --git a/CHANGELOG.txt b/CHANGELOG.txt index ab8093a80a..4f40b873f9 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -22,6 +22,7 @@ Changelog * Optimized preview data storage (Bertrand Bordage) * Added `render_landing_page` method to `AbstractForm` to be easily overridden and pass `form_submission` to landing page context (Stein Strindhaug) * Added `heading` kwarg to `InlinePanel` to allow heading to be set independently of button label (Adrian Turjak) + * The value type returned from a `StructBlock` can now be customised (LB (Ben Johnston)) * Fix: Do not remove stopwords when generating slugs from non-ASCII titles, to avoid issues with incorrect word boundaries (Sævar Öfjörð Magnússon) * Fix: The PostgreSQL search backend now preserves ordering of the `QuerySet` when searching with `order_by_relevance=False` (Bertrand Bordage) * Fix: Using `modeladmin_register` as a decorator no longer replaces the decorated class with `None` (Tim Heap) diff --git a/docs/releases/2.0.rst b/docs/releases/2.0.rst index 2835d701b1..42c0480b0b 100644 --- a/docs/releases/2.0.rst +++ b/docs/releases/2.0.rst @@ -35,6 +35,7 @@ Other features * Optimized preview data storage (Bertrand Bordage) * Added ``render_landing_page`` method to ``AbstractForm`` to be easily overridden and pass ``form_submission`` to landing page context (Stein Strindhaug) * Added ``heading`` kwarg to ``InlinePanel`` to allow heading to be set independently of button label (Adrian Turjak) + * The value type returned from a ``StructBlock`` can now be customised. See :ref:`custom_value_class_for_structblock` (LB (Ben Johnston)) Bug fixes ~~~~~~~~~ diff --git a/docs/topics/streamfield.rst b/docs/topics/streamfield.rst index 935d753297..4e1ae11876 100644 --- a/docs/topics/streamfield.rst +++ b/docs/topics/streamfield.rst @@ -377,6 +377,9 @@ This defines ``PersonBlock()`` as a block type that can be re-used as many times Further options are available for customising the display of a ``StructBlock`` within the page editor - see :ref:`custom_editing_interfaces_for_structblock`. +You can also customise how the value of a ``StructBlock`` is prepared for using in templates - see :ref:`custom_value_class_for_structblock`. + + ListBlock ~~~~~~~~~ @@ -848,6 +851,81 @@ To add additional variables, you can override the block's ``get_form_context`` m form_template = 'myapp/block_forms/person.html' +.. _custom_value_class_for_structblock: + +Custom value class for ``StructBlock`` +-------------------------------------- + +To customise the methods available for a ``StructBlock`` value, you can specify a ``value_class`` attribute (either as a keyword argument to the ``StructBlock`` constructor, or in a subclass's ``Meta``) to override how the value is prepared. + +This ``value_class`` must be a subclass of ``StructValue``, any additional methods can access the value from sub-blocks via the block key on ``self`` (e.g. ``self.get('my_block')``). + +Example: + +.. code-block:: python + + from wagtail.core.models import Page + from wagtail.core.blocks import ( + CharBlock, PageChooserBlock, StructValue, StructBlock, TextBlock, URLBlock) + + + class LinkStructValue(StructValue): + def url(self): + external_url = self.get('external_url') + page = self.get('page') + if external_url: + return external_url + elif page: + return page.url + + + class QuickLinkBlock(StructBlock): + text = CharBlock(label="link text", required=True) + page = PageChooserBlock(label="page", required=False) + external_url = URLBlock(label="external URL", required=False) + + class Meta: + icon = 'site' + value_class = LinkStructValue + + + class MyPage(Page): + quick_links = StreamField([('links', QuickLinkBlock())], blank=True) + quotations = StreamField([('quote', StructBlock([ + ('quote', TextBlock(required=True)), + ('page', PageChooserBlock(required=False)), + ('external_url', URLBlock(required=False)), + ], icon='openquote', value_class=LinkStructValue))], blank=True) + + content_panels = Page.content_panels + [ + StreamFieldPanel('quick_links'), + StreamFieldPanel('quotations'), + ] + + + +Your extended value class methods will be available in your template: + +.. code-block:: html+django + + {% load wagtailcore_tags %} + + + +
+ {% for quotation in page.quotations %} +
+ {{ quotation.value.quote }} +
+ {% endfor %} +
+ + + Custom block types ------------------ diff --git a/wagtail/core/blocks/struct_block.py b/wagtail/core/blocks/struct_block.py index 53d514677d..f771effb92 100644 --- a/wagtail/core/blocks/struct_block.py +++ b/wagtail/core/blocks/struct_block.py @@ -14,6 +14,26 @@ from .utils import js_dict __all__ = ['BaseStructBlock', 'StructBlock', 'StructValue'] +class StructValue(collections.OrderedDict): + """ A class that generates a StructBlock value from provded sub-blocks """ + def __init__(self, block, *args): + super().__init__(*args) + self.block = block + + def __html__(self): + return self.block.render(self) + + def render_as_block(self, context=None): + return self.block.render(self, context=context) + + @cached_property + def bound_blocks(self): + return collections.OrderedDict([ + (name, block.bind(self.get(name))) + for name, block in self.block.child_blocks.items() + ]) + + class BaseStructBlock(Block): def __init__(self, local_blocks=None, **kwargs): @@ -42,7 +62,7 @@ class BaseStructBlock(Block): rather than a StructValue; for consistency, we need to convert it to a StructValue for StructBlock to work with """ - return StructValue(self, self.meta.default.items()) + return self._to_struct_value(self.meta.default.items()) def js_initializer(self): # skip JS setup entirely if no children have js_initializers @@ -88,7 +108,7 @@ class BaseStructBlock(Block): return render_to_string(self.meta.form_template, context) def value_from_datadict(self, data, files, prefix): - return StructValue(self, [ + return self._to_struct_value([ (name, block.value_from_datadict(data, files, '%s-%s' % (prefix, name))) for name, block in self.child_blocks.items() ]) @@ -113,11 +133,11 @@ class BaseStructBlock(Block): # and delegate the errors contained in the 'params' dict to the child blocks instead raise ValidationError('Validation error in StructBlock', params=errors) - return StructValue(self, result) + return self._to_struct_value(result) def to_python(self, value): - # recursively call to_python on children and return as a StructValue - return StructValue(self, [ + """ Recursively call to_python on children and return as a StructValue """ + return self._to_struct_value([ ( name, (child_block.to_python(value[name]) if name in value else child_block.get_default()) @@ -127,15 +147,19 @@ class BaseStructBlock(Block): for name, child_block in self.child_blocks.items() ]) + def _to_struct_value(self, block_items): + """ Return a Structvalue representation of the sub-blocks in this block """ + return self.meta.value_class(self, block_items) + def get_prep_value(self, value): - # recursively call get_prep_value on children and return as a plain dict + """ Recursively call get_prep_value on children and return as a plain dict """ return dict([ (name, self.child_blocks[name].get_prep_value(val)) for name, val in value.items() ]) def get_api_representation(self, value, context=None): - # recursively call get_api_representation on children and return as a plain dict + """ Recursively call get_api_representation on children and return as a plain dict """ return dict([ (name, self.child_blocks[name].get_api_representation(val, context=context)) for name, val in value.items() @@ -179,6 +203,7 @@ class BaseStructBlock(Block): default = {} form_classname = 'struct-block' form_template = 'wagtailadmin/block_forms/struct.html' + value_class = StructValue # 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 @@ -187,22 +212,3 @@ class BaseStructBlock(Block): class StructBlock(BaseStructBlock, metaclass=DeclarativeSubBlocksMetaclass): pass - - -class StructValue(collections.OrderedDict): - def __init__(self, block, *args): - super().__init__(*args) - self.block = block - - def __html__(self): - return self.block.render(self) - - def render_as_block(self, context=None): - return self.block.render(self, context=context) - - @cached_property - def bound_blocks(self): - return collections.OrderedDict([ - (name, block.bind(self.get(name))) - for name, block in self.block.child_blocks.items() - ]) diff --git a/wagtail/core/tests/test_blocks.py b/wagtail/core/tests/test_blocks.py index d6f23801c3..858f7d22ec 100644 --- a/wagtail/core/tests/test_blocks.py +++ b/wagtail/core/tests/test_blocks.py @@ -16,13 +16,13 @@ from django.utils.html import format_html from django.utils.safestring import SafeData, mark_safe from django.utils.translation import ugettext_lazy as __ +from wagtail.core import blocks +from wagtail.core.models import Page +from wagtail.core.rich_text import RichText from wagtail.tests.testapp.blocks import LinkBlock as CustomLinkBlock from wagtail.tests.testapp.blocks import SectionBlock from wagtail.tests.testapp.models import EventPage, SimplePage from wagtail.tests.utils import WagtailTestUtils -from wagtail.core import blocks -from wagtail.core.models import Page -from wagtail.core.rich_text import RichText class FooStreamBlock(blocks.StreamBlock): @@ -1348,6 +1348,173 @@ class TestStructBlock(SimpleTestCase): self.assertEqual(result, """

Bonjour

monde italique
""") +class TestStructBlockWithCustomStructValue(SimpleTestCase): + + def test_initialisation(self): + + class CustomStructValue(blocks.StructValue): + def joined(self): + return self.get('title', '') + self.get('link', '') + + block = blocks.StructBlock([ + ('title', blocks.CharBlock()), + ('link', blocks.URLBlock()), + ], value_class=CustomStructValue) + + self.assertEqual(list(block.child_blocks.keys()), ['title', 'link']) + + block_value = block.to_python({'title': 'Birthday party', 'link': 'https://myparty.co.uk'}) + self.assertIsInstance(block_value, CustomStructValue) + + default_value = block.get_default() + self.assertIsInstance(default_value, CustomStructValue) + + value_from_datadict = block.value_from_datadict({ + 'mylink-title': "Torchbox", + 'mylink-link': "http://www.torchbox.com" + }, {}, 'mylink') + + self.assertIsInstance(value_from_datadict, CustomStructValue) + + value = block.to_python({'title': 'Torchbox', 'link': 'http://www.torchbox.com/'}) + clean_value = block.clean(value) + self.assertTrue(isinstance(clean_value, CustomStructValue)) + self.assertEqual(clean_value['title'], 'Torchbox') + + value = block.to_python({'title': 'Torchbox', 'link': 'not a url'}) + with self.assertRaises(ValidationError): + block.clean(value) + + + def test_initialisation_from_subclass(self): + + class LinkStructValue(blocks.StructValue): + def url(self): + return self.get('page') or self.get('link') + + class LinkBlock(blocks.StructBlock): + title = blocks.CharBlock() + page = blocks.PageChooserBlock(required=False) + link = blocks.URLBlock(required=False) + + class Meta: + value_class = LinkStructValue + + block = LinkBlock() + + self.assertEqual(list(block.child_blocks.keys()), ['title', 'page', 'link']) + + block_value = block.to_python({'title': 'Website', 'link': 'https://website.com'}) + self.assertIsInstance(block_value, LinkStructValue) + + default_value = block.get_default() + self.assertIsInstance(default_value, LinkStructValue) + + + def test_initialisation_with_multiple_subclassses(self): + class LinkStructValue(blocks.StructValue): + def url(self): + return self.get('page') or self.get('link') + + class LinkBlock(blocks.StructBlock): + title = blocks.CharBlock() + page = blocks.PageChooserBlock(required=False) + link = blocks.URLBlock(required=False) + + class Meta: + value_class = LinkStructValue + + class StyledLinkBlock(LinkBlock): + classname = blocks.CharBlock() + + block = StyledLinkBlock() + + self.assertEqual(list(block.child_blocks.keys()), ['title', 'page', 'link', 'classname']) + + value_from_datadict = block.value_from_datadict({ + 'queen-title': "Torchbox", + 'queen-link': "http://www.torchbox.com", + 'queen-classname': "fullsize", + }, {}, 'queen') + + self.assertIsInstance(value_from_datadict, LinkStructValue) + + def test_initialisation_with_mixins(self): + class LinkStructValue(blocks.StructValue): + pass + + class StylingMixinStructValue(blocks.StructValue): + pass + + class LinkBlock(blocks.StructBlock): + title = blocks.CharBlock() + link = blocks.URLBlock() + + class Meta: + value_class = LinkStructValue + + class StylingMixin(blocks.StructBlock): + classname = blocks.CharBlock() + + class StyledLinkBlock(StylingMixin, LinkBlock): + source = blocks.CharBlock() + + block = StyledLinkBlock() + + self.assertEqual(list(block.child_blocks.keys()), + ['title', 'link', 'classname', 'source']) + + block_value = block.to_python({ + 'title': 'Website', 'link': 'https://website.com', + 'source': 'google', 'classname': 'full-size', + }) + self.assertIsInstance(block_value, LinkStructValue) + + + def test_value_property(self): + + class SectionStructValue(blocks.StructValue): + @property + def foo(self): + return 'bar %s' % self.get('title', '') + + class SectionBlock(blocks.StructBlock): + title = blocks.CharBlock() + body = blocks.RichTextBlock() + + class Meta: + value_class = SectionStructValue + + block = SectionBlock() + struct_value = block.to_python({'title': 'hello', 'body': 'world'}) + value = struct_value.foo + self.assertEqual(value, 'bar hello') + + def test_render_with_template(self): + + class SectionStructValue(blocks.StructValue): + def title_with_suffix(self): + title = self.get('title') + if title: + return 'SUFFIX %s' % title + return 'EMPTY TITLE' + + class SectionBlock(blocks.StructBlock): + title = blocks.CharBlock(required=False) + + class Meta: + value_class = SectionStructValue + + block = SectionBlock(template='tests/blocks/struct_block_custom_value.html') + struct_value = block.to_python({'title': 'hello'}) + html = block.render(struct_value) + self.assertEqual(html, '
SUFFIX hello
\n') + + struct_value = block.to_python({}) + html = block.render(struct_value) + self.assertEqual(html, '
EMPTY TITLE
\n') + + class TestListBlock(WagtailTestUtils, SimpleTestCase): def test_initialise_with_class(self): block = blocks.ListBlock(blocks.CharBlock) diff --git a/wagtail/tests/testapp/templates/tests/blocks/struct_block_custom_value.html b/wagtail/tests/testapp/templates/tests/blocks/struct_block_custom_value.html new file mode 100644 index 0000000000..7fff5d7127 --- /dev/null +++ b/wagtail/tests/testapp/templates/tests/blocks/struct_block_custom_value.html @@ -0,0 +1 @@ +
{{ value.title_with_suffix }}