diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index fceeeb99b..e5f5d094d 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -47,7 +47,7 @@ jobs: - name: Build site (_site directory name is used for Jekyll compatiblity) run: mkdocs build --config-file ./mkdocs.yml --site-dir ./_site - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 deploy: needs: build diff --git a/docs/custom-features/divisions.md b/docs/custom-features/divisions.md new file mode 100644 index 000000000..ae73b55ce --- /dev/null +++ b/docs/custom-features/divisions.md @@ -0,0 +1,24 @@ +# Divisions + +The site's target audience can be grouped into divisions; e.g. the charity division, the public sector division, and the Wagtail division. All content going forward can be associated to one of these divisions. + +The idea is that if you're a charity organisation, you can find content that's specific and relevant for you because the relevant content will all be in one place. + +This feature allows content to be associated to a specific `DivisionPage`, which allows us to display the same theme, logo and navigation for any content related to a division. (Because of this, a page should only have one associated division.) + +## Options + +The available options are dependent on the `DivisionPage`s that have been created. + +## Division configuration + +The `tbx.core.utils.models.DivisionMixin` provides a mechanism for associating a specific division with a page. It offers the following functionality: + +- `division` field: Adds a ForeignKey field to associate a specific division with a page. +- `final_division`: A cached property that determines the appropriate division to associate to a page. It first checks if the page has a `division` specified. If not, it traverses the page's ancestors to find the first page that either has a `division` specified or is a `DivisionPage`, defaulting to `None`. + +--- + +???+ note + + Please ensure that the Editors' guide is updated accordingly whenever any changes are made to this feature. A private link, for Torchbox employees only, can be found at https://intranet.torchbox.com/torchbox-com-project-docs. diff --git a/docs/custom-features/theme.md b/docs/custom-features/theme.md index 71d5b9675..d172ecf17 100644 --- a/docs/custom-features/theme.md +++ b/docs/custom-features/theme.md @@ -6,11 +6,11 @@ The Theme feature enables the customization of page styles through the applicati The available color themes are defined using the `tbx.core.utils.models.ColourTheme` enumeration. Each theme option consists of a CSS class name and a human-readable label. The following themes are available: -- `ColourTheme.NONE`: No specific theme applied. When the theme is set to "None", this means we don't add a `theme-****` class to the page, and the default theme (_Coral_, at the time of writing these docs) is applied. +- `ColourTheme.NONE`: No specific theme applied. When the theme is set to "None", this means we don't add a `theme-****` class to the page, and the default theme (_Coral_, at the time of writing these docs) is applied (unless the page inherits a theme). - `ColourTheme.CORAL`: Applies a `theme-coral` class to the page. +- `ColourTheme.NEBULINE`: Applies a `theme-nebuline` class to the page. - `ColourTheme.LAGOON`: Applies a `theme-lagoon` class to the page. -- `ColourTheme.BANANA`: Applies a `theme-banana` class to the page. -- `ColourTheme.EARTH`: Applies a `theme-earth` class to the page. +- `ColourTheme.GREEN`: Applies a `theme-green` class to the page. ???+ tip diff --git a/docs/front-end/breakpoints.md b/docs/front-end/breakpoints.md index a316f4200..78b6d3a07 100644 --- a/docs/front-end/breakpoints.md +++ b/docs/front-end/breakpoints.md @@ -13,7 +13,9 @@ $breakpoints: ( 'x-large' '(min-width: 1280px)', // secondary breakpoints - use sparingly 'small' '(min-width: 410px)', - 'xx-large' '(min-width: 1800px)' + 'menu' '(min-width: 800px)', + 'xx-large' '(min-width: 1440px)', + 'xxx-large' '(min-width: 1800px)' ); ``` diff --git a/docs/front-end/fonts.md b/docs/front-end/fonts.md index fe69b6c5d..ff9548655 100644 --- a/docs/front-end/fonts.md +++ b/docs/front-end/fonts.md @@ -2,7 +2,7 @@ The site uses 'Outfit' as the main font and 'sans-serif' as the fallback font. -Outfit is avaible from [Google Fonts](https://fonts.google.com/specimen/Outfit) and is a variable font. This means that the font can be loaded as a single file and the weight and style can be adjusted using CSS. Any weight between 300 and 600 can be used. +Outfit is avaible from [Google Fonts](https://fonts.google.com/specimen/Outfit) and is a variable font. This means that the font can be loaded as a single file and the weight and style can be adjusted using CSS. Any weight between 200 and 600 can be used. We originally limited the number of font variants for reasons of sustainability, although the fact that this is a variable font means that extra weights could be added if required without impacting carbon emissions. @@ -12,7 +12,7 @@ The font is loaded using the following CSS in `sass/base/_fonts.scss`: @font-face { font-family: 'Outfit'; font-style: normal; - font-weight: 300 600; + font-weight: 200 600; font-display: swap; src: url(../fonts/outfit-variable-font.woff2) format('woff2-variations'); } diff --git a/docs/front-end/grid.md b/docs/front-end/grid.md index df3480c97..e537c5c4a 100644 --- a/docs/front-end/grid.md +++ b/docs/front-end/grid.md @@ -36,7 +36,7 @@ Any alignment or spacing rules for a component should be added using BEM syntax grid-column: 2 / span 4; @include media-query(large) { - grid-column: 4 / span 9; + grid-column: 2 / span 9; } } } diff --git a/docs/front-end/motif-headings.md b/docs/front-end/motif-headings.md deleted file mode 100644 index 69579594e..000000000 --- a/docs/front-end/motif-headings.md +++ /dev/null @@ -1,9 +0,0 @@ -# Motif headings - -The site makes use of coloured drop-cap letters in major headings, with an optional animation on heading ones. - -Because some letters are narrower than others, there was an issue with a large gap after the initial drop-cap, as well as an issue with the letter I where it was too narrow to see the final background flame image. This has been solved with some custom classes for particular letters. - -See the `motif-heading.html` component (under `atoms` in the styleguide) and the equivalent `motif-heading.scss` sass file. - -There has been an issue with google displaying the heading one without the first letter in search results, probably because of the transparent styling on the first letter. As an attempt to resolve this, for h1s, we now move the motif heading into a paragraph tag, with a visually hidden h1 tag. diff --git a/docs/front-end/themes_and_modes.md b/docs/front-end/themes_and_modes.md index 6e928fff8..73966aa6a 100644 --- a/docs/front-end/themes_and_modes.md +++ b/docs/front-end/themes_and_modes.md @@ -8,13 +8,13 @@ The CSS to set the mode is an html class of either `.mode-dark` or `.mode-light` An editor has an option to select a theme on each page. Selecting a theme on a page will change it for that page, and all child pages, unless another selection is made further down the page tree. -There are currently 4 themes in use: coral, lagoon, banana and earth. The CSS to set the theme is an html class of either `.theme-coral`, `.theme-lagoon`, `.theme-banana` or `.theme-earth`. +There are currently 4 themes in use: coral, nebuline, lagoon and green. The CSS to set the theme is an html class of either `.theme-coral`, `.theme-nebuline`, `.theme-lagoon` or `.theme-green`. ???+ note - If you are adding a new theme, check the colour contrast for all the new accent colours added, used in the drop-caps (remember to check both dark mode and light mode). They need to pass colour contrast as if the entire drop-cap was filled with that colour. + If you are adding a new theme, check the colour contrast for all the new accent colours added, used in the heading motifs (remember to check both dark mode and light mode). They need to pass colour contrast as if the entire heading was filled with that colour. - The drop-caps svgs use a semi-transparent version of the accent colours, and the contrast of the resulting colour also needs to be checked. The [colour contrast checker](https://chromewebstore.google.com/detail/colour-contrast-checker/nmmjeclfkgjdomacpcflgdkgpphpmnfe?hl=en-GB&utm_source=ext_sidebar) is a useful chrome extension to assist with this. + The motifs svgs use a semi-transparent version of the accent colours, and the contrast of the resulting colour also needs to be checked. The [colour contrast checker](https://chromewebstore.google.com/detail/colour-contrast-checker/nmmjeclfkgjdomacpcflgdkgpphpmnfe?hl=en-GB&utm_source=ext_sidebar) is a useful chrome extension to assist with this. ## Defaults diff --git a/docs/front-end/utility-classes.md b/docs/front-end/utility-classes.md index b0fbb2928..e4e57e4fe 100644 --- a/docs/front-end/utility-classes.md +++ b/docs/front-end/utility-classes.md @@ -4,6 +4,6 @@ Most CSS classes used in this build are designed to fit with a particular compon Some, such as the `listing` component are designed to be a base with variations across a number of variant components - this is made clear by the component naming: `listing--image.html`, `listing--simple.html` etc. -However, we have some utility classes which are designed for re-use, along with the option to to use Tailwind utility classes. Classes that are designed for re-use include `button`, `heading--[xyz]`, `link`, `supporting`, `.body` and `icon--listing-arrow`. +However, we have some utility classes which are designed for re-use, along with the option to to use Tailwind utility classes. Classes that are designed for re-use include `button`, `heading--[xyz]`, `text--[xyz]`, `link`, and `icon--listing-arrow`. The same tailwind utility classes that are avialable in `wagtail-kit` are available in this build. `tailwind.config.js` includes the custom spacing for the build, allowing us to use classes such as `mb-spacerMini` where it wouldn't be practical to create a new scss component just for a one-off spacing adjustment. diff --git a/mkdocs.yml b/mkdocs.yml index 104f9337b..15c5aa2c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,7 +73,6 @@ nav: - front-end/tooling.md - front-end/placeholder-images.md - front-end/utility-classes.md - - front-end/motif-headings.md - 'Markdown block and codehilite': 'front-end/markdown-codehilite.md' - front-end/impact-report.md - front-end/lite-youtube.md @@ -81,10 +80,11 @@ nav: - front-end/incident-form.md - 'Navigation': 'navigation.md' - 'Custom features': + - 'Contact': 'custom-features/contact.md' + - 'Division': 'custom-features/divisions.md' - 'Migration-friendly StreamFields': 'custom-features/migration-friendly-streamfields.md' - - 'Theme': 'custom-features/theme.md' - 'Modes': 'custom-features/modes.md' - - 'Contact': 'custom-features/contact.md' + - 'Theme': 'custom-features/theme.md' - 'Continuous integration': 'continuous-integration.md' - 'Anonymised data': 'anonymised-data.md' - 'Data import': 'data-import.md' diff --git a/package-lock.json b/package-lock.json index 6e0997fd6..78572bcfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "UNLICENSED", "dependencies": { "js-cookie": "^3.0.5", - "lite-youtube-embed": "^0.3.2" + "lite-youtube-embed": "^0.3.2", + "swiper": "^11.2.1" }, "devDependencies": { "@types/jest": "^29.5.6", @@ -12434,6 +12435,24 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/swiper": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.1.tgz", + "integrity": "sha512-62G69+iQRIfUqTmJkWpZDcX891Ra8O9050ckt1/JI2H+0483g+gq0m7gINecDqMtDh2zt5dK+uzBRxGhGOOvQA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index d06c1da4f..a5d21b808 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ }, "dependencies": { "js-cookie": "^3.0.5", - "lite-youtube-embed": "^0.3.2" + "lite-youtube-embed": "^0.3.2", + "swiper": "^11.2.1" } } diff --git a/poetry.lock b/poetry.lock index 8931a7954..a2bf2629a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "anyascii" @@ -392,7 +392,6 @@ files = [ {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, - {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, @@ -403,7 +402,6 @@ files = [ {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, - {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, @@ -2688,4 +2686,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.13" -content-hash = "b1b9d166c57ec57307a3ad7da45bb4f1eba8be84de8e83949a7724950a934987" +content-hash = "95007a8fa0e7782b2629bed71456091f26e2981c94b2136cc2758fd50461fb9e" diff --git a/pyproject.toml b/pyproject.toml index 6ed8a5cf6..436a232cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,8 @@ mkdocs-material = "^9.5.41" pymdown-extensions = "^10.11.2" # Testing +factory-boy = "^3.3.0" +faker = "^33.3.0" wagtail-factories = "^4.2.1" diff --git a/tailwind.config.js b/tailwind.config.js index 13514ec96..95b08abe0 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -22,6 +22,7 @@ module.exports = { transparent: 'transparent', background: 'var(--color--background)', heading: 'var(--color--heading)', + themePrimary: 'var(--color--theme-primary)', }, screens: { sm: '410px', @@ -38,7 +39,9 @@ module.exports = { spacerMini: '15px', spacerMiniPlus: '20px', spacerSmall: '30px', + spacerSmallPlus: '40px', spacerMedium: '60px', + spacerMediumPlus: '100px', spacerLarge: '120px', spacer: '160px', spacerHalf: '80px', diff --git a/tbx/blog/migrations/0027_update_theme_colour_choices.py b/tbx/blog/migrations/0027_update_theme_colour_choices.py new file mode 100644 index 000000000..13e0b803a --- /dev/null +++ b/tbx/blog/migrations/0027_update_theme_colour_choices.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.16 on 2024-12-09 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("blog", "0026_add_earth_colour_theme"), + ] + + operations = [ + migrations.AlterField( + model_name="blogindexpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + migrations.AlterField( + model_name="blogpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ] diff --git a/tbx/blog/migrations/0028_divisionmixin_and_navigationsetmixin.py b/tbx/blog/migrations/0028_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..9599164be --- /dev/null +++ b/tbx/blog/migrations/0028_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,57 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("blog", "0027_update_theme_colour_choices"), + ("divisions", "0002_divisionmixin_and_navigationsetmixin"), + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ] + + operations = [ + migrations.AddField( + model_name="blogindexpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="blogindexpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + migrations.AddField( + model_name="blogpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="blogpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + ] diff --git a/tbx/blog/models.py b/tbx/blog/models.py index f75dda0bc..8bff1ead6 100644 --- a/tbx/blog/models.py +++ b/tbx/blog/models.py @@ -19,20 +19,18 @@ from bs4 import BeautifulSoup from tbx.core.blocks import StoryBlock +from tbx.core.models import BasePage from tbx.core.utils.fields import StreamField from tbx.core.utils.models import ( ColourThemeMixin, - NavigationFields, + ContactMixin, SocialFields, ) from tbx.images.models import CustomImage -from tbx.people.models import ContactMixin from tbx.taxonomy.models import Sector, Service -class BlogIndexPage( - ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page -): +class BlogIndexPage(BasePage): template = "patterns/pages/blog/blog_listing.html" subpage_types = ["BlogPage"] @@ -48,7 +46,7 @@ def blog_posts(self): prefetch_author_images = models.Prefetch( "authors__author__image", queryset=CustomImage.objects.prefetch_renditions( - "format-webp|fill-72x72", + "format-webp|fill-100x100", "format-webp|fill-144x144", "format-webp|fill-286x286", ), @@ -119,20 +117,8 @@ def get_context(self, request, *args, **kwargs): ) return context - promote_panels = ( - [ - MultiFieldPanel(Page.promote_panels, "Common page configuration"), - ] - + NavigationFields.promote_panels - + ColourThemeMixin.promote_panels - + ContactMixin.promote_panels - + [ - MultiFieldPanel(SocialFields.promote_panels, "Social fields"), - ] - ) - -class BlogPage(ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page): +class BlogPage(BasePage): template = "patterns/pages/blog/blog_detail.html" parent_page_types = ["BlogIndexPage"] @@ -158,7 +144,7 @@ class BlogPage(ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, P related_services = ParentalManyToManyField( "taxonomy.Service", related_name="blog_posts" ) - search_fields = Page.search_fields + [ + search_fields = BasePage.search_fields + [ index.SearchField("body"), ] @@ -188,7 +174,7 @@ def related_blog_posts(self): prefetch_author_images = models.Prefetch( "authors__author__image", queryset=CustomImage.objects.prefetch_renditions( - "format-webp|fill-72x72", + "format-webp|fill-100x100", "format-webp|fill-144x144", "format-webp|fill-286x286", ), @@ -243,7 +229,7 @@ def read_time(self): def type(self): return "BLOG POST" - content_panels = Page.content_panels + [ + content_panels = BasePage.content_panels + [ InlinePanel("authors", label="Author", min_num=1), FieldPanel("date"), FieldPanel("body"), diff --git a/tbx/core/blocks.py b/tbx/core/blocks.py index 3cdb0c4aa..7c19347c9 100644 --- a/tbx/core/blocks.py +++ b/tbx/core/blocks.py @@ -2,6 +2,7 @@ from datetime import datetime import logging +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.forms.utils import ErrorList @@ -234,6 +235,9 @@ def get_button_file_size(self): class CallToActionBlock(blocks.StructBlock): text = blocks.CharBlock(required=True, max_length=255) + description = blocks.RichTextBlock( + features=settings.PARAGRAPH_RICH_TEXT_FEATURES, required=False + ) button_text = blocks.CharBlock(max_length=55) button_link = blocks.StreamBlock( [ @@ -261,6 +265,182 @@ class Meta: template = "patterns/molecules/streamfield/blocks/contact_call_to_action.html" +class DynamicHeroBlock(blocks.StructBlock): + """ + This block displays text that will be cycled through. + """ + + static_text = blocks.CharBlock(required=False) + dynamic_text = blocks.ListBlock( + blocks.CharBlock(), + help_text=( + "The hero will cycle through these texts on larger screen sizes " + "and only show the first text on smaller screen sizes." + ), + required=False, + ) + + class Meta: + icon = "title" + template = "patterns/molecules/streamfield/blocks/dynamic_hero_block.html" + + +class FeaturedPageCardBlock(blocks.StructBlock): + heading = blocks.CharBlock(required=False) + subheading = blocks.CharBlock(required=False) + description = blocks.RichTextBlock(features=settings.NO_HEADING_RICH_TEXT_FEATURES) + image = ImageChooserBlock() + link_text = blocks.CharBlock() + accessible_link_text = blocks.CharBlock( + help_text=( + "Used by screen readers. This should be descriptive for accessibility. " + 'If not filled, the "Link text" field will be used instead.' + ), + required=False, + ) + page = blocks.PageChooserBlock() + + class Meta: + icon = "breadcrumb-expand" + + +class FeaturedServicesBlock(blocks.StructBlock): + title = blocks.CharBlock(max_length=255, required=False) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) + cards = blocks.ListBlock( + FeaturedPageCardBlock(), + max_num=4, + min_num=2, + ) + + class Meta: + group = "Custom" + icon = "link" + template = "patterns/molecules/streamfield/blocks/featured_services_block.html" + + +class FourPhotoCollageBlock(blocks.StructBlock): + """ + Accepts 4 photos shown as a collage + text below. + Used on the division page and the service area page. + """ + + images = blocks.ListBlock( + ImageWithAltTextBlock(label="Photo"), + min_num=4, + max_num=4, + label="Photos", + help_text="Exactly four required.", + default=[{"image": None, "alt_text": ""}] * 4, + ) + caption = blocks.RichTextBlock( + features=settings.PARAGRAPH_RICH_TEXT_FEATURES, required=False + ) + description = blocks.RichTextBlock( + features=settings.PARAGRAPH_RICH_TEXT_FEATURES, required=False + ) + + class Meta: + group = "Custom" + icon = "image" + template = "patterns/molecules/streamfield/blocks/four_photo_collage_block.html" + + +class KeyPointIconChoice(models.TextChoices): + CALENDAR = "key-calendar", "calendar icon" + CONVERSATION = "key-conversation", "chat bubbles icon" + LIGHTBULB = "key-lightbulb", "lightbulb icon" + MAIL = "key-mail", "mail icon" + MEGAPHONE = "key-megaphone", "megaphone icon" + PEOPLE = "key-people", "people icon" + BULLSEYE = "key-bullseye", "target icon" + UP_ARROW = "key-up-arrow", "up arrow icon" + + +class IconKeyPointBlock(blocks.StructBlock): + icon = blocks.ChoiceBlock( + choices=KeyPointIconChoice.choices, + default=KeyPointIconChoice.LIGHTBULB, + max_length=32, + ) + icon_label = blocks.CharBlock() + heading = blocks.CharBlock() + description = blocks.RichTextBlock(features=settings.NO_HEADING_RICH_TEXT_FEATURES) + + class Meta: + icon = "breadcrumb-expand" + + +class IconKeyPointsBlock(blocks.StructBlock): + """Used on the service area page.""" + + title = blocks.CharBlock(max_length=255, required=False) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) + key_points = blocks.ListBlock(IconKeyPointBlock(label="Key point"), min_num=1) + + class Meta: + group = "Custom" + icon = "list-ul" + template = "patterns/molecules/streamfield/blocks/icon_keypoints_block.html" + + +class IntroductionWithImagesBlock(blocks.StructBlock): + """Used on the division page.""" + + introduction = blocks.RichTextBlock(features=settings.PARAGRAPH_RICH_TEXT_FEATURES) + description = blocks.RichTextBlock( + blank=True, features=settings.NO_HEADING_RICH_TEXT_FEATURES + ) + images = blocks.ListBlock( + ImageWithAltTextBlock(label="Photo"), + min_num=2, + max_num=2, + label="Photos", + help_text="Exactly two required.", + default=[{"image": None, "alt_text": ""}] * 2, + ) + + class Meta: + group = "Custom" + icon = "pilcrow" + template = ( + "patterns/molecules/streamfield/blocks/introduction_with_images_block.html" + ) + + +class LinkColumnsBlock(blocks.StructBlock): + """ + Displays a list of links in columns. + Used on the service area page. + """ + + title = blocks.CharBlock(max_length=255, required=False) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) + links = LinkBlock(max_num=None, min_num=1) + + class Meta: + group = "Custom" + icon = "link" + template = "patterns/molecules/streamfield/blocks/link_columns_block.html" + + +class PartnersBlock(blocks.StructBlock): + title = blocks.CharBlock(max_length=255, required=False) + partner_logos = blocks.ListBlock(CustomImageChooserBlock(), label="Logos") + + class Meta: + icon = "openquote" + label = "Partner logos" + template = "patterns/molecules/streamfield/blocks/partners_block.html" + group = "Custom" + + class ShowcaseBlock(blocks.StructBlock): """ This block is a standard ShowcaseBlock, available on the home page and @@ -297,6 +477,49 @@ class IconChoice(models.TextChoices): WAGTAIL = "wagtail", "wagtail icon" +class DivisionSignpostCardBlock(blocks.StructBlock): + class ColourTheme(models.TextChoices): + CORAL = "theme-coral", "Coral" + NEBULINE = "theme-nebuline", "Nebuline" + LAGOON = "theme-lagoon", "Lagoon" + + card_colour = blocks.ChoiceBlock( + choices=ColourTheme.choices, default=ColourTheme.CORAL, max_length=20 + ) + heading = blocks.CharBlock(required=False) + description = blocks.RichTextBlock(features=settings.NO_HEADING_RICH_TEXT_FEATURES) + image = ImageChooserBlock() + link_text = blocks.CharBlock() + accessible_link_text = blocks.CharBlock( + help_text=( + "Used by screen readers. This should be descriptive for accessibility. " + 'If not filled, the "Link text" field will be used instead.' + ), + required=False, + ) + page = blocks.PageChooserBlock() + + class Meta: + icon = "breadcrumb-expand" + + +class DivisionSignpostBlock(blocks.StructBlock): + title = blocks.CharBlock(max_length=255, required=False) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) + cards = blocks.ListBlock( + DivisionSignpostCardBlock(), + max_num=3, + min_num=3, + ) + + class Meta: + group = "Custom" + icon = "thumbtack" + template = "patterns/molecules/streamfield/blocks/division_signpost_block.html" + + class HomepageShowcaseBlock(blocks.StructBlock): """ This block is similar to the ShowcaseBlock, but is rendered larger @@ -435,11 +658,16 @@ def clean(self, value): class BlogChooserBlock(blocks.StructBlock): featured_blog_heading = blocks.CharBlock(max_length=255) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) blog_pages = blocks.ListBlock( blocks.PageChooserBlock(page_type="blog.BlogPage"), min_num=1, max_num=3, ) + primary_button = LinkBlock(label="Primary button", required=False) + secondary_button = LinkBlock(label="Secondary button", required=False) def get_context(self, value, parent_context=None): context = super().get_context(value, parent_context=parent_context) @@ -461,11 +689,16 @@ def get_context(self, value, parent_context=None): class WorkChooserBlock(blocks.StructBlock): featured_work_heading = blocks.CharBlock(max_length=255) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) work_pages = blocks.ListBlock( blocks.PageChooserBlock(page_type=["work.WorkPage", "work.HistoricalWorkPage"]), min_num=1, max_num=3, ) + primary_button = LinkBlock(label="Primary button", required=False) + secondary_button = LinkBlock(label="Secondary button", required=False) def get_context(self, value, parent_context=None): context = super().get_context(value, parent_context) @@ -814,6 +1047,37 @@ class NumericStatisticsBlock(blocks.StructBlock): class Meta: icon = "table" + label_format = "{headline_number} {description} {further_details}" + + +class AbstractStatisticsGroupBlock(blocks.StructBlock): + """ + A base class for shared fields between numeric and textual stat blocks. + """ + + title = blocks.CharBlock(max_length=255, required=False) + intro = blocks.RichTextBlock( + features=settings.NO_HEADING_RICH_TEXT_FEATURES, required=False + ) + + class Meta: + abstract = True + + +class NumericStatisticsGroupBlock(AbstractStatisticsGroupBlock): + statistics = blocks.ListBlock( + NumericStatisticsBlock(), + max_num=4, + min_num=1, + ) + + class Meta: + group = "Custom" + icon = "table" + label = "Numeric statistics" + template = ( + "patterns/molecules/streamfield/blocks/numeric_stats_group_block.html" + ) class TextualStatisticsBlock(blocks.StructBlock): @@ -834,6 +1098,22 @@ class Meta: icon = "info-circle" +class TextualStatisticsGroupBlock(AbstractStatisticsGroupBlock): + statistics = blocks.ListBlock( + TextualStatisticsBlock(), + max_num=4, + min_num=1, + ) + + class Meta: + group = "Custom" + icon = "table" + label = "Textual statistics" + template = ( + "patterns/molecules/streamfield/blocks/textual_stats_group_block.html" + ) + + class TypedTableBlock(blocks.StructBlock): table = WagtailTypedTableBlock( [ @@ -944,6 +1224,7 @@ class StandardPageStoryBlock(StoryBlock): class HomePageStoryBlock(blocks.StreamBlock): + division_signpost = DivisionSignpostBlock() showcase = ShowcaseBlock(label="Standard showcase") homepage_showcase = HomepageShowcaseBlock(label="Large showcase with icons") featured_case_study = FeaturedCaseStudyBlock() diff --git a/tbx/core/factories.py b/tbx/core/factories.py index 41da713c2..979c4f846 100644 --- a/tbx/core/factories.py +++ b/tbx/core/factories.py @@ -1,15 +1,33 @@ -from wagtail.blocks import RichTextBlock +from wagtail import blocks import factory +from faker import Faker import wagtail_factories -from tbx.core.blocks import StoryBlock +from tbx.core.blocks import DynamicHeroBlock, StoryBlock from tbx.core.models import HomePage, StandardPage +fake = Faker() + + +class DynamicHeroBlockFactory(wagtail_factories.StructBlockFactory): + class Meta: + model = DynamicHeroBlock + + static_text = fake.sentence() + + @factory.post_generation + def dynamic_text(obj, create, extracted, **kwargs): + values = extracted or fake.sentences(nb=5) + obj["dynamic_text"] = blocks.list_block.ListValue( + blocks.ListBlock(blocks.CharBlock()), values + ) + + class RichTextBlockFactory(wagtail_factories.blocks.BlockFactory): class Meta: - model = RichTextBlock + model = blocks.RichTextBlock class StoryBlockFactory(wagtail_factories.StreamBlockFactory): @@ -25,6 +43,8 @@ class Meta: class HomePageFactory(wagtail_factories.PageFactory): title = "Home" + hero_heading_1 = "The digital partner" + hero_heading_2 = "for positive change" class Meta: model = HomePage diff --git a/tbx/core/migrations/0038_update_theme_colour_choices.py b/tbx/core/migrations/0038_update_theme_colour_choices.py new file mode 100644 index 000000000..d5cac1add --- /dev/null +++ b/tbx/core/migrations/0038_update_theme_colour_choices.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.16 on 2024-12-09 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("torchbox", "0037_merge_20240725_1000"), + ] + + operations = [ + migrations.AlterField( + model_name="homepage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + migrations.AlterField( + model_name="standardpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ] diff --git a/tbx/core/migrations/0039_remove_homepage_introduction_homepage_hero_heading_and_more.py b/tbx/core/migrations/0039_remove_homepage_introduction_homepage_hero_heading_and_more.py new file mode 100644 index 000000000..b25c20c5e --- /dev/null +++ b/tbx/core/migrations/0039_remove_homepage_introduction_homepage_hero_heading_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.16 on 2024-12-04 08:04 + +from django.db import migrations, models + +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("torchbox", "0038_update_theme_colour_choices"), + ] + + operations = [ + migrations.RemoveField( + model_name="homepage", + name="introduction", + ), + migrations.AddField( + model_name="homepage", + name="hero_heading_1", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="homepage", + name="hero_heading_2", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="homepage", + name="hero_introduction", + field=wagtail.fields.RichTextField(blank=True), + ), + ] diff --git a/tbx/core/migrations/0040_divisionmixin_and_navigationsetmixin.py b/tbx/core/migrations/0040_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..2920e8310 --- /dev/null +++ b/tbx/core/migrations/0040_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("divisions", "0002_divisionmixin_and_navigationsetmixin"), + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ( + "torchbox", + "0039_remove_homepage_introduction_homepage_hero_heading_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="homepage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + migrations.AddField( + model_name="standardpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="standardpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + ] diff --git a/tbx/core/models.py b/tbx/core/models.py index 8af14912e..3bae5af6b 100644 --- a/tbx/core/models.py +++ b/tbx/core/models.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.functional import cached_property +from django.utils.safestring import mark_safe from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel @@ -11,12 +12,18 @@ from wagtail.snippets.models import register_snippet from tbx.core.utils.fields import StreamField +from tbx.core.utils.formatting import ( + convert_bold_links_to_pink, + convert_italic_links_to_purple, +) from tbx.core.utils.models import ( ColourThemeMixin, + ContactMixin, + DivisionMixin, NavigationFields, + NavigationSetMixin, SocialFields, ) -from tbx.people.models import ContactMixin from .blocks import HomePageStoryBlock, StandardPageStoryBlock @@ -113,6 +120,43 @@ class Meta: abstract = True +class BasePage( + ColourThemeMixin, + ContactMixin, + DivisionMixin, + NavigationFields, + NavigationSetMixin, + SocialFields, + Page, +): + class Meta: + abstract = True + + promote_panels = ( + [ + MultiFieldPanel(Page.promote_panels, "Common page configuration"), + ] + + NavigationFields.promote_panels + + NavigationSetMixin.promote_panels + + ColourThemeMixin.promote_panels + + DivisionMixin.promote_panels + + ContactMixin.promote_panels + + [ + MultiFieldPanel(SocialFields.promote_panels, "Social fields"), + ] + ) + + @cached_property + def breadcrumbs(self): + """ + Return a a list of the current page's ancestors where the first one is + either a division page, or the homepage if no ancestor is a division page. + """ + # The homepage has depth=2 + min_depth = 2 if self.final_division is None else self.final_division.depth + return self.get_ancestors().filter(depth__gte=min_depth) + + class HomePagePartnerLogo(Orderable): page = ParentalKey("torchbox.HomePage", related_name="logos") image = models.ForeignKey( @@ -129,9 +173,21 @@ class HomePagePartnerLogo(Orderable): # Home Page -class HomePage(ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page): +class HomePage( + ColourThemeMixin, + ContactMixin, + NavigationFields, + NavigationSetMixin, + SocialFields, + Page, +): template = "patterns/pages/home/home_page.html" - introduction = models.TextField(blank=True) + + parent_page_types = ["wagtailcore.Page"] + + hero_heading_1 = models.CharField(max_length=255) + hero_heading_2 = models.CharField(max_length=255) + hero_introduction = RichTextField(blank=True, features=["bold", "italic", "link"]) body = StreamField(HomePageStoryBlock()) class Meta: @@ -143,9 +199,39 @@ def partner_logos(self): return [logo.image for logo in logos] return [] - content_panels = Page.content_panels + [ - FieldPanel("introduction"), - InlinePanel("logos", heading="Partner logos", label="logo", max_num=7), + content_panels = BasePage.content_panels + [ + MultiFieldPanel( + [ + FieldPanel( + "hero_heading_1", + heading="Heading (Part 1)", + help_text="This is the non-bold part of the heading.", + ), + FieldPanel( + "hero_heading_2", + heading="Heading (Part 2)", + help_text="This is the bold part of the heading.", + ), + FieldPanel( + "hero_introduction", + heading="Introduction", + # mark_safe needed so the HTML tags aren't escaped + help_text=mark_safe( # noqa: S308 + "Use bold to mark links as" + ' pink,' + " and use italics to mark links as" + ' purple ' + "(the colours will only take effect on larger screen sizes)." + ), + ), + ], + heading="Hero", + help_text=( + "When combined, part 1 & part 2 of the heading can be treated as one" + " sentence or one paragraph, depending on the presence of punctuation." + ), + ), + InlinePanel("logos", heading="Partner logos", label="logo", max_num=12), FieldPanel("body"), ] @@ -154,6 +240,7 @@ def partner_logos(self): MultiFieldPanel(Page.promote_panels, "Common page configuration"), ] + NavigationFields.promote_panels + + NavigationSetMixin.promote_panels + ColourThemeMixin.promote_panels + ContactMixin.promote_panels + [ @@ -164,34 +251,23 @@ def partner_logos(self): def get_context(self, request): context = super().get_context(request) context["is_home_page"] = True + context["hero_introduction"] = convert_bold_links_to_pink( + convert_italic_links_to_purple(self.hero_introduction) + ) return context # Standard page -class StandardPage( - ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page -): +class StandardPage(BasePage): template = "patterns/pages/standard/standard_page.html" body = StreamField(StandardPageStoryBlock()) - content_panels = Page.content_panels + [ + content_panels = BasePage.content_panels + [ FieldPanel("body"), ] - promote_panels = ( - [ - MultiFieldPanel(Page.promote_panels, "Common page configuration"), - ] - + NavigationFields.promote_panels - + ColourThemeMixin.promote_panels - + ContactMixin.promote_panels - + [ - MultiFieldPanel(SocialFields.promote_panels, "Social fields"), - ] - ) - - search_fields = Page.search_fields + [ + search_fields = BasePage.search_fields + [ index.SearchField("body"), ] diff --git a/tbx/core/tests/test_colour_theme.py b/tbx/core/tests/test_colour_theme.py index a83f7798b..fb704dc7b 100644 --- a/tbx/core/tests/test_colour_theme.py +++ b/tbx/core/tests/test_colour_theme.py @@ -173,7 +173,7 @@ def test_theme_on_an_indexpage(self): self.assertNotIn(theme, html_classes) def test_custom_theme_on_descendants(self): - self.workindex.theme = ColourTheme.BANANA + self.workindex.theme = ColourTheme.NEBULINE self.workindex.save() self.workindex.refresh_from_db() @@ -183,32 +183,32 @@ def test_custom_theme_on_descendants(self): self.assertEqual(self.blogindex.theme, ColourTheme.NONE) self.assertEqual(self.blogindex.theme_class, ColourTheme.NONE) - self.assertEqual(self.workindex.theme, ColourTheme.BANANA) - self.assertEqual(self.workindex.theme_class, ColourTheme.BANANA) + self.assertEqual(self.workindex.theme, ColourTheme.NEBULINE) + self.assertEqual(self.workindex.theme_class, ColourTheme.NEBULINE) # child of workindex work = HistoricalWorkPageFactory(parent=self.workindex) self.assertEqual(work.theme, ColourTheme.NONE) - self.assertEqual(work.theme_class, ColourTheme.BANANA) + self.assertEqual(work.theme_class, ColourTheme.NEBULINE) # another child of workindex, this time we set the theme morework = HistoricalWorkPageFactory( - parent=self.workindex, theme=ColourTheme.EARTH + parent=self.workindex, theme=ColourTheme.GREEN ) - self.assertEqual(morework.theme, ColourTheme.EARTH) - self.assertEqual(morework.theme_class, ColourTheme.EARTH) + self.assertEqual(morework.theme, ColourTheme.GREEN) + self.assertEqual(morework.theme_class, ColourTheme.GREEN) # sibling shouldn't be affected self.assertEqual(work.theme, ColourTheme.NONE) - self.assertEqual(work.theme_class, ColourTheme.BANANA) + self.assertEqual(work.theme_class, ColourTheme.NEBULINE) # parent shouldn't be affected - self.assertEqual(self.workindex.theme, ColourTheme.BANANA) - self.assertEqual(self.workindex.theme_class, ColourTheme.BANANA) + self.assertEqual(self.workindex.theme, ColourTheme.NEBULINE) + self.assertEqual(self.workindex.theme_class, ColourTheme.NEBULINE) # child of morework std_page = StandardPageFactory(parent=morework) # `theme_class` should be inherited from parent - self.assertEqual(std_page.theme_class, ColourTheme.EARTH) + self.assertEqual(std_page.theme_class, ColourTheme.GREEN) # but `theme` should remain unset self.assertEqual(std_page.theme, ColourTheme.NONE) @@ -241,10 +241,10 @@ def test_custom_theme_on_descendants(self): # Check that we have the correct classes applied self.assertIn(f"template-{class_suffix}", html_classes) - self.assertIn(ColourTheme.BANANA, html_classes) + self.assertIn(ColourTheme.NEBULINE, html_classes) # The rest of the theme classes should not be applied for theme in list( - filter(lambda theme: theme != ColourTheme.BANANA, self.themes) + filter(lambda theme: theme != ColourTheme.NEBULINE, self.themes) ): self.assertNotIn(theme, html_classes) @@ -258,9 +258,9 @@ def test_custom_theme_on_descendants(self): # Check that we have the correct classes applied self.assertIn(f"template-{class_suffix}", html_classes) - self.assertIn(ColourTheme.EARTH, html_classes) + self.assertIn(ColourTheme.GREEN, html_classes) # The rest of the theme classes should not be applied for theme in list( - filter(lambda theme: theme != ColourTheme.EARTH, self.themes) + filter(lambda theme: theme != ColourTheme.GREEN, self.themes) ): self.assertNotIn(theme, html_classes) diff --git a/tbx/core/tests/test_division_mixin.py b/tbx/core/tests/test_division_mixin.py new file mode 100644 index 000000000..41c6b315a --- /dev/null +++ b/tbx/core/tests/test_division_mixin.py @@ -0,0 +1,106 @@ +from wagtail.models import Site +from wagtail.test.utils import WagtailPageTestCase + +from tbx.core.factories import HomePageFactory +from tbx.divisions.factories import DivisionPageFactory +from tbx.services.factories import ServiceAreaPageFactory + + +class TestDivisionMixin(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + # Set up the site & homepage. + site = Site.objects.get(is_default_site=True) + root = site.root_page.specific + cls.home = HomePageFactory(parent=root) + + site.root_page = cls.home + site.save() + + # Set up a services "index" page. + cls.services = ServiceAreaPageFactory(title="Services", parent=cls.home) + cls.service_1 = ServiceAreaPageFactory(title="Service 1", parent=cls.services) + cls.service_2 = ServiceAreaPageFactory(title="Service 2", parent=cls.service_1) + + # Set up a division page. + cls.division_1 = DivisionPageFactory(parent=cls.home) + cls.division_2 = DivisionPageFactory(title="Public sector", parent=cls.home) + + def test_division_selected(self): + """ + For a page that has division selected, + final_division should return the selected page. + """ + self.service_1.division = self.division_1 + self.service_1.save() + + service_3 = ServiceAreaPageFactory( + division=self.division_2, + parent=self.service_2, + title="Service 3", + ) + + self.assertEqual(self.service_1.final_division, self.division_1) + self.assertEqual(service_3.final_division, self.division_2) + + def test_division_self(self): + """ + For a division page, + final_division should return the page itself. + """ + self.assertEqual(self.division_1.final_division, self.division_1) + self.assertEqual(self.division_2.final_division, self.division_2) + + def test_division_selected_on_ancestor(self): + """ + For a page that does not have a division selected + but an ancestor page has a division selected, + final_division should return the ancestor's selected page. + """ + self.service_1.division = self.division_1 + self.service_1.save() + + service_3 = ServiceAreaPageFactory( + parent=self.service_2, + title="Service 3", + ) + + self.assertEqual(self.service_1.final_division, self.division_1) + self.assertEqual(self.service_2.final_division, self.division_1) + self.assertEqual(service_3.final_division, self.division_1) + + def test_division_as_ancestor(self): + """ + For a page that does not have a division selected + but an ancestor page is a DivisionPage, + final_division should return the ancestor DivisionPage. + """ + service_a = ServiceAreaPageFactory( + title="Service A", + parent=self.division_1, + ) + service_b = ServiceAreaPageFactory( + title="Service A", + parent=service_a, + ) + + self.assertEqual(service_a.final_division, self.division_1) + self.assertEqual(service_b.final_division, self.division_1) + + def test_no_division(self): + """ + For a page that does not have a division selected + and is not a descendant of a DivisionPage + nor has an ancestor with a selected division, + final_division should return None. + """ + service_3 = ServiceAreaPageFactory( + parent=self.service_2, + title="Service 3", + ) + + self.assertIsNone(self.service_1.final_division) + self.assertIsNone(self.service_2.final_division) + self.assertIsNone(service_3.final_division) diff --git a/tbx/core/tests/test_formatting.py b/tbx/core/tests/test_formatting.py new file mode 100644 index 000000000..3c931bcd5 --- /dev/null +++ b/tbx/core/tests/test_formatting.py @@ -0,0 +1,42 @@ +from django.test import TestCase + +from ..utils.formatting import ( + convert_bold_links_to_pink, + convert_italic_links_to_purple, +) + + +class ConvertBoldLinksToPinkTestCase(TestCase): + def test_doesnt_convert_non_link(self): + html_text = "Hello world foo bar" + result = convert_bold_links_to_pink(html_text) + self.assertEqual(html_text, result) + + def test_convert_link(self): + html_text = 'Hello world foo bar' + result = convert_bold_links_to_pink(html_text) + self.assertEqual( + result, + ( + 'Hello world ' + 'foo bar' + ), + ) + + +class ConvertItalicLinksToPurpleTestCase(TestCase): + def test_doesnt_convert_non_link(self): + html_text = "Hello world foo bar" + result = convert_italic_links_to_purple(html_text) + self.assertEqual(html_text, result) + + def test_convert_link(self): + html_text = 'Hello world foo bar' + result = convert_italic_links_to_purple(html_text) + self.assertEqual( + result, + ( + 'Hello world ' + 'foo bar' + ), + ) diff --git a/tbx/core/tests/test_models.py b/tbx/core/tests/test_models.py index 11cc55291..2d575542d 100644 --- a/tbx/core/tests/test_models.py +++ b/tbx/core/tests/test_models.py @@ -1,4 +1,8 @@ -from wagtail.models import Site +from django.apps import apps +from django.test import TestCase +from django.utils.module_loading import import_string, module_has_submodule + +from wagtail.models import Page, Site from wagtail.test.utils import WagtailPageTestCase from wagtail.test.utils.form_data import ( nested_form_data, @@ -10,14 +14,38 @@ from tbx.core.models import HomePage, StandardPage -class TestHomePageFactory(WagtailPageTestCase): - def test_create(self): - HomePageFactory() +class TestPageFactory(TestCase): + """Sanity tests to make sure all pages have a factory.""" + + # Exclude these modules from the check. + # (They currently don't have factories. Un-exclude once they have factories.) + EXCLUDE = ["tbx.events", "tbx.impact_reports"] + + def test_pages(self): + app_configs = apps.get_app_configs() + home_page = HomePageFactory() + + # Create one of every page type using their factory. + for app in app_configs: + for model in app.models.values(): + if issubclass(model, Page) and model not in [Page, HomePage]: + if app.name in self.EXCLUDE: + continue + + with self.subTest(model=model.__name__): + # Get the model's factory + self.assertTrue( + module_has_submodule(app.module, "factories"), + msg=f"App '{app.name}' does not have a factories module.", + ) + + page_factory = import_string( + f"{app.module.__name__}.factories.{model.__name__}Factory" + ) + page = page_factory(parent=home_page) -class TestStandardPageFactory(WagtailPageTestCase): - def test_create(self): - StandardPageFactory() + self.assertIsInstance(page, model) class TestStandardPage(WagtailPageTestCase): diff --git a/tbx/core/tests/test_navigation_mixin.py b/tbx/core/tests/test_navigation_mixin.py new file mode 100644 index 000000000..1548c6d4a --- /dev/null +++ b/tbx/core/tests/test_navigation_mixin.py @@ -0,0 +1,121 @@ +from wagtail.models import Site +from wagtail.test.utils import WagtailPageTestCase + +from tbx.core.factories import HomePageFactory +from tbx.divisions.factories import DivisionPageFactory +from tbx.navigation.factories import NavigationSetFactory +from tbx.services.factories import ServiceAreaPageFactory + + +class TestNavigationMixin(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + # Set up the site & homepage. + site = Site.objects.get(is_default_site=True) + root = site.root_page.specific + cls.home = HomePageFactory(parent=root) + + site.root_page = cls.home + site.save() + + # Set up a navigation set. + cls.nav_set_1 = NavigationSetFactory(name="Charity nav set") + cls.nav_set_2 = NavigationSetFactory(name="Public sector nav set") + + # Set up a division page. + cls.division_1 = DivisionPageFactory( + override_navigation_set=cls.nav_set_1, + parent=cls.home, + ) + cls.division_2 = DivisionPageFactory( + title="Public sector", + override_navigation_set=cls.nav_set_2, + parent=cls.home, + ) + + # Set up a services "index" page. + cls.services = ServiceAreaPageFactory(title="Services", parent=cls.home) + cls.service_1 = ServiceAreaPageFactory(title="Service 1", parent=cls.services) + cls.service_2 = ServiceAreaPageFactory(title="Service 2", parent=cls.service_1) + + def test_navigation_set_selected(self): + """ + For a page that has an override navigation set selected, + navigation_set should return the selected navigation set. + """ + self.service_1.override_navigation_set = self.nav_set_1 + self.service_1.save() + + service_3 = ServiceAreaPageFactory( + override_navigation_set=self.nav_set_2, + parent=self.service_2, + title="Service 3", + ) + + self.assertEqual(self.service_1.navigation_set, self.nav_set_1) + self.assertEqual(service_3.navigation_set, self.nav_set_2) + + def test_navigation_set_selected_on_ancestor(self): + """ + For a page that does not have a navigation set selected + but an ancestor page has a navigation set selected, + navigation_set should return the ancestor's selected navigation set. + """ + self.service_1.override_navigation_set = self.nav_set_1 + self.service_1.save() + + service_3 = ServiceAreaPageFactory( + parent=self.service_2, + title="Service 3", + ) + + self.assertEqual(self.service_1.navigation_set, self.nav_set_1) + self.assertEqual(self.service_2.navigation_set, self.nav_set_1) + self.assertEqual(service_3.navigation_set, self.nav_set_1) + + def test_division_with_navigation_set_selected_on_ancestor(self): + """ + For a page that does not have a navigation set selected + but an ancestor page has a division with a navigation set selected, + navigation_set should return that ancestor's selected navigation set. + """ + self.service_1.division = self.division_2 + self.service_1.save() + + service_3 = ServiceAreaPageFactory( + parent=self.service_2, + title="Service 3", + ) + service_4 = ServiceAreaPageFactory( + division=self.division_1, + parent=service_3, + title="Service 4", + ) + service_5 = ServiceAreaPageFactory( + parent=service_4, + title="Service 5", + ) + + self.assertEqual(self.service_1.navigation_set, self.nav_set_2) + self.assertEqual(self.service_2.navigation_set, self.nav_set_2) + self.assertEqual(service_3.navigation_set, self.nav_set_2) + self.assertEqual(service_4.navigation_set, self.nav_set_1) + self.assertEqual(service_5.navigation_set, self.nav_set_1) + + def test_no_navigation_set(self): + """ + For a page that does not have a navigation set selected + and is not a descendant of a DivisionPage with a navigation set + nor has an ancestor with a selected navigation set, + navigation_set should return None. + """ + service_3 = ServiceAreaPageFactory( + parent=self.service_2, + title="Service 3", + ) + + self.assertIsNone(self.service_1.navigation_set) + self.assertIsNone(self.service_2.navigation_set) + self.assertIsNone(service_3.navigation_set) diff --git a/tbx/core/utils/formatting.py b/tbx/core/utils/formatting.py new file mode 100644 index 000000000..44face92c --- /dev/null +++ b/tbx/core/utils/formatting.py @@ -0,0 +1,29 @@ +from bs4 import BeautifulSoup + + +def convert_bold_links_to_pink(html_text): + """Convert tags inside links into .""" + soup = BeautifulSoup(html_text, "html.parser") + # Limit the changes to anchor tags (). + for anchor_tag in soup.find_all("a"): + # Find all bold tags () and convert them to span tags (). + for bold_tag in anchor_tag.find_all("b"): + tag_content = "".join([str(c) for c in bold_tag.contents]) + html_text = html_text.replace( + str(bold_tag), f'{tag_content}' + ) + return html_text + + +def convert_italic_links_to_purple(html_text): + """Convert tags inside links into .""" + soup = BeautifulSoup(html_text, "html.parser") + # Limit the changes to anchor tags (). + for anchor_tag in soup.find_all("a"): + # Find all italic tags () and convert them to span tags (). + for italic_tag in anchor_tag.find_all("i"): + tag_content = "".join([str(c) for c in italic_tag.contents]) + html_text = html_text.replace( + str(italic_tag), f'{tag_content}' + ) + return html_text diff --git a/tbx/core/utils/models.py b/tbx/core/utils/models.py index 826465a58..0ee46e7a0 100644 --- a/tbx/core/utils/models.py +++ b/tbx/core/utils/models.py @@ -44,6 +44,69 @@ def nav_text(self): return self.navigation_text or self.title +class NavigationSetMixin(models.Model): + override_navigation_set = models.ForeignKey( + "navigation.NavigationSet", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) + + class Meta: + abstract = True + + promote_panels = [ + FieldPanel("override_navigation_set"), + ] + + @cached_property + def navigation_set(self): + """ + Returns a NavigationSet. + + If a navigation set field is set on the current page, use that. + Or if a division with a navigation set is selected, use that. + If not, check the ancestors. + + The closest ancestor that fulfills one of the following will be followed: + - the navigation set field is populated, OR + - the division field is populated with a DivisionPage that has a navigation set. + """ + + if self.override_navigation_set: + return self.override_navigation_set + + # If a division page with a navigation set is selected, use that. + if getattr(self, "division", None) and getattr( + self.division, "override_navigation_set", None + ): + return self.division.override_navigation_set + + try: + page = next( + p + for p in self.get_ancestors() + .filter(depth__gt=2) + .specific() + .defer_streamfields() + .order_by("-depth") + if ( + getattr(p, "override_navigation_set", None) + or ( + getattr(p, "division", None) + and getattr(p.division, "override_navigation_set", None) + ) + ) + ) + except StopIteration: + page = None + + return page and ( + page.override_navigation_set or page.division.override_navigation_set + ) + + # Generic social fields abstract class to add social image/text to any new content type easily. class SocialFields(models.Model): social_image = models.ForeignKey( @@ -91,9 +154,9 @@ class SocialMediaSettings(BaseSiteSetting): class ColourTheme(models.TextChoices): NONE = "", "None" CORAL = "theme-coral", "Coral" + NEBULINE = "theme-nebuline", "Nebuline" LAGOON = "theme-lagoon", "Lagoon" - BANANA = "theme-banana", "Banana" - EARTH = "theme-earth", "Earth" + GREEN = "theme-green", "Green" class ColourThemeMixin(models.Model): @@ -105,14 +168,18 @@ class ColourThemeMixin(models.Model): max_length=25, blank=True, choices=ColourTheme.choices, - help_text=_( - "The theme will be applied to this page and all of its " - "descendants. If no theme is selected, it will be derived from " - "this page's ancestors." - ), ) - promote_panels = [FieldPanel("theme")] + promote_panels = [ + FieldPanel( + "theme", + help_text=_( + "The theme will be applied to this page and all of its descendants. " + "If no theme is selected, it will be derived from " + "this page's ancestors." + ), + ), + ] class Meta: abstract = True @@ -130,3 +197,112 @@ def theme_class(self): ) except StopIteration: return ColourTheme.NONE + + +class ContactMixin(models.Model): + """ + Provides a `contact` field so that a page can have its own contact + in the site-wide footer, instead of the default contact. + """ + + contact = models.ForeignKey( + "people.Contact", + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="+", + help_text="The contact will be applied to this page's footer and all of its " + "descendants.\nIf no contact is selected, it will be derived from " + "this page's ancestors, eventually falling back to the default contact.", + ) + + promote_panels = [FieldPanel("contact")] + + class Meta: + abstract = True + + @cached_property + def footer_contact(self): + """ + Use the page's own contact if set, otherwise, derive the contact from + its ancestors, and finally fall back to the default contact. + + NOTE: if, for some reason, a default contact doesn't exist, this will + return None, in which case, we'll not display the block in the footer template. + """ + from tbx.people.models import Contact + + if contact := self.contact: + return contact + + ancestors = ( + self.get_ancestors().defer_streamfields().specific().order_by("-depth") + ) + for ancestor in ancestors: + if getattr(ancestor, "contact_id", None) is not None: + return ancestor.contact + + # _in theory_, there should only be one Contact object with default_contact=True. + # (see `tbx.people.models.Contact.save()`) + return Contact.objects.filter(default_contact=True).first() + + +class DivisionMixin(models.Model): + """ + Provides a 'division' field to allow pages to be associated to a Division. + """ + + division = models.ForeignKey( + "divisions.DivisionPage", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + + promote_panels = [ + FieldPanel( + "division", + help_text=_( + "The division will be applied to this page and its descendants. " + "If no division is selected, it will be derived from " + "this page's ancestors. " + "If one of the ancestors is a division page, that will be used." + ), + ), + ] + + class Meta: + abstract = True + + @cached_property + def final_division(self): + """ + Returns a DivisionPage. + + If a division field is set on the current page, use that. + If not, check the ancestors. + + The closest ancestor that fulfills one of the following will be followed: + - the division field is populated, OR + - the ancestor page is a DivisionPage. + """ + from tbx.divisions.models import DivisionPage + + if self.division: + return self.division + + if isinstance(self, DivisionPage): + return self + + try: + return next( + getattr(p, "division", None) or p + for p in self.get_ancestors() + .filter(depth__gt=2) + .specific() + .defer_streamfields() + .order_by("-depth") + if isinstance(getattr(p, "division", None) or p, DivisionPage) + ) + except StopIteration: + pass diff --git a/tbx/divisions/__init__.py b/tbx/divisions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tbx/divisions/blocks.py b/tbx/divisions/blocks.py new file mode 100644 index 000000000..1999960b2 --- /dev/null +++ b/tbx/divisions/blocks.py @@ -0,0 +1,18 @@ +from tbx.core.blocks import ( + FeaturedServicesBlock, + FourPhotoCollageBlock, + IntroductionWithImagesBlock, + NumericStatisticsGroupBlock, + PartnersBlock, + StoryBlock, + TextualStatisticsGroupBlock, +) + + +class DivisionStoryBlock(StoryBlock): + four_photo_collage = FourPhotoCollageBlock() + introduction_with_images = IntroductionWithImagesBlock() + numeric_statistics = NumericStatisticsGroupBlock() + textual_statistics = TextualStatisticsGroupBlock() + partners_block = PartnersBlock() + featured_services = FeaturedServicesBlock() diff --git a/tbx/divisions/factories.py b/tbx/divisions/factories.py new file mode 100644 index 000000000..1dcdbd6b4 --- /dev/null +++ b/tbx/divisions/factories.py @@ -0,0 +1,38 @@ +from wagtail import blocks + +import factory +import wagtail_factories + +from tbx.core.blocks import DynamicHeroBlock +from tbx.core.factories import DynamicHeroBlockFactory, StoryBlockFactory + +from .models import DivisionPage + + +class DynamicHeroStreamBlock(blocks.StreamBlock): + hero = DynamicHeroBlock() + + +class DynamicHeroStreamBlockFactory(wagtail_factories.StreamBlockFactory): + class Meta: + model = DynamicHeroStreamBlock + + hero = factory.SubFactory(DynamicHeroBlockFactory) + + +class DivisionPageFactory(wagtail_factories.PageFactory): + class Meta: + model = DivisionPage + + title = "Charity" + logo = DivisionPage.Logo.CHARITY + + @factory.post_generation + def hero(obj, create, extracted, **kwargs): + blocks = kwargs or {"0": "hero"} + obj.hero = DynamicHeroStreamBlockFactory(**blocks) + + @factory.post_generation + def body(obj, create, extracted, **kwargs): + blocks = kwargs or {"0": "paragraph"} + obj.body = StoryBlockFactory(**blocks) diff --git a/tbx/divisions/migrations/0001_initial.py b/tbx/divisions/migrations/0001_initial.py new file mode 100644 index 000000000..8d435de1a --- /dev/null +++ b/tbx/divisions/migrations/0001_initial.py @@ -0,0 +1,89 @@ +# Generated by Django 4.2.16 on 2024-12-18 08:18 + +from django.db import migrations, models +import django.db.models.deletion + +import tbx.core.utils.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("wagtailcore", "0094_alter_page_locale"), + ("people", "0011_update_theme_colour_choices"), + ("images", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="DivisionPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "navigation_text", + models.CharField( + blank=True, + help_text="\n Text entered here will appear instead of the page title in the navigation menu.\n For top-level menu items do this in the navigaiton settings instead.\n ", + max_length=255, + ), + ), + ("social_text", models.CharField(blank=True, max_length=255)), + ( + "theme", + models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ("label", models.CharField(blank=True, max_length=50)), + ("hero", tbx.core.utils.fields.StreamField(block_lookup={})), + ( + "body", + tbx.core.utils.fields.StreamField(blank=True, block_lookup={}), + ), + ( + "contact", + models.ForeignKey( + blank=True, + help_text="The contact will be applied to this page's footer and all of its descendants.\nIf no contact is selected, it will be derived from this page's ancestors, eventually falling back to the default contact.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="people.contact", + ), + ), + ( + "social_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page", models.Model), + ), + ] diff --git a/tbx/divisions/migrations/0002_divisionmixin_and_navigationsetmixin.py b/tbx/divisions/migrations/0002_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..cc94b8739 --- /dev/null +++ b/tbx/divisions/migrations/0002_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("divisions", "0001_initial"), + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ] + + operations = [ + migrations.AddField( + model_name="divisionpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="divisionpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + ] diff --git a/tbx/divisions/migrations/0003_remove_divisionpage_label_divisionpage_logo.py b/tbx/divisions/migrations/0003_remove_divisionpage_label_divisionpage_logo.py new file mode 100644 index 000000000..ee8182dc6 --- /dev/null +++ b/tbx/divisions/migrations/0003_remove_divisionpage_label_divisionpage_logo.py @@ -0,0 +1,61 @@ +# Generated by Django 5.1.4 on 2025-02-20 08:52 + +from django.db import migrations, models + + +def label_to_logo(apps, schema_editor): + DivisionPage = apps.get_model("divisions", "DivisionPage") + + DivisionPage.objects.update( + logo=models.Case( + models.When(label="Charity", then=models.Value("logo-charity")), + models.When(label="Public", then=models.Value("logo-public")), + models.When( + label="Wagtail CMS services", then=models.Value("logo-wagtail") + ), + default=models.Value("logo-torchbox"), + ) + ) + + +def logo_to_label(apps, schema_editor): + DivisionPage = apps.get_model("divisions", "DivisionPage") + + DivisionPage.objects.update( + logo=models.Case( + models.When(label="logo-charity", then=models.Value("Charity")), + models.When(label="logo-public", then=models.Value("Public")), + models.When( + label="logo-wagtail", then=models.Value("Wagtail CMS services") + ), + default=models.Value(""), + ) + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("divisions", "0002_divisionmixin_and_navigationsetmixin"), + ] + + operations = [ + migrations.AddField( + model_name="divisionpage", + name="logo", + field=models.CharField( + choices=[ + ("logo-torchbox", "Torchbox"), + ("logo-charity", "Torchbox Charity"), + ("logo-public", "Torchbox Public"), + ("logo-wagtail", "Torchbox Wagtail"), + ], + default="logo-torchbox", + max_length=50, + ), + ), + migrations.RunPython(label_to_logo, logo_to_label), + migrations.RemoveField( + model_name="divisionpage", + name="label", + ), + ] diff --git a/tbx/divisions/migrations/__init__.py b/tbx/divisions/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tbx/divisions/models.py b/tbx/divisions/models.py new file mode 100644 index 000000000..56293a76c --- /dev/null +++ b/tbx/divisions/models.py @@ -0,0 +1,41 @@ +from django.db import models + +from wagtail.admin.panels import FieldPanel + +from tbx.core.blocks import DynamicHeroBlock +from tbx.core.models import BasePage +from tbx.core.utils.fields import StreamField + +from .blocks import DivisionStoryBlock + + +class DivisionPage(BasePage): + template = "patterns/pages/divisions/division_page.html" + + parent_page_types = ["torchbox.HomePage"] + + class Logo(models.TextChoices): + TORCHBOX = "logo-torchbox", "Torchbox" + CHARITY = "logo-charity", "Torchbox Charity" + PUBLIC = "logo-public", "Torchbox Public" + WAGTAIL = "logo-wagtail", "Torchbox Wagtail" + + logo = models.CharField(choices=Logo, default=Logo.TORCHBOX, max_length=50) + + hero = StreamField([("hero", DynamicHeroBlock())], max_num=1, min_num=1) + body = StreamField(DivisionStoryBlock(), blank=True) + + content_panels = BasePage.content_panels + [ + FieldPanel( + "logo", + heading="Division logo", + help_text=( + "The logo displayed for this page and any other pages" + " under this division. (e.g. Charity)" + ), + ), + FieldPanel("hero"), + FieldPanel("body"), + ] + + promote_panels = BasePage.promote_panels diff --git a/tbx/events/migrations/0009_update_theme_colour_choices.py b/tbx/events/migrations/0009_update_theme_colour_choices.py new file mode 100644 index 000000000..5a3f94528 --- /dev/null +++ b/tbx/events/migrations/0009_update_theme_colour_choices.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-12-09 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0008_add_earth_colour_theme"), + ] + + operations = [ + migrations.AlterField( + model_name="eventindexpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ] diff --git a/tbx/events/migrations/0010_divisionmixin_and_navigationsetmixin.py b/tbx/events/migrations/0010_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..87a76dca2 --- /dev/null +++ b/tbx/events/migrations/0010_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("divisions", "0002_divisionmixin_and_navigationsetmixin"), + ("events", "0009_update_theme_colour_choices"), + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ] + + operations = [ + migrations.AddField( + model_name="eventindexpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="eventindexpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + ] diff --git a/tbx/events/models.py b/tbx/events/models.py index 48423fe16..2973ff9f0 100644 --- a/tbx/events/models.py +++ b/tbx/events/models.py @@ -2,23 +2,15 @@ from django.utils import timezone from django.utils.http import urlencode -from wagtail.admin.panels import FieldPanel, MultiFieldPanel +from wagtail.admin.panels import FieldPanel from wagtail.fields import RichTextField -from wagtail.models import Page +from tbx.core.models import BasePage from tbx.core.utils.fields import StreamField -from tbx.core.utils.models import ( - ColourThemeMixin, - NavigationFields, - SocialFields, -) from tbx.events.blocks import EventItemBlock -from tbx.people.models import ContactMixin -class EventIndexPage( - ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page -): +class EventIndexPage(BasePage): template = "patterns/pages/events/events_listing.html" no_events_message = RichTextField( features=["bold", "italic", "link", "superscript", "subscript"], @@ -30,23 +22,11 @@ class EventIndexPage( events = StreamField([("event", EventItemBlock())], blank=True) - content_panels = Page.content_panels + [ + content_panels = BasePage.content_panels + [ FieldPanel("no_events_message"), FieldPanel("events"), ] - promote_panels = ( - [ - MultiFieldPanel(Page.promote_panels, "Common page configuration"), - ] - + NavigationFields.promote_panels - + ColourThemeMixin.promote_panels - + ContactMixin.promote_panels - + [ - MultiFieldPanel(SocialFields.promote_panels, "Social fields"), - ] - ) - def get_events(self, time_filter=None): today = timezone.localdate() if time_filter == "past": diff --git a/tbx/impact_reports/migrations/0005_update_theme_colour_choices.py b/tbx/impact_reports/migrations/0005_update_theme_colour_choices.py new file mode 100644 index 000000000..2d7772993 --- /dev/null +++ b/tbx/impact_reports/migrations/0005_update_theme_colour_choices.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-12-09 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("impact_reports", "0004_add_earth_colour_theme"), + ] + + operations = [ + migrations.AlterField( + model_name="impactreportpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ] diff --git a/tbx/impact_reports/migrations/0006_divisionmixin_and_navigationsetmixin.py b/tbx/impact_reports/migrations/0006_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..9648ed376 --- /dev/null +++ b/tbx/impact_reports/migrations/0006_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("divisions", "0002_divisionmixin_and_navigationsetmixin"), + ("impact_reports", "0005_update_theme_colour_choices"), + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ] + + operations = [ + migrations.AddField( + model_name="impactreportpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="impactreportpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + ] diff --git a/tbx/impact_reports/models.py b/tbx/impact_reports/models.py index a4a0fff32..4c87d50a7 100755 --- a/tbx/impact_reports/models.py +++ b/tbx/impact_reports/models.py @@ -8,22 +8,14 @@ MultiFieldPanel, TitleFieldPanel, ) -from wagtail.models import Page from wagtail.search import index +from tbx.core.models import BasePage from tbx.core.utils.fields import StreamField -from tbx.core.utils.models import ( - ColourThemeMixin, - NavigationFields, - SocialFields, -) from tbx.impact_reports.blocks import ImpactReportStoryBlock -from tbx.people.models import ContactMixin -class ImpactReportPage( - ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page -): +class ImpactReportPage(BasePage): template = "patterns/pages/impact_reports/impact_report_page.html" hero_image = models.ForeignKey( @@ -58,19 +50,7 @@ class ImpactReportPage( FieldPanel("body"), ] - promote_panels = ( - [ - MultiFieldPanel(Page.promote_panels, "Common page configuration"), - ] - + NavigationFields.promote_panels - + ColourThemeMixin.promote_panels - + ContactMixin.promote_panels - + [ - MultiFieldPanel(SocialFields.promote_panels, "Social fields"), - ] - ) - - search_fields = Page.search_fields + [ + search_fields = BasePage.search_fields + [ index.SearchField("body"), ] diff --git a/tbx/navigation/blocks.py b/tbx/navigation/blocks.py index ddddc87be..281bb6eee 100644 --- a/tbx/navigation/blocks.py +++ b/tbx/navigation/blocks.py @@ -99,6 +99,10 @@ def clean(self, value): return struct_value +class SecondaryNavLinkBlock(LinkBlock): + description = blocks.TextBlock(required=False) + + class FooterLinkBlock(LinkValidationMixin, blocks.StructBlock): """ Used to select links for the footer logos @@ -133,6 +137,30 @@ class ChildDisplay(models.TextChoices): ) +class SecondaryNavInnerMenuBlock(blocks.StructBlock): + section_heading = blocks.CharBlock(required=False) + child_links = blocks.StreamBlock( + [("link", SecondaryNavLinkBlock(icon="link"))], + required=False, + ) + section_link = blocks.PageChooserBlock(required=False) + section_link_text = blocks.CharBlock( + help_text="Leave blank to use the page's own title", + required=False, + ) + + +class SecondaryNavMenuBlock(blocks.StructBlock): + section_heading = blocks.CharBlock() + child_links = blocks.StreamBlock( + [ + ("link", SecondaryNavLinkBlock(icon="link")), + ("menu", SecondaryNavInnerMenuBlock()), + ], + required=False, + ) + + class FooterLogoBlock(blocks.StructBlock): image = CustomImageChooserBlock() link = FooterLinkBlock() diff --git a/tbx/navigation/factories.py b/tbx/navigation/factories.py new file mode 100644 index 000000000..29a6ed8a7 --- /dev/null +++ b/tbx/navigation/factories.py @@ -0,0 +1,11 @@ +import factory +from factory.django import DjangoModelFactory + +from tbx.navigation.models import NavigationSet + + +class NavigationSetFactory(DjangoModelFactory): + class Meta: + model = NavigationSet + + name = factory.Faker("text", max_nb_chars=20) diff --git a/tbx/navigation/migrations/0007_divisionmixin_and_navigationsetmixin.py b/tbx/navigation/migrations/0007_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..ee1ead391 --- /dev/null +++ b/tbx/navigation/migrations/0007_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models + +import tbx.core.utils.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("navigation", "0006_use_custom_streamfield"), + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="NavigationSet", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("navigation", tbx.core.utils.fields.StreamField(block_lookup={})), + ( + "latest_revision", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.revision", + verbose_name="latest revision", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/tbx/navigation/migrations/0008_navigationsettings_footer_newsletter_cta.py b/tbx/navigation/migrations/0008_navigationsettings_footer_newsletter_cta.py new file mode 100644 index 000000000..850b9fef4 --- /dev/null +++ b/tbx/navigation/migrations/0008_navigationsettings_footer_newsletter_cta.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.5 on 2025-02-10 08:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ] + + operations = [ + migrations.AddField( + model_name="navigationsettings", + name="footer_newsletter_cta_text", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="navigationsettings", + name="footer_newsletter_cta_url", + field=models.URLField(blank=True), + ), + ] diff --git a/tbx/navigation/models.py b/tbx/navigation/models.py index 67225e4e7..193c979a6 100644 --- a/tbx/navigation/models.py +++ b/tbx/navigation/models.py @@ -1,18 +1,47 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.core.cache import cache from django.core.cache.utils import make_template_fragment_key +from django.core.exceptions import ValidationError +from django.db import models from modelcluster.models import ClusterableModel -from wagtail.admin.panels import FieldPanel +from wagtail.admin.panels import FieldPanel, MultiFieldPanel from wagtail.contrib.settings.models import BaseSiteSetting, register_setting +from wagtail.models import RevisionMixin +from wagtail.snippets.models import register_snippet from tbx.core.utils.fields import StreamField from tbx.navigation.blocks import ( FooterLogoBlock, LinkBlock, PrimaryNavLinkBlock, + SecondaryNavMenuBlock, ) +@register_snippet +class NavigationSet(RevisionMixin, models.Model): + name = models.CharField(max_length=255) + navigation = StreamField( + [ + ("link", LinkBlock(icon="link")), + ("menu", SecondaryNavMenuBlock()), + ], + ) + + # This will let us do revision.navigation_set + _revisions = GenericRelation( + "wagtailcore.Revision", related_query_name="navigation_set" + ) + + def __str__(self): + return self.name + + @property + def revisions(self): + return self._revisions + + @register_setting(icon="list-ul") class NavigationSettings(BaseSiteSetting, ClusterableModel): primary_navigation = StreamField( @@ -30,11 +59,20 @@ class NavigationSettings(BaseSiteSetting, ClusterableModel): blank=True, help_text="Single list of logos that appear before the footer box", ) + footer_newsletter_cta_url = models.URLField(blank=True) + footer_newsletter_cta_text = models.CharField(blank=True, max_length=255) panels = [ FieldPanel("primary_navigation"), FieldPanel("footer_links"), FieldPanel("footer_logos"), + MultiFieldPanel( + [ + FieldPanel("footer_newsletter_cta_url", heading="External link"), + FieldPanel("footer_newsletter_cta_text", heading="Text"), + ], + heading="Footer newsletter CTA", + ), ] def save(self, **kwargs): @@ -55,3 +93,10 @@ def save(self, **kwargs): for key in fragment_keys ] cache.delete_many(keys) + + def clean(self): + super().clean() + + if self.footer_newsletter_cta_url and not self.footer_newsletter_cta_text: + msg = "The CTA footer text is required when a URL is supplied" + raise ValidationError({"footer_newsletter_cta_text": msg}) diff --git a/tbx/navigation/tests/test_link_block.py b/tbx/navigation/tests/test_link_block.py index 9e43cde63..8d7bf43b6 100644 --- a/tbx/navigation/tests/test_link_block.py +++ b/tbx/navigation/tests/test_link_block.py @@ -3,7 +3,7 @@ import wagtail_factories -from tbx.core.models import HomePage +from tbx.core.factories import HomePageFactory from tbx.images.models import CustomImage from tbx.navigation.blocks import ( LinkBlock, @@ -19,13 +19,6 @@ class Meta: model = CustomImage -class HomePageFactory(wagtail_factories.PageFactory): - title = "Home" - - class Meta: - model = HomePage - - class TestLinkBlock(TestCase): def setUp(self): root_page = wagtail_factories.PageFactory(parent=None) diff --git a/tbx/navigation/tests/test_navigation_settings.py b/tbx/navigation/tests/test_navigation_settings.py new file mode 100644 index 000000000..69218c961 --- /dev/null +++ b/tbx/navigation/tests/test_navigation_settings.py @@ -0,0 +1,35 @@ +from django.test import SimpleTestCase + +from wagtail.admin.panels.base import get_form_for_model + +from tbx.navigation.models import NavigationSettings + + +class NavigationSettingsFormTestCase(SimpleTestCase): + def setUp(self): + self.form_class = get_form_for_model( + NavigationSettings, + fields=[ + "footer_newsletter_cta_url", + "footer_newsletter_cta_text", + ], + ) + + def test_cta_optional(self): + form = self.form_class( + data={"footer_newsletter_cta_url": "", "footer_newsletter_cta_text": ""} + ) + self.assertTrue(form.is_valid()) + + def test_cta_text_required_if_url_supplied(self): + form = self.form_class( + data={ + "footer_newsletter_cta_url": "https://example.com", + "footer_newsletter_cta_text": "", + } + ) + self.assertFormError( + form, + "footer_newsletter_cta_text", + "The CTA footer text is required when a URL is supplied", + ) diff --git a/tbx/people/migrations/0011_update_theme_colour_choices.py b/tbx/people/migrations/0011_update_theme_colour_choices.py new file mode 100644 index 000000000..c1d404eb7 --- /dev/null +++ b/tbx/people/migrations/0011_update_theme_colour_choices.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.16 on 2024-12-09 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("people", "0010_add_email_text_field_to_contact"), + ] + + operations = [ + migrations.AlterField( + model_name="personindexpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + migrations.AlterField( + model_name="personpage", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ] diff --git a/tbx/people/migrations/0012_divisionmixin_and_navigationsetmixin.py b/tbx/people/migrations/0012_divisionmixin_and_navigationsetmixin.py new file mode 100644 index 000000000..bc870ee82 --- /dev/null +++ b/tbx/people/migrations/0012_divisionmixin_and_navigationsetmixin.py @@ -0,0 +1,57 @@ +# Generated by Django 5.1.4 on 2025-01-28 09:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("divisions", "0002_divisionmixin_and_navigationsetmixin"), + ("navigation", "0007_divisionmixin_and_navigationsetmixin"), + ("people", "0011_update_theme_colour_choices"), + ] + + operations = [ + migrations.AddField( + model_name="personindexpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="personindexpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + migrations.AddField( + model_name="personpage", + name="division", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="divisions.divisionpage", + ), + ), + migrations.AddField( + model_name="personpage", + name="override_navigation_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.navigationset", + ), + ), + ] diff --git a/tbx/people/models.py b/tbx/people/models.py index 68c91e468..db5769031 100644 --- a/tbx/people/models.py +++ b/tbx/people/models.py @@ -17,9 +17,11 @@ from wagtail.signals import page_published from wagtail.snippets.models import register_snippet +from tbx.core.models import BasePage from tbx.core.utils.fields import StreamField from tbx.core.utils.models import ( ColourThemeMixin, + ContactMixin, NavigationFields, SocialFields, ) @@ -124,53 +126,7 @@ def button_text(self): ] -class ContactMixin(models.Model): - """ - Provides a `contact` field so that a page can have its own contact - in the site-wide footer, instead of the default contact. - """ - - contact = models.ForeignKey( - "people.Contact", - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name="+", - help_text="The contact will be applied to this page's footer and all of its " - "descendants.\nIf no contact is selected, it will be derived from " - "this page's ancestors, eventually falling back to the default contact.", - ) - - promote_panels = [FieldPanel("contact")] - - class Meta: - abstract = True - - @cached_property - def footer_contact(self): - """ - Use the page's own contact if set, otherwise, derive the contact from - its ancestors, and finally fall back to the default contact. - - NOTE: if, for some reason, a default contact doesn't exist, this will - return None, in which case, we'll not display the block in the footer template. - """ - if contact := self.contact: - return contact - - ancestors = ( - self.get_ancestors().defer_streamfields().specific().order_by("-depth") - ) - for ancestor in ancestors: - if getattr(ancestor, "contact_id", None) is not None: - return ancestor.contact - - # _in theory_, there should only be one Contact object with default_contact=True. - # (see `tbx.people.models.Contact.save()`) - return Contact.objects.filter(default_contact=True).first() - - -class PersonPage(ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page): +class PersonPage(BasePage): template = "patterns/pages/team/team_detail.html" parent_page_types = ["PersonIndexPage"] @@ -186,12 +142,12 @@ class PersonPage(ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, ) related_teams = ParentalManyToManyField("taxonomy.Team", related_name="people") - search_fields = Page.search_fields + [ + search_fields = BasePage.search_fields + [ index.SearchField("intro"), index.SearchField("biography"), ] - content_panels = Page.content_panels + [ + content_panels = BasePage.content_panels + [ FieldPanel("role"), FieldPanel("intro"), FieldPanel("biography"), @@ -304,31 +260,17 @@ def work_index(self): # Person index -class PersonIndexPage( - ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page -): +class PersonIndexPage(BasePage): strapline = models.CharField(max_length=255) template = "patterns/pages/team/team_listing.html" subpage_types = ["PersonPage"] - content_panels = Page.content_panels + [ + content_panels = BasePage.content_panels + [ FieldPanel("strapline"), ] - promote_panels = ( - [ - MultiFieldPanel(Page.promote_panels, "Common page configuration"), - ] - + NavigationFields.promote_panels - + ColourThemeMixin.promote_panels - + ContactMixin.promote_panels - + [ - MultiFieldPanel(SocialFields.promote_panels, "Social fields"), - ] - ) - def __str__(self) -> str: return self.title diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.html b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.html new file mode 100644 index 000000000..0128ec31f --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.html @@ -0,0 +1,3 @@ +{% include "patterns/molecules/streamfield/blocks/introduction_with_images_block.html" %} +{% include "patterns/molecules/streamfield/blocks/partners_block.html" %} +{% include "patterns/molecules/streamfield/blocks/four_photo_collage_block.html" %} diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.yaml b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.yaml new file mode 100644 index 000000000..e80b4f2ee --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.yaml @@ -0,0 +1,19 @@ +tags: + srcset_image: + # Four photo collage block + 'item.image format-webp loading="lazy" fill-{500x500,600x600} sizes="(max-width: 1022px) 600px, 500px" class="four-photo-collage__image" alt=item.alt_text': + raw: | + + # Introduction with images block + 'item.image format-webp loading="lazy" fill-{200x300,400x600,600x900} sizes="(max-width: 598px) 150px, (max-width: 799px) 300px, 700px" class="intro-with-images__image" alt=item.image_alt_text': + raw: 'alt' + # Partners block + image: + partner_logo max-107x107 format-webp as partner_logo_image: + target_var: partner_logo_image + raw: + url: 'https://picsum.photos/107/107.webp' + partner_logo max-214x214 format-webp as partner_logo_image_retina: + target_var: partner_logo_image_retina + raw: + url: 'https://picsum.photos/214/214.webp' diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.html b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.html index 93e506b9f..7dd6a7320 100644 --- a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.html +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.html @@ -1,4 +1,5 @@ +{% include "patterns/molecules/streamfield/blocks/division_signpost_block.html" %} {% include "patterns/molecules/streamfield/blocks/promo_block.html" %} {% include "patterns/molecules/streamfield/blocks/event_block.html" %} {% include "patterns/molecules/streamfield/blocks/featured_case_study.html" %} diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.yaml b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.yaml index 353b06208..4a4347fc5 100644 --- a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.yaml +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/home_story_container.yaml @@ -1,5 +1,10 @@ tags: srcset_image: + # Division signpost + 'card.image format-webp fill-{540x280,490x280} sizes="(max-width: 598px) 540px, (min-width: 599px) 490px" alt=""': + raw: | + + # Featured case study 'value.featured_case_study_image format-webp fill-{400x225,800x450,1600x900,1280x720} sizes="(max-width: 1022px) 90vw, (max-width: 1789px) 50vw, (min-width: 1800px) 1120px" class="featured-case-study__image"': raw: 'Jeth-JulyImage_from_iOS' @@ -15,12 +20,12 @@ tags: raw: | alt text # avatar image for blog chooser - 'avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': raw: | - - 'avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" class="avatar__image"': + + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" class="avatar__image"': raw: | - + image: # Featured case study logo value.featured_case_study_logo max-110x70 format-webp class="featured-case-study__company-logo": diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_area_story_container.html b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_area_story_container.html new file mode 100644 index 000000000..6ca2540b6 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_area_story_container.html @@ -0,0 +1,8 @@ + +{% include "patterns/molecules/streamfield/blocks/four_photo_collage_block.html" %} +{% include "patterns/molecules/streamfield/blocks/icon_keypoints_block.html" %} +{% include "patterns/molecules/streamfield/blocks/contact_call_to_action.html" %} +{% include "patterns/molecules/streamfield/blocks/work_chooser_block.html" %} +{% include "patterns/molecules/streamfield/blocks/pullquote_block.html" %} +{% include "patterns/molecules/streamfield/blocks/blog_chooser_block.html" %} +{% include "patterns/molecules/streamfield/blocks/link_columns_block.html" %} diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_area_story_container.yaml b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_area_story_container.yaml new file mode 100644 index 000000000..eff03061c --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_area_story_container.yaml @@ -0,0 +1,50 @@ +tags: + srcset_image: + # blog_chooser_block.yaml & contact_call_to_action.yaml + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': + raw: | + + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" class="avatar__image"': + raw: | + + # four_photo_collage_block.yaml + 'item.image format-webp loading="lazy" fill-{500x500,600x600} sizes="(max-width: 1022px) 600px, 500px" class="four-photo-collage__image" alt=item.alt_text': + raw: | + + pageurl: + # blog_chooser_block.yaml + blog_page.blog_index as blog_index_url: + raw: '../../../pages/blog/blog_listing.html' + blog_page as blog_post_url: + raw: '../../../pages/blog/blog_detail.html' + # work_chooser_block.yaml + work_page.work_index as work_index_url: + raw: '../../../pages/work/work_index_page.html' + work_page as work_page_url: + raw: '../../../pages/work/work_page.html' + image: + # pullquote_block.yaml + value.logo max-110x70 format-webp class="pullquote__company-logo": + raw: | + + # work_chooser_block.yaml + listing_image fill-370x370 format-webp as listing_desktop_image: + target_var: listing_desktop_image + raw: + alt: 'desktop image' + url: 'https://picsum.photos/370/370.webp' + listing_image fill-740x740 format-webp as listing_desktop_image_retina: + target_var: listing_desktop_image_retina + raw: + alt: 'desktop image retina' + url: 'https://picsum.photos/740/740.webp' + listing_image fill-370x335 format-webp as listing_mobile_image: + target_var: listing_mobile_image + raw: + alt: 'mobile image' + url: 'https://picsum.photos/370/335.webp' + listing_image fill-740x670 format-webp as listing_mobile_image_retina: + target_var: listing_mobile_image_retina + raw: + alt: 'mobile image retina' + url: 'https://picsum.photos/740/670.webp' diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_story_container.yaml b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_story_container.yaml index a3d528c49..fc07fe867 100644 --- a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_story_container.yaml +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/service_story_container.yaml @@ -22,12 +22,12 @@ tags: alt text # avatar image for blog chooser - 'avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': raw: | - - 'avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" class="avatar__image"': + + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" class="avatar__image"': raw: | - + # values block 'value_item.image.image format-webp width-{482,964} sizes="(max-width: 598px) 100vw, (min-width: 599px) 30vw" loading="lazy" class="values__image"': raw: 'test alt' diff --git a/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.html b/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.html index 2b5301077..70a6b2ac0 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.html +++ b/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.html @@ -2,8 +2,8 @@
{% if lazy_load %} - {% srcset_image avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image" %} + {% srcset_image avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image" %} {% else %} - {% srcset_image avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" class="avatar__image" %} + {% srcset_image avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" class="avatar__image" %} {% endif %}
diff --git a/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.yaml b/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.yaml index 148d0b303..487476f54 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.yaml +++ b/tbx/project_styleguide/templates/patterns/atoms/avatar/avatar.yaml @@ -1,8 +1,8 @@ tags: srcset_image: - 'avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" loading="lazy" class="avatar__image"': raw: | - - 'avatar format-webp fill-{72x72,144x144,286x286} sizes="(max-width: 598px) 72px, (min-width: 599px) 145px" alt="" class="avatar__image"': + + 'avatar format-webp fill-{100x100,144x144,286x286} sizes="(max-width: 598px) 100px, (min-width: 599px) 145px" alt="" class="avatar__image"': raw: | - + diff --git a/tbx/project_styleguide/templates/patterns/atoms/date_and_reading_time/date_and_reading_time.html b/tbx/project_styleguide/templates/patterns/atoms/date_and_reading_time/date_and_reading_time.html index 7cb34e116..d1f358781 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/date_and_reading_time/date_and_reading_time.html +++ b/tbx/project_styleguide/templates/patterns/atoms/date_and_reading_time/date_and_reading_time.html @@ -1,9 +1,10 @@ -{% if date %} +{% if date and not hide_date %} {% endif %} -{% if reading_time and date %} - -{% endif %} + {% if reading_time %} + {% if date and not hide_date %} + + {% endif %} {{ reading_time }} min{{ reading_time|pluralize }} read {% endif %} diff --git a/tbx/project_styleguide/templates/patterns/atoms/icons/background-swirls.html b/tbx/project_styleguide/templates/patterns/atoms/icons/background-swirls.html new file mode 100644 index 000000000..5c0b10bc1 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/atoms/icons/background-swirls.html @@ -0,0 +1,20 @@ + +{% comment %} +This SVG doesn't work well when added to sprites.html, +so we've separated it into its own HTML file. +{% endcomment %} + diff --git a/tbx/project_styleguide/templates/patterns/atoms/icons/icon.html b/tbx/project_styleguide/templates/patterns/atoms/icons/icon.html index 324731870..1d3841d8d 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/icons/icon.html +++ b/tbx/project_styleguide/templates/patterns/atoms/icons/icon.html @@ -1,12 +1,17 @@ -{% load wagtailcore_tags %} +{% comment %} +Use with include, e.g. `{% include "patterns/atoms/icons/icon.html" with name="mybutton" classname="icon--stroke button__icon" %}` -{# Use with include, e.g. `{% include "patterns/atoms/icons/icon.html" with name="mybutton" classname="icon--stroke button__icon" %}` #} +Accepts the following parameters: +- name: what the icon is called in the sprites.html file +- alt_text: accessible text for screen readers (if blank, the svg is hidden from screen readers) +- classname: any additional classes for the svg element +{% endcomment %} +{% load wagtailcore_tags %} - - diff --git a/tbx/project_styleguide/templates/patterns/atoms/icons/quote-icon.html b/tbx/project_styleguide/templates/patterns/atoms/icons/quote-icon.html index 023d9b847..1fa45b6de 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/icons/quote-icon.html +++ b/tbx/project_styleguide/templates/patterns/atoms/icons/quote-icon.html @@ -5,8 +5,8 @@ - - + + {% endwith %} diff --git a/tbx/project_styleguide/templates/patterns/atoms/icons/results-ring.html b/tbx/project_styleguide/templates/patterns/atoms/icons/results-ring.html index edecef0d0..0c6f9d263 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/icons/results-ring.html +++ b/tbx/project_styleguide/templates/patterns/atoms/icons/results-ring.html @@ -1,3 +1,3 @@ diff --git a/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.html b/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.html deleted file mode 100644 index 9e0860bd2..000000000 --- a/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.html +++ /dev/null @@ -1,25 +0,0 @@ -{% load util_tags %} -{% if heading_level|ifinlist:'2,3,4,5,6' %} - {# If the heading level is anything other than one, output the heading tag with the motif styles #} - -{% else %} - {% comment %} - If the heading level is 1 (this is also the default which is an empty string), - output the h1 tag with a visually hidden style, then output a paragraph tag - with the motif heading styles. This is to mitigate an issue where google - was showing the motif headings without the first letter in search results, probably - because of the `color:transparent` CSS on the initial cap. - {% endcomment %} - {{ heading }} - -{% endif %} diff --git a/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.md b/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.md deleted file mode 100644 index 70c7d50d2..000000000 --- a/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.md +++ /dev/null @@ -1,8 +0,0 @@ -## Motif heading - -- The motif heading is used in conjunction with a drop cap title class that animates the first letter of the heading. -- The current heading classes are `motif-heading--one`, `motif-heading--one-b` and `motif-heading--two`. -- It can optinally accept a heading level value but this does not automatically change the heading class - classes need to be added manually. -- There is no animation on the heading if the user has opted for reduced motion. -- To achieve the animation the fist letter is wrapped in a span. -- To avoid issues when announcing the heading to screen readers the heading has an aria-label that is the same as the text content of the heading. diff --git a/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.yaml b/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.yaml deleted file mode 100644 index c7dc6f715..000000000 --- a/tbx/project_styleguide/templates/patterns/atoms/motif-heading/motif-heading.yaml +++ /dev/null @@ -1,3 +0,0 @@ -context: - heading: Always be curious, daring, and eager to learn - classes: motif-heading--one diff --git a/tbx/project_styleguide/templates/patterns/atoms/section-title/section-title.html b/tbx/project_styleguide/templates/patterns/atoms/section-title/section-title.html index b256c7af9..8b5165cf8 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/section-title/section-title.html +++ b/tbx/project_styleguide/templates/patterns/atoms/section-title/section-title.html @@ -1,3 +1,3 @@ -

+

{{ title }}

diff --git a/tbx/project_styleguide/templates/patterns/atoms/sprites/sprites.html b/tbx/project_styleguide/templates/patterns/atoms/sprites/sprites.html index 0c393b77a..d22694fd7 100644 --- a/tbx/project_styleguide/templates/patterns/atoms/sprites/sprites.html +++ b/tbx/project_styleguide/templates/patterns/atoms/sprites/sprites.html @@ -15,6 +15,10 @@ + + + + @@ -31,8 +35,16 @@ - - + + + + + + + + + + @@ -43,14 +55,48 @@ -