Browse Source

Add documentation for customising StreamField validation

Matt Westcott 2 years ago
parent
commit
a07fe20ceb

+ 1 - 0
docs/advanced_topics/index.md

@@ -23,5 +23,6 @@ boundblocks_and_values
 multi_site_multi_instance_multi_tenancy
 formbuilder_routablepage_redirect
 streamfield_migrations
+streamfield_validation
 reference_index
 ```

+ 75 - 0
docs/advanced_topics/streamfield_validation.md

@@ -0,0 +1,75 @@
+(streamfield_validation)=
+
+# StreamField validation
+
+```{versionadded} 5.0
+Support for custom validation logic on StreamField blocks was improved and simplified.
+```
+
+All StreamField blocks implement a `clean` method which accepts a block value and returns a cleaned version of that value, or raises a `ValidationError` if the value fails validation. Built-in validation rules, such as checking that a URLBlock value is a correctly-formatted URL, are implemented through this method. Additionally, for blocks that act as containers for other blocks, such as StructBlock, the `clean` method recursively calls the `clean` methods of its child blocks and handles raising validation errors back to the caller as required.
+
+The `clean` method can be overridden on block subclasses to implement custom validation logic. For example, a StructBlock that requires either one of its child blocks to be filled in could be implemented as follows:
+
+```python
+from django.core.exceptions import ValidationError
+from wagtail.blocks import StructBlock, PageChooserBlock, URLBlock
+
+class LinkBlock(StructBlock):
+    page = PageChooserBlock(required=False)
+    url = URLBlock(required=False)
+
+    def clean(self, value):
+        result = super().clean(value)
+        if not(result['page'] or result['url']):
+            raise ValidationError("Either page or URL must be specified")
+        return result
+```
+
+## Controlling where error messages are rendered
+
+In the above example, an exception of type `ValidationError` is raised, which causes the error to be attached and rendered against the StructBlock as a whole. For more control over where the error appears, the exception class `wagtail.blocks.StructBlockValidationError` can be raised instead. The constructor for this class accepts the following arguments:
+
+* `non_block_errors` - a list of error messages or `ValidationError` instances to be raised against the StructBlock as a whole
+* `block_errors` - a dict of `ValidationError` instances to be displayed against specific child blocks of the StructBlock, where the key is the child block's name
+
+The following example demonstrates raising a validation error attached to the 'description' block within the StructBlock:
+
+```python
+from django.core.exceptions import ValidationError
+from wagtail.blocks import CharBlock, StructBlock, StructBlockValidationError, TextBlock
+
+class TopicBlock(StructBlock):
+    keyword = CharBlock()
+    description = TextBlock()
+
+    def clean(self, value):
+        result = super().clean(value)
+        if result["keyword"] not in result["description"]:
+            raise StructBlockValidationError(block_errors={
+                "description": ValidationError("Description must contain the keyword")
+            })
+        return result
+```
+
+ListBlock and StreamBlock also have corresponding exception classes `wagtail.blocks.ListBlockValidationError` and `wagtail.blocks.StreamBlockValidationError`, which work similarly, except that the keys of the `block_errors` dict are the numeric indexes of the blocks where the errors are to be attached:
+
+```python
+from django.core.exceptions import ValidationError
+from wagtail.blocks import ListBlock, ListBlockValidationError
+
+class AscendingListBlock(ListBlock):
+    # example usage:
+    # price_list = AscendingListBlock(FloatBlock())
+
+    def clean(self, value):
+        result = super().clean(value)
+        errors = {}
+        for i in range(1, len(result)):
+            if result[i] < result[i - 1]:
+                errors[i] = ValidationError("Values must be in ascending order")
+
+        if errors:
+            raise ListBlockValidationError(block_errors=errors)
+
+        return result
+```

+ 31 - 0
docs/reference/contrib/typed_table_block.md

@@ -73,3 +73,34 @@ Or:
     {% endif %}
 {% endfor %}
 ```
+
+## Custom validation
+
+As with other blocks, validation logic on `TypedTableBlock` can be customised by overriding the `clean` method (see [](streamfield_validation)). Raising a `ValidationError` exception from this method will attach the error message to the table as a whole. To attach errors to individual cells, the exception class `wagtail.contrib.typed_table_block.blocks.TypedTableBlockValidationError` can be used - in addition to the standard `non_block_errors` argument, this accepts a `cell_errors` argument consisting of a nested dict structure where the outer keys are row indexes and the inner keys are column indexes. For example:
+
+```python
+from django.core.exceptions import ValidationError
+from wagtail.blocks import IntegerBlock
+from wagtail.contrib.typed_table_block.blocks import TypedTableBlock, TypedTableBlockValidationError
+
+
+class LuckyTableBlock(TypedTableBlock):
+    number = IntegerBlock()
+
+    def clean(self, value):
+        result = super().clean(value)
+        errors = {}
+        print(result.row_data)
+        for row_num, row in enumerate(result.row_data):
+            row_errors = {}
+            for col_num, cell in enumerate(row['values']):
+                if cell == 13:
+                    row_errors[col_num] = ValidationError("Table cannot contain the number 13")
+            if row_errors:
+                errors[row_num] = row_errors
+
+        if errors:
+            raise TypedTableBlockValidationError(cell_errors=errors)
+
+        return result
+```

+ 4 - 0
docs/topics/streamfield.md

@@ -552,6 +552,10 @@ hero_image = my_page.body.first_block_by_name('image')
 <div class="hero-image">{{ page.body.first_block_by_name.image }}</div>
 ```
 
+## Custom validation
+
+Custom validation logic can be added to blocks by overriding the block's `clean` method. For more information, see [](streamfield_validation).
+
 ## Migrations
 
 Since StreamField data is stored as a single JSON field, rather than being arranged in a formal database structure, it will often be necessary to write data migrations when changing the data structure of a StreamField or converting to or from other field types. For more information on how StreamField interacts with Django's migration system, and a guide to migrating rich text to StreamFields, see [](streamfield_migrations).