diff --git a/actions/tests/test_graphql_plan.py b/actions/tests/test_graphql_plan.py index 9503d33e4..ac76f083d 100644 --- a/actions/tests/test_graphql_plan.py +++ b/actions/tests/test_graphql_plan.py @@ -283,9 +283,10 @@ def test_plan_root_page_exists(graphql_client_query_data, plan_with_pages): assert any(page['__typename'] == 'PlanRootPage' and page['id'] == str(plan.root_page.id) for page in pages) -def test_plan_root_page_contains_block(graphql_client_query_data, plan_with_pages): +@pytest.mark.parametrize('layout', ['big_image', 'side_by_side']) +def test_plan_root_page_contains_block(graphql_client_query_data, plan_with_pages, layout): plan = plan_with_pages - hero_data = {'layout': 'big_image', 'heading': 'foo', 'lead': 'bar'} + hero_data = {'layout': layout, 'heading': 'foo', 'lead': 'bar'} plan.root_page.body = json.dumps([ {'type': 'front_page_hero', 'value': hero_data}, ]) diff --git a/actions/tests/test_graphql_planpage.py b/actions/tests/test_graphql_planpage.py index 6d708aaf3..6ce9f6d2d 100644 --- a/actions/tests/test_graphql_planpage.py +++ b/actions/tests/test_graphql_planpage.py @@ -6,7 +6,12 @@ from indicators.blocks import IndicatorBlock from indicators.tests.factories import IndicatorGroupBlockFactory from pages.blocks import CardBlock, QuestionBlock -from pages.tests.factories import CardListBlockFactory, QuestionAnswerBlockFactory +from pages.tests.factories import ( + CardListBlockFactory, + FrontPageHeroAdditionalSettingsBlockFactory, + FrontPageHeroBlockFactory, + QuestionAnswerBlockFactory, +) pytestmark = pytest.mark.django_db @@ -95,11 +100,13 @@ def expected_result_multi_use_image_fragment(image_block): } -def test_front_page_hero_block(graphql_client_query_data, front_page_hero_block, plan_with_pages): +@pytest.mark.parametrize('layout', ['big_image', 'side_by_side']) +def test_front_page_hero_block(graphql_client_query_data, plan_with_pages, layout): plan = plan_with_pages page = plan.root_page + block = FrontPageHeroBlockFactory.create(layout=layout) page.body = [ - ('front_page_hero', front_page_hero_block), + ('front_page_hero', block), ] page.save() assert_body_block( @@ -117,14 +124,61 @@ def test_front_page_hero_block(graphql_client_query_data, front_page_hero_block, """, extra_fragments=[MULTI_USE_IMAGE_FRAGMENT], expected={ - 'heading': front_page_hero_block['heading'], - 'image': expected_result_multi_use_image_fragment(front_page_hero_block['image']), - 'layout': 'big_image', - 'lead': str(front_page_hero_block['lead']), + 'heading': block['heading'], + 'image': expected_result_multi_use_image_fragment(block['image']), + 'layout': layout, + 'lead': str(block['lead']), }, ) +@pytest.mark.parametrize('layout', ['small_image', 'side_by_side']) +@pytest.mark.parametrize('fit_image', [False, True]) +def test_front_page_hero_block_fit_image(graphql_client_query_data, plan_with_pages, layout, fit_image): + plan = plan_with_pages + page = plan.root_page + additional_settings = FrontPageHeroAdditionalSettingsBlockFactory.create(fit_image=fit_image) + block = FrontPageHeroBlockFactory.create(layout=layout, additional_settings=additional_settings) + page.body = [ + ('front_page_hero', block), + ] + page.save() + assert_body_block( + graphql_client_query_data, + plan=plan, + page=page, + block_fields=""" + additionalSettings { + fitImage + } + """, + expected={ + 'additionalSettings': {'fitImage': fit_image}, + }, + ) + + +def test_front_page_hero_block_additional_settings_hidden_for_big_image(graphql_client_query_data, plan_with_pages): + plan = plan_with_pages + page = plan.root_page + block = FrontPageHeroBlockFactory.create(layout='big_image') + page.body = [ + ('front_page_hero', block), + ] + page.save() + assert_body_block( + graphql_client_query_data, + plan=plan, + page=page, + block_fields=""" + additionalSettings { + fitImage + } + """, + expected={'additionalSettings': None}, + ) + + def test_category_list_block(graphql_client_query_data, category_list_block, plan_with_pages): plan = plan_with_pages page = plan.root_page diff --git a/conftest.py b/conftest.py index 4c04e1894..d0ea3d05f 100644 --- a/conftest.py +++ b/conftest.py @@ -139,6 +139,7 @@ def request(self, **kwargs): register(pages_factories.CategoryPageFactory) register(pages_factories.CategoryTypePageFactory, parent=LazyFixture(lambda plan: plan.root_page)) register(pages_factories.CategoryTypePageLevelLayoutFactory) +register(pages_factories.FrontPageHeroAdditionalSettingsBlockFactory) register(pages_factories.FrontPageHeroBlockFactory) register(pages_factories.PageChooserBlockFactory, parent=LazyFixture(lambda plan: plan.root_page)) register(pages_factories.PageLinkBlockFactory) diff --git a/pages/blocks.py b/pages/blocks.py index a756a5cad..8f2e0454c 100644 --- a/pages/blocks.py +++ b/pages/blocks.py @@ -25,7 +25,8 @@ from grapple.registry import registry from grapple.types.streamfield import ListBlock as GrappleListBlock, StructBlockItem -from kausal_common.graphene.grapple import make_grapple_field +from kausal_common.blocks.conditional_struct_block import ConditionalFieldVisibility, ConditionalStructBlock, Match +from kausal_common.graphene.grapple import grapple_field, make_grapple_field from actions.blocks.choosers import CategoryChooserBlock from actions.models.category import Category @@ -182,26 +183,118 @@ class Meta: ] +class FrontPageHeroAdditionalSettingsValue(blocks.StructValue): + background_colour: str + fit_image: bool + show_image_accent: bool + background_covers_full_section: bool + + @register_streamfield_block -class FrontPageHeroBlock(blocks.StructBlock): +class FrontPageHeroAdditionalSettingsBlock(blocks.StructBlock): + background_colour = blocks.CharBlock( + required=False, + label=_('Background colour'), + help_text=_('CSS colour value, e.g. #FFFFFF or rgb(255, 255, 255)'), + ) + fit_image = blocks.BooleanBlock( + required=False, + default=True, + label=_('Fit image without clipping'), + help_text=_('Show the image at its exact scaled dimensions; it will never be cropped'), + ) + show_image_accent = blocks.BooleanBlock( + required=False, + default=True, + label=_('Show image accent'), + help_text=_('Display a brand color accent bar below the image'), + ) + background_covers_full_section = blocks.BooleanBlock( + required=False, + default=False, + label=_('Background covers full block'), + help_text=_('Extend the background colour to cover the content below the image, not just the image area'), + ) + + class Meta: + label = _('Additional settings') + collapsed = True + label_format = '' + value_class = FrontPageHeroAdditionalSettingsValue + + graphql_fields = [ + GraphQLString('background_colour'), + GraphQLBoolean('fit_image'), + GraphQLBoolean('show_image_accent'), + GraphQLBoolean('background_covers_full_section'), + ] + + +_LAYOUTS_WITH_ADDITIONAL_SETTINGS = {'small_image', 'side_by_side'} + + +def _resolve_hero_additional_settings(root, _info) -> FrontPageHeroAdditionalSettingsValue | None: + # Standalone graphene.Field resolvers receive the raw BoundBlock (StreamChild) as root, + # not the StructValue — unlike grapple's MethodType-bound resolvers which receive the + # StructValue as `instance`. Use getattr to handle both cases. + data = getattr(root, 'value', root) + if data.get('layout') not in _LAYOUTS_WITH_ADDITIONAL_SETTINGS: + return None + return data.get('additional_settings') + + +@register_streamfield_block +class FrontPageHeroBlock(ConditionalStructBlock): + conditional_rules = [ + ConditionalFieldVisibility( + show='additional_settings', + when=Match(layout=('small_image', 'side_by_side')), + ), + ConditionalFieldVisibility( + show=['additional_settings', 'show_image_accent'], + when=Match(layout=('small_image',)), + ), + ConditionalFieldVisibility( + show=['additional_settings', 'background_covers_full_section'], + when=Match(layout=('small_image',)), + ), + ] + layout = blocks.ChoiceBlock( choices=[ - ('big_image', _('Big image')), - ('small_image', _('Small image')), + ('big_image', _('Full-width: content over image')), + ('small_image', _('Large image: image above, content below')), + ('side_by_side', _('Side-by-side: image left, content right')), ] ) image = ImageChooserBlock(label=_('Image')) heading = blocks.CharBlock(classname='full title', label=_('Heading'), required=False) lead = blocks.RichTextBlock(label=_('Lead'), required=False) + additional_settings = FrontPageHeroAdditionalSettingsBlock(required=False) class Meta: label = _('Front page hero block') + def clean(self, value): + result = super().clean(value) + if result.get('layout') not in _LAYOUTS_WITH_ADDITIONAL_SETTINGS: + additional = result.get('additional_settings') + if additional is not None: + additional['fit_image'] = False + additional['background_colour'] = '' + return result + graphql_fields = [ GraphQLString('layout', required=True), GraphQLImage('image'), GraphQLString('heading'), GraphQLString('lead'), + grapple_field( + 'additional_settings', + lambda: registry.streamfield_blocks[FrontPageHeroAdditionalSettingsBlock], + resolver=_resolve_hero_additional_settings, + required=False, + ), ] diff --git a/pages/migrations/0069_add_front_page_hero_additional_settings.py b/pages/migrations/0069_add_front_page_hero_additional_settings.py new file mode 100644 index 000000000..f224f592f --- /dev/null +++ b/pages/migrations/0069_add_front_page_hero_additional_settings.py @@ -0,0 +1,525 @@ +# Generated by Django 6.0.2 on 2026-06-03 12:51 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0068_alter_categorypage_body_alter_planrootpage_body_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="planrootpage", + name="body", + field=wagtail.fields.StreamField( + [ + ("front_page_hero", 9), + ("category_list", 13), + ("indicator_group", 19), + ("indicator_highlights", 20), + ("indicator_showcase", 26), + ("dashboard_row", 41), + ("action_highlights", 42), + ("related_plans", 43), + ("cards", 48), + ("action_links", 54), + ("text", 55), + ("action_status_graphs", 56), + ("category_tree_map", 59), + ("large_image", 61), + ("embed", 67), + ("paths_outcome", 71), + ("change_log_message", 74), + ], + block_lookup={ + 0: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("big_image", "Full-width: content over image"), + ( + "small_image", + "Large image: image above, content below", + ), + ( + "side_by_side", + "Side-by-side: image left, content right", + ), + ] + }, + ), + 1: ( + "wagtail.images.blocks.ImageChooserBlock", + (), + {"label": "Image"}, + ), + 2: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "full title", + "label": "Heading", + "required": False, + }, + ), + 3: ( + "wagtail.blocks.RichTextBlock", + (), + {"label": "Lead", "required": False}, + ), + 4: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "CSS colour value, e.g. #FFFFFF or rgb(255, 255, 255)", + "label": "Background colour", + "required": False, + }, + ), + 5: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": True, + "help_text": "Show the image at its exact scaled dimensions; it will never be cropped", + "label": "Fit image without clipping", + "required": False, + }, + ), + 6: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": True, + "help_text": "Display a brand color accent bar below the image", + "label": "Show image accent", + "required": False, + }, + ), + 7: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "Extend the background colour to cover the content below the image, not just the image area", + "label": "Background covers full block", + "required": False, + }, + ), + 8: ( + "wagtail.blocks.StructBlock", + [ + [ + ("background_colour", 4), + ("fit_image", 5), + ("show_image_accent", 6), + ("background_covers_full_section", 7), + ] + ], + {"required": False}, + ), + 9: ( + "wagtail.blocks.StructBlock", + [ + [ + ("layout", 0), + ("image", 1), + ("heading", 2), + ("lead", 3), + ("additional_settings", 8), + ] + ], + {}, + ), + 10: ( + "actions.blocks.choosers.CategoryTypeChooserBlock", + (), + {"required": False}, + ), + 11: ( + "actions.blocks.choosers.CategoryChooserBlock", + (), + {"required": False}, + ), + 12: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("cards", "Cards"), ("table", "Table")], + "label": "Style", + }, + ), + 13: ( + "wagtail.blocks.StructBlock", + [ + [ + ("category_type", 10), + ("category", 11), + ("heading", 2), + ("lead", 3), + ("style", 12), + ] + ], + {}, + ), + 14: ("wagtail.blocks.CharBlock", (), {"required": False}), + 15: ("indicators.blocks.IndicatorChooserBlock", (), {}), + 16: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("graph", "Graph"), + ("progress", "Progress"), + ("animated", "Animated"), + ] + }, + ), + 17: ( + "wagtail.blocks.StructBlock", + [[("indicator", 15), ("style", 16)]], + {}, + ), + 18: ("wagtail.blocks.ListBlock", (17,), {}), + 19: ( + "wagtail.blocks.StructBlock", + [[("title", 14), ("indicators", 18)]], + {}, + ), + 20: ("indicators.blocks.IndicatorHighlightsBlock", (), {}), + 21: ("wagtail.blocks.RichTextBlock", (), {"required": False}), + 22: ("wagtail.blocks.PageChooserBlock", (), {"required": False}), + 23: ( + "wagtail.blocks.StructBlock", + [[("text", 14), ("page", 22)]], + {}, + ), + 24: ( + "wagtail.blocks.IntegerBlock", + (), + { + "help_text": "How many significants digits to use for the values displayed", + "max_value": 5, + "min_value": 1, + "required": False, + }, + ), + 25: ("wagtail.blocks.BooleanBlock", (), {"required": False}), + 26: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 14), + ("body", 21), + ("indicator", 15), + ("link_button", 23), + ("significant_digits", 24), + ("indicator_is_normalized", 25), + ] + ], + {}, + ), + 27: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Help text for the field to be shown in the UI", + "required": False, + }, + ), + 28: ( + "indicators.blocks.IndicatorChooserBlock", + (), + {"help_text": "Choose the indicator for data visualization"}, + ), + 29: ( + "indicators.blocks.DimensionChooserBlock", + (), + { + "help_text": "Choose the indicator dimension that will be used for categories in the visualization", + "required": False, + }, + ), + 30: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("stacked", "Stacked bars"), + ("grouped", "Grouped bars"), + ] + }, + ), + 31: ( + "wagtail.blocks.StructBlock", + [ + [ + ("help_text", 27), + ("indicator", 28), + ("dimension", 29), + ("bar_type", 30), + ] + ], + {}, + ), + 32: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "Show total line", + "required": False, + }, + ), + 33: ( + "wagtail.blocks.StructBlock", + [ + [ + ("help_text", 27), + ("indicator", 28), + ("dimension", 29), + ("show_total_line", 32), + ] + ], + {}, + ), + 34: ( + "wagtail.blocks.IntegerBlock", + (), + { + "help_text": "Enter the year you want to visualize", + "required": True, + }, + ), + 35: ( + "wagtail.blocks.StructBlock", + [ + [ + ("help_text", 27), + ("indicator", 28), + ("dimension", 29), + ("year", 34), + ] + ], + {}, + ), + 36: ("wagtail.blocks.StructBlock", [[("indicator", 28)]], {}), + 37: ( + "wagtail.blocks.CharBlock", + (), + {"max_length": 100, "required": True}, + ), + 38: ("wagtail.blocks.StructBlock", [[("text", 37)]], {}), + 39: ("wagtail.blocks.RichTextBlock", (), {"required": True}), + 40: ("wagtail.blocks.StructBlock", [[("text", 39)]], {}), + 41: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("bar_chart", 31), + ("line_chart", 33), + ("area_chart", 33), + ("pie_chart", 35), + ("indicator_summary", 36), + ("header", 38), + ("paragraph", 40), + ] + ], + {}, + ), + 42: ("actions.blocks.action_list.ActionHighlightsBlock", (), {}), + 43: ("actions.blocks.RelatedPlanListBlock", (), {}), + 44: ("wagtail.blocks.CharBlock", (), {}), + 45: ( + "wagtail.images.blocks.ImageChooserBlock", + (), + {"required": False}, + ), + 46: ( + "wagtail.blocks.StructBlock", + [ + [ + ("image", 45), + ("heading", 44), + ("content", 14), + ("link", 14), + ] + ], + {}, + ), + 47: ("wagtail.blocks.ListBlock", (46,), {}), + 48: ( + "wagtail.blocks.StructBlock", + [[("heading", 44), ("lead", 14), ("cards", 47)]], + {}, + ), + 49: ("wagtail.blocks.CharBlock", (), {"label": "Heading"}), + 50: ("wagtail.blocks.CharBlock", (), {"label": "Lead"}), + 51: ("actions.blocks.choosers.CategoryChooserBlock", (), {}), + 52: ( + "wagtail.blocks.StructBlock", + [[("heading", 49), ("lead", 50), ("category", 51)]], + {}, + ), + 53: ("wagtail.blocks.ListBlock", (52,), {"label": "Links"}), + 54: ( + "wagtail.blocks.StructBlock", + [[("cards", 53)]], + {"label": "Links to actions in specific category"}, + ), + 55: ("wagtail.blocks.RichTextBlock", (), {"label": "Text"}), + 56: ("pages.blocks.ActionStatusGraphsBlock", (), {}), + 57: ( + "actions.blocks.choosers.CategoryTypeChooserBlock", + (), + {"required": True}, + ), + 58: ( + "actions.blocks.choosers.CategoryAttributeTypeChooserBlock", + (), + {"label": "Value field", "required": True}, + ), + 59: ( + "wagtail.blocks.StructBlock", + [ + [ + ("heading", 2), + ("lead", 3), + ("category_type", 57), + ("value_attribute", 58), + ] + ], + {}, + ), + 60: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("maximum", "Maximum"), + ("fit_to_column", "Fit to column"), + ], + "label": "Width", + }, + ), + 61: ( + "wagtail.blocks.StructBlock", + [[("image", 1), ("width", 60)]], + {}, + ), + 62: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Helps screen readers describe this embedded content", + "label": "Title", + "required": False, + }, + ), + 63: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Helps screen readers understand what this embed shows", + "label": "Description", + "required": False, + }, + ), + 64: ("wagtail.blocks.CharBlock", (), {"label": "URL"}), + 65: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("s", "small"), + ("m", "medium"), + ("l", "large"), + ], + "label": "Size", + }, + ), + 66: ( + "wagtail.blocks.StructBlock", + [[("url", 64), ("height", 65)]], + {}, + ), + 67: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 62), + ("description", 63), + ("embed", 66), + ("full_width", 25), + ] + ], + {}, + ), + 68: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "What heading should be used in the public UI for the Outcome?", + "label": "Heading", + "required": False, + }, + ), + 69: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "Help text for the Outcome to be shown in the public UI", + "label": "Help text", + "required": False, + }, + ), + 70: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Kausal Paths outcome node to be used", + "max_length": 200, + "required": True, + "verbose_name": "Kausal Paths outcome node ID", + }, + ), + 71: ( + "wagtail.blocks.StructBlock", + [[("heading", 68), ("help_text", 69), ("outcome_node_id", 70)]], + {}, + ), + 72: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "Heading to show instead of the default", + "label": "Field label", + "required": False, + }, + ), + 73: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "Help text for the field to be shown in the UI", + "label": "Help text", + "required": False, + }, + ), + 74: ( + "wagtail.blocks.StructBlock", + [[("field_label", 72), ("field_help_text", 73)]], + {}, + ), + }, + ), + ), + ] diff --git a/pages/tests/factories.py b/pages/tests/factories.py index d8b92d091..b1b6f9164 100644 --- a/pages/tests/factories.py +++ b/pages/tests/factories.py @@ -66,6 +66,16 @@ class Meta: questions = ListBlockFactory(QuestionBlockFactory) +class FrontPageHeroAdditionalSettingsBlockFactory(StructBlockFactory): + class Meta: + model = pages.blocks.FrontPageHeroAdditionalSettingsBlock + + background_colour = '' + fit_image = True + show_image_accent = True + background_covers_full_section = False + + class FrontPageHeroBlockFactory(StructBlockFactory): class Meta: model = pages.blocks.FrontPageHeroBlock @@ -74,6 +84,9 @@ class Meta: image = SubFactory[pages.blocks.FrontPageHeroBlock, ImageChooserBlock](ImageChooserBlockFactory) heading = 'Front page hero block heading' lead = RichText('

Front page hero block lead

') + additional_settings = SubFactory[pages.blocks.FrontPageHeroBlock, pages.blocks.FrontPageHeroAdditionalSettingsBlock]( + FrontPageHeroAdditionalSettingsBlockFactory + ) class PageLinkBlockFactory(StructBlockFactory):