diff --git a/wagtail/admin/templates/wagtailadmin/generic/streamfield_block_preview.html b/wagtail/admin/templates/wagtailadmin/generic/streamfield_block_preview.html new file mode 100644 index 0000000000..d1c3d14d1b --- /dev/null +++ b/wagtail/admin/templates/wagtailadmin/generic/streamfield_block_preview.html @@ -0,0 +1,28 @@ +{% load wagtailcore_tags i18n %} +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} + + + + {% block head %} + + + + + {{ page_title }} + + {% block css %} + {% endblock %} + {% endblock %} + + +
+ {% block content %} + {% include_block bound_block %} + {% endblock %} +
+ + {% block js %} + {% endblock %} + + diff --git a/wagtail/admin/tests/test_block_preview.py b/wagtail/admin/tests/test_block_preview.py new file mode 100644 index 0000000000..c7422c0a4f --- /dev/null +++ b/wagtail/admin/tests/test_block_preview.py @@ -0,0 +1,228 @@ +from django.contrib.auth.models import Permission +from django.http import HttpRequest +from django.test import TestCase +from django.urls import reverse +from django.utils.http import urlencode + +from wagtail import blocks +from wagtail.test.utils import WagtailTestUtils + + +class TestStreamFieldBlockPreviewView(WagtailTestUtils, TestCase): + def get(self, block): + return self.client.get( + reverse("wagtailadmin_block_preview"), + {"id": block.definition_prefix}, + ) + + def setUp(self): + self.user = self.login() + + def test_simple(self): + block = blocks.CharBlock( + label="Single-line text", + description="A single line of text", + preview_value="Hello, world!", + ) + response = self.get(block) + self.assertEqual(response.status_code, 200) + soup = self.get_soup(response.content) + + html = soup.select_one("html") + self.assertIsNotNone(html) + self.assertEqual(html["lang"], "en") + self.assertEqual(html["dir"], "ltr") + + color_scheme = soup.select_one("meta[name=color-scheme]") + self.assertIsNotNone(color_scheme) + self.assertEqual(color_scheme["content"], "dark light") + + robots = soup.select_one("meta[name=robots]") + self.assertIsNotNone(robots) + self.assertEqual(robots["content"], "noindex") + + title = soup.select_one("title") + self.assertIsNotNone(title) + self.assertEqual(title.text.strip(), "Preview for Single-line text (CharBlock)") + + main = soup.select_one("main") + self.assertIsNotNone(main) + self.assertEqual(main.text.strip(), "Hello, world!") + + def test_nonexisting_block(self): + response = self.client.get(reverse("wagtailadmin_block_preview")) + self.assertEqual(response.status_code, 404) + + response = self.client.get( + reverse("wagtailadmin_block_preview"), + {"id": "nonexisting"}, + ) + self.assertEqual(response.status_code, 404) + + def test_no_admin_permission(self): + self.user.is_superuser = False + self.user.save() + + block = blocks.CharBlock() + response = self.get(block) + self.assertRedirects( + response, + reverse("wagtailadmin_login") + + "?" + + urlencode({"next": response.wsgi_request.get_full_path()}), + ) + + def test_minimal_permission(self): + self.user.is_superuser = False + self.user.user_permissions.add( + Permission.objects.get( + content_type__app_label="wagtailadmin", + codename="access_admin", + ) + ) + self.user.save() + + block = blocks.CharBlock() + response = self.get(block) + self.assertEqual(response.status_code, 200) + + def test_no_preview_value_no_default(self): + block = blocks.Block() + response = self.get(block) + self.assertEqual(response.status_code, 200) + soup = self.get_soup(response.content) + main = soup.select_one("main") + self.assertIsNotNone(main) + self.assertEqual(main.text.strip(), "None") + + def test_preview_value_falls_back_to_default(self): + block = blocks.IntegerBlock(default=42) + response = self.get(block) + self.assertEqual(response.status_code, 200) + soup = self.get_soup(response.content) + main = soup.select_one("main") + self.assertIsNotNone(main) + self.assertEqual(main.text.strip(), "42") + + def test_preview_template(self): + class PreviewTemplateViaMeta(blocks.Block): + class Meta: + preview_template = "tests/custom_block_preview.html" + + class PreviewTemplateViaMethod(blocks.Block): + def get_preview_template(self, value=None, context=None): + return "tests/custom_block_preview.html" + + cases = [ + ("meta", PreviewTemplateViaMeta()), + ("method", PreviewTemplateViaMethod()), + ("kwarg", blocks.Block(preview_template="tests/custom_block_preview.html")), + ] + + for via, block in cases: + with self.subTest(via=via): + response = self.get(block) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "tests/custom_block_preview.html") + + response = self.get(block) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "tests/custom_block_preview.html") + + soup = self.get_soup(response.content) + custom_wrapper = soup.select_one("main .my-preview-wrapper") + self.assertIsNotNone(custom_wrapper) + + custom_css = soup.select_one("link[rel=stylesheet]") + self.assertIsNotNone(custom_css) + self.assertEqual(custom_css["href"], "/static/css/custom.css") + + custom_js = soup.select_one("script[src]") + self.assertIsNotNone(custom_js) + self.assertEqual(custom_js["src"], "/static/js/custom.js") + + def test_preview_value(self): + class PreviewValueViaMeta(blocks.Block): + class Meta: + preview_value = "Hello, world!" + + class PreviewValueViaMethod(blocks.Block): + def get_preview_value(self): + return "Hello, world!" + + cases = [ + ("meta", PreviewValueViaMeta()), + ("method", PreviewValueViaMethod()), + ("kwarg", blocks.Block(preview_value="Hello, world!")), + ] + + for via, block in cases: + with self.subTest(via=via): + response = self.get(block) + self.assertEqual(response.status_code, 200) + soup = self.get_soup(response.content) + main = soup.select_one("main") + self.assertIsNotNone(main) + self.assertEqual(main.text.strip(), "Hello, world!") + + def test_custom_preview_context(self): + preview_value = "With a custom context" + label = "Fancy block" + + class CustomPreviewContextBlock(blocks.Block): + def get_preview_context(block, value, parent_context=None): + self.assertEqual(value, preview_value) + self.assertIsNotNone(parent_context) + self.assertIsInstance(parent_context.get("request"), HttpRequest) + self.assertIs(parent_context.get("block_def"), block) + self.assertIs( + parent_context.get("block_class"), + CustomPreviewContextBlock, + ) + self.assertIsInstance( + parent_context.get("bound_block"), + blocks.BoundBlock, + ) + self.assertEqual( + parent_context.get("page_title"), + "Preview for Fancy block (CustomPreviewContextBlock)", + ) + return { + **parent_context, + "extra": "Added by get_preview_context", + "page_title": "Custom title", + } + + block = CustomPreviewContextBlock(label=label, preview_value=preview_value) + response = self.get(block) + self.assertEqual(response.status_code, 200) + soup = self.get_soup(response.content) + main = soup.select_one("main") + self.assertIsNotNone(main) + self.assertEqual(main.text.strip(), preview_value) + # Ensure custom context can add and override values + self.assertEqual(response.context["extra"], "Added by get_preview_context") + self.assertEqual(response.context["page_title"], "Custom title") + # Ensure that the default context is still present + self.assertIs(response.context["block_def"], block) + + def test_static_image_preview(self): + class StaticImagePreviewBlock(blocks.Block): + def get_preview_context(self, value, parent_context=None): + return { + "image_path": "block_previews/preview.jpg", + "image_description": "A preview of the block", + } + + class Meta: + preview_template = "tests/static_block_preview.html" + + block = StaticImagePreviewBlock() + response = self.get(block) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "tests/static_block_preview.html") + soup = self.get_soup(response.content) + img = soup.select_one("html body main img") + self.assertIsNotNone(img) + self.assertEqual(img["src"], "/static/block_previews/preview.jpg") + self.assertEqual(img["alt"], "A preview of the block") diff --git a/wagtail/admin/urls/__init__.py b/wagtail/admin/urls/__init__.py index 0ace9414ad..8f7df02a26 100644 --- a/wagtail/admin/urls/__init__.py +++ b/wagtail/admin/urls/__init__.py @@ -19,6 +19,7 @@ from wagtail.admin.urls import reports as wagtailadmin_reports_urls from wagtail.admin.urls import workflows as wagtailadmin_workflows_urls from wagtail.admin.views import account, chooser, dismissibles, home, tags from wagtail.admin.views.bulk_action import index as bulk_actions +from wagtail.admin.views.generic.preview import StreamFieldBlockPreview from wagtail.admin.views.pages import listing from wagtail.utils.urlpatterns import decorate_urlpatterns @@ -118,6 +119,11 @@ urlpatterns = [ namespace="wagtailadmin_editing_sessions", ), ), + path( + "block-preview/", + StreamFieldBlockPreview.as_view(), + name="wagtailadmin_block_preview", + ), ] diff --git a/wagtail/admin/views/generic/preview.py b/wagtail/admin/views/generic/preview.py index cde8bb246e..cbad77ffb8 100644 --- a/wagtail/admin/views/generic/preview.py +++ b/wagtail/admin/views/generic/preview.py @@ -7,9 +7,12 @@ from django.http.request import QueryDict from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse from django.utils.decorators import method_decorator -from django.views.generic import View +from django.utils.functional import cached_property +from django.utils.translation import gettext +from django.views.generic import TemplateView, View from wagtail.admin.panels import get_edit_handler +from wagtail.blocks.base import Block from wagtail.models import PreviewableMixin, RevisionMixin from wagtail.utils.decorators import xframe_options_sameorigin_override @@ -163,3 +166,55 @@ class PreviewRevision(View): raise PermissionDenied return self.revision_object.make_preview_request(request, preview_mode) + + +@method_decorator(xframe_options_sameorigin_override, name="get") +class StreamFieldBlockPreview(TemplateView): + template_name = "wagtailadmin/generic/streamfield_block_preview.html" + http_method_names = ("get",) + + @cached_property + def block_id(self): + return self.request.GET.get("id") + + @cached_property + def block_def(self) -> Block: + if not (block := Block.definition_registry.get(self.block_id)): + raise Http404 + return block + + @cached_property + def block_value(self): + return self.block_def.get_preview_value() + + @cached_property + def page_title(self): + return gettext("Preview for %(block_label)s (%(block_type)s)") % { + "block_label": self.block_def.label, + "block_type": self.block_def.__class__.__name__, + } + + @cached_property + def base_context(self): + # Do NOT use the name `block` in the context, as it will conflict with + # the current block inside a template {% block %} tag. + return { + "request": self.request, + "block_def": self.block_def, + "block_class": self.block_def.__class__, + "bound_block": self.block_def.bind(self.block_value), + "page_title": self.page_title, + } + + def get_template_names(self): + templates = [self.template_name] + if preview_template := self.block_def.get_preview_template( + self.block_value, self.base_context + ): + templates.insert(0, preview_template) + return templates + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update(self.base_context) + return self.block_def.get_preview_context(self.block_value, context) diff --git a/wagtail/blocks/base.py b/wagtail/blocks/base.py index bbf184f9d8..72f0e21223 100644 --- a/wagtail/blocks/base.py +++ b/wagtail/blocks/base.py @@ -159,7 +159,7 @@ class Block(metaclass=BaseBlock): model definition time (e.g. something like StructValue which incorporates a pointer back to the block definition object). """ - return self.normalize(self.meta.default) + return self.normalize(getattr(self.meta, "default", None)) def clean(self, value): """ @@ -271,6 +271,28 @@ class Block(metaclass=BaseBlock): return mark_safe(render_to_string(template, new_context)) + def get_preview_context(self, value, parent_context=None): + # We do not fall back to `get_context` here, because the preview context + # will be used for a complete view, not just the block. Instead, the + # default preview context uses `{% include_block %}`, which will use + # `get_context`. + return parent_context or {} + + def get_preview_template(self, value, context=None): + # We do not fall back to `get_template` here, because the template will + # be used for a complete view, not just the block. In most cases, the + # block's template cannot stand alone for the preview, as it would be + # missing the necessary static assets. + # + # Instead, the default preview template uses `{% include_block %}`, + # which will use `get_template` if a template is defined. + return getattr(self.meta, "preview_template", None) + + def get_preview_value(self): + if hasattr(self.meta, "preview_value"): + return self.normalize(self.meta.preview_value) + return self.get_default() + def get_api_representation(self, value, context=None): """ Can be used to customise the API response and defaults to the value returned by get_prep_value. diff --git a/wagtail/test/testapp/templates/tests/custom_block_preview.html b/wagtail/test/testapp/templates/tests/custom_block_preview.html new file mode 100644 index 0000000000..63c8e76031 --- /dev/null +++ b/wagtail/test/testapp/templates/tests/custom_block_preview.html @@ -0,0 +1,16 @@ +{% extends "wagtailadmin/generic/streamfield_block_preview.html" %} +{% load static %} + +{% block content %} +
+ {{ block.super }} +
+{% endblock %} + +{% block css %} + +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/wagtail/test/testapp/templates/tests/static_block_preview.html b/wagtail/test/testapp/templates/tests/static_block_preview.html new file mode 100644 index 0000000000..51436e1ba8 --- /dev/null +++ b/wagtail/test/testapp/templates/tests/static_block_preview.html @@ -0,0 +1,5 @@ +{% extends "wagtailadmin/generic/streamfield_block_preview.html" %} +{% load static %} +{% block content %} + {{ image_description }} +{% endblock %}