From 84ed530d9c62723e8dfe9228db7a97c0d9953bcd Mon Sep 17 00:00:00 2001 From: Jhonatan Lopes Date: Thu, 28 Mar 2024 15:18:17 -0300 Subject: [PATCH] [TP-268][Main Nav] Nav dropdown (#12114) * Better NavColumn block with default values * Add NavOverview block * Setup rich text features on overview description * Define an ExtendedStructBlockFactory * Use ExtendedStructBlockFactory * Add NavDropdown block * Lint * Fix types --- network-api/networkapi/nav/blocks.py | 128 +++++++++++++++- network-api/networkapi/nav/factories.py | 80 +++++++--- .../networkapi/nav/tests/test_blocks.py | 143 ++++++++++++++++-- .../wagtailcustomization/factories/blocks.py | 12 ++ 4 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 network-api/networkapi/wagtailcustomization/factories/blocks.py diff --git a/network-api/networkapi/nav/blocks.py b/network-api/networkapi/nav/blocks.py index d5cc0573a69..549c1a39cdb 100644 --- a/network-api/networkapi/nav/blocks.py +++ b/network-api/networkapi/nav/blocks.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from django.core.exceptions import ValidationError +from django.forms.utils import ErrorList from wagtail import blocks from wagtail.telepath import register @@ -57,12 +59,134 @@ class Meta: register(BaseLinkBlockAdapter(), NavButton) +class NavColumnValue(blocks.StructValue): + @property + def has_button(self) -> bool: + return bool(self.get("button")) + + @property + def button(self) -> NavButton | None: + button = self.get("button") + if button: + return button[0] + return None + + class NavColumn(blocks.StructBlock): title = blocks.CharBlock(max_length=100) - links = blocks.ListBlock(NavItem, min_num=1, max_num=4) - button = blocks.ListBlock(NavButton, required=False, min_num=0, max_num=1) + # Empty default so that it starts collapsed: + nav_items = blocks.ListBlock(NavItem, min_num=1, max_num=4, label="Items", default=[]) + button = blocks.ListBlock( + NavButton, + required=False, + min_num=0, + max_num=1, + default=[], + label="Column Button", + help_text="Adds a CTA button to the bottom of the nav column.", + ) class Meta: label = "Navigation Column" icon = "list-ul" template = "nav/blocks/nav_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) + + class Meta: + label = "Navigation Overview" + icon = "pilcrow" + template = "nav/blocks/overview_block.html" + + +class NavDropdownValue(blocks.StructValue): + @property + def has_overview(self) -> bool: + return bool(self.get("overview")) + + @property + def overview(self) -> NavOverview | None: + overview = self.get("overview") + if overview: + return overview[0] + return None + + @property + def has_button(self) -> bool: + return bool(self.get("button")) + + @property + def button(self) -> NavButton | None: + button = self.get("button") + if button: + return button[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") + overview = blocks.ListBlock( + NavOverview(label="Overview"), + min_num=0, + max_num=1, + label="Overview", + help_text="If added, the overview will take the place of the first column", + default=[], + ) + columns = blocks.ListBlock( + NavColumn(label="Column"), + min_num=1, + max_num=4, + label="Columns", + help_text="Add up to 4 columns of navigation links", + ) + button = blocks.ListBlock( + NavButton, + required=False, + min_num=0, + max_num=1, + default=[], + label="Dropdown Button", + help_text="Use it to add a CTA to link to the contents of the dropdown menu", + ) + + 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.")] + ), + ) + ] + ) + errors["columns"] = ErrorList( + [ + blocks.ListBlockValidationError( + block_errors={}, + non_block_errors=ErrorList( + [ValidationError('A maximum of 3 columns can be added together with an "overview".')] + ), + ) + ] + ) + + if errors: + raise blocks.StructBlockValidationError(block_errors=errors) + + return result + + class Meta: + label = "Navigation Dropdown" + icon = "bars" + template = "nav/blocks/nav_dropdown_block.html" + value_class = NavDropdownValue diff --git a/network-api/networkapi/nav/factories.py b/network-api/networkapi/nav/factories.py index 935bf6f84c8..7ef5cdb5007 100644 --- a/network-api/networkapi/nav/factories.py +++ b/network-api/networkapi/nav/factories.py @@ -1,11 +1,13 @@ import factory import wagtail_factories from wagtail import models as wagtail_models +from wagtail.rich_text import RichText from networkapi.nav import blocks as nav_blocks +from networkapi.wagtailcustomization.factories.blocks import ExtendedStructBlockFactory -class NavItemFactory(wagtail_factories.StructBlockFactory): +class NavItemFactory(ExtendedStructBlockFactory): """Factory for NavLinkBlock. Use traits to create instances based on the type of link needed: @@ -24,14 +26,6 @@ class NavItemFactory(wagtail_factories.StructBlockFactory): class Meta: model = nav_blocks.NavItem - @classmethod - def _construct_struct_value(cls, block_class, params): - """Use NavLinkValue to create the StructValue instance.""" - return nav_blocks.NavItemValue( - block_class(), - [(name, value) for name, value in params.items()], - ) - class Params: page_link = factory.Trait( link_to="page", @@ -53,7 +47,7 @@ class Params: relative_url = "" -class NavButtonFactory(wagtail_factories.StructBlockFactory): +class NavButtonFactory(ExtendedStructBlockFactory): """Factory for NavButtonBlock. Use traits to create instances based on the type of link needed: @@ -72,14 +66,6 @@ class NavButtonFactory(wagtail_factories.StructBlockFactory): class Meta: model = nav_blocks.NavButton - @classmethod - def _construct_struct_value(cls, block_class, params): - """Use NavLinkValue to create the StructValue instance.""" - return nav_blocks.NavItemValue( - block_class(), - [(name, value) for name, value in params.items()], - ) - class Params: page_link = factory.Trait( link_to="page", @@ -100,13 +86,63 @@ class Params: relative_url = "" -class NavColumnFactory(wagtail_factories.StructBlockFactory): +class NavColumnFactory(ExtendedStructBlockFactory): class Meta: model = nav_blocks.NavColumn class Params: - with_button = False + no_button = factory.Trait(button=[]) + + title = factory.Faker("sentence", nb_words=3) + nav_items = wagtail_factories.ListBlockFactory( + NavItemFactory, + **{ + "0__external_url_link": True, + "1__external_url_link": True, + "2__external_url_link": True, + "3__external_url_link": True, + }, + ) + button = wagtail_factories.ListBlockFactory(NavButtonFactory, **{"0__external_url_link": True}) + + +class NavOverviewFactory(wagtail_factories.StructBlockFactory): + class Meta: + model = nav_blocks.NavOverview title = factory.Faker("sentence", nb_words=3) - links = factory.List([factory.SubFactory(NavItemFactory) for _ in range(4)]) - button = factory.LazyAttribute(lambda o: [NavButtonFactory()] if o.with_button else []) + description = RichText(str(factory.Faker("sentence", nb_words=6))) + + +class NavDropdownFactory(ExtendedStructBlockFactory): + class Meta: + model = nav_blocks.NavDropdown + + class Params: + no_overview = factory.Trait(overview=[]) + all_columns = factory.Trait( + overview=[], + 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), + }, + ), + ) + no_button = factory.Trait(button=[]) + + 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), + }, + ) + button = wagtail_factories.ListBlockFactory(NavButtonFactory, **{"0__external_url_link": True}) diff --git a/network-api/networkapi/nav/tests/test_blocks.py b/network-api/networkapi/nav/tests/test_blocks.py index 92ab08aa114..4fd92376f96 100644 --- a/network-api/networkapi/nav/tests/test_blocks.py +++ b/network-api/networkapi/nav/tests/test_blocks.py @@ -123,30 +123,149 @@ def test_default(self): """Assert that default nav_blocks.NavColumn factory works and is an external URL.""" block = nav_factories.NavColumnFactory() - self.assertEqual(len(block["links"]), 4) - for link in block["links"]: + self.assertEqual(len(block["nav_items"]), 4) + for link in block["nav_items"]: self.assertIsInstance(link.block, nav_blocks.NavItem) self.assertIsInstance(link, nav_blocks.NavItemValue) - self.assertCountEqual(block["button"], []) - - def test_with_button(self): - """Create a nav_blocks.NavColumn with a button.""" - block = nav_factories.NavColumnFactory(with_button=True) - + self.assertIsNotNone(block.button) self.assertEqual(len(block["button"]), 1) - self.assertIsInstance(block["button"][0].block, nav_blocks.NavButton) + self.assertEqual(block.button, block["button"][0]) + self.assertTrue(block.has_button) + + def test_without_button(self): + """Create a nav_blocks.NavColumn without a button.""" + block = nav_factories.NavColumnFactory(no_button=True) + + self.assertEqual(len(block["button"]), 0) + self.assertFalse(block.has_button) + self.assertIsNone(block.button) + + def test_with_variable_number_of_links(self): + """Create a nav_blocks.NavColumn with links.""" + block = nav_factories.NavColumnFactory( + **{ + "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.NavItem) + self.assertIsInstance(link, nav_blocks.NavItemValue) def test_needs_to_provide_at_least_one_link(self): with self.assertRaises(StructBlockValidationError): - block = nav_factories.NavColumnFactory(links=[]) + block = nav_factories.NavColumnFactory(nav_items=[]) nav_blocks.NavColumn().clean(block) def test_needs_to_provide_at_most_four_links(self): with self.assertRaises(StructBlockValidationError): - block = nav_factories.NavColumnFactory(links=[nav_factories.NavItemFactory() for _ in range(5)]) + block = nav_factories.NavColumnFactory( + **{ + "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.NavColumn().clean(block) def test_cannot_have_more_than_one_button(self): with self.assertRaises(StructBlockValidationError): - block = nav_factories.NavColumnFactory(button=[nav_factories.NavButtonFactory() for _ in range(2)]) + block = nav_factories.NavColumnFactory( + **{ + "button__0__external_url_link": True, + "button__1__external_url_link": True, + } + ) nav_blocks.NavColumn().clean(block) + + +class TestNavDropdownBlock(TestCase): + def test_default_block_factory(self): + """Default factory creates a block with an overview and 3 columns.""" + 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["columns"]), 3) + for column in block["columns"]: + self.assertIsInstance(column.block, nav_blocks.NavColumn) + self.assertIsInstance(column, nav_blocks.NavColumnValue) + + 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) + nav_blocks.NavDropdown().clean(block) + + self.assertEqual(len(block["overview"]), 0) + self.assertFalse(block.has_overview) + self.assertIsNone(block.overview) + + self.assertEqual(len(block["columns"]), 4) + for column in block["columns"]: + self.assertIsInstance(column.block, nav_blocks.NavColumn) + self.assertIsInstance(column, nav_blocks.NavColumnValue) + + def test_block_without_overview(self): + """Create a nav_blocks.NavDropdown without an overview.""" + block = nav_factories.NavDropdownFactory(no_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["columns"]), 3) + + def test_block_without_button(self): + """Create a nav_blocks.NavDropdown without a button.""" + block = nav_factories.NavDropdownFactory(no_button=True) + nav_blocks.NavDropdown().clean(block) + + self.assertEqual(len(block["button"]), 0) + self.assertFalse(block.has_button) + self.assertIsNone(block.button) + + def test_needs_at_least_one_column(self): + with self.assertRaises(StructBlockValidationError): + block = nav_factories.NavDropdownFactory(columns=[]) + nav_blocks.NavDropdown().clean(block) + + def test_cannot_have_more_than_four_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", + "columns__4__title": "Column 5", + } + ) + nav_blocks.NavDropdown().clean(block) + + def test_block_with_overview_cannot_have_more_than_three_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", + "columns__3__title": "Column 4", + } + ) + nav_blocks.NavDropdown().clean(block) diff --git a/network-api/networkapi/wagtailcustomization/factories/blocks.py b/network-api/networkapi/wagtailcustomization/factories/blocks.py new file mode 100644 index 00000000000..1299e644904 --- /dev/null +++ b/network-api/networkapi/wagtailcustomization/factories/blocks.py @@ -0,0 +1,12 @@ +from wagtail_factories.blocks import StructBlockFactory + + +class ExtendedStructBlockFactory(StructBlockFactory): + @classmethod + def _construct_struct_value(cls, block_class, params): + """Use value_class defined on model's Meta to create the StructValue instance.""" + struct_value_class = cls._meta.model().meta.value_class + return struct_value_class( + block_class(), + [(name, value) for name, value in params.items()], + )