diff --git a/apps/core/reports.py b/apps/core/reports.py new file mode 100644 index 00000000..619911f0 --- /dev/null +++ b/apps/core/reports.py @@ -0,0 +1,114 @@ +from django.conf import settings +from django.forms import Media +from django.utils.html import strip_tags +from django.utils.translation import gettext_lazy as _ +from wagtail.admin.views.reports import PageReportView +from wagtail.permissions import page_permission_policy + +from apps.core.models import ContentPage + + +def iter_annotated_blocks(page): + """Yield (index, StreamChild) for each text_annotated block in the page's body.""" + for block_index, block in enumerate(page.body): + if block.block_type == "text_annotated": + yield block_index, block + + +def _truncate(text, max_length): + if len(text) > max_length: + return text[:max_length] + "…" + return text + + +def _parse_version(version): + if not version: + return None + try: + return tuple(int(part) for part in version.split(".")) + except ValueError: + return None + + +def _build_block_detail(block_index, block, current_version): + value = block.value + change_type = value.get("change_type") + version = value.get("version") + content = value.get("content") + + current = _parse_version(current_version) + parsed_version = _parse_version(version) + outdated = bool(current and parsed_version and parsed_version < current) + + return { + "block_index": block_index, + "version": version, + "change_type": change_type, + "content_preview": _truncate(strip_tags(str(content)).strip(), 100), + "outdated": outdated, + } + + +class BlockUsageReportView(PageReportView): + page_title = _("Annotated blocks usage") + header_icon = "doc-empty-inverse" + results_template_name = "core/admin/reports/block_usage_results.html" + index_url_name = "block_usage_report" + index_results_url_name = "block_usage_report_results" + any_permission_required = ["add", "change", "publish"] + + #: Loaded once via `{{ media.css }}` / `{{ media.js }}` in the listing + #: template (rendered on full page load, not re-included on every + #: AJAX filter/pagination refresh of the results fragment). + media = Media( + css={"all": ["core/css/block_usage_report.css"]}, + js=["core/js/block_usage_report.js"], + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.current_version = getattr(settings, "WAGTAIL_GUIDE_CURRENT_VERSION", None) + + def get_queryset(self): + permitted_pages = page_permission_policy.instances_user_has_permission_for( + self.request.user, "change" + ).exact_type(ContentPage) + + pages = [] + for page in permitted_pages.specific(): + block_details = [ + _build_block_detail(block_index, block, self.current_version) + for block_index, block in iter_annotated_blocks(page) + ] + if not block_details: + continue + + page.block_usage_blocks = block_details + page.block_usage_flagged_count = sum( + 1 for b in block_details if b["outdated"] + ) + page.block_usage_flagged = page.block_usage_flagged_count > 0 + page.block_usage_min_version = min( + (b["version"] for b in block_details if b["version"]), default="" + ) + pages.append(page) + + pages.sort( + key=lambda p: ( + not p.block_usage_flagged, + not p.live, + p.block_usage_min_version or "", + ) + ) + return pages + + def decorate_paginated_queryset(self, object_list): + # The per-page block breakdown is already attached in get_queryset, + # but export relies on the decorated object_list too, so keep it as-is. + return object_list + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["current_version"] = self.current_version + context["media"] += self.media + return context diff --git a/apps/core/static/core/css/block_usage_focus.css b/apps/core/static/core/css/block_usage_focus.css new file mode 100644 index 00000000..991e5018 --- /dev/null +++ b/apps/core/static/core/css/block_usage_focus.css @@ -0,0 +1,5 @@ +.block-usage--highlight { + outline: 3px solid #ff9c00; + outline-offset: 2px; + transition: outline 0.3s ease; +} diff --git a/apps/core/static/core/css/block_usage_report.css b/apps/core/static/core/css/block_usage_report.css new file mode 100644 index 00000000..1ae251ab --- /dev/null +++ b/apps/core/static/core/css/block_usage_report.css @@ -0,0 +1,68 @@ +.block-usage-report ul { + list-style: none; + margin: 0; + padding: 0; +} + +.block-usage-report li { + margin-bottom: 10px; + line-height: 1.5; + min-height: 1.5em; +} + +.block-usage-report li:last-child { + margin-bottom: 0; +} + +.block-usage-report .block-usage__block { + padding-bottom: 10px; + border-bottom: 1px solid var(--w-color-border-furniture, #d9d9d9); +} + +.block-usage-report .block-usage__block:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.block-usage-report .block-usage__block--flagged .block-usage__block-link { + font-weight: 600; +} + +.block-usage-report .block-usage__block-link { + display: block; + color: var(--w-color-text-link-default, #1e5b8c); + text-decoration: none; +} + +.block-usage-report .block-usage__block-link:hover { + text-decoration: underline; + color: var(--w-color-text-link-hover, var(--w-color-text-link-default, #1e5b8c)); +} + +.block-usage-report .block-usage__version { + font-variant-numeric: tabular-nums; +} + +.block-usage-report [data-block-index].block-usage__row--hover { + background-color: var(--w-color-surface-menu-item-active, #edf1f4); + outline: 2px solid var(--w-color-border-furniture, #d9d9d9); + outline-offset: 2px; +} + +@media (max-width: 900px) { + .block-usage-report th.block-usage-col-type, + .block-usage-report td.block-usage-col-type { + display: none; + } +} + +@media (max-width: 600px) { + .block-usage-report th.block-usage-col-change-type, + .block-usage-report td.block-usage-col-change-type { + display: none; + } + + .block-usage-report .block-usage__block-link { + font-size: 0.85em; + } +} diff --git a/apps/core/static/core/js/block_usage_focus.js b/apps/core/static/core/js/block_usage_focus.js new file mode 100644 index 00000000..159a837c --- /dev/null +++ b/apps/core/static/core/js/block_usage_focus.js @@ -0,0 +1,51 @@ +(function blockUsageFocus() { + // Scroll to and briefly highlight a StreamField block when the page + // editor is opened with a `#block-index-N` URL fragment (used by the + // "Annotated blocks usage" report to link directly to a specific block). + // + // Blocks are identified by their position in the stream rather than + // their UUID, because a block's UUID can differ between the live page + // and the latest draft revision, whereas its position is stable. + function focusBlock(index) { + const children = document.querySelectorAll('[data-streamfield-child]'); + if (children.length <= index) return false; + const el = children[index]; + + // StreamField blocks render asynchronously (Telepath), so expanding + // a collapsed block and scrolling to it needs to happen after the + // element actually exists in the DOM. + const toggle = el.querySelector('[data-panel-toggle]'); + if (toggle && toggle.getAttribute('aria-expanded') === 'false') { + toggle.click(); + } + + setTimeout(() => { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('block-usage--highlight'); + setTimeout(() => { + el.classList.remove('block-usage--highlight'); + }, 3000); + }, 200); + return true; + } + + function init() { + const hash = window.location.hash; + const prefix = '#block-index-'; + if (!hash || hash.indexOf(prefix) !== 0) return; + const index = parseInt(hash.slice(prefix.length), 10); + if (Number.isNaN(index)) return; + + // Poll for up to 10s: the target block may not have rendered yet. + let attempts = 0; + const interval = setInterval(() => { + if (focusBlock(index) || attempts > 100) { + clearInterval(interval); + } + attempts += 1; + }, 100); + } + + if (document.readyState === 'complete') init(); + else window.addEventListener('load', init); +})(); diff --git a/apps/core/static/core/js/block_usage_report.js b/apps/core/static/core/js/block_usage_report.js new file mode 100644 index 00000000..30207c53 --- /dev/null +++ b/apps/core/static/core/js/block_usage_report.js @@ -0,0 +1,26 @@ +(function blockUsageReport() { + function closestRowCell(target) { + return target.closest('.block-usage-report [data-block-index]'); + } + + function toggleHighlight(target, add) { + const cell = closestRowCell(target); + if (!cell) return; + const key = cell.getAttribute('data-block-index'); + const table = cell.closest('.block-usage-report'); + if (!table) return; + table + .querySelectorAll(`[data-block-index="${CSS.escape(key)}"]`) + .forEach((el) => { + el.classList.toggle('block-usage__row--hover', add); + }); + } + + document.addEventListener('mouseover', (event) => { + toggleHighlight(event.target, true); + }); + + document.addEventListener('mouseout', (event) => { + toggleHighlight(event.target, false); + }); +})(); diff --git a/apps/core/templates/core/admin/reports/block_usage_results.html b/apps/core/templates/core/admin/reports/block_usage_results.html new file mode 100644 index 00000000..9782c2a7 --- /dev/null +++ b/apps/core/templates/core/admin/reports/block_usage_results.html @@ -0,0 +1,106 @@ +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} +{% load i18n wagtailadmin_tags %} + +{% block results %} + {% if current_version %} +

