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 %}
+
+{% endblock %}