diff --git a/ietf/conftest.py b/ietf/conftest.py index dbe5225e..6194af43 100644 --- a/ietf/conftest.py +++ b/ietf/conftest.py @@ -45,4 +45,4 @@ def iab_blog_feed(monkeypatch: pytest.MonkeyPatch): mock_get = Mock() mock_get.return_value.text = "" monkeypatch.setattr("ietf.home.models.get_request", mock_get) - return mock_get + return mock_get \ No newline at end of file diff --git a/ietf/context_processors.py b/ietf/context_processors.py index 18cbdf9d..3486bf25 100644 --- a/ietf/context_processors.py +++ b/ietf/context_processors.py @@ -4,7 +4,7 @@ from ietf.home.models import HomePage, IABHomePage from ietf.utils.models import SecondaryMenuItem, SocialMediaSettings -from ietf.utils.context_processors import get_main_menu +from ietf.utils.context_processors import get_footer, get_main_menu def home_page(site): @@ -46,4 +46,5 @@ def global_pages(request): "MENU": lambda: get_main_menu(site), "SECONDARY_MENU": lambda: secondary_menu(site), "SOCIAL_MENU": lambda: social_menu(site), + "FOOTER": lambda: get_footer(), } diff --git a/ietf/static_src/css/custom-spacers.scss b/ietf/static_src/css/bs-configure.scss similarity index 85% rename from ietf/static_src/css/custom-spacers.scss rename to ietf/static_src/css/bs-configure.scss index 068f3d5a..6de4a683 100644 --- a/ietf/static_src/css/custom-spacers.scss +++ b/ietf/static_src/css/bs-configure.scss @@ -11,3 +11,5 @@ $custom-spacers: ( ); $spacers: map-merge($spacers, $custom-spacers); + +$enable-negative-margins: true; diff --git a/ietf/static_src/css/main.scss b/ietf/static_src/css/main.scss index b0d9dd8b..ba9ac636 100644 --- a/ietf/static_src/css/main.scss +++ b/ietf/static_src/css/main.scss @@ -2,7 +2,7 @@ @import 'bootstrap/scss/functions'; @import 'bootstrap/scss/variables'; -@import './custom-spacers.scss'; +@import './bs-configure.scss'; @import '@ietf-tools/common-bootstrap-theme/scss/ietf-theme.scss'; @import 'bootstrap/scss/bootstrap'; diff --git a/ietf/static_src/css/utilities.scss b/ietf/static_src/css/utilities.scss index f10be72e..70e5dbfd 100644 --- a/ietf/static_src/css/utilities.scss +++ b/ietf/static_src/css/utilities.scss @@ -21,3 +21,9 @@ .fw-semibold { font-weight: 600 !important; } + +.u-border-lg-bottom-0 { + @include media-breakpoint-up(lg) { + border-bottom: 0 !important; + } +} diff --git a/ietf/templates/includes/footer.html b/ietf/templates/includes/footer.html index afd08de3..b15a2b42 100644 --- a/ietf/templates/includes/footer.html +++ b/ietf/templates/includes/footer.html @@ -1,9 +1,64 @@ + + diff --git a/ietf/templates/includes/header.html b/ietf/templates/includes/header.html index 2e880664..552cff8f 100644 --- a/ietf/templates/includes/header.html +++ b/ietf/templates/includes/header.html @@ -103,13 +103,6 @@ -
- {% for item in SOCIAL_MENU %} - - - - {% endfor %} -
diff --git a/ietf/templates/includes/megamenu.html b/ietf/templates/includes/megamenu.html index e4203207..2a952df3 100644 --- a/ietf/templates/includes/megamenu.html +++ b/ietf/templates/includes/megamenu.html @@ -68,7 +68,7 @@
{{ section.title }}
{% for link in section.links %}
  • - {{ link.title }} + {{ link.text }}
  • {% endfor %} diff --git a/ietf/templates/includes/styles/footer.scss b/ietf/templates/includes/styles/footer.scss new file mode 100644 index 00000000..d60874d4 --- /dev/null +++ b/ietf/templates/includes/styles/footer.scss @@ -0,0 +1,30 @@ +footer section { + h4[role=button] { + @include media-breakpoint-up(lg) { + cursor: text; + } + } + + h4 .bi-chevron-down { + display: block; + float: right; + @include media-breakpoint-up(lg) { + display: none; + } + } + + &.expanded h4 .bi-chevron-down { + transform: rotate(180deg); + } + + ul { + display: none; + @include media-breakpoint-up(lg) { + display: block; + } + } + + &.expanded ul { + display: block; + } +} diff --git a/ietf/templates/includes/styles/index.scss b/ietf/templates/includes/styles/index.scss index 90c7e145..65f0fed5 100644 --- a/ietf/templates/includes/styles/index.scss +++ b/ietf/templates/includes/styles/index.scss @@ -1,4 +1,5 @@ @import 'header.scss'; +@import 'footer.scss'; .block-paragraph { .h2, h2 { diff --git a/ietf/utils/blocks.py b/ietf/utils/blocks.py index b1be571f..2c12ac43 100644 --- a/ietf/utils/blocks.py +++ b/ietf/utils/blocks.py @@ -1,3 +1,4 @@ +from django.utils.functional import cached_property from wagtail.blocks import ( CharBlock, FloatBlock, @@ -7,6 +8,7 @@ RichTextBlock, StreamBlock, StructBlock, + StructValue, URLBlock, ) from wagtail.contrib.table_block.blocks import TableBlock @@ -28,11 +30,36 @@ class Meta: template = "blocks/note_well_block.html" +class LinkStructValue(StructValue): + @cached_property + def url(self): + if external_url := self.get("external_url"): + return external_url + + if page := self.get("page"): + return page.url + + return "" + + @cached_property + def text(self): + if title := self.get("title"): + return title + + if page := self.get("page"): + return page.title + + return self.get("external_url") + + class LinkBlock(StructBlock): page = PageChooserBlock(label="Page", required=False) title = CharBlock(label="Link text", required=False) external_url = URLBlock(label="External URL", required=False) + class Meta: # type: ignore + value_class = LinkStructValue + class MainMenuSection(StructBlock): title = CharBlock(label="Section title", required=True) diff --git a/ietf/utils/context_processors.py b/ietf/utils/context_processors.py index e47fcc9e..3b200206 100644 --- a/ietf/utils/context_processors.py +++ b/ietf/utils/context_processors.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from operator import attrgetter -from ietf.utils.models import MainMenuItem +from ietf.utils.models import FooterColumn, MainMenuItem class MainMenu: @@ -17,33 +17,6 @@ def get_introduction(self, page): return "" - def get_link_url(self, link): - if external_url := link.get("external_url"): - return external_url - - if page := link.get("page"): - return page.get_url(current_site=self.site) - - return "" - - def get_link_title(self, link): - if title := link.get("title"): - return title - - if page := link.get("page"): - return page.title - - return link.get("external_url") - - def get_section_links(self, section): - for link in section.value.get("links"): - item = { - "title": self.get_link_title(link), - "url": self.get_link_url(link), - } - if item["title"] and item["url"]: - yield item - def get_menu_item(self, item): main_section_links = [ { @@ -55,7 +28,11 @@ def get_menu_item(self, item): secondary_sections = [ { "title": section.value.get("title"), - "links": list(self.get_section_links(section)), + "links": [ + link + for link in section.value.get("links") + if link.text and link.url + ], } for section in item.secondary_sections ] @@ -71,10 +48,7 @@ def get_menu_item(self, item): } def get_menu(self): - return [ - self.get_menu_item(item) - for item in self.get_items() - ] + return [self.get_menu_item(item) for item in self.get_items()] class PreviewMainMenu(MainMenu): @@ -108,3 +82,17 @@ def get_main_menu(site): return get_iab_main_menu(site) return MainMenu(site).get_menu() + + +def get_footer(): + return FooterColumn.objects.all() + + +def get_preview_footer(current): + items = [ + current if item == current else item + for item in FooterColumn.objects.all() + ] + if not current.pk: + items.append(current) + return sorted(items, key=attrgetter("sort_order")) diff --git a/ietf/utils/migrations/0010_footercolumn.py b/ietf/utils/migrations/0010_footercolumn.py new file mode 100644 index 00000000..35d73676 --- /dev/null +++ b/ietf/utils/migrations/0010_footercolumn.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.7 on 2024-03-29 14:06 + +from django.db import migrations, models +import wagtail.blocks +import wagtail.fields +import wagtail.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('utils', '0009_megamenu'), + ] + + operations = [ + migrations.CreateModel( + name='FooterColumn', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('links', wagtail.fields.StreamField([('link', wagtail.blocks.StructBlock([('page', wagtail.blocks.PageChooserBlock(label='Page', required=False)), ('title', wagtail.blocks.CharBlock(label='Link text', required=False)), ('external_url', wagtail.blocks.URLBlock(label='External URL', required=False))]))], blank=True, use_json_field=True)), + ('sort_order', models.PositiveSmallIntegerField()), + ], + options={ + 'ordering': ['sort_order'], + }, + bases=(wagtail.models.PreviewableMixin, models.Model), + ), + ] diff --git a/ietf/utils/models.py b/ietf/utils/models.py index 3d94bb89..7e2ddb60 100644 --- a/ietf/utils/models.py +++ b/ietf/utils/models.py @@ -9,7 +9,7 @@ from wagtail.models import Orderable, PreviewableMixin, Site from wagtailorderable.models import Orderable as WagtailOrderable -from ietf.utils.blocks import MainMenuSection +from ietf.utils.blocks import LinkBlock, MainMenuSection class LinkFields(models.Model): @@ -197,6 +197,35 @@ class Meta: verbose_name_plural = "Secondary Menu" +class FooterColumn(PreviewableMixin, models.Model): + title = models.CharField(max_length=255) + links = StreamField( + [ + ("link", LinkBlock()), + ], + blank=True, + use_json_field=True, + ) + sort_order = models.PositiveSmallIntegerField() + + class Meta: + ordering = ["sort_order"] + + def __str__(self): # pragma: no cover + return self.title + + def get_preview_template(self, request, mode_name): + return "previews/footer_column.html" + + def get_preview_context(self, request, mode_name): + from .context_processors import get_preview_footer + + return { + **super().get_preview_context(request, mode_name), + "FOOTER": get_preview_footer(current=self), + } + + @register_setting class SocialMediaSettings(BaseSiteSetting): twitter_handle = models.CharField( diff --git a/ietf/utils/templates/previews/footer_column.html b/ietf/utils/templates/previews/footer_column.html new file mode 100644 index 00000000..b4473501 --- /dev/null +++ b/ietf/utils/templates/previews/footer_column.html @@ -0,0 +1 @@ +{% extends settings.utils.LayoutSettings.base_template %} diff --git a/ietf/utils/tests/test_footer.py b/ietf/utils/tests/test_footer.py new file mode 100644 index 00000000..fe405cda --- /dev/null +++ b/ietf/utils/tests/test_footer.py @@ -0,0 +1,99 @@ +from bs4 import BeautifulSoup +import pytest +from django.test import Client, RequestFactory + +from ietf.home.models import HomePage +from ietf.standard.factories import StandardIndexPageFactory, StandardPageFactory +from ietf.standard.models import StandardIndexPage, StandardPage +from ietf.utils.models import FooterColumn + +pytestmark = pytest.mark.django_db + + +class TestFooterColumns: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.standard_index: StandardIndexPage = StandardIndexPageFactory( + parent=self.home, + ) # type: ignore + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.standard_index, + show_in_menus=True, + ) # type: ignore + + def test_links(self): + FooterColumn.objects.create( + title="Column Title", + links=[ + { + "type": "link", + "value": { + "page": self.standard_index.pk, + }, + }, + { + "type": "link", + "value": { + "page": self.standard_page.pk, + "title": "My Page Title", + }, + }, + { + "type": "link", + "value": { + "external_url": "http://example.com", + }, + }, + { + "type": "link", + "value": { + "external_url": "http://example.com", + "title": "My External Link Title", + }, + }, + ], + sort_order=1, + ) + + response = self.client.get("/") + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + + # Select the single footer column. + [section] = soup.select("footer section") + + # Select the column's heading. + [h4] = section.select("h4") + assert h4.get_text().strip() == "Column Title" + + # Select the links. They should match what we specified in the `links` + # field. + [link1, link2, link3, link4] = section.select("ul li a") + assert link1.get_text().strip() == self.standard_index.title + assert link1.attrs["href"] == self.standard_index.url + assert link2.get_text().strip() == "My Page Title" + assert link2.attrs["href"] == self.standard_page.url + assert link3.get_text().strip() == "http://example.com" + assert link3.attrs["href"] == "http://example.com" + assert link4.get_text().strip() == "My External Link Title" + assert link4.attrs["href"] == "http://example.com" + + def test_order_in_preview(self): + item1 = FooterColumn.objects.create(title="One", sort_order=10) + item2 = FooterColumn.objects.create(title="Two", sort_order=20) + + item1.sort_order = 30 + context = item1.get_preview_context(RequestFactory().get("/"), "") + assert [i.title for i in context["FOOTER"]] == ["Two", "One"] + + def test_order_in_preview_new_object(self): + item1 = FooterColumn.objects.create(title="One", sort_order=10) + item2 = FooterColumn(title="Two", sort_order=5) + + context = item2.get_preview_context(RequestFactory().get("/"), "") + assert [i.title for i in context["FOOTER"]] == ["Two", "One"] diff --git a/ietf/utils/wagtail_hooks.py b/ietf/utils/wagtail_hooks.py index 046d5072..d75dca5c 100644 --- a/ietf/utils/wagtail_hooks.py +++ b/ietf/utils/wagtail_hooks.py @@ -6,10 +6,10 @@ from wagtail_modeladmin.options import ModelAdmin, modeladmin_register from wagtailorderable.modeladmin.mixins import OrderableMixin -from ietf.utils.models import MainMenuItem, SecondaryMenuItem +from .models import FooterColumn, MainMenuItem, SecondaryMenuItem -@hooks.register("insert_global_admin_css") +@hooks.register("insert_global_admin_css") # type: ignore def editor_css(): return format_html( '