+ {% blocktrans with version=current_version %} + The guide currently covers Wagtail {{ version }}. + {% endblocktrans %} +

+ {% endif %} + + + + + + + + + + + + + + + + + + + + + {% for page in object_list %} + + + + + + + + + + {% endfor %} + +
{% trans 'Title' %}{% trans 'Status' %}{% trans 'Type' %}{% trans 'Change type' %}{% trans 'Version' %}{% trans 'Content' %}{% trans 'Review' %}
+ + {{ page.specific_deferred.get_admin_display_title }} + + {% if page.live %} + {% trans 'View' %} + {% endif %} + + {% include "wagtailadmin/shared/page_status_tag.html" with page=page %} + {{ page.page_type_display_name }} +
    + {% for block in page.block_usage_blocks %} +
  • + {% if block.change_type %} + + {{ block.change_type|title }} + + {% else %} + — + {% endif %} +
  • + {% endfor %} +
+
+
    + {% for block in page.block_usage_blocks %} +
  • + {% if block.version %} + + {{ block.version }} + + {% else %} + — + {% endif %} +
  • + {% endfor %} +
+
+ + + {% if page.block_usage_flagged %} + {% trans 'Needs update' %} + {% else %} + {% trans 'Up to date' %} + {% endif %} +
+{% endblock %} + +{% block no_results_message %} +

