diff --git a/docs/reference/streamfield/blocks.rst b/docs/reference/streamfield/blocks.rst index ed683b5613..389c6e89dc 100644 --- a/docs/reference/streamfield/blocks.rst +++ b/docs/reference/streamfield/blocks.rst @@ -440,9 +440,11 @@ Structural block types ]))), ]) - The following additional option is available as either a keyword argument or a Meta class attribute: + The following additional options are available as either keyword arguments or Meta class attributes: :param form_classname: An HTML ``class`` attribute to set on the root element of this block as displayed in the editing interface. + :param min_num: Minimum number of sub-blocks that the list must have. + :param max_num: Maximum number of sub-blocks that the list may have. .. class:: wagtail.core.blocks.StreamBlock diff --git a/wagtail/core/blocks/list_block.py b/wagtail/core/blocks/list_block.py index 908bb69695..6a780c3f80 100644 --- a/wagtail/core/blocks/list_block.py +++ b/wagtail/core/blocks/list_block.py @@ -98,6 +98,16 @@ class ListBlock(Block): else: errors.append(None) + if self.meta.min_num is not None and self.meta.min_num > len(value): + non_block_errors.append(ValidationError( + _('The minimum number of items is %d') % self.meta.min_num + )) + + if self.meta.max_num is not None and self.meta.max_num < len(value): + non_block_errors.append(ValidationError( + _('The maximum number of items is %d') % self.meta.max_num + )) + if any(errors) or non_block_errors: raise ListBlockValidationError(block_errors=errors, non_block_errors=non_block_errors) @@ -172,6 +182,10 @@ class ListBlock(Block): # descendant block type icon = "placeholder" form_classname = None + min_num = None + max_num = None + + MUTABLE_META_ATTRIBUTES = ['min_num', 'max_num'] class ListBlockAdapter(Adapter): @@ -193,6 +207,12 @@ class ListBlockAdapter(Adapter): meta['helpText'] = help_text meta['helpIcon'] = get_help_icon() + if block.meta.min_num is not None: + meta['minNum'] = block.meta.min_num + + if block.meta.max_num is not None: + meta['maxNum'] = block.meta.max_num + return [ block.name, block.child_block, diff --git a/wagtail/core/tests/test_blocks.py b/wagtail/core/tests/test_blocks.py index 7432489311..f317c701be 100644 --- a/wagtail/core/tests/test_blocks.py +++ b/wagtail/core/tests/test_blocks.py @@ -2179,6 +2179,34 @@ class TestListBlock(WagtailTestUtils, SimpleTestCase): }, }) + def test_adapt_with_min_num_max_num(self): + class LinkBlock(blocks.StructBlock): + title = blocks.CharBlock() + link = blocks.URLBlock() + + block = blocks.ListBlock(LinkBlock, min_num=2, max_num=5) + + block.set_name('test_listblock') + js_args = ListBlockAdapter().js_args(block) + + self.assertEqual(js_args[0], 'test_listblock') + self.assertIsInstance(js_args[1], LinkBlock) + self.assertEqual(js_args[2], {'title': None, 'link': None}) + self.assertEqual(js_args[3], { + 'label': 'Test listblock', + 'icon': 'placeholder', + 'classname': None, + 'minNum': 2, + 'maxNum': 5, + 'strings': { + 'DELETE': 'Delete', + 'DUPLICATE': 'Duplicate', + 'MOVE_DOWN': 'Move down', + 'MOVE_UP': 'Move up', + 'ADD': 'Add', + }, + }) + def test_searchable_content(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() @@ -2332,6 +2360,32 @@ class TestListBlock(WagtailTestUtils, SimpleTestCase): }, }) + def test_min_num_validation_errors(self): + block = blocks.ListBlock(blocks.CharBlock(), min_num=2) + + with self.assertRaises(ValidationError) as catcher: + block.clean(['foo']) + self.assertEqual(catcher.exception.params, { + 'block_errors': [None], + 'non_block_errors': ['The minimum number of items is 2'] + }) + + # a value with >= 2 blocks should pass validation + self.assertTrue(block.clean(['foo', 'bar'])) + + def test_max_num_validation_errors(self): + block = blocks.ListBlock(blocks.CharBlock(), max_num=2) + + with self.assertRaises(ValidationError) as catcher: + block.clean(['foo', 'bar', 'baz']) + self.assertEqual(catcher.exception.params, { + 'block_errors': [None, None, None], + 'non_block_errors': ['The maximum number of items is 2'] + }) + + # a value with <= 2 blocks should pass validation + self.assertTrue(block.clean(['foo', 'bar'])) + class TestListBlockWithFixtures(TestCase): fixtures = ['test.json']