diff --git a/network-api/networkapi/nav/factories.py b/network-api/networkapi/nav/factories.py index 7ef5cdb5007..03c6cd50f23 100644 --- a/network-api/networkapi/nav/factories.py +++ b/network-api/networkapi/nav/factories.py @@ -1,9 +1,11 @@ import factory import wagtail_factories +from factory.django import DjangoModelFactory from wagtail import models as wagtail_models from wagtail.rich_text import RichText from networkapi.nav import blocks as nav_blocks +from networkapi.nav import models as nav_models from networkapi.wagtailcustomization.factories.blocks import ExtendedStructBlockFactory @@ -146,3 +148,19 @@ class Params: }, ) button = wagtail_factories.ListBlockFactory(NavButtonFactory, **{"0__external_url_link": True}) + + +class NavMenuFactory(DjangoModelFactory): + class Meta: + model = nav_models.NavMenu + + title = factory.Faker("sentence", nb_words=3) + dropdowns = wagtail_factories.StreamFieldFactory( + {"dropdown": factory.SubFactory(NavDropdownFactory)}, + **{ + "0": "dropdown", + "1": "dropdown", + "2": "dropdown", + "3": "dropdown", + }, + ) diff --git a/network-api/networkapi/nav/migrations/0001_initial.py b/network-api/networkapi/nav/migrations/0001_initial.py new file mode 100644 index 00000000000..726955e21e3 --- /dev/null +++ b/network-api/networkapi/nav/migrations/0001_initial.py @@ -0,0 +1,182 @@ +# Generated by Django 4.2.10 on 2024-03-28 18:22 + +import uuid + +import django.db.models.deletion +import wagtail.blocks +import wagtail.fields +import wagtail.models +from django.db import migrations, models + +import networkapi.nav.blocks + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0089_log_entry_data_json_null_to_object"), + ] + + operations = [ + migrations.CreateModel( + name="NavMenu", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("translation_key", models.UUIDField(default=uuid.uuid4, editable=False)), + ("live", models.BooleanField(default=True, editable=False, verbose_name="live")), + ( + "has_unpublished_changes", + models.BooleanField(default=False, editable=False, verbose_name="has unpublished changes"), + ), + ( + "first_published_at", + models.DateTimeField(blank=True, db_index=True, null=True, verbose_name="first published at"), + ), + ( + "last_published_at", + models.DateTimeField(editable=False, null=True, verbose_name="last published at"), + ), + ("go_live_at", models.DateTimeField(blank=True, null=True, verbose_name="go live date/time")), + ("expire_at", models.DateTimeField(blank=True, null=True, verbose_name="expiry date/time")), + ("expired", models.BooleanField(default=False, editable=False, verbose_name="expired")), + ("title", models.CharField(help_text="For internal identification only", max_length=100)), + ( + "dropdowns", + wagtail.fields.StreamField( + [ + ( + "dropdown", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="How the dropdown menu will be labelled in the nav bar", + max_length=100, + ), + ), + ( + "overview", + wagtail.blocks.ListBlock( + wagtail.blocks.StructBlock( + [ + ("title", wagtail.blocks.CharBlock(max_length=100)), + ( + "description", + wagtail.blocks.RichTextBlock( + features=["bold", "italic"], max_length=200 + ), + ), + ], + label="Overview", + ), + default=[], + help_text="If added, the overview will take the place of the first column", + label="Overview", + max_num=1, + min_num=0, + ), + ), + ( + "columns", + wagtail.blocks.ListBlock( + wagtail.blocks.StructBlock( + [ + ("title", wagtail.blocks.CharBlock(max_length=100)), + ( + "nav_items", + wagtail.blocks.ListBlock( + networkapi.nav.blocks.NavItem, + default=[], + label="Items", + max_num=4, + min_num=1, + ), + ), + ( + "button", + wagtail.blocks.ListBlock( + networkapi.nav.blocks.NavButton, + default=[], + help_text="Adds a CTA button to the bottom of the nav column.", + label="Column Button", + max_num=1, + min_num=0, + required=False, + ), + ), + ], + label="Column", + ), + help_text="Add up to 4 columns of navigation links", + label="Columns", + max_num=4, + min_num=1, + ), + ), + ( + "button", + wagtail.blocks.ListBlock( + networkapi.nav.blocks.NavButton, + default=[], + help_text="Use it to add a CTA to link to the contents of the dropdown menu", + label="Dropdown Button", + max_num=1, + min_num=0, + required=False, + ), + ), + ], + label="Dropdown", + ), + ) + ], + help_text="Add up to 5 dropdown menus", + use_json_field=True, + ), + ), + ( + "latest_revision", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.revision", + verbose_name="latest revision", + ), + ), + ( + "live_revision", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.revision", + verbose_name="live revision", + ), + ), + ( + "locale", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="wagtailcore.locale", + ), + ), + ], + options={ + "verbose_name": "Navigation Menu", + "verbose_name_plural": "Navigation Menus", + "abstract": False, + "unique_together": {("translation_key", "locale")}, + }, + bases=(wagtail.models.PreviewableMixin, models.Model), + ), + ] diff --git a/network-api/networkapi/nav/migrations/__init__.py b/network-api/networkapi/nav/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/network-api/networkapi/nav/models.py b/network-api/networkapi/nav/models.py new file mode 100644 index 00000000000..f6efd1a8917 --- /dev/null +++ b/network-api/networkapi/nav/models.py @@ -0,0 +1,38 @@ +from django.db import models +from wagtail import models as wagtail_models +from wagtail.admin import panels +from wagtail.fields import StreamField + +from networkapi.nav import blocks as nav_blocks + + +class NavMenu( + wagtail_models.PreviewableMixin, + wagtail_models.DraftStateMixin, + wagtail_models.RevisionMixin, + wagtail_models.TranslatableMixin, + models.Model, +): + title = models.CharField(max_length=100, help_text="For internal identification only") + + dropdowns = StreamField( + [ + ("dropdown", nav_blocks.NavDropdown(label="Dropdown")), + ], + use_json_field=True, + min_num=1, + max_num=5, + help_text="Add up to 5 dropdown menus", + ) + + panels = [ + panels.FieldPanel("title"), + panels.FieldPanel("dropdowns"), + ] + + class Meta(wagtail_models.TranslatableMixin.Meta): + verbose_name = "Navigation Menu" + verbose_name_plural = "Navigation Menus" + + def __str__(self) -> str: + return self.title diff --git a/network-api/networkapi/nav/tests/test_models.py b/network-api/networkapi/nav/tests/test_models.py new file mode 100644 index 00000000000..6595bc2da37 --- /dev/null +++ b/network-api/networkapi/nav/tests/test_models.py @@ -0,0 +1,29 @@ +from django.test import TestCase +from wagtail.blocks import StreamBlockValidationError + +from networkapi.nav import factories as nav_factories +from networkapi.nav import models as nav_models + + +class NavMenuTests(TestCase): + def test_default_factory(self): + """Test that the default factory creates a NavMenu with 4 dropdowns.""" + menu = nav_factories.NavMenuFactory() + self.assertIsInstance(menu, nav_models.NavMenu) + self.assertEqual(len(menu.dropdowns), 4) + + def test_cannot_create_nav_menu_without_dropdowns(self): + with self.assertRaises(StreamBlockValidationError): + menu = nav_factories.NavMenuFactory(dropdowns={}) + menu.dropdowns.stream_block.clean([]) + + def test_cannot_create_nav_menu_with_more_than_five_dropdowns(self): + with self.assertRaises(StreamBlockValidationError): + menu = nav_factories.NavMenuFactory( + dropdowns__0="dropdown", + dropdowns__1="dropdown", + dropdowns__2="dropdown", + dropdowns__3="dropdown", + dropdowns__4="dropdown", + ) + menu.dropdowns.stream_block.clean([]) diff --git a/network-api/networkapi/nav/wagtail_hooks.py b/network-api/networkapi/nav/wagtail_hooks.py new file mode 100644 index 00000000000..eba9b1212f2 --- /dev/null +++ b/network-api/networkapi/nav/wagtail_hooks.py @@ -0,0 +1,25 @@ +from wagtail.snippets.models import register_snippet +from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup + +from networkapi.nav.models import NavMenu + + +class NavMenuViewSet(SnippetViewSet): + model = NavMenu + icon = "list-ul" + menu_order = 100 + menu_label = "Navigation Menus" + list_display = ("title",) + search_fields = ("title",) + ordering = ("title",) + + +class NavDropdownViewSetGroup(SnippetViewSetGroup): + items = (NavMenuViewSet,) + menu_label = "Main Navigation" + menu_name = "Main Navigation" + add_to_admin_menu = True + menu_order = 1600 + + +register_snippet(NavDropdownViewSetGroup)