Add breadcrumbs and new AJAX-based filtering to built-in report views

The export buttons, while working correctly on initial load, is still
broken upon filtering on the client-side. This is because the base
listing results template has yet to include the re-rendered header
buttons. As a result, the export links won't be updated on filter.

This will be fixed in the next commit.

Also, the "by task" and "by workflow" links in the workflow reports are
gone because the slim header doesn't support passing arbitrary actions
fragment. This will be fixed in another commit using header_buttons.
pull/11987/head
Sage Abdullah 2024-05-23 10:47:09 +01:00
rodzic 105df51ae3
commit 7ab8e6932f
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: EB1A33CC51CC0217
11 zmienionych plików z 217 dodań i 51 usunięć

Wyświetl plik

@ -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
# <project>/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'),
]
```

Wyświetl plik

@ -1,16 +1 @@
{% extends "wagtailadmin/generic/listing.html" %}
{% load wagtailadmin_tags %}
{% block main_header %}
{% fragment as report_actions %}
<div class="report__actions">
{% block actions %}
{% if view.list_export %}
{% include view.export_buttons_template_name %}
{% endif %}
{% endblock %}
</div>
{% endfragment %}
{% include "wagtailadmin/shared/header.html" with title=title icon=header_icon merged=1 extra_actions=report_actions %}
{% endblock %}

Wyświetl plik

@ -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(
"""
<select name="locked_by" id="id_locked_by">
<option value="" selected>---------</option>
</select>
""",
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"""
<select name="locked_by" id="id_locked_by">
<option value="" selected>---------</option>
<option value="{self.user.pk}">{self.user}</option>
</select>
""",
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()

Wyświetl plik

@ -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)

Wyświetl plik

@ -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",
),
]

Wyświetl plik

@ -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"),

Wyświetl plik

@ -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"),

Wyświetl plik

@ -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

Wyświetl plik

@ -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(

Wyświetl plik

@ -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)

Wyświetl plik

@ -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"),