diff --git a/docs/extending/adding_reports.md b/docs/extending/adding_reports.md index 52929e1d56..5003aed1ec 100644 --- a/docs/extending/adding_reports.md +++ b/docs/extending/adding_reports.md @@ -7,6 +7,7 @@ It is possible to create your own custom reports in the Wagtail admin. Two base `wagtail.admin.views.reports.ReportView`, which provides basic listing and spreadsheet export functionality, and `wagtail.admin.views.reports.PageReportView`, which additionally provides a default set of fields suitable for page listings. For this example, we'll add a report which shows any pages with unpublished changes. +We will register this view using the `unpublished_changes_report` name for the URL pattern. ```python # /views.py @@ -14,7 +15,8 @@ from wagtail.admin.views.reports import PageReportView class UnpublishedChangesReportView(PageReportView): - pass + index_url_name = "unpublished_changes_report" + index_results_url_name = "unpublished_changes_report_results" ``` ## Defining your report @@ -70,7 +72,7 @@ displaying action buttons, as well as the title, time of the last update, status In this example, we'll change this to a new template in a later section. .. versionadded:: 6.2 - The ``results_template_name`` attribute was added to support the use of the ``wagtail.admin.ui.tables`` framework. + The ``results_template_name`` attribute was added to support updating the listing via AJAX upon filtering and to allow the use of the ``wagtail.admin.ui.tables`` framework. .. attribute:: title @@ -86,6 +88,21 @@ The name of your report, which will be displayed in the header. For our example, The name of the icon, using the standard Wagtail icon names. For example, the locked pages view uses ``"locked"``, and for our example report, we'll set it to ``'doc-empty-inverse'``. +.. attribute:: index_url_name + +(string) + +The name of the URL pattern registered for the report view. + +.. attribute:: index_results_url_name + +(string) + +The name of the URL pattern registered for the results view (the report view with ``.as_view(results_only=True)``). + +.. versionadded:: 6.2 + The ``index_results_url_name`` attribute was added to support updating the listing via AJAX upon filtering. + ``` ## Spreadsheet exports @@ -197,6 +214,8 @@ def register_unpublished_changes_report_menu_item(): def register_unpublished_changes_report_url(): return [ path('reports/unpublished-changes/', UnpublishedChangesReportView.as_view(), name='unpublished_changes_report'), + # Add a results-only view to add support for AJAX-based filtering + path('reports/unpublished-changes/results/', UnpublishedChangesReportView.as_view(results_only=True), name='unpublished_changes_report_results'), ] ``` @@ -225,7 +244,8 @@ from wagtail.admin.views.reports import PageReportView from wagtail.models import Page class UnpublishedChangesReportView(PageReportView): - + index_url_name = "unpublished_changes_report" + index_results_url_name = "unpublished_changes_report_results" header_icon = 'doc-empty-inverse' results_template_name = 'reports/unpublished_changes_report_results.html' title = "Pages with unpublished changes" @@ -260,6 +280,7 @@ def register_unpublished_changes_report_menu_item(): def register_unpublished_changes_report_url(): return [ path('reports/unpublished-changes/', UnpublishedChangesReportView.as_view(), name='unpublished_changes_report'), + path('reports/unpublished-changes/results/', UnpublishedChangesReportView.as_view(results_only=True), name='unpublished_changes_report_results'), ] ``` diff --git a/wagtail/admin/templates/wagtailadmin/reports/base_report.html b/wagtail/admin/templates/wagtailadmin/reports/base_report.html index 3afd40c68c..698016bd94 100644 --- a/wagtail/admin/templates/wagtailadmin/reports/base_report.html +++ b/wagtail/admin/templates/wagtailadmin/reports/base_report.html @@ -1,16 +1 @@ {% extends "wagtailadmin/generic/listing.html" %} -{% load wagtailadmin_tags %} - -{% block main_header %} - {% fragment as report_actions %} -
- {% block actions %} - {% if view.list_export %} - {% include view.export_buttons_template_name %} - {% endif %} - {% endblock %} -
- {% endfragment %} - - {% include "wagtailadmin/shared/header.html" with title=title icon=header_icon merged=1 extra_actions=report_actions %} -{% endblock %} diff --git a/wagtail/admin/tests/test_reports_views.py b/wagtail/admin/tests/test_reports_views.py index 3ca31dd62c..99a8b097b4 100644 --- a/wagtail/admin/tests/test_reports_views.py +++ b/wagtail/admin/tests/test_reports_views.py @@ -31,9 +31,26 @@ from wagtail.test.testapp.models import ( SimplePage, ) from wagtail.test.utils import WagtailTestUtils +from wagtail.test.utils.template_tests import AdminTemplateTestUtils -class TestLockedPagesView(WagtailTestUtils, TestCase): +class BaseReportViewTestCase(AdminTemplateTestUtils, WagtailTestUtils, TestCase): + url_name = None + + def assertActiveFilter(self, soup, filter_name, filter_value): + # Should render the export buttons inside the header "more" dropdown + # with the filtered URL + links = soup.select("#w-slim-header-buttons .w-dropdown a") + unfiltered_url = reverse(self.url_name) + filtered_url = f"{unfiltered_url}?{filter_name}={filter_value}" + self.assertEqual(len(links), 2) + self.assertEqual( + [link.get("href") for link in links], + [f"{filtered_url}&export=xlsx", f"{filtered_url}&export=csv"], + ) + + +class TestLockedPagesView(BaseReportViewTestCase): def setUp(self): self.user = self.login() @@ -52,19 +69,21 @@ class TestLockedPagesView(WagtailTestUtils, TestCase): response, "wagtailadmin/reports/locked_pages_results.html", ) + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Locked pages"}], + response.content, + ) # Initially there should be no locked pages self.assertContains(response, "No locked pages found.") - # No user locked anything - self.assertInHTML( - """ - - """, - response.content.decode(), - ) + # Should render the filter inside the drilldown + soup = self.get_soup(response.content) + locked_by_options = soup.select(".w-drilldown select[name='locked_by'] option") + # No user locked anything, so there should be no option for the filter + self.assertEqual(len(locked_by_options), 1) + self.assertEqual(locked_by_options[0].text, "---------") + self.assertEqual(locked_by_options[0].get("value"), "") parent_page = Page.objects.first() parent_page.add_child( @@ -96,19 +115,23 @@ class TestLockedPagesView(WagtailTestUtils, TestCase): response, "wagtailadmin/reports/locked_pages_results.html", ) + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Locked pages"}], + response.content, + ) self.assertNotContains(response, "No locked pages found.") self.assertContains(response, "First locked page") self.assertContains(response, "Second locked page") - self.assertInHTML( - f""" - - """, - response.content.decode(), - ) + # Should render the filter inside the drilldown + soup = self.get_soup(response.content) + locked_by_options = soup.select(".w-drilldown select[name='locked_by'] option") + # The options should only display users who have locked pages + self.assertEqual(len(locked_by_options), 2) + self.assertEqual(locked_by_options[0].text, "---------") + self.assertIsNone(locked_by_options[0].value) + self.assertEqual(locked_by_options[1].text, str(self.user)) + self.assertEqual(locked_by_options[1].get("value"), str(self.user.pk)) # Locked by current user shown in indicator self.assertContains(response, "locked-indicator indicator--is-inverse") @@ -234,8 +257,9 @@ class TestLockedPagesView(WagtailTestUtils, TestCase): self.assertEqual(worksheet["E2"].number_format, ExcelDateFormatter().get()) -class TestFilteredLockedPagesView(WagtailTestUtils, TestCase): +class TestFilteredLockedPagesView(BaseReportViewTestCase): fixtures = ["test.json"] + url_name = "wagtailadmin_reports:locked_pages" def setUp(self): self.user = self.login() @@ -269,6 +293,9 @@ class TestFilteredLockedPagesView(WagtailTestUtils, TestCase): self.assertNotContains(response, "My locked page") self.assertNotContains(response, "Christmas") + soup = self.get_soup(response.content) + self.assertActiveFilter(soup, "live", "false") + def test_filter_by_user(self): response = self.get(params={"locked_by": self.user.pk}) self.assertEqual(response.status_code, 200) @@ -277,8 +304,9 @@ class TestFilteredLockedPagesView(WagtailTestUtils, TestCase): self.assertNotContains(response, "My locked page") -class TestFilteredLogEntriesView(WagtailTestUtils, TestCase): +class TestFilteredLogEntriesView(BaseReportViewTestCase): fixtures = ["test.json"] + url_name = "wagtailadmin_reports:site_history" def setUp(self): self.user = self.login() @@ -361,15 +389,20 @@ class TestFilteredLogEntriesView(WagtailTestUtils, TestCase): self.assertSetEqual(actual, set(expected)) def assert_filter_actions(self, response, expected): + soup = self.get_soup(response.content) actual = { - choice[0] - for choice in response.context["filters"].filters["action"].extra["choices"] + choice.get("value") + for choice in soup.select(".w-drilldown select[name='action'] option") } - self.assertSetEqual(actual, set(expected)) + self.assertSetEqual(actual, set(expected) | {""}) def test_unfiltered(self): response = self.get() self.assertEqual(response.status_code, 200) + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Site history"}], + response.content, + ) self.assert_log_entries( response, [ @@ -449,6 +482,9 @@ class TestFilteredLogEntriesView(WagtailTestUtils, TestCase): ], ) + soup = self.get_soup(response.content) + self.assertActiveFilter(soup, "action", "wagtail.edit") + def test_hide_commenting_actions(self): response = self.get(params={"hide_commenting_actions": "on"}) self.assertEqual(response.status_code, 200) @@ -547,7 +583,7 @@ class TestExcelDateFormatter(TestCase): self.assertEqual(formatter.format("m/d/Y g:i A"), "mm/dd/yyyy h:mm AM/PM") -class TestAgingPagesView(WagtailTestUtils, TestCase): +class TestAgingPagesView(BaseReportViewTestCase): def setUp(self): self.user = self.login() self.root = Page.objects.first() @@ -571,6 +607,10 @@ class TestAgingPagesView(WagtailTestUtils, TestCase): response, "wagtailadmin/reports/aging_pages_results.html", ) + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Aging pages"}], + response.content, + ) def test_displays_only_published_pages(self): response = self.get() @@ -766,8 +806,9 @@ class TestAgingPagesViewPermissions(WagtailTestUtils, TestCase): self.assertEqual(response.status_code, 200) -class TestFilteredAgingPagesView(WagtailTestUtils, TestCase): +class TestFilteredAgingPagesView(BaseReportViewTestCase): fixtures = ["test.json"] + url_name = "wagtailadmin_reports:aging_pages" def setUp(self): self.user = self.login() @@ -790,14 +831,23 @@ class TestFilteredAgingPagesView(WagtailTestUtils, TestCase): self.assertNotContains(response, self.aboutus_page.title) def test_filter_by_content_type(self): - response = self.get( - params={"content_type": self.aboutus_page.specific.content_type.pk} - ) + ct_pk = self.aboutus_page.specific.content_type.pk + response = self.get(params={"content_type": ct_pk}) self.assertEqual(response.status_code, 200) self.assertContains(response, self.aboutus_page.title) self.assertNotContains(response, self.home_page.title) + soup = self.get_soup(response.content) + self.assertActiveFilter(soup, "content_type", ct_pk) + + # Should render the filter inside the drilldown component + ct_select = soup.select_one(".w-drilldown select[name='content_type']") + self.assertIsNotNone(ct_select) + selected_option = ct_select.select_one("option[selected]") + self.assertIsNotNone(selected_option) + self.assertEqual(selected_option.get("value"), str(ct_pk)) + def test_filter_by_last_published_at(self): self.home_page.last_published_at = timezone.now() self.home_page.save() @@ -808,7 +858,7 @@ class TestFilteredAgingPagesView(WagtailTestUtils, TestCase): self.assertNotContains(response, self.home_page.title) -class PageTypesUsageReportViewTest(WagtailTestUtils, TestCase): +class PageTypesUsageReportViewTest(BaseReportViewTestCase): fixtures = ["test.json"] def setUp(self): @@ -829,6 +879,10 @@ class PageTypesUsageReportViewTest(WagtailTestUtils, TestCase): response, "wagtailadmin/reports/page_types_usage_results.html", ) + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Page types usage"}], + response.content, + ) def test_displays_only_page_types(self): """Asserts that the correct models are included in the queryset.""" @@ -1009,6 +1063,14 @@ class PageTypesReportFiltersTests(WagtailTestUtils, TestCase): self.assertEqual(event_page_row.last_edited_page.locale, self.fr_locale) self.assertEqual(simple_page_row.last_edited_page.locale, self.fr_locale) + # Should render the filter inside the drilldown component + soup = self.get_soup(response.content) + locale_select = soup.select_one(".w-drilldown select[name='page_locale']") + self.assertIsNotNone(locale_select) + selected_option = locale_select.select_one("option[selected]") + self.assertIsNotNone(selected_option) + self.assertEqual(selected_option.get("value"), "fr") + def test_site_filtering_with_single_site(self): """Asserts that the site filter is not displayed when there is only one site.""" sites = Site.objects.all() diff --git a/wagtail/admin/tests/test_workflows.py b/wagtail/admin/tests/test_workflows.py index 0687c61bcc..c23e7ddd40 100644 --- a/wagtail/admin/tests/test_workflows.py +++ b/wagtail/admin/tests/test_workflows.py @@ -1493,7 +1493,7 @@ class TestEditTaskView(AdminTemplateTestUtils, WagtailTestUtils, TestCase): self.assertEqual(moderator_url_finder.get_edit_url(self.task), expected_url) -class BasePageWorkflowTests(WagtailTestUtils, TestCase): +class BasePageWorkflowTests(AdminTemplateTestUtils, WagtailTestUtils, TestCase): model_name = "page" def setUp(self): @@ -2927,10 +2927,18 @@ class TestPageWorkflowReport(BasePageWorkflowTests): self.assertContains(response, "test_workflow") self.assertContains(response, "Sebastian Mitter") self.assertContains(response, "March 31, 2020") + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Workflows"}], + response.content, + ) response = self.client.get(reverse("wagtailadmin_reports:workflow_tasks")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Hello world!") + self.assertBreadcrumbsItemsRendered( + [{"url": "", "label": "Workflow tasks"}], + response.content, + ) def test_workflow_report_filtered(self): # the moderator can review the task, so the workflow state should show up even when reports are filtered by reviewable @@ -2943,12 +2951,52 @@ class TestPageWorkflowReport(BasePageWorkflowTests): self.assertContains(response, "Sebastian Mitter") self.assertContains(response, "March 31, 2020") + # Should render the export buttons inside the header "more" dropdown + # with the filtered URL + soup = self.get_soup(response.content) + links = soup.select("#w-slim-header-buttons .w-dropdown a") + unfiltered_url = reverse("wagtailadmin_reports:workflow") + filtered_url = f"{unfiltered_url}?reviewable=true" + self.assertEqual(len(links), 2) + self.assertEqual( + [link.get("href") for link in links], + [f"{filtered_url}&export=xlsx", f"{filtered_url}&export=csv"], + ) + + # Should render the filter inside the drilldown component + inputs = soup.select(".w-drilldown input[name='reviewable'][type='radio']") + self.assertEqual(len(inputs), 2) + self.assertEqual(inputs[0].get("value"), "") + self.assertIsNone(inputs[0].get("checked")) + self.assertEqual(inputs[1].get("value"), "true") + self.assertEqual(inputs[1].get("checked"), "") + response = self.client.get( reverse("wagtailadmin_reports:workflow_tasks"), {"reviewable": "true"} ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Hello world!") + # Should render the export buttons inside the header "more" dropdown + # with the filtered URL + soup = self.get_soup(response.content) + links = soup.select("#w-slim-header-buttons .w-dropdown a") + unfiltered_url = reverse("wagtailadmin_reports:workflow_tasks") + filtered_url = f"{unfiltered_url}?reviewable=true" + self.assertEqual(len(links), 2) + self.assertEqual( + [link.get("href") for link in links], + [f"{filtered_url}&export=xlsx", f"{filtered_url}&export=csv"], + ) + + # Should render the filter inside the drilldown component + inputs = soup.select(".w-drilldown input[name='reviewable'][type='radio']") + self.assertEqual(len(inputs), 2) + self.assertEqual(inputs[0].get("value"), "") + self.assertIsNone(inputs[0].get("checked")) + self.assertEqual(inputs[1].get("value"), "true") + self.assertEqual(inputs[1].get("checked"), "") + # the submitter cannot review the task, so the workflow state shouldn't show up when reports are filtered by reviewable self.login(self.submitter) response = self.client.get( @@ -3325,7 +3373,7 @@ class TestSnippetNotificationPreferencesHTML(TestSnippetNotificationPreferences) pass -class TestDisableViews(AdminTemplateTestUtils, BasePageWorkflowTests): +class TestDisableViews(BasePageWorkflowTests): def test_disable_workflow(self): """Test that deactivating a workflow sets it to inactive and cancels in progress states""" self.login(self.submitter) diff --git a/wagtail/admin/urls/reports.py b/wagtail/admin/urls/reports.py index 26400a2fcc..9a61eec4e5 100644 --- a/wagtail/admin/urls/reports.py +++ b/wagtail/admin/urls/reports.py @@ -11,11 +11,43 @@ from wagtail.admin.views.reports.workflows import WorkflowTasksView, WorkflowVie app_name = "wagtailadmin_reports" urlpatterns = [ path("locked/", LockedPagesView.as_view(), name="locked_pages"), + path( + "locked/results/", + LockedPagesView.as_view(results_only=True), + name="locked_pages_results", + ), path("workflow/", WorkflowView.as_view(), name="workflow"), + path( + "workflow/results/", + WorkflowView.as_view(results_only=True), + name="workflow_results", + ), path("workflow_tasks/", WorkflowTasksView.as_view(), name="workflow_tasks"), + path( + "workflow_tasks/results/", + WorkflowTasksView.as_view(results_only=True), + name="workflow_tasks_results", + ), path("site-history/", LogEntriesView.as_view(), name="site_history"), + path( + "site-history/results/", + LogEntriesView.as_view(results_only=True), + name="site_history_results", + ), path("aging-pages/", AgingPagesView.as_view(), name="aging_pages"), path( - "page-types-usage/", PageTypesUsageReportView.as_view(), name="page_types_usage" + "aging-pages/results/", + AgingPagesView.as_view(results_only=True), + name="aging_pages_results", + ), + path( + "page-types-usage/", + PageTypesUsageReportView.as_view(), + name="page_types_usage", + ), + path( + "page-types-usage/results/", + PageTypesUsageReportView.as_view(results_only=True), + name="page_types_usage_results", ), ] diff --git a/wagtail/admin/views/reports/aging_pages.py b/wagtail/admin/views/reports/aging_pages.py index 16c36ee481..eb496f0121 100644 --- a/wagtail/admin/views/reports/aging_pages.py +++ b/wagtail/admin/views/reports/aging_pages.py @@ -33,6 +33,8 @@ class AgingPagesView(PageReportView): title = _("Aging pages") header_icon = "time" filterset_class = AgingPagesReportFilterSet + index_url_name = "wagtailadmin_reports:aging_pages" + index_results_url_name = "wagtailadmin_reports:aging_pages_results" export_headings = { "status_string": _("Status"), "last_published_at": _("Last published at"), diff --git a/wagtail/admin/views/reports/audit_logging.py b/wagtail/admin/views/reports/audit_logging.py index 176664e8f1..aebffb25a6 100644 --- a/wagtail/admin/views/reports/audit_logging.py +++ b/wagtail/admin/views/reports/audit_logging.py @@ -108,6 +108,8 @@ class LogEntriesView(ReportView): title = _("Site history") header_icon = "history" filterset_class = SiteHistoryReportFilterSet + index_url_name = "wagtailadmin_reports:site_history" + index_results_url_name = "wagtailadmin_reports:site_history_results" export_headings = { "object_id": _("ID"), diff --git a/wagtail/admin/views/reports/base.py b/wagtail/admin/views/reports/base.py index 970ed07168..9161d56344 100644 --- a/wagtail/admin/views/reports/base.py +++ b/wagtail/admin/views/reports/base.py @@ -9,6 +9,12 @@ class ReportView(SpreadsheetExportMixin, BaseListingView): results_template_name = "wagtailadmin/reports/base_report_results.html" title = "" paginate_by = 50 + _show_breadcrumbs = True + + def get_breadcrumbs_items(self): + return super().get_breadcrumbs_items() + [ + {"url": "", "label": self.get_page_title()} + ] def get_page_title(self): # WagtailAdminTemplateMixin uses `page_title`, but the documented approach diff --git a/wagtail/admin/views/reports/locked_pages.py b/wagtail/admin/views/reports/locked_pages.py index 8ccd3378de..1c841a5ef4 100644 --- a/wagtail/admin/views/reports/locked_pages.py +++ b/wagtail/admin/views/reports/locked_pages.py @@ -42,6 +42,8 @@ class LockedPagesView(PageReportView): "locked_by", ] filterset_class = LockedPagesReportFilterSet + index_url_name = "wagtailadmin_reports:locked_pages" + index_results_url_name = "wagtailadmin_reports:locked_pages_results" def get_filename(self): return "locked-pages-report-{}".format( diff --git a/wagtail/admin/views/reports/page_types_usage.py b/wagtail/admin/views/reports/page_types_usage.py index 6816bed5a7..acdbda5e01 100644 --- a/wagtail/admin/views/reports/page_types_usage.py +++ b/wagtail/admin/views/reports/page_types_usage.py @@ -100,6 +100,8 @@ class PageTypesUsageReportView(ReportView): title = _("Page types usage") header_icon = "doc-empty-inverse" filterset_class = PageTypesUsageReportFilterSet + index_url_name = "wagtailadmin_reports:page_types_usage" + index_results_url_name = "wagtailadmin_reports:page_types_usage_results" def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/wagtail/admin/views/reports/workflows.py b/wagtail/admin/views/reports/workflows.py index 13445b359c..8f9006c0b1 100644 --- a/wagtail/admin/views/reports/workflows.py +++ b/wagtail/admin/views/reports/workflows.py @@ -140,6 +140,8 @@ class WorkflowView(ReportView): title = _("Workflows") header_icon = "tasks" filterset_class = WorkflowReportFilterSet + index_url_name = "wagtailadmin_reports:workflow" + index_results_url_name = "wagtailadmin_reports:workflow_results" export_headings = { "content_object.pk": _("Page/Snippet ID"), @@ -213,6 +215,8 @@ class WorkflowTasksView(ReportView): title = _("Workflow tasks") header_icon = "thumbtack" filterset_class = WorkflowTasksReportFilterSet + index_url_name = "wagtailadmin_reports:workflow_tasks" + index_results_url_name = "wagtailadmin_reports:workflow_tasks_results" export_headings = { "workflow_state.content_object.pk": _("Page/Snippet ID"),