Browse Source

Added blocks_by_name, first_block_by_name methods to StreamValue

- A shortcut for accessing StreamField blocks by name
tijani 3 years ago
parent
commit
8cdb3f3a60
5 changed files with 168 additions and 2 deletions
  1. 2 1
      CHANGELOG.txt
  2. 1 0
      docs/releases/4.0.md
  3. 37 0
      docs/topics/streamfield.md
  4. 61 1
      wagtail/blocks/stream_block.py
  5. 67 0
      wagtail/tests/test_blocks.py

+ 2 - 1
CHANGELOG.txt

@@ -76,6 +76,8 @@ Changelog
  * Add an extra confirmation prompt when deleting pages with a large number of child pages (Jaspreet Singh)
  * Adopt the slim header in page listing views, with buttons moved under the "Actions" dropdown (Paarth Agarwal)
  * Improve help block styles in Windows High Contrast Mode with less reliance on communication via colour alone (Anuja Verma)
+ * Replace latin abbreviations (i.e. / e.g.) with common English phrases so that documentation is easier to understand (Dominik Lech)
+ * Add shortcut for accessing StreamField blocks by block name with new `blocks_by_name` and `first_block_by_name` methods on `StreamValue` (Tidiane Dia, Matt Westcott)
  * Fix: Typo in `ResumeWorkflowActionFormatter` message (Stefan Hammer)
  * Fix: Throw a meaningful error when saving an image to an unrecognised image format (Christian Franke)
  * Fix: Remove extra padding for headers with breadcrumbs on mobile viewport (Steven Steinwand)
@@ -2204,7 +2206,6 @@ Changelog
  * Added a system check to warn if Pillow is compiled without JPEG / PNG support
  * Page chooser now prevents users from selecting the root node where this would be invalid
  * New translations for Dutch (Netherlands), Georgian, Swedish and Turkish (Turkey)
- * Replace latin abbreviations (i.e. / e.g.) with common English phrases so that documentation is easier to understand (Dominik Lech)
  * Fix: Page slugs are no longer auto-updated from the page title if the page is already published
  * Fix: Deleting a page permission from the groups admin UI does not immediately submit the form
  * Fix: Wagtail userbar is shown on pages that do not pass a `page` variable to the template (e.g. because they override the `serve` method)

+ 1 - 0
docs/releases/4.0.md

@@ -97,6 +97,7 @@ Wagtail’s page preview is now available in a side panel within the page editor
  * Adopt the slim header in page listing views, with buttons moved under the "Actions" dropdown (Paarth Agarwal)
  * Improve help block styles in Windows High Contrast Mode with less reliance on communication via colour alone (Anuja Verma)
  * Replace latin abbreviations (i.e. / e.g.) with common English phrases so that documentation is easier to understand (Dominik Lech)
+ * Add shortcut for accessing StreamField blocks by block name with new [`blocks_by_name` and `first_block_by_name` methods on `StreamValue`](streamfield_retrieving_blocks_by_name) (Tidiane Dia, Matt Westcott)
 
 ### Bug fixes
 

+ 37 - 0
docs/topics/streamfield.md

