diff --git a/network-api/networkapi/nav/blocks.py b/network-api/networkapi/nav/blocks.py index 549c1a39cdb..be38da5337c 100644 --- a/network-api/networkapi/nav/blocks.py +++ b/network-api/networkapi/nav/blocks.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from django.forms.utils import ErrorList from wagtail import blocks +from wagtail.images.blocks import ImageChooserBlock from wagtail.telepath import register from networkapi.wagtailpages.pagemodels.customblocks.common.base_link_block import ( @@ -48,6 +49,33 @@ class Meta: register(BaseLinkBlockAdapter(), NavItem) +class NavFeaturedItem(BaseLinkBlock): + icon = ImageChooserBlock() + + def __init__(self, local_blocks=None, **kwargs): + # Use __init__ method to change the order of the blocks when constructing + # them through inheritance + super().__init__(local_blocks, **kwargs) + self.child_blocks = self.base_blocks.copy() + child_blocks = OrderedDict( + { + "label": self.child_blocks.pop("label"), + "icon": self.child_blocks.pop("icon"), + } + ) + child_blocks.update({k: v for k, v in self.child_blocks.items()}) + self.child_blocks = child_blocks + + class Meta: + value_class = NavItemValue + label = "Featured Navigation Link" + icon = "link" + template = "nav/blocks/featured_item_block.html" + + +register(BaseLinkBlockAdapter(), NavFeaturedItem) + + class NavButton(BaseLinkBlock): class Meta: value_class = NavItemValue @@ -93,6 +121,18 @@ class Meta: value_class = NavColumnValue +class NavFeaturedColumn(blocks.StructBlock): + title = blocks.CharBlock(max_length=100) + # Empty default so that it starts collapsed: + nav_items = blocks.ListBlock(NavFeaturedItem, min_num=1, max_num=4, label="Items", default=[]) + + class Meta: + label = "Featured Navigation Column" + icon = "list-ul" + template = "nav/blocks/featured_column_block.html" + value_class = NavColumnValue + + class NavOverview(blocks.StructBlock): title = blocks.CharBlock(max_length=100) description = blocks.RichTextBlock(features=["bold", "italic"], max_length=200) @@ -126,6 +166,17 @@ def button(self) -> NavButton | None: return button[0] return None + @property + def has_featured_column(self) -> bool: + return bool(self.get("featured_column")) + + @property + def featured_column(self) -> NavFeaturedColumn | None: + featured_column = self.get("featured_column") + if featured_column: + return featured_column[0] + return None + class NavDropdown(blocks.StructBlock): title = blocks.CharBlock(max_length=100, help_text="How the dropdown menu will be labelled in the nav bar") @@ -144,6 +195,14 @@ class NavDropdown(blocks.StructBlock): label="Columns", help_text="Add up to 4 columns of navigation links", ) + featured_column = blocks.ListBlock( + NavFeaturedColumn(label="Featured Column"), + min_num=0, + max_num=1, + label="Featured Column", + help_text="A column made of items and icons. If added, it will take the place of the last column", + default=[], + ) button = blocks.ListBlock( NavButton, required=False, @@ -158,24 +217,62 @@ def clean(self, value): result = super().clean(value) errors = {} - if result["overview"] and len(result["columns"]) > 3: - errors["overview"] = ErrorList( - [ - blocks.ListBlockValidationError( - block_errors={}, - non_block_errors=ErrorList( - [ValidationError("Overview cannot be used with more than 3 nav columns.")] - ), - ) - ] - ) + has_overview = bool(result["overview"]) + has_featured_column = bool(result["featured_column"]) + + allowed_number_of_columns = 4 + if has_overview: + allowed_number_of_columns -= 1 + if has_featured_column: + allowed_number_of_columns -= 1 + + current_number_of_columns = len(result["columns"]) + + if current_number_of_columns > allowed_number_of_columns: + if has_overview and not has_featured_column: + err_msg_overview = 'A maximum of 3 columns can be added together with an "overview".' + err_msg_columns = 'A maximum of 3 columns can be added together with an "overview".' + err_msg_featured_column = "" + elif has_featured_column and not has_overview: + err_msg_overview = "" + err_msg_columns = 'A maximum of 3 columns can be added together with a "featured column".' + err_msg_featured_column = "Featured column cannot be used with more than 3 nav columns." + elif has_overview and has_featured_column: + err_msg_overview = ( + 'A maximum of 2 columns can be added together with an "overview" and a "featured column".' + ) + err_msg_columns = ( + 'A maximum of 2 columns can be added together with an "overview" and a "featured column".' + ) + err_msg_featured_column = ( + 'A maximum of 2 columns can be added together with an "overview" and a "featured column".' + ) + + if err_msg_overview: + errors["overview"] = ErrorList( + [ + blocks.ListBlockValidationError( + block_errors={}, + non_block_errors=ErrorList([ValidationError(err_msg_overview)]), + ) + ] + ) + + if err_msg_featured_column: + errors["featured_column"] = ErrorList( + [ + blocks.ListBlockValidationError( + block_errors={}, + non_block_errors=ErrorList([ValidationError(err_msg_featured_column)]), + ) + ] + ) + errors["columns"] = ErrorList( [ blocks.ListBlockValidationError( block_errors={}, - non_block_errors=ErrorList( - [ValidationError('A maximum of 3 columns can be added together with an "overview".')] - ), + non_block_errors=ErrorList([ValidationError(err_msg_columns)]), ) ] ) diff --git a/network-api/networkapi/nav/factories.py b/network-api/networkapi/nav/factories.py index 03c6cd50f23..41f4fd94a27 100644 --- a/network-api/networkapi/nav/factories.py +++ b/network-api/networkapi/nav/factories.py @@ -49,6 +49,31 @@ class Params: relative_url = "" +class NavFeaturedItemFactory(ExtendedStructBlockFactory): + class Meta: + model = nav_blocks.NavFeaturedItem + + class Params: + page_link = factory.Trait( + link_to="page", + page=factory.Iterator(wagtail_models.Page.objects.filter(locale_id="1")), + ) + external_url_link = factory.Trait(link_to="external_url", external_url=factory.Faker("url")) + relative_url_link = factory.Trait(link_to="relative_url", relative_url=f'/{factory.Faker("uri_path")}') + + label = factory.Faker("sentence", nb_words=3) + icon = factory.SubFactory(wagtail_factories.ImageChooserBlockFactory) + + # Setup default link as external URL (it won't pass validation without a link type defined though + # so it's still necessary to use the factory with traits) + link_to = "external_url" + # Set all link types to None by default. Only define the needed link type in the factory + # trait to avoid conflicts + page = None + external_url = "" + relative_url = "" + + class NavButtonFactory(ExtendedStructBlockFactory): """Factory for NavButtonBlock. @@ -108,6 +133,22 @@ class Params: button = wagtail_factories.ListBlockFactory(NavButtonFactory, **{"0__external_url_link": True}) +class NavFeaturedColumnFactory(ExtendedStructBlockFactory): + class Meta: + model = nav_blocks.NavFeaturedColumn + + title = factory.Faker("sentence", nb_words=3) + nav_items = wagtail_factories.ListBlockFactory( + NavFeaturedItemFactory, + **{ + "0__external_url_link": True, + "1__external_url_link": True, + "2__external_url_link": True, + "3__external_url_link": True, + }, + ) + + class NavOverviewFactory(wagtail_factories.StructBlockFactory): class Meta: model = nav_blocks.NavOverview @@ -121,8 +162,24 @@ class Meta: model = nav_blocks.NavDropdown class Params: - no_overview = factory.Trait(overview=[]) - all_columns = factory.Trait( + with_overview = factory.Trait( + overview=wagtail_factories.ListBlockFactory( + NavOverviewFactory, + **{ + "0__title": factory.Faker("sentence", nb_words=3), + }, + ), + columns=wagtail_factories.ListBlockFactory( + NavColumnFactory, + **{ + "0__title": factory.Faker("sentence", nb_words=3), + "1__title": factory.Faker("sentence", nb_words=3), + "2__title": factory.Faker("sentence", nb_words=3), + }, + ), + featured_column=[], + ) + with_featured_column = factory.Trait( overview=[], columns=wagtail_factories.ListBlockFactory( NavColumnFactory, @@ -130,23 +187,49 @@ class Params: "0__title": factory.Faker("sentence", nb_words=3), "1__title": factory.Faker("sentence", nb_words=3), "2__title": factory.Faker("sentence", nb_words=3), - "3__title": factory.Faker("sentence", nb_words=3), + }, + ), + featured_column=wagtail_factories.ListBlockFactory( + NavFeaturedColumnFactory, + **{ + "0__title": factory.Faker("sentence", nb_words=3), + }, + ), + ) + with_overview_and_featured_column = factory.Trait( + overview=wagtail_factories.ListBlockFactory( + NavOverviewFactory, + **{ + "0__title": factory.Faker("sentence", nb_words=3), + }, + ), + columns=wagtail_factories.ListBlockFactory( + NavColumnFactory, + **{ + "0__title": factory.Faker("sentence", nb_words=3), + "1__title": factory.Faker("sentence", nb_words=3), + }, + ), + featured_column=wagtail_factories.ListBlockFactory( + NavFeaturedColumnFactory, + **{ + "0__title": factory.Faker("sentence", nb_words=3), }, ), ) no_button = factory.Trait(button=[]) - overview = wagtail_factories.ListBlockFactory( - NavOverviewFactory, **{"0__title": factory.Faker("sentence", nb_words=3)} - ) + overview = wagtail_factories.ListBlockFactory(NavOverviewFactory) columns = wagtail_factories.ListBlockFactory( NavColumnFactory, **{ "0__title": factory.Faker("sentence", nb_words=3), "1__title": factory.Faker("sentence", nb_words=3), "2__title": factory.Faker("sentence", nb_words=3), + "3__title": factory.Faker("sentence", nb_words=3), }, ) + featured_column = wagtail_factories.ListBlockFactory(NavFeaturedColumnFactory) button = wagtail_factories.ListBlockFactory(NavButtonFactory, **{"0__external_url_link": True}) diff --git a/network-api/networkapi/nav/migrations/0001_initial.py b/network-api/networkapi/nav/migrations/0001_initial.py index 726955e21e3..36c37b042ca 100644 --- a/network-api/networkapi/nav/migrations/0001_initial.py +++ b/network-api/networkapi/nav/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-03-28 18:22 +# Generated by Django 4.2.11 on 2024-04-01 16:39 import uuid @@ -116,6 +116,32 @@ class Migration(migrations.Migration): min_num=1, ), ), + ( + "featured_column", + wagtail.blocks.ListBlock( + wagtail.blocks.StructBlock( + [ + ("title", wagtail.blocks.CharBlock(max_length=100)), + ( + "nav_items", + wagtail.blocks.ListBlock( + networkapi.nav.blocks.NavFeaturedItem, + default=[], + label="Items", + max_num=4, + min_num=1, + ), + ), + ], + label="Featured Column", + ), + default=[], + help_text="A column made of items and icons. If added, it will take the place of the last column", + label="Featured Column", + max_num=1, + min_num=0, + ), + ), ( "button", wagtail.blocks.ListBlock( diff --git a/network-api/networkapi/nav/tests/test_blocks.py b/network-api/networkapi/nav/tests/test_blocks.py index 4fd92376f96..06cfb11a56b 100644 --- a/network-api/networkapi/nav/tests/test_blocks.py +++ b/network-api/networkapi/nav/tests/test_blocks.py @@ -65,6 +65,17 @@ def test_needs_to_provide_at_least_one_link(self): nav_blocks.NavItem().clean(block) +class TestNavFeaturedItemBlock(TestCase): + def test_default(self): + """Assert that default nav_blocks.NavFeaturedItem factory works and is an external URL.""" + block = nav_factories.NavFeaturedItemFactory() + + # Assert that the page link is custom URL and that it is correct + url = block["external_url"] + self.assertEqual(block.url, url) + self.assertTrue(block.open_in_new_window) + + class TestNavButton(TestCase): def test_default(self): """Assert that default nav_blocks.NavButton factory works and is an external URL.""" @@ -184,52 +195,111 @@ def test_cannot_have_more_than_one_button(self): nav_blocks.NavColumn().clean(block) +class TestNavFeaturedColumnBlock(TestCase): + def test_default(self): + """Assert that default nav_blocks.NavFeaturedColumn factory works and is an external URL.""" + block = nav_factories.NavFeaturedColumnFactory() + + self.assertEqual(len(block["nav_items"]), 4) + for link in block["nav_items"]: + self.assertIsInstance(link.block, nav_blocks.NavFeaturedItem) + self.assertIsInstance(link, nav_blocks.NavItemValue) + + def test_with_variable_number_of_links(self): + """Create a nav_blocks.NavFeaturedColumn with links.""" + block = nav_factories.NavFeaturedColumnFactory( + **{ + "nav_items__0__page_url_link": True, + "nav_items__1__relative_url_link": True, + "nav_items__2__external_url_link": True, + } + ) + + self.assertEqual(len(block["nav_items"]), 4) + for link in block["nav_items"]: + self.assertIsInstance(link.block, nav_blocks.NavFeaturedItem) + self.assertIsInstance(link, nav_blocks.NavItemValue) + + def test_needs_to_provide_at_least_one_link(self): + with self.assertRaises(StructBlockValidationError): + block = nav_factories.NavFeaturedColumnFactory(nav_items=[]) + nav_blocks.NavFeaturedColumn().clean(block) + + def test_needs_to_provide_at_most_four_links(self): + with self.assertRaises(StructBlockValidationError): + block = nav_factories.NavFeaturedColumnFactory( + **{ + "nav_items__0__external_url_link": True, + "nav_items__1__external_url_link": True, + "nav_items__2__external_url_link": True, + "nav_items__3__external_url_link": True, + "nav_items__4__external_url_link": True, + } + ) + nav_blocks.NavFeaturedColumn().clean(block) + + class TestNavDropdownBlock(TestCase): def test_default_block_factory(self): - """Default factory creates a block with an overview and 3 columns.""" + """Default factory creates a block with 4 columns and a button""" block = nav_factories.NavDropdownFactory() nav_blocks.NavDropdown().clean(block) - self.assertEqual(len(block["overview"]), 1) - self.assertIsInstance(block["overview"][0].block, nav_blocks.NavOverview) - self.assertTrue(block.has_overview) - self.assertEqual(block.overview, block["overview"][0]) + self.assertEqual(len(block["overview"]), 0) + self.assertFalse(block.has_overview) + self.assertIsNone(block.overview) - self.assertEqual(len(block["columns"]), 3) + self.assertEqual(len(block["columns"]), 4) for column in block["columns"]: self.assertIsInstance(column.block, nav_blocks.NavColumn) self.assertIsInstance(column, nav_blocks.NavColumnValue) + self.assertEqual(len(block["featured_column"]), 0) + self.assertFalse(block.has_featured_column) + self.assertIsNone(block.featured_column) + self.assertEqual(len(block["button"]), 1) self.assertIsInstance(block["button"][0].block, nav_blocks.NavButton) self.assertTrue(block.has_button) self.assertEqual(block.button, block["button"][0]) - def test_block_with_four_columns(self): - """Create a nav_blocks.NavDropdown with four columns.""" - block = nav_factories.NavDropdownFactory(all_columns=True) + def test_block_with_overview(self): + """Create a nav_blocks.NavDropdown with an overview.""" + block = nav_factories.NavDropdownFactory(with_overview=True) nav_blocks.NavDropdown().clean(block) - self.assertEqual(len(block["overview"]), 0) - self.assertFalse(block.has_overview) - self.assertIsNone(block.overview) + self.assertEqual(len(block["overview"]), 1) + self.assertTrue(block.has_overview) + self.assertEqual(block.overview, block["overview"][0]) - self.assertEqual(len(block["columns"]), 4) - for column in block["columns"]: - self.assertIsInstance(column.block, nav_blocks.NavColumn) - self.assertIsInstance(column, nav_blocks.NavColumnValue) + self.assertEqual(len(block["columns"]), 3) - def test_block_without_overview(self): - """Create a nav_blocks.NavDropdown without an overview.""" - block = nav_factories.NavDropdownFactory(no_overview=True) + def test_block_with_featured_column(self): + """Create a nav_blocks.NavDropdown with a featured column.""" + block = nav_factories.NavDropdownFactory(with_featured_column=True) nav_blocks.NavDropdown().clean(block) - self.assertEqual(len(block["overview"]), 0) - self.assertFalse(block.has_overview) - self.assertIsNone(block.overview) + self.assertEqual(len(block["featured_column"]), 1) + self.assertTrue(block.has_featured_column) + self.assertEqual(block.featured_column, block["featured_column"][0]) self.assertEqual(len(block["columns"]), 3) + def test_block_with_overview_and_featured_column(self): + """Create a nav_blocks.NavDropdown with an overview and a featured column.""" + block = nav_factories.NavDropdownFactory(with_overview_and_featured_column=True) + nav_blocks.NavDropdown().clean(block) + + self.assertEqual(len(block["overview"]), 1) + self.assertTrue(block.has_overview) + self.assertEqual(block.overview, block["overview"][0]) + + self.assertEqual(len(block["featured_column"]), 1) + self.assertTrue(block.has_featured_column) + self.assertEqual(block.featured_column, block["featured_column"][0]) + + self.assertEqual(len(block["columns"]), 2) + def test_block_without_button(self): """Create a nav_blocks.NavDropdown without a button.""" block = nav_factories.NavDropdownFactory(no_button=True) @@ -269,3 +339,29 @@ def test_block_with_overview_cannot_have_more_than_three_columns(self): } ) nav_blocks.NavDropdown().clean(block) + + def test_block_with_featured_column_cannot_have_more_than_three_columns(self): + with self.assertRaises(StructBlockValidationError): + block = nav_factories.NavDropdownFactory( + **{ + "columns__0__title": "Column 1", + "columns__1__title": "Column 2", + "columns__2__title": "Column 3", + "columns__3__title": "Column 4", + "featured_column__0__title": "Featured column", + } + ) + nav_blocks.NavDropdown().clean(block) + + def test_block_with_featured_column_and_overview_cannot_have_more_than_two_columns(self): + with self.assertRaises(StructBlockValidationError): + block = nav_factories.NavDropdownFactory( + **{ + "overview__0__title": "Overview", + "columns__0__title": "Column 1", + "columns__1__title": "Column 2", + "columns__2__title": "Column 3", + "featured_column__0__title": "Featured column", + } + ) + nav_blocks.NavDropdown().clean(block)