{% trans 'No pages with versioned blocks found.' %}

+{% endblock %} \ No newline at end of file diff --git a/apps/core/tests/test_block_usage_report.py b/apps/core/tests/test_block_usage_report.py new file mode 100644 index 00000000..f83d497c --- /dev/null +++ b/apps/core/tests/test_block_usage_report.py @@ -0,0 +1,105 @@ +import json +from http import HTTPStatus + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from apps.core.factories import ContentPageFactory, HomePageFactory +from apps.core.reports import iter_annotated_blocks + + +def _annotated_body(**kwargs): + value = {"content": kwargs.pop("content", "

Some content

")} + value.update(kwargs) + return json.dumps([{"type": "text_annotated", "value": value}]) + + +class TestBlockUsageReport(TestCase): + def setUp(self): + self.home = HomePageFactory() + self.user = get_user_model().objects.create_superuser( + username="admin", password="test" + ) + self.client.login(username="admin", password="test") + self.url = reverse("block_usage_report") + + def test_report_accessible_by_superuser(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_report_requires_login(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, HTTPStatus.FOUND) + + def test_pages_without_annotated_blocks_excluded(self): + ContentPageFactory(body=json.dumps([{"type": "text", "value": "

Hi

"}])) + response = self.client.get(self.url) + self.assertContains(response, "No pages with versioned blocks found.") + + def test_removed_block_at_outdated_version_flagged(self): + ContentPageFactory(body=_annotated_body(change_type="removed", version="7.0")) + response = self.client.get(self.url) + self.assertContains(response, "Removed") + self.assertContains(response, "7.0") + self.assertContains(response, "Needs update") + + def test_removed_block_at_current_version_not_flagged(self): + ContentPageFactory(body=_annotated_body(change_type="removed", version="7.4")) + response = self.client.get(self.url) + self.assertContains(response, "Removed") + self.assertContains(response, "Up to date") + + def test_outdated_version_flagged(self): + ContentPageFactory(body=_annotated_body(change_type="added", version="5.0")) + response = self.client.get(self.url) + self.assertContains(response, "5.0") + self.assertNotContains(response, "Outdated") + self.assertContains(response, "Needs update") + + def test_current_version_not_outdated(self): + ContentPageFactory(body=_annotated_body(change_type="added", version="7.4")) + response = self.client.get(self.url) + self.assertNotContains(response, "Outdated") + self.assertContains(response, "Up to date") + + def test_flagged_pages_sorted_first(self): + ContentPageFactory( + title="Flagged page", + body=_annotated_body(change_type="added", version="5.0"), + ) + ContentPageFactory( + title="Clean page", + body=json.dumps( + [ + {"type": "text_annotated", "value": {"content": "

ok

"}}, + ] + ), + ) + response = self.client.get(self.url) + body = response.content.decode() + self.assertTrue(body.index("Flagged page") < body.index("Clean page")) + + +class TestIterAnnotatedBlocks(TestCase): + def setUp(self): + self.home = HomePageFactory() + + def test_yields_only_annotated_blocks(self): + body = json.dumps( + [ + {"type": "text", "value": "

plain

"}, + {"type": "text_annotated", "value": {"content": "

a

"}}, + {"type": "text_annotated", "value": {"content": "

b

"}}, + ] + ) + page = ContentPageFactory(body=body) + blocks = list(iter_annotated_blocks(page)) + self.assertEqual(len(blocks), 2) + self.assertEqual(blocks[0][0], 1) + self.assertEqual(blocks[1][0], 2) + + def test_empty_when_no_annotated_blocks(self): + body = json.dumps([{"type": "text", "value": "

plain

"}]) + self.assertEqual(list(iter_annotated_blocks(ContentPageFactory(body=body))), []) diff --git a/apps/core/wagtail_hooks.py b/apps/core/wagtail_hooks.py index 49801f0c..f1dd24d0 100644 --- a/apps/core/wagtail_hooks.py +++ b/apps/core/wagtail_hooks.py @@ -1,5 +1,7 @@ import wagtail.admin.rich_text.editors.draftail.features as draftail_features -from django.urls import reverse +from django.templatetags.static import static +from django.urls import path, reverse +from django.utils.html import format_html from wagtail import hooks from wagtail.admin.menu import MenuItem from wagtail.admin.rich_text.converters.html_to_contentstate import ( @@ -7,11 +9,13 @@ ) from wagtail.admin.ui.components import Component from wagtail.models import Page +from wagtail.permissions import page_permission_policy from wagtail.snippets.models import register_snippet from wagtail.snippets.views.snippets import SnippetViewSet from wagtail_ai.panels import AIDescriptionFieldPanel from apps.core.models.feedback import Feedback +from apps.core.reports import BlockUsageReportView STYLE_GUIDE_URL = "https://github.com/wagtail/guide/blob/main/docs/style-guide.md" CONTRIBUTING_URL = "https://github.com/wagtail/guide/blob/main/CONTRIBUTING.md" @@ -96,6 +100,49 @@ def register_agent_skills_help_menu_item(): ) +class BlockUsageReportMenuItem(MenuItem): + def is_shown(self, request): + return page_permission_policy.user_has_any_permission( + request.user, ["add", "change", "publish"] + ) + + +@hooks.register("register_reports_menu_item") +def register_block_usage_report_menu_item(): + return BlockUsageReportMenuItem( + "Annotated blocks usage", + reverse("block_usage_report"), + name="block-usage", + icon_name="doc-empty-inverse", + order=1300, + ) + + +@hooks.register("register_admin_urls") +def register_block_usage_report_urls(): + return [ + path( + "reports/block-usage/", + BlockUsageReportView.as_view(), + name="block_usage_report", + ), + path( + "reports/block-usage/results/", + BlockUsageReportView.as_view(results_only=True), + name="block_usage_report_results", + ), + ] + + +@hooks.register("insert_editor_js") +def insert_block_focus_js(): + return format_html( + '\n', + static("core/css/block_usage_focus.css"), + static("core/js/block_usage_focus.js"), + ) + + @hooks.register("register_rich_text_features") def register_inline_code_feature(features): features.default_features.append("code") diff --git a/apps/guide/settings/base.py b/apps/guide/settings/base.py index b6ef5eb1..5c3bba8d 100644 --- a/apps/guide/settings/base.py +++ b/apps/guide/settings/base.py @@ -380,6 +380,10 @@ WAGTAIL_SITE_NAME = "Wagtail user guide 📚️" +# Current Wagtail version covered by the guide, in x.y format (e.g. "7.4"). +# Used by the Block usage report to flag annotated blocks that reference +WAGTAIL_GUIDE_CURRENT_VERSION = "7.4" + # Search # https://docs.wagtail.org/en/stable/topics/search/backends.html WAGTAILSEARCH_BACKENDS = {