@@ -519,6 +519,43 @@ my_page.body.append(('paragraph', RichText("<p>And they all lived happily ever a
 my_page.save()
 ```
 
+(streamfield_retrieving_blocks_by_name)=
+
+## Retrieving blocks by name
+
+```{versionadded} 4.0
+The `blocks_by_name` and `first_block_by_name` methods were added.
+```
+
+StreamField values provide a `blocks_by_name` method for retrieving all blocks of a given name:
+
+```python
+my_page.body.blocks_by_name('heading')  # returns a list of 'heading' blocks
+```
+
+Calling `blocks_by_name` with no arguments returns a `dict`-like object, mapping block names to the list of blocks of that name. This is particularly useful in template code, where passing arguments isn't possible:
+
+```html+django
+<h2>Table of contents</h2>
+<ol>
+    {% for heading_block in page.body.blocks_by_name.heading %}
+        <li>{{ heading_block.value }}</li>
+    {% endfor %}
+</ol>
+```
+
+The `first_block_by_name` method returns the first block of the given name in the stream, or `None` if no matching block is found:
+
+```
+hero_image = my_page.body.first_block_by_name('image')
+```
+
+`first_block_by_name` can also be called without arguments to return a `dict`-like mapping:
+
+```html+django
+<div class="hero-image">{{ page.body.first_block_by_name.image }}</div>
+```
+
 (streamfield_migrating_richtext)=
 
 ## Migrating RichTextFields to StreamField

+ 61 - 1
wagtail/blocks/stream_block.py

@@ -1,7 +1,7 @@
 import itertools
 import uuid
 from collections import OrderedDict, defaultdict
-from collections.abc import MutableSequence
+from collections.abc import Mapping, MutableSequence
 
 from django import forms
 from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
@@ -460,6 +460,52 @@ class StreamValue(MutableSequence):
         def __repr__(self):
             return repr(list(self))
 
+    class BlockNameLookup(Mapping):
+        """
+        Dict-like object returned from `blocks_by_name`, for looking up a stream's blocks by name.
+        Uses lazy evaluation on access, so that we're not redundantly constructing StreamChild
+        instances for blocks of different names.
+        """
+
+        def __init__(self, stream_value, find_all=True):
+            self.stream_value = stream_value
+            self.block_names = stream_value.stream_block.child_blocks.keys()
+            self.find_all = (
+                find_all  # whether to return all results rather than just the first
+            )
+
+        def __getitem__(self, block_name):
+            result = [] if self.find_all else None
+
+            if block_name not in self.block_names:
+                # skip the search and return an empty result
+                return result
+
+            for i in range(len(self.stream_value)):
+                # Skip over blocks that have not yet been instantiated from _raw_data and are of
+                # different names to the one we're looking for
+                if (
+                    self.stream_value._bound_blocks[i] is None
+                    and self.stream_value._raw_data[i]["type"] != block_name
+                ):
+                    continue
+
+                block = self.stream_value[i]
+                if block.block_type == block_name:
+                    if self.find_all:
+                        result.append(block)
+                    else:
+                        return block
+
+            return result
+
+        def __iter__(self):
+            for block_name in self.block_names:
+                yield block_name
+
+        def __len__(self):
+            return len(self.block_names)
+
     def __init__(self, stream_block, stream_data, is_lazy=False, raw_text=None):
         """
         Construct a StreamValue linked to the given StreamBlock,
@@ -590,6 +636,20 @@ class StreamValue(MutableSequence):
 
         return prep_value
 
+    def blocks_by_name(self, block_name=None):
+        lookup = StreamValue.BlockNameLookup(self, find_all=True)
+        if block_name:
+            return lookup[block_name]
+        else:
+            return lookup
+
+    def first_block_by_name(self, block_name=None):
+        lookup = StreamValue.BlockNameLookup(self, find_all=False)
+        if block_name:
+            return lookup[block_name]
+        else:
+            return lookup
+
     def __eq__(self, other):
         if not isinstance(other, StreamValue) or len(other) != len(self):
             return False

+ 67 - 0
wagtail/tests/test_blocks.py

@@ -3969,6 +3969,73 @@ class TestStreamBlock(WagtailTestUtils, SimpleTestCase):
             },
         )
 
+    def test_block_names(self):
+        class ArticleBlock(blocks.StreamBlock):
+            heading = blocks.CharBlock()
+            paragraph = blocks.TextBlock()
+            date = blocks.DateBlock()
+
+        block = ArticleBlock()
+        value = block.to_python(
+            [
+                {
+                    "type": "heading",
+                    "value": "My title",
+                },
+                {
+                    "type": "paragraph",
+                    "value": "My first paragraph",
+                },
+                {
+                    "type": "paragraph",
+                    "value": "My second paragraph",
+                },
+            ]
+        )
+        blocks_by_name = value.blocks_by_name()
+        assert isinstance(blocks_by_name, blocks.StreamValue.BlockNameLookup)
+
+        # unpack results to a dict of {block name: list of block values} for easier comparison
+        result = {
+            block_name: [block.value for block in blocks]
+            for block_name, blocks in blocks_by_name.items()
+        }
+        self.assertEqual(
+            result,
+            {
+                "heading": ["My title"],
+                "paragraph": ["My first paragraph", "My second paragraph"],
+                "date": [],
+            },
+        )
+
+        paragraph_blocks = value.blocks_by_name(block_name="paragraph")
+        # We can also access by indexing on the stream
+        self.assertEqual(paragraph_blocks, value.blocks_by_name()["paragraph"])
+
+        self.assertEqual(len(paragraph_blocks), 2)
+        for block in paragraph_blocks:
+            self.assertEqual(block.block_type, "paragraph")
+
+        self.assertEqual(value.blocks_by_name(block_name="date"), [])
+        self.assertEqual(value.blocks_by_name(block_name="invalid_type"), [])
+
+        first_heading_block = value.first_block_by_name(block_name="heading")
+        self.assertEqual(first_heading_block.block_type, "heading")
+        self.assertEqual(first_heading_block.value, "My title")
+
+        self.assertIs(value.first_block_by_name(block_name="date"), None)
+        self.assertIs(value.first_block_by_name(block_name="invalid_type"), None)
+
+        # first_block_by_name with no argument returns a dict-like lookup of first blocks per name
+        first_blocks_by_name = value.first_block_by_name()
+        first_heading_block = first_blocks_by_name["heading"]
+        self.assertEqual(first_heading_block.block_type, "heading")
+        self.assertEqual(first_heading_block.value, "My title")
+
+        self.assertIs(first_blocks_by_name["date"], None)
+        self.assertIs(first_blocks_by_name["invalid_type"], None)
+
     def test_adapt_with_classname_via_class_meta(self):
         """form_classname from meta to be used as an additional class when rendering stream block"""