Browse Source

documentation - migrate docs/extending/ to md

Noble Mittal 2 years ago
parent
commit
0be024e024

+ 1 - 1
CHANGELOG.txt

@@ -5,7 +5,7 @@ Changelog
 ~~~~~~~~~~~~~~~~
 
  * Add clarity to confirmation when being asked to convert an external link to an internal one (Thijs Kramer)
- * Convert various pages in the documentation to Markdown (Khanh Hoang, Vu Pham, Daniel Kirkham, LB (Ben) Johnston, Thiago Costa de Souza, Benedict Faw)
+ * Convert various pages in the documentation to Markdown (Khanh Hoang, Vu Pham, Daniel Kirkham, LB (Ben) Johnston, Thiago Costa de Souza, Benedict Faw, Noble Mittal)
  * Add `base_url_path` to `ModelAdmin` so that the default URL structure of app_label/model_name can be overridden (Vu Pham, Khanh Hoang)
  * Add `full_url` to the API output of `ImageRenditionField` (Paarth Agarwal)
  * Fix issue where `ModelAdmin` index listings with export list enabled would show buttons with an incorrect layout (Josh Woodcock)

+ 1 - 0
CONTRIBUTORS.rst

@@ -604,6 +604,7 @@ Contributors
 * Jaspreet Singh
 * Yves Serrano
 * Hugh Rawlinson
+* Noble Mittal
 
 Translators
 ===========

+ 1 - 1
docs/advanced_topics/amp.md

@@ -209,7 +209,7 @@ TEMPLATES = [
 You should now be able to use the `amp_mode_active` variable in templates.
 For example:
 
-```html+Django
+```html+django
 {% if amp_mode_active %}
     AMP MODE IS ACTIVE!
 {% endif %}

+ 1 - 1
docs/advanced_topics/embeds.md

@@ -50,7 +50,7 @@ You can nest embeds into a template by passing the URL and an optional
 
 The `max_width` argument is sent to the provider when fetching the embed code.
 
-```html+Django
+```html+django
 {% load wagtailembeds_tags %}
 
 {# Embed a YouTube video #}

+ 5 - 5
docs/advanced_topics/i18n.md

@@ -325,7 +325,7 @@ If both settings are set to the same value, this example should work well for yo
 otherwise skip to the next section that has a more complicated example which takes
 this into account.
 
-```html+Django
+```html+django
 
 {# make sure these are at the top of the file #}
 {% load i18n wagtailcore_tags %}
@@ -342,7 +342,7 @@ this into account.
 
 Let's break this down:
 
-```html+Django
+```html+django
 {% if page %}
     ...
 {% endif %}
@@ -350,7 +350,7 @@ Let's break this down:
 
 If this is part of a shared base template it may be used in situations where no page object is available, such as 404 error responses, so check that we have a page before proceeding.
 
-```html+Django
+```html+django
 {% for translation in page.get_translations.live %}
     ...
 {% endfor %}
@@ -358,14 +358,14 @@ If this is part of a shared base template it may be used in situations where no
 
 This `for` block iterates through all published translations of the current page.
 
-```html+Django
+```html+django
 {% get_language_info for translation.locale.language_code as lang %}
 ```
 
 This is a Django built-in tag that gets info about the language of the translation.
 For more information, see [get_language_info() in the Django docs](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/#django.utils.translation.get_language_info).
 
-```html+Django
+```html+django
 <a href="{% pageurl translation %}" rel="alternate" hreflang="{{ language_code }}">
     {{ lang.name_local }}
 </a>

+ 252 - 0
docs/extending/adding_reports.md

@@ -0,0 +1,252 @@
+# Adding reports
+
+Reports are views with listings of pages matching a specific query. They can also export these listings in spreadsheet format.
+They are found in the _Reports_ submenu: by default, the _Locked Pages_ report is provided, allowing an overview of locked pages on the site.
+
+It is possible to create your own custom reports in the Wagtail admin. Two base classes are provided:
+`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.
+
+```python
+# <project>/views.py
+from wagtail.admin.views.reports import PageReportView
+
+
+class UnpublishedChangesReportView(PageReportView):
+    pass
+```
+
+## Defining your report
+
+The most important attributes and methods to customise to define your report are:
+
+```{eval-rst}
+.. method:: get_queryset(self)
+```
+
+This retrieves the queryset of pages for your report. For our example:
+
+```python
+# <project>/views.py
+
+from wagtail.admin.views.reports import PageReportView
+from wagtail.models import Page
+
+
+class UnpublishedChangesReportView(PageReportView):
+
+    def get_queryset(self):
+        return Page.objects.filter(has_unpublished_changes=True)
+```
+
+```{eval-rst}
+
+.. attribute:: template_name
+
+(string)
+
+The template used to render your report. For ``ReportView``, this defaults to ``"wagtailadmin/reports/base_report.html"``,
+which provides an empty report page layout; for ``PageReportView``, this defaults to
+``"wagtailadmin/reports/base_page_report.html"`` which provides a listing based on the explorer views,
+displaying action buttons, as well as the title, time of the last update, status, and specific type of any pages.
+In this example, we'll change this to a new template in a later section.
+
+.. attribute:: title
+
+(string)
+
+The name of your report, which will be displayed in the header. For our example, we'll set it to
+``"Pages with unpublished changes"``.
+
+.. attribute:: header_icon
+
+(string)
+
+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'``.
+
+```
+
+## Spreadsheet exports
+
+```{eval-rst}
+
+.. attribute:: list_export
+
+(list)
+
+A list of the fields/attributes for each model which are exported as columns in the spreadsheet view. For ``ReportView``, this
+is empty by default, and for ``PageReportView``, it corresponds to the listing fields: the title, time of the last update, status,
+and specific type of any pages. For our example, we might want to know when the page was last published, so we'll set
+``list_export`` as follows:
+
+``list_export = PageReportView.list_export + ['last_published_at']``
+
+.. attribute:: export_headings
+
+(dictionary)
+
+A dictionary of any fields/attributes in ``list_export`` for which you wish to manually specify a heading for the spreadsheet
+column, and their headings. If unspecified, the heading will be taken from the field ``verbose_name`` if applicable, and the
+attribute string otherwise. For our example, ``last_published_at`` will automatically get a heading of ``"Last Published At"``,
+but a simple "Last Published" looks neater. We'll add that by setting ``export_headings``:
+
+``export_headings = dict(last_published_at='Last Published', **PageReportView.export_headings)``
+
+.. attribute:: custom_value_preprocess
+
+(dictionary)
+
+A dictionary of ``(value_class_1, value_class_2, ...)`` tuples mapping to ``{export_format: preprocessing_function}`` dictionaries,
+allowing custom preprocessing functions to be applied when exporting field values of specific classes (or their subclasses). If
+unspecified (and ``ReportView.custom_field_preprocess`` also does not specify a function), ``force_str`` will be used. To prevent
+preprocessing, set the preprocessing_function to ``None``.
+
+.. attribute:: custom_field_preprocess
+
+(dictionary)
+
+A dictionary of ``field_name`` strings mapping to ``{export_format: preprocessing_function}`` dictionaries,
+allowing custom preprocessing functions to be applied when exporting field values of specific classes (or their subclasses). This
+will take priority over functions specified in ``ReportView.custom_value_preprocess``. If unspecified (and
+``ReportView.custom_value_preprocess`` also does not specify a function), ``force_str`` will be used. To prevent
+preprocessing, set the preprocessing_function to ``None``.
+
+```
+
+## Customising templates
+
+For this example \"pages with unpublished changes\" report, we\'ll add an extra column to the listing template, showing the last publication date for each page. To do this, we\'ll extend two templates: `wagtailadmin/reports/base_page_report.html`, and `wagtailadmin/reports/listing/_list_page_report.html`.
+
+```html+django
+{# <project>/templates/reports/unpublished_changes_report.html #}
+
+{% extends 'wagtailadmin/reports/base_page_report.html' %}
+
+{% block listing %}
+    {% include 'reports/include/_list_unpublished_changes.html' %}
+{% endblock %}
+
+{% block no_results %}
+    <p>No pages with unpublished changes.</p>
+{% endblock %}
+```
+
+```html+django
+{# <project>/templates/reports/include/_list_unpublished_changes.html #}
+
+{% extends 'wagtailadmin/reports/listing/_list_page_report.html' %}
+
+{% block extra_columns %}
+    <th>Last Published</th>
+{% endblock %}
+
+{% block extra_page_data %}
+    <td valign="top">
+        {{ page.last_published_at }}
+    </td>
+{% endblock %}
+```
+
+Finally, we\'ll set `UnpublishedChangesReportView.template_name` to this new template: `'reports/unpublished_changes_report.html'`.
+
+## Adding a menu item and admin URL
+
+To add a menu item for your new report to the _Reports_ submenu, you will need to use the `register_reports_menu_item` hook (see: [Register Reports Menu Item](register_reports_menu_item)). To add an admin url for the report, you will need to use the `register_admin_urls` hook (see: [Register Admin URLs](register_admin_urls)). This can be done as follows:
+
+```python
+# <project>/wagtail_hooks.py
+
+from django.urls import path, reverse
+
+from wagtail.admin.menu import AdminOnlyMenuItem
+from wagtail import hooks
+
+from .views import UnpublishedChangesReportView
+
+@hooks.register('register_reports_menu_item')
+def register_unpublished_changes_report_menu_item():
+    return AdminOnlyMenuItem("Pages with unpublished changes", reverse('unpublished_changes_report'), classnames='icon icon-' + UnpublishedChangesReportView.header_icon, order=700)
+
+@hooks.register('register_admin_urls')
+def register_unpublished_changes_report_url():
+    return [
+        path('reports/unpublished-changes/', UnpublishedChangesReportView.as_view(), name='unpublished_changes_report'),
+    ]
+```
+
+Here, we use the `AdminOnlyMenuItem` class to ensure our report icon is only shown to superusers. To make the report visible to all users, you could replace this with `MenuItem`.
+
+## The full code
+
+```python
+# <project>/views.py
+
+from wagtail.admin.views.reports import PageReportView
+from wagtail.models import Page
+
+
+class UnpublishedChangesReportView(PageReportView):
+
+    header_icon = 'doc-empty-inverse'
+    template_name = 'reports/unpublished_changes_report.html'
+    title = "Pages with unpublished changes"
+
+    list_export = PageReportView.list_export + ['last_published_at']
+    export_headings = dict(last_published_at='Last Published', **PageReportView.export_headings)
+
+    def get_queryset(self):
+        return Page.objects.filter(has_unpublished_changes=True)
+```
+
+```python
+# <project>/wagtail_hooks.py
+
+from django.urls import path, reverse
+
+from wagtail.admin.menu import AdminOnlyMenuItem
+from wagtail import hooks
+
+from .views import UnpublishedChangesReportView
+
+@hooks.register('register_reports_menu_item')
+def register_unpublished_changes_report_menu_item():
+    return AdminOnlyMenuItem("Pages with unpublished changes", reverse('unpublished_changes_report'), classnames='icon icon-' + UnpublishedChangesReportView.header_icon, order=700)
+
+@hooks.register('register_admin_urls')
+def register_unpublished_changes_report_url():
+    return [
+        path('reports/unpublished-changes/', UnpublishedChangesReportView.as_view(), name='unpublished_changes_report'),
+    ]
+```
+
+```html+django
+{# <project>/templates/reports/unpublished_changes_report.html #}
+
+{% extends 'wagtailadmin/reports/base_page_report.html' %}
+
+{% block listing %}
+    {% include 'reports/include/_list_unpublished_changes.html' %}
+{% endblock %}
+
+{% block no_results %}
+    <p>No pages with unpublished changes.</p>
+{% endblock %}
+```
+
+```html+django
+{# <project>/templates/reports/include/_list_unpublished_changes.html #}
+
+{% extends 'wagtailadmin/reports/listing/_list_page_report.html' %}
+
+{% block extra_columns %}
+    <th>Last Published</th>
+{% endblock %}
+
+{% block extra_page_data %}
+    <td valign="top">
+        {{ page.last_published_at }}
+    </td>
+{% endblock %}
+```

+ 0 - 258
docs/extending/adding_reports.rst

@@ -1,258 +0,0 @@
-Adding reports
-==============
-
-Reports are views with listings of pages matching a specific query. They can also export these listings in spreadsheet format.
-They are found in the `Reports` submenu: by default, the `Locked Pages` report is provided, allowing an overview of locked pages on the site.
-
-It is possible to create your own custom reports in the Wagtail admin. Two base classes are provided:
-``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.
-
-.. code-block:: python
-
-    # <project>/views.py
-
-    from wagtail.admin.views.reports import PageReportView
-
-
-    class UnpublishedChangesReportView(PageReportView):
-        pass
-
-
-Defining your report
-~~~~~~~~~~~~~~~~~~~~~
-
-The most important attributes and methods to customise to define your report are:
-
-.. method:: get_queryset(self)
-
-This retrieves the queryset of pages for your report. For our example:
-
-.. code-block:: python
-
-    # <project>/views.py
-
-    from wagtail.admin.views.reports import PageReportView
-    from wagtail.models import Page
-
-
-    class UnpublishedChangesReportView(PageReportView):
-
-        def get_queryset(self):
-            return Page.objects.filter(has_unpublished_changes=True)
-
-.. attribute:: template_name
-
-(string)
-
-The template used to render your report. For ``ReportView``, this defaults to ``"wagtailadmin/reports/base_report.html"``,
-which provides an empty report page layout; for ``PageReportView``, this defaults to
-``"wagtailadmin/reports/base_page_report.html"`` which provides a listing based on the explorer views,
-displaying action buttons, as well as the title, time of the last update, status, and specific type of any pages.
-In this example, we'll change this to a new template in a later section.
-
-.. attribute:: title
-
-(string)
-
-The name of your report, which will be displayed in the header. For our example, we'll set it to
-``"Pages with unpublished changes"``.
-
-.. attribute:: header_icon
-
-(string)
-
-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'``.
-
-Spreadsheet exports
--------------------
-
-.. attribute:: list_export
-
-(list)
-
-A list of the fields/attributes for each model which are exported as columns in the spreadsheet view. For ``ReportView``, this
-is empty by default, and for ``PageReportView``, it corresponds to the listing fields: the title, time of the last update, status,
-and specific type of any pages. For our example, we might want to know when the page was last published, so we'll set
-``list_export`` as follows:
-
-``list_export = PageReportView.list_export + ['last_published_at']``
-
-.. attribute:: export_headings
-
-(dictionary)
-
-A dictionary of any fields/attributes in ``list_export`` for which you wish to manually specify a heading for the spreadsheet
-column, and their headings. If unspecified, the heading will be taken from the field ``verbose_name`` if applicable, and the
-attribute string otherwise. For our example, ``last_published_at`` will automatically get a heading of ``"Last Published At"``,
-but a simple "Last Published" looks neater. We'll add that by setting ``export_headings``:
-
-``export_headings = dict(last_published_at='Last Published', **PageReportView.export_headings)``
-
-.. attribute:: custom_value_preprocess
-
-(dictionary)
-
-A dictionary of ``(value_class_1, value_class_2, ...)`` tuples mapping to ``{export_format: preprocessing_function}`` dictionaries,
-allowing custom preprocessing functions to be applied when exporting field values of specific classes (or their subclasses). If
-unspecified (and ``ReportView.custom_field_preprocess`` also does not specify a function), ``force_str`` will be used. To prevent
-preprocessing, set the preprocessing_function to ``None``.
-
-.. attribute:: custom_field_preprocess
-
-(dictionary)
-
-A dictionary of ``field_name`` strings mapping to ``{export_format: preprocessing_function}`` dictionaries,
-allowing custom preprocessing functions to be applied when exporting field values of specific classes (or their subclasses). This
-will take priority over functions specified in ``ReportView.custom_value_preprocess``. If unspecified (and
-``ReportView.custom_value_preprocess`` also does not specify a function), ``force_str`` will be used. To prevent
-preprocessing, set the preprocessing_function to ``None``.
-
-Customising templates
----------------------
-
-For this example "pages with unpublished changes" report, we'll add an extra column to the listing template, showing the last
-publication date for each page. To do this, we'll extend two templates: ``wagtailadmin/reports/base_page_report.html``, and
-``wagtailadmin/reports/listing/_list_page_report.html``.
-
-.. code-block:: html+django
-
-    {# <project>/templates/reports/unpublished_changes_report.html #}
-
-    {% extends 'wagtailadmin/reports/base_page_report.html' %}
-
-    {% block listing %}
-        {% include 'reports/include/_list_unpublished_changes.html' %}
-    {% endblock %}
-
-    {% block no_results %}
-        <p>No pages with unpublished changes.</p>
-    {% endblock %}
-
-
-.. code-block:: html+django
-
-    {# <project>/templates/reports/include/_list_unpublished_changes.html #}
-
-    {% extends 'wagtailadmin/reports/listing/_list_page_report.html' %}
-
-    {% block extra_columns %}
-        <th>Last Published</th>
-    {% endblock %}
-
-    {% block extra_page_data %}
-        <td valign="top">
-            {{ page.last_published_at }}
-        </td>
-    {% endblock %}
-
-Finally, we'll set ``UnpublishedChangesReportView.template_name`` to this new template: ``'reports/unpublished_changes_report.html'``.
-
-
-Adding a menu item and admin URL
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-To add a menu item for your new report to the `Reports` submenu, you will need to use the ``register_reports_menu_item`` hook (see: :ref:`register_reports_menu_item`). To add an admin
-url for the report, you will need to use the ``register_admin_urls`` hook (see :ref:`register_admin_urls`). This can be done as follows:
-
-.. code-block:: python
-
-    # <project>/wagtail_hooks.py
-
-    from django.urls import path, reverse
-
-    from wagtail.admin.menu import AdminOnlyMenuItem
-    from wagtail import hooks
-
-    from .views import UnpublishedChangesReportView
-
-    @hooks.register('register_reports_menu_item')
-    def register_unpublished_changes_report_menu_item():
-        return AdminOnlyMenuItem("Pages with unpublished changes", reverse('unpublished_changes_report'), classnames='icon icon-' + UnpublishedChangesReportView.header_icon, order=700)
-
-    @hooks.register('register_admin_urls')
-    def register_unpublished_changes_report_url():
-        return [
-            path('reports/unpublished-changes/', UnpublishedChangesReportView.as_view(), name='unpublished_changes_report'),
-        ]
-
-Here, we use the ``AdminOnlyMenuItem`` class to ensure our report icon is only shown to superusers. To make the report visible to all users,
-you could replace this with ``MenuItem``.
-
-
-The full code
-~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    # <project>/views.py
-
-    from wagtail.admin.views.reports import PageReportView
-    from wagtail.models import Page
-
-
-    class UnpublishedChangesReportView(PageReportView):
-
-        header_icon = 'doc-empty-inverse'
-        template_name = 'reports/unpublished_changes_report.html'
-        title = "Pages with unpublished changes"
-
-        list_export = PageReportView.list_export + ['last_published_at']
-        export_headings = dict(last_published_at='Last Published', **PageReportView.export_headings)
-
-        def get_queryset(self):
-            return Page.objects.filter(has_unpublished_changes=True)
-
-.. code-block:: python
-
-    # <project>/wagtail_hooks.py
-
-    from django.urls import path, reverse
-
-    from wagtail.admin.menu import AdminOnlyMenuItem
-    from wagtail import hooks
-
-    from .views import UnpublishedChangesReportView
-
-    @hooks.register('register_reports_menu_item')
-    def register_unpublished_changes_report_menu_item():
-        return AdminOnlyMenuItem("Pages with unpublished changes", reverse('unpublished_changes_report'), classnames='icon icon-' + UnpublishedChangesReportView.header_icon, order=700)
-
-    @hooks.register('register_admin_urls')
-    def register_unpublished_changes_report_url():
-        return [
-            path('reports/unpublished-changes/', UnpublishedChangesReportView.as_view(), name='unpublished_changes_report'),
-        ]
-
-.. code-block:: html+django
-
-    {# <project>/templates/reports/unpublished_changes_report.html #}
-
-    {% extends 'wagtailadmin/reports/base_page_report.html' %}
-
-    {% block listing %}
-        {% include 'reports/include/_list_unpublished_changes.html' %}
-    {% endblock %}
-
-    {% block no_results %}
-        <p>No pages with unpublished changes.</p>
-    {% endblock %}
-
-
-.. code-block:: html+django
-
-    {# <project>/templates/reports/include/_list_unpublished_changes.html #}
-
-    {% extends 'wagtailadmin/reports/listing/_list_page_report.html' %}
-
-    {% block extra_columns %}
-        <th>Last Published</th>
-    {% endblock %}
-
-    {% block extra_page_data %}
-        <td valign="top">
-            {{ page.last_published_at }}
-        </td>
-    {% endblock %}

+ 229 - 0
docs/extending/admin_views.md

@@ -0,0 +1,229 @@
+# Creating admin views
+
+The most common use for adding custom views to the Wagtail admin is to provide an interface for managing a Django model. The [Index](../reference/contrib/modeladmin/index) app makes this simple, providing ready-made views for listing, creating and editing objects with minimal configuration.
+
+For other kinds of admin view that don't fit this pattern, you can write your own Django views and register them as part of the Wagtail admin through [hooks](admin_hooks). In this example, we'll implement a view that displays a calendar for the current year, using [the calendar module](https://docs.python.org/3/library/calendar.html) from Python's standard library.
+
+## Defining a view
+
+Within a Wagtail project, create a new `wagtailcalendar` app with `./manage.py startapp wagtailcalendar` and add it to your project's `INSTALLED_APPS`. (In this case we're using the name 'wagtailcalendar' to avoid clashing with the standard library's `calendar` module - in general there is no need to use a 'wagtail' prefix.)
+
+Edit `views.py` as follows - note that this is a plain Django view with no Wagtail-specific code.
+
+```python
+import calendar
+
+from django.http import HttpResponse
+from django.utils import timezone
+
+
+def index(request):
+    current_year = timezone.now().year
+    calendar_html = calendar.HTMLCalendar().formatyear(current_year)
+
+    return HttpResponse(calendar_html)
+```
+
+## Registering a URL route
+
+At this point, the standard practice for a Django project would be to add a URL route for this view to your project's top-level URL config module. However, in this case we want the view to only be available to logged-in users, and to appear within the `/admin/` URL namespace which is managed by Wagtail. This is done through the [Register Admin URLs](register_admin_urls) hook.
+
+On startup, Wagtail looks for a `wagtail_hooks` submodule within each installed app. In this submodule, you can define functions to be run at various points in Wagtail's operation, such as building the URL config for the admin, and constructing the main menu.
+
+Create a `wagtail_hooks.py` file within the `wagtailcalendar` app containing the following:
+
+```python
+from django.urls import path
+from wagtail import hooks
+
+from .views import index
+
+
+@hooks.register('register_admin_urls')
+def register_calendar_url():
+    return [
+        path('calendar/', index, name='calendar'),
+    ]
+```
+
+The calendar will now be visible at the URL `/admin/calendar/`.
+
+![A calendar, presented in unstyled HTML](../_static/images/adminviews_calendar.png)
+
+## Adding a template
+
+Currently this view is outputting a plain HTML fragment. Let's insert this into the usual Wagtail admin page furniture, by creating a template that extends Wagtail's base template `"wagtailadmin/base.html"`.
+
+```{note}
+The base template and HTML structure are not considered astable part of Wagtail's API, and may change in futurereleases.
+```
+
+Update `views.py` as follows:
+
+```python
+import calendar
+from django.shortcuts import render
+from django.utils import timezone
+
+def index(request):
+    current_year = timezone.now().year
+    calendar_html = calendar.HTMLCalendar().formatyear(current_year)
+
+    return render(request, 'wagtailcalendar/index.html', {
+        'current_year': current_year,
+        'calendar_html': calendar_html,
+    })
+```
+
+Now create a `templates/wagtailcalendar/` folder within the `wagtailcalendar` app, containing `index.html` as follows:
+
+```html+django
+{% extends "wagtailadmin/base.html" %}
+{% block titletag %}{{ current_year }} calendar{% endblock %}
+
+{% block extra_css %}
+    {{ block.super }}
+    <style>
+        table.month {
+            margin: 20px;
+        }
+        table.month td, table.month th {
+            padding: 5px;
+        }
+    </style>
+{% endblock %}
+
+{% block content %}
+    {% include "wagtailadmin/shared/header.html" with title="Calendar" icon="date" %}
+
+    <div class="nice-padding">
+        {{ calendar_html|safe }}
+    </div>
+{% endblock %}
+```
+
+Here we are overriding three of the blocks defined in the base template: `titletag` (which sets the content of the HTML `<title>` tag), `extra_css` (which allows us to provide additional CSS styles specific to this page), and `content` (for the main content area of the page). We're also including the standard header bar component, and setting a title and icon. For a list of the recognised icon identifiers, see the [style guide](styleguide).
+
+Revisiting `/admin/calendar/` will now show the calendar within the Wagtail admin page furniture.
+
+![A calendar, shown within the Wagtail admin interface](../_static/images/adminviews_calendar_template.png)
+
+## Adding a menu item
+
+Our calendar view is now complete, but there's no way to reach it from the rest of the admin backend. To add an item to the sidebar menu, we'll use another hook, [Register Admin Menu Item](register_admin_menu_item). Update `wagtail_hooks.py` as follows:
+
+```python
+from django.urls import path, reverse
+
+from wagtail.admin.menu import MenuItem
+from wagtail import hooks
+
+from .views import index
+
+
+@hooks.register('register_admin_urls')
+def register_calendar_url():
+    return [
+        path('calendar/', index, name='calendar'),
+    ]
+
+
+@hooks.register('register_admin_menu_item')
+def register_calendar_menu_item():
+    return MenuItem('Calendar', reverse('calendar'), icon_name='date')
+```
+
+A 'Calendar' item will now appear in the menu.
+
+![Wagtail admin sidebar menu, showing a "Calendar" menu item with a date icon](../_static/images/adminviews_menu.png)
+
+## Adding a group of menu items
+
+Sometimes you want to group custom views together in a single menu item in the sidebar. Let's create another view to display only the current calendar month:
+
+```{code-block} python
+:emphasize-lines: 13-18
+
+import calendar
+
+from django.http import HttpResponse
+from django.utils import timezone
+
+
+def index(request):
+    current_year = timezone.now().year
+    calendar_html = calendar.HTMLCalendar().formatyear(current_year)
+
+    return HttpResponse(calendar_html)
+
+def month(request):
+    current_year = timezone.now().year
+    current_month = timezone.now().month
+    calendar_html = calendar.HTMLCalendar().format_month(current_year, current_month)
+
+    return HttpResponse(calendar_html)
+```
+
+We also need to update `wagtail_hooks.py` to register our URL in the admin interface:
+
+```{code-block} python
+:emphasize-lines: 11
+
+from django.urls import path
+from wagtail import hooks
+
+from .views import index, month
+
+
+@hooks.register('register_admin_urls')
+def register_calendar_url():
+    return [
+        path('calendar/', index, name='calendar'),
+        path('calendar/month/', month, name='calendar-month'),
+    ]
+```
+
+The calendar will now be visible at the URL `/admin/calendar/month/`.
+
+![A single calender month](../_static/images/adminviews_calendarmonth.png)
+
+Finally we can alter our `wagtail_hooks.py` to include a group of custom menu items. This is similar to adding a single item but involves importing two more classes, `SubMenu` and `SubmenuMenuItem`.
+
+```{code-block} python
+:emphasize-lines: 3-4, 21-26
+
+from django.urls import path, reverse
+
+from wagtail.admin.menu import MenuItem, SubmenuMenuItem
+from wagtail.contrib.modeladmin.menus import SubMenu
+from wagtail import hooks
+
+
+from .views import index, month
+
+
+@hooks.register('register_admin_urls')
+def register_calendar_url():
+    return [
+        path('calendar/', index, name='calendar'),
+        path('calendar/month/', month, name='calendar-month'),
+    ]
+
+
+@hooks.register('register_admin_menu_item')
+def register_calendar_menu_item():
+    menu_items = [
+        MenuItem('Calendar', reverse('calendar'), icon_name='date'),
+        MenuItem('Current month', reverse('calendar-month'), icon_name='date'),
+    ]
+
+    return SubmenuMenuItem('Calendar', SubMenu(menu_items), classnames='icon icon-date')
+```
+
+The 'Calendar' item will now appear as a group of menu items.
+
+![Wagtail admin sidebar menu, showing a "Calendar" group menu item with a date icon](../_static/images/adminviews_menu_group.png)
+
+When expanded, the 'Calendar' item will now show our two custom menu items.
+
+![Wagtail admin sidebar 'Calendar' menu expaned, showing two child menu items, 'Calendar' and 'Month'.](../_static/images/adminviews_menu_group_expanded.png)

+ 0 - 252
docs/extending/admin_views.rst

@@ -1,252 +0,0 @@
-Creating admin views
-====================
-
-The most common use for adding custom views to the Wagtail admin is to provide an interface for managing a Django model. The :doc:`/reference/contrib/modeladmin/index` app makes this simple, providing ready-made views for listing, creating and editing objects with minimal configuration.
-
-For other kinds of admin view that don't fit this pattern, you can write your own Django views and register them as part of the Wagtail admin through :ref:`hooks <admin_hooks>`. In this example, we'll implement a view that displays a calendar for the current year, using `the calendar module <https://docs.python.org/3/library/calendar.html>`_ from Python's standard library.
-
-
-Defining a view
----------------
-
-Within a Wagtail project, create a new ``wagtailcalendar`` app with ``./manage.py startapp wagtailcalendar`` and add it to your project's ``INSTALLED_APPS``. (In this case we're using the name 'wagtailcalendar' to avoid clashing with the standard library's ``calendar`` module - in general there is no need to use a 'wagtail' prefix.)
-
-Edit ``views.py`` as follows - note that this is a plain Django view with no Wagtail-specific code.
-
-.. code-block:: python
-
-  import calendar
-
-  from django.http import HttpResponse
-  from django.utils import timezone
-
-
-  def index(request):
-      current_year = timezone.now().year
-      calendar_html = calendar.HTMLCalendar().formatyear(current_year)
-
-      return HttpResponse(calendar_html)
-
-
-Registering a URL route
------------------------
-
-At this point, the standard practice for a Django project would be to add a URL route for this view to your project's top-level URL config module. However, in this case we want the view to only be available to logged-in users, and to appear within the ``/admin/`` URL namespace which is managed by Wagtail. This is done through the :ref:`register_admin_urls` hook.
-
-On startup, Wagtail looks for a ``wagtail_hooks`` submodule within each installed app. In this submodule, you can define functions to be run at various points in Wagtail's operation, such as building the URL config for the admin, and constructing the main menu.
-
-Create a ``wagtail_hooks.py`` file within the ``wagtailcalendar`` app containing the following:
-
-.. code-block:: python
-
-  from django.urls import path
-  from wagtail import hooks
-
-  from .views import index
-
-
-  @hooks.register('register_admin_urls')
-  def register_calendar_url():
-      return [
-          path('calendar/', index, name='calendar'),
-      ]
-
-The calendar will now be visible at the URL ``/admin/calendar/``.
-
-.. figure:: ../_static/images/adminviews_calendar.png
-   :alt: A calendar, presented in unstyled HTML
-
-
-Adding a template
------------------
-
-Currently this view is outputting a plain HTML fragment. Let's insert this into the usual Wagtail admin page furniture, by creating a template that extends Wagtail's base template ``"wagtailadmin/base.html"``.
-
-.. note::
-   The base template and HTML structure are not considered a stable part of Wagtail's API, and may change in future releases.
-
-Update ``views.py`` as follows:
-
-.. code-block:: python
-
-  import calendar
-  from django.shortcuts import render
-  from django.utils import timezone
-
-  def index(request):
-      current_year = timezone.now().year
-      calendar_html = calendar.HTMLCalendar().formatyear(current_year)
-
-      return render(request, 'wagtailcalendar/index.html', {
-          'current_year': current_year,
-          'calendar_html': calendar_html,
-      })
-
-Now create a ``templates/wagtailcalendar/`` folder within the ``wagtailcalendar`` app, containing ``index.html`` as follows:
-
-.. code-block:: html+django
-
-  {% extends "wagtailadmin/base.html" %}
-  {% block titletag %}{{ current_year }} calendar{% endblock %}
-
-  {% block extra_css %}
-      {{ block.super }}
-      <style>
-          table.month {
-              margin: 20px;
-          }
-          table.month td, table.month th {
-              padding: 5px;
-          }
-      </style>
-  {% endblock %}
-
-  {% block content %}
-      {% include "wagtailadmin/shared/header.html" with title="Calendar" icon="date" %}
-
-      <div class="nice-padding">
-          {{ calendar_html|safe }}
-      </div>
-  {% endblock %}
-
-Here we are overriding three of the blocks defined in the base template: ``titletag`` (which sets the content of the HTML ``<title>`` tag), ``extra_css`` (which allows us to provide additional CSS styles specific to this page), and ``content`` (for the main content area of the page). We're also including the standard header bar component, and setting a title and icon. For a list of the recognised icon identifiers, see the :ref:`styleguide`.
-
-Revisiting ``/admin/calendar/`` will now show the calendar within the Wagtail admin page furniture.
-
-.. figure:: ../_static/images/adminviews_calendar_template.png
-   :alt: A calendar, shown within the Wagtail admin interface
-
-
-Adding a menu item
-------------------
-
-Our calendar view is now complete, but there's no way to reach it from the rest of the admin backend. To add an item to the sidebar menu, we'll use another hook, :ref:`register_admin_menu_item`. Update ``wagtail_hooks.py`` as follows:
-
-.. code-block:: python
-
-  from django.urls import path, reverse
-
-  from wagtail.admin.menu import MenuItem
-  from wagtail import hooks
-
-  from .views import index
-
-
-  @hooks.register('register_admin_urls')
-  def register_calendar_url():
-      return [
-          path('calendar/', index, name='calendar'),
-      ]
-
-
-  @hooks.register('register_admin_menu_item')
-  def register_calendar_menu_item():
-      return MenuItem('Calendar', reverse('calendar'), icon_name='date')
-
-A 'Calendar' item will now appear in the menu.
-
-.. figure:: ../_static/images/adminviews_menu.png
-   :alt: Wagtail admin sidebar menu, showing a "Calendar" menu item with a date icon
-
-
-Adding a group of menu items
-----------------------------
-
-Sometimes you want to group custom views together in a single menu item in the sidebar. Let's create another view to display only the current calendar month:
-
-
-.. code-block:: python
-  :emphasize-lines: 13-18
-
-  import calendar
-
-  from django.http import HttpResponse
-  from django.utils import timezone
-
-
-  def index(request):
-      current_year = timezone.now().year
-      calendar_html = calendar.HTMLCalendar().formatyear(current_year)
-
-      return render(request, 'wagtailcalendar/index.html', {
-          'current_year': current_year,
-          'calendar_html': calendar_html,
-      })
-
-  def month(request):
-      current_year = timezone.now().year
-      current_month = timezone.now().month
-      calendar_html = calendar.HTMLCalendar().formatmonth(current_year, current_month)
-
-      return render(request, 'wagtailcalendar/index.html', {
-          'current_year': current_year,
-          'calendar_html': calendar_html,
-      })
-
-We also need to update ``wagtail_hooks.py`` to register our URL in the admin interface:
-
-
-.. code-block:: python
-  :emphasize-lines: 11
-
-  from django.urls import path
-  from wagtail import hooks
-
-  from .views import index, month
-
-
-  @hooks.register('register_admin_urls')
-  def register_calendar_url():
-      return [
-          path('calendar/', index, name='calendar'),
-          path('calendar/month/', month, name='calendar-month'),
-      ]
-
-The calendar will now be visible at the URL ``/admin/calendar/month/``.
-
-.. figure:: ../_static/images/adminviews_calendarmonth.png
-   :alt: A single calendar month
-
-
-Finally we can alter our ``wagtail_hooks.py`` to include a group of custom menu items. This is similar to adding a single item but involves importing two more classes, ``SubMenu`` and ``SubmenuMenuItem``.
-
-.. code-block:: python
-  :emphasize-lines: 3-4,21-26
-
-  from django.urls import path, reverse
-
-  from wagtail.admin.menu import MenuItem, SubmenuMenuItem
-  from wagtail.contrib.modeladmin.menus import SubMenu
-  from wagtail import hooks
-
-
-  from .views import index, month
-
-
-  @hooks.register('register_admin_urls')
-  def register_calendar_url():
-      return [
-          path('calendar/', index, name='calendar'),
-          path('calendar/month/', month, name='calendar-month'),
-      ]
-
-
-  @hooks.register('register_admin_menu_item')
-  def register_calendar_menu_item():
-      menu_items = [
-          MenuItem('Calendar', reverse('calendar'), icon_name='date'),
-          MenuItem('Current month', reverse('calendar-month'), icon_name='date'),
-      ]
-
-      return SubmenuMenuItem('Calendar', SubMenu(menu_items), classnames='icon icon-date')
-
-
-The 'Calendar' item will now appear as a group of menu items.
-
-.. figure:: ../_static/images/adminviews_menu_group.png
-   :alt: Wagtail admin sidebar menu, showing a "Calendar" group menu item with a date icon
-
-When expanded, the 'Calendar' item will now show our two custom menu items.
-
-.. figure:: ../_static/images/adminviews_menu_group_expanded.png
-   :alt: Wagtail admin sidebar 'Calendar' menu expaned, showing two child menu items, 'Calendar' and 'Month'.

+ 126 - 0
docs/extending/audit_log.md

@@ -0,0 +1,126 @@
+(audit_log)=
+
+# Audit log
+
+Wagtail provides a mechanism to log actions performed on its objects. Common activities such as page creation, update, deletion, locking and unlocking, revision scheduling and privacy changes are automatically logged at the model level.
+
+The Wagtail admin uses the action log entries to provide a site-wide and page specific history of changes. It uses a
+registry of 'actions' that provide additional context for the logged action.
+
+The audit log-driven Page history replaces the revisions list page, but provide a filter for revision-specific entries.
+
+```{note}
+The audit log does not replace revisions.
+```
+
+The `wagtail.log_actions.log` function can be used to add logging to your own code.
+
+```{eval-rest}
+.. function:: log(instance, action, user=None, uuid=None, title=None, data=None)
+
+   Adds an entry to the audit log.
+
+   :param instance: The model instance that the action is performed on
+   :param action: The code name for the action being performed. This can be one of the names listed below, or a custom action defined through the :ref:`register_log_actions` hook.
+   :param user: Optional - the user initiating the action. For actions logged within an admin view, this defaults to the logged-in user.
+   :param uuid: Optional - log entries given the same UUID indicates that they occurred as part of the same user action (e.g. a page being immediately published on creation).
+   :param title: The string representation of the instance being logged. By default, Wagtail will attempt to use the instance's ``str`` representation, or ``get_admin_display_title`` for page objects.
+   :param data: Optional - a dictionary of additional JSON-serialisable data to store against the log entry
+```
+
+```{note}
+When adding logging, you need to log the action or actions that happen to the object. For example, if the user creates and publishes a page, there should be a "create" entry and a "publish" entry. Or, if the user copies a published page and chooses to keep it published, there should be a "copy" and a "publish" entry for new page.
+```
+
+```python
+
+    # mypackage/views.py
+    from wagtail.log_actions import log
+
+    def copy_for_translation(page):
+        # ...
+        page.copy(log_action='mypackage.copy_for_translation')
+
+    def my_method(request, page):
+        # ..
+        # Manually log an action
+        data = {
+            'make': {'it': 'so'}
+        }
+        log(
+            instance=page, action='mypackage.custom_action', data=data
+        )
+```
+
+```{versionchanged} 2.15
+The `log` function was added. Previously, logging was only implemented for pages, and invoked through the `PageLogEntry.objects.log_action` method.
+```
+
+## Log actions provided by Wagtail
+
+| Action                            | Notes                                                                            |
+| --------------------------------- | -------------------------------------------------------------------------------- |
+| `wagtail.create`                  | The object was created                                                           |
+| `wagtail.edit`                    | The object was edited (for pages, saved as draft)                                |
+| `wagtail.delete`                  | The object was deleted. Will only surface in the Site History for administrators |
+| `wagtail.publish`                 | The page was published                                                           |
+| `wagtail.publish.schedule`        | Draft is scheduled for publishing                                                |
+| `wagtail.publish.scheduled`       | Draft published via `publish_scheduled_pages` management command                 |
+| `wagtail.schedule.cancel`         | Draft scheduled for publishing cancelled via "Cancel scheduled publish"          |
+| `wagtail.unpublish`               | The page was unpublished                                                         |
+| `wagtail.unpublish.scheduled`     | Page unpublished via `publish_scheduled_pages` management command                |
+| `wagtail.lock`                    | Page was locked                                                                  |
+| `wagtail.unlock`                  | Page was unlocked                                                                |
+| `wagtail.moderation.approve`      | The revision was approved for moderation                                         |
+| `wagtail.moderation.reject`       | The revision was rejected                                                        |
+| `wagtail.rename`                  | A page was renamed                                                               |
+| `wagtail.revert`                  | The page was reverted to a previous draft                                        |
+| `wagtail.copy`                    | The page was copied to a new location                                            |
+| `wagtail.copy_for_translation`    | The page was copied into a new locale for translation                            |
+| `wagtail.move`                    | The page was moved to a new location                                             |
+| `wagtail.reorder`                 | The order of the page under it's parent was changed                              |
+| `wagtail.view_restriction.create` | The page was restricted                                                          |
+| `wagtail.view_restriction.edit`   | The page restrictions were updated                                               |
+| `wagtail.view_restriction.delete` | The page restrictions were removed                                               |
+| `wagtail.workflow.start`          | The page was submitted for moderation in a Workflow                              |
+| `wagtail.workflow.approve`        | The draft was approved at a Workflow Task                                        |
+| `wagtail.workflow.reject`         | The draft was rejected, and changes requested at a Workflow Task                 |
+| `wagtail.workflow.resume`         | The draft was resubmitted to the workflow                                        |
+| `wagtail.workflow.cancel`         | The workflow was cancelled                                                       |
+
+## Log context
+
+The `wagtail.log_actions` module provides a context manager to simplify code that logs a large number of actions,
+such as import scripts:
+
+```python
+from wagtail.log_actions import LogContext
+
+with LogContext(user=User.objects.get(username='admin')):
+    # ...
+    log(page, 'wagtail.edit')
+    # ...
+    log(page, 'wagtail.publish')
+```
+
+All `log` calls within the block will then be attributed to the specified user, and assigned a common UUID. A log context is created automatically for views within the Wagtail admin.
+
+## Log models
+
+Logs are stored in the database via the models `wagtail.models.PageLogEntry` (for actions on Page instances) and
+`wagtail.models.ModelLogEntry` (for actions on all other models). Page logs are stored in their own model to
+ensure that reports can be filtered according to the current user's permissions, which could not be done efficiently
+with a generic foreign key.
+
+If your own models have complex reporting requirements that would make `ModelLogEntry` unsuitable, you can configure
+them to be logged to their own log model; this is done by subclassing the abstract `wagtail.models.BaseLogEntry`
+model, and registering that model with the log registry's `register_model` method:
+
+```python
+from myapp.models import Sprocket, SprocketLogEntry
+# here SprocketLogEntry is a subclass of BaseLogEntry
+
+@hooks.register('register_log_actions')
+def sprocket_log_model(actions):
+    actions.register_model(Sprocket, SprocketLogEntry)
+```

+ 0 - 135
docs/extending/audit_log.rst

@@ -1,135 +0,0 @@
-.. _audit_log:
-
-Audit log
-=========
-
-Wagtail provides a mechanism to log actions performed on its objects. Common activities such as page creation, update, deletion,
-locking and unlocking, revision scheduling and privacy changes are automatically logged at the model level.
-
-The Wagtail admin uses the action log entries to provide a site-wide and page specific history of changes. It uses a
-registry of 'actions' that provide additional context for the logged action.
-
-The audit log-driven Page history replaces the revisions list page, but provide a filter for revision-specific entries.
-
-.. note:: The audit log does not replace revisions.
-
-The ``wagtail.log_actions.log`` function can be used to add logging to your own code.
-
-.. function:: log(instance, action, user=None, uuid=None, title=None, data=None)
-
-   Adds an entry to the audit log.
-
-   :param instance: The model instance that the action is performed on
-   :param action: The code name for the action being performed. This can be one of the names listed below, or a custom action defined through the :ref:`register_log_actions` hook.
-   :param user: Optional - the user initiating the action. For actions logged within an admin view, this defaults to the logged-in user.
-   :param uuid: Optional - log entries given the same UUID indicates that they occurred as part of the same user action (e.g. a page being immediately published on creation).
-   :param title: The string representation of the instance being logged. By default, Wagtail will attempt to use the instance's ``str`` representation, or ``get_admin_display_title`` for page objects.
-   :param data: Optional - a dictionary of additional JSON-serialisable data to store against the log entry
-
-.. note:: When adding logging, you need to log the action or actions that happen to the object. For example, if the
-        user creates and publishes a page, there should be a "create" entry and a "publish" entry. Or, if the user copies
-        a published page and chooses to keep it published, there should be a "copy" and a "publish" entry for new page.
-
-.. code-block:: python
-
-    # mypackage/views.py
-    from wagtail.log_actions import log
-
-    def copy_for_translation(page):
-        # ...
-        page.copy(log_action='mypackage.copy_for_translation')
-
-    def my_method(request, page):
-        # ..
-        # Manually log an action
-        data = {
-            'make': {'it': 'so'}
-        }
-        log(
-            instance=page, action='mypackage.custom_action', data=data
-        )
-
-
-.. versionchanged:: 2.15
-
-  The ``log`` function was added. Previously, logging was only implemented for pages, and invoked through the ``PageLogEntry.objects.log_action`` method.
-
-
-Log actions provided by Wagtail
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-===================================  =====
-Action                               Notes
-===================================  =====
-``wagtail.create``                   The object was created
-``wagtail.edit``                     The object was edited (for pages, saved as draft)
-``wagtail.delete``                   The object was deleted. Will only surface in the Site History for administrators
-``wagtail.publish``                  The page was published
-``wagtail.publish.schedule``         Draft is scheduled for publishing
-``wagtail.publish.scheduled``        Draft published via ``publish_scheduled_pages`` management command
-``wagtail.schedule.cancel``          Draft scheduled for publishing cancelled via "Cancel scheduled publish"
-``wagtail.unpublish``                The page was unpublished
-``wagtail.unpublish.scheduled``      Page unpublished via ``publish_scheduled_pages`` management command
-``wagtail.lock``                     Page was locked
-``wagtail.unlock``                   Page was unlocked
-``wagtail.moderation.approve``       The revision was approved for moderation
-``wagtail.moderation.reject``        The revision was rejected
-``wagtail.rename``                   A page was renamed
-``wagtail.revert``                   The page was reverted to a previous draft
-``wagtail.copy``                     The page was copied to a new location
-``wagtail.copy_for_translation``     The page was copied into a new locale for translation
-``wagtail.move``                     The page was moved to a new location
-``wagtail.reorder``                  The order of the page under it's parent was changed
-``wagtail.view_restriction.create``  The page was restricted
-``wagtail.view_restriction.edit``    The page restrictions were updated
-``wagtail.view_restriction.delete``  The page restrictions were removed
-
-``wagtail.workflow.start``           The page was submitted for moderation in a Workflow
-``wagtail.workflow.approve``         The draft was approved at a Workflow Task
-``wagtail.workflow.reject``          The draft was rejected, and changes requested at a Workflow Task
-``wagtail.workflow.resume``          The draft was resubmitted to the workflow
-``wagtail.workflow.cancel``          The workflow was cancelled
-===================================  =====
-
-
-Log context
-~~~~~~~~~~~
-
-The ``wagtail.log_actions`` module provides a context manager to simplify code that logs a large number of actions,
-such as import scripts:
-
-.. code-block:: python
-
-    from wagtail.log_actions import LogContext
-
-    with LogContext(user=User.objects.get(username='admin')):
-        # ...
-        log(page, 'wagtail.edit')
-        # ...
-        log(page, 'wagtail.publish')
-
-
-All ``log`` calls within the block will then be attributed to the specified user, and assigned a common UUID. A log context
-is created automatically for views within the Wagtail admin.
-
-
-Log models
-~~~~~~~~~~
-
-Logs are stored in the database via the models ``wagtail.models.PageLogEntry`` (for actions on Page instances) and
-``wagtail.models.ModelLogEntry`` (for actions on all other models). Page logs are stored in their own model to
-ensure that reports can be filtered according to the current user's permissions, which could not be done efficiently
-with a generic foreign key.
-
-If your own models have complex reporting requirements that would make ``ModelLogEntry`` unsuitable, you can configure
-them to be logged to their own log model; this is done by subclassing the abstract ``wagtail.models.BaseLogEntry``
-model, and registering that model with the log registry's ``register_model`` method:
-
-.. code-block:: python
-
-    from myapp.models import Sprocket, SprocketLogEntry
-    # here SprocketLogEntry is a subclass of BaseLogEntry
-
-    @hooks.register('register_log_actions')
-    def sprocket_log_model(actions):
-        actions.register_model(Sprocket, SprocketLogEntry)

+ 145 - 0
docs/extending/custom_account_settings.md

@@ -0,0 +1,145 @@
+# Customising the user account settings form
+
+This document describes how to customise the user account settings form that can be found by clicking "Account settings"
+at the bottom of the main menu.
+
+## Adding new panels
+
+Each panel on this form is a separate model form which can operate on an instance of either the user model, or the {class}`wagtail.users.models.UserProfile`.
+
+### Basic example
+
+Here is an example of how to add a new form that operates on the user model:
+
+```python
+# forms.py
+
+from django import forms
+from django.contrib.auth import get_user_model
+
+class CustomSettingsForm(forms.ModelForm):
+
+    class Meta:
+        model = get_user_model()
+        fields = [...]
+```
+
+```python
+# hooks.py
+
+from wagtail.admin.views.account import BaseSettingsPanel
+from wagtail import hooks
+from .forms import CustomSettingsForm
+
+@hooks.register('register_account_settings_panel')
+class CustomSettingsPanel(BaseSettingsPanel):
+    name = 'custom'
+    title = "My custom settings"
+    order = 500
+    form_class = CustomSettingsForm
+    form_object = 'user'
+```
+
+The attributes are as follows:
+
+-   `name` - A unique name for the panel. All form fields are prefixed with this name, so it must be lowercase and cannot contain symbols -
+-   `title` - The heading that is displayed to the user
+-   `order` - Used to order panels on a tab. The builtin Wagtail panels start at `100` and increase by `100` for each panel.
+-   `form_class` - A `ModelForm` subclass that operates on a user or a profile
+-   `form_object` - Set to `user` to operate on the user, and `profile` to operate on the profile
+-   `tab` (optional) - Set which tab the panel appears on.
+-   `template_name` (optional) - Override the default template used for rendering the panel
+
+## Operating on the `UserProfile` model
+
+To add a panel that alters data on the user's {class}`wagtail.users.models.UserProfile` instance, set `form_object` to `'profile'`:
+
+```python
+# forms.py
+
+from django import forms
+from wagtail.users.models import UserProfile
+
+class CustomProfileSettingsForm(forms.ModelForm):
+
+    class Meta:
+        model = UserProfile
+        fields = [...]
+```
+
+```python
+# hooks.py
+
+from wagtail.admin.views.account import BaseSettingsPanel
+from wagtail import hooks
+from .forms import CustomProfileSettingsForm
+
+@hooks.register('register_account_settings_panel')
+class CustomSettingsPanel(BaseSettingsPanel):
+    name = 'custom'
+    title = "My custom settings"
+    order = 500
+    form_class = CustomProfileSettingsForm
+    form_object = 'profile'
+```
+
+## Creating new tabs
+
+You can define a new tab using the `SettingsTab` class:
+
+```python
+# hooks.py
+
+from wagtail.admin.views.account import BaseSettingsPanel, SettingsTab
+from wagtail import hooks
+from .forms import CustomSettingsForm
+
+custom_tab = SettingsTab('custom', "Custom settings", order=300)
+
+@hooks.register('register_account_settings_panel')
+class CustomSettingsPanel(BaseSettingsPanel):
+    name = 'custom'
+    title = "My custom settings"
+    tab = custom_tab
+    order = 100
+    form_class = CustomSettingsForm
+```
+
+`SettingsTab` takes three arguments:
+
+-   `name` - A slug to use for the tab (this is placed after the `#` when linking to a tab)
+-   `title` - The display name of the title
+-   `order` - The order of the tab. The builtin Wagtail tabs start at `100` and increase by `100` for each tab
+
+## Customising the template
+
+You can provide a custom template for the panel by specifying a template name:
+
+```python
+# hooks.py
+
+from wagtail.admin.views.account import BaseSettingsPanel
+from wagtail import hooks
+from .forms import CustomSettingsForm
+
+@hooks.register('register_account_settings_panel')
+class CustomSettingsPanel(BaseSettingsPanel):
+    name = 'custom'
+    title = "My custom settings"
+    order = 500
+    form_class = CustomSettingsForm
+    template_name = 'myapp/admin/custom_settings.html'
+```
+
+```html+django
+
+{# templates/myapp/admin/custom_settings.html #}
+
+{# This is the default template Wagtail uses, which just renders the form #}
+
+<ul class="fields">
+    {% for field in form %}
+        {% include "wagtailadmin/shared/field_as_li.html" with field=field %}
+    {% endfor %}
+</ul>
+```

+ 0 - 156
docs/extending/custom_account_settings.rst

@@ -1,156 +0,0 @@
-Customising the user account settings form
-==========================================
-
-This document describes how to customise the user account settings form that can be found by clicking "Account settings"
-at the bottom of the main menu.
-
-Adding new panels
------------------
-
-Each panel on this form is a separate model form which can operate on an instance of either the user model, or the
-:class:`~wagtail.users.models.UserProfile` model.
-
-Basic example
-~~~~~~~~~~~~~
-
-Here is an example of how to add a new form that operates on the user model:
-
-  .. code-block:: python
-
-    # forms.py
-
-    from django import forms
-    from django.contrib.auth import get_user_model
-
-    class CustomSettingsForm(forms.ModelForm):
-
-        class Meta:
-            model = get_user_model()
-            fields = [...]
-
-  .. code-block:: python
-
-    # hooks.py
-
-    from wagtail.admin.views.account import BaseSettingsPanel
-    from wagtail import hooks
-    from .forms import CustomSettingsForm
-
-    @hooks.register('register_account_settings_panel')
-    class CustomSettingsPanel(BaseSettingsPanel):
-        name = 'custom'
-        title = "My custom settings"
-        order = 500
-        form_class = CustomSettingsForm
-        form_object = 'user'
-
-
-The attributes are as follows:
-
-- ``name`` - A unique name for the panel. All form fields are prefixed with this name, so it must be lowercase and cannot contain symbols -
-- ``title`` - The heading that is displayed to the user
-- ``order`` - Used to order panels on a tab. The builtin Wagtail panels start at ``100`` and increase by ``100`` for each panel.
-- ``form_class`` - A ``ModelForm`` subclass that operates on a user or a profile
-- ``form_object`` - Set to ``user`` to operate on the user, and ``profile`` to operate on the profile
-- ``tab`` (optional) - Set which tab the panel appears on.
-- ``template_name`` (optional) - Override the default template used for rendering the panel
-
-Operating on the ``UserProfile`` model
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-To add a panel that alters data on the user's :class:`~wagtail.users.models.UserProfile` instance, set ``form_object`` to ``'profile'``:
-
-
-  .. code-block:: python
-
-    # forms.py
-
-    from django import forms
-    from wagtail.users.models import UserProfile
-
-    class CustomProfileSettingsForm(forms.ModelForm):
-
-        class Meta:
-            model = UserProfile
-            fields = [...]
-
-  .. code-block:: python
-
-    # hooks.py
-
-    from wagtail.admin.views.account import BaseSettingsPanel
-    from wagtail import hooks
-    from .forms import CustomProfileSettingsForm
-
-    @hooks.register('register_account_settings_panel')
-    class CustomSettingsPanel(BaseSettingsPanel):
-        name = 'custom'
-        title = "My custom settings"
-        order = 500
-        form_class = CustomProfileSettingsForm
-        form_object = 'profile'
-
-
-Creating new tabs
-~~~~~~~~~~~~~~~~~
-
-You can define a new tab using the ``SettingsTab`` class:
-
-.. code-block:: python
-
-    # hooks.py
-
-    from wagtail.admin.views.account import BaseSettingsPanel, SettingsTab
-    from wagtail import hooks
-    from .forms import CustomSettingsForm
-
-    custom_tab = SettingsTab('custom', "Custom settings", order=300)
-
-    @hooks.register('register_account_settings_panel')
-    class CustomSettingsPanel(BaseSettingsPanel):
-        name = 'custom'
-        title = "My custom settings"
-        tab = custom_tab
-        order = 100
-        form_class = CustomSettingsForm
-
-``SettingsTab`` takes three arguments:
-
- - ``name`` - A slug to use for the tab (this is placed after the ``#`` when linking to a tab)
- - ``title`` - The display name of the title
- - ``order`` - The order of the tab. The builtin Wagtail tabs start at ``100`` and increase by ``100`` for each tab
-
-
-Customising the template
-~~~~~~~~~~~~~~~~~~~~~~~~
-
-You can provide a custom template for the panel by specifying a template name:
-
-.. code-block:: python
-
-    # hooks.py
-
-    from wagtail.admin.views.account import BaseSettingsPanel
-    from wagtail import hooks
-    from .forms import CustomSettingsForm
-
-    @hooks.register('register_account_settings_panel')
-    class CustomSettingsPanel(BaseSettingsPanel):
-        name = 'custom'
-        title = "My custom settings"
-        order = 500
-        form_class = CustomSettingsForm
-        template_name = 'myapp/admin/custom_settings.html'
-
-
-.. code-block:: html+Django
-
-    {# templates/myapp/admin/custom_settings.html #}
-
-    {# This is the default template Wagtail uses, which just renders the form #}
-
-    <ul class="fields">
-        {% for field in form %}
-            {% include "wagtailadmin/shared/field_as_li.html" with field=field %}
-        {% endfor %}
-    </ul>

+ 208 - 0
docs/extending/custom_bulk_actions.md

@@ -0,0 +1,208 @@
+(custom_bulk_actions)=
+
+# Adding custom bulk actions
+
+This document describes how to add custom bulk actions to different listings.
+
+## Registering a custom bulk action
+
+```python
+from wagtail.admin.views.bulk_action import BulkAction
+from wagtail import hooks
+
+
+@hooks.register('register_bulk_action')
+class CustomDeleteBulkAction(BulkAction):
+    display_name = _("Delete")
+    aria_label = _("Delete selected objects")
+    action_type = "delete"
+    template_name = "/path/to/confirm_bulk_delete.html"
+    models = [...]
+
+    @classmethod
+    def execute_action(cls, objects, **kwargs):
+        for obj in objects:
+            do_something(obj)
+        return num_parent_objects, num_child_objects  # return the count of updated objects
+```
+
+The attributes are as follows:
+
+-   `display_name` - The label that will be displayed on the button in the user interface
+-   `aria_label` - The `aria-label` attribute that will be applied to the button in the user interface
+-   `action_type` - A unique identifier for the action. Will be required in the url for bulk actions
+-   `template_name` - The path to the confirmation template
+-   `models` - A list of models on which the bulk action can act
+-   `action_priority` (optional) - A number that is used to determine the placement of the button in the list of buttons
+-   `classes` (optional) - A set of CSS classnames that will be used on the button in the user interface
+
+An example for a confirmation template is as follows:
+
+```html+django
+<!-- /path/to/confirm_bulk_delete.html -->
+
+{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
+{% load i18n wagtailadmin_tags %}
+
+{% block titletag %}{% blocktrans trimmed count counter=items|length %}Delete 1 item{% plural %}Delete {{ counter }} items{% endblocktrans %}{% endblock %}
+
+{% block header %}
+    {% trans "Delete" as del_str %}
+    {% include "wagtailadmin/shared/header.html" with title=del_str icon="doc-empty-inverse" %}
+{% endblock header %}
+
+{% block items_with_access %}
+        {% if items %}
+        <p>{% trans "Are you sure you want to delete these items?" %}</p>
+        <ul>
+            {% for item in items %}
+            <li>
+                <a href="" target="_blank" rel="noreferrer">{{ item.item.title }}</a>
+            </li>
+            {% endfor %}
+        </ul>
+        {% endif %}
+{% endblock items_with_access %}
+
+{% block items_with_no_access %}
+
+{% blocktrans trimmed asvar no_access_msg count counter=items_with_no_access|length %}You don't have permission to delete this item{% plural %}You don't have permission to delete these items{% endblocktrans %}
+{% include './list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
+
+{% endblock items_with_no_access %}
+
+{% block form_section %}
+{% if items %}
+    {% trans 'Yes, delete' as action_button_text %}
+    {% trans "No, don't delete" as no_action_button_text %}
+    {% include 'wagtailadmin/bulk_actions/confirmation/form.html' with action_button_class="serious" %}
+{% else %}
+    {% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
+{% endif %}
+{% endblock form_section %}
+```
+
+```html+django
+<!-- ./list_items_with_no_access.html -->
+{% extends 'wagtailadmin/bulk_actions/confirmation/list_items_with_no_access.html' %}
+{% load i18n %}
+
+{% block per_item %}
+    {% if item.can_edit %}
+    <a href="{% url 'wagtailadmin_pages:edit' item.item.id %}" target="_blank" rel="noreferrer">{{ item.item.title }}</a>
+    {% else %}
+    {{ item.item.title }}
+    {% endif %}
+{% endblock per_item %}
+```
+
+The `execute_action` classmethod is the only method that must be overridden for the bulk action to work properly. It takes a list of objects as the only required argument, and a bunch of keyword arguments that can be supplied by overriding the `get_execution_context` method. For example.
+
+```python
+@classmethod
+def execute_action(cls, objects, **kwargs):
+    # the kwargs here is the output of the get_execution_context method
+    user = kwargs.get('user', None)
+    num_parent_objects, num_child_objects = 0, 0
+    # you could run the action per object or run them in bulk using django's bulk update and delete methods
+    for obj in objects:
+        num_child_objects += obj.get_children().count()
+        num_parent_objects += 1
+        obj.delete(user=user)
+        num_parent_objects += 1
+    return num_parent_objects, num_child_objects
+```
+
+The `get_execution_context` method can be overridden to provide context to the `execute_action`
+
+```python
+def get_execution_context(self):
+    return { 'user': self.request.user }
+```
+
+The `get_context_data` method can be overridden to pass additional context to the confirmation template.
+
+```python
+def get_context_data(self, **kwargs):
+    context = super().get_context_data(**kwargs)
+    context['new_key'] = some_value
+    return context
+```
+
+Thes `check_perm` method can be overridden to check if an object has some permission or not. objects for which the `check_perm` returns `False` will be available in the context under the key `'items_with_no_access'`.
+
+```python
+def check_perm(self, obj):
+    return obj.has_perm('some_perm')  # returns True or False
+```
+
+The success message shown on the admin can be customised by overriding the `get_success_message` method.
+
+```python
+def get_success_message(self, num_parent_objects, num_child_objects):
+    return _("{} objects, including {} child objects have been updated".format(num_parent_objects, num_child_objects))
+```
+
+## Adding bulk actions to the page explorer
+
+When creating a custom bulk action class for pages, subclass from `wagtail.admin.views.pages.bulk_actions.page_bulk_action.PageBulkAction` instead of `wagtail.admin.views.bulk_action.BulkAction`
+
+### Basic example
+
+```python
+from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
+from wagtail import hooks
+
+
+@hooks.register('register_bulk_action')
+class CustomPageBulkAction(PageBulkAction):
+    ...
+```
+
+## Adding bulk actions to the Images listing
+
+When creating a custom bulk action class for images, subclass from `wagtail.images.views.bulk_actions.image_bulk_action.ImageBulkAction` instead of `wagtail.admin.views.bulk_action.BulkAction`
+
+### Basic example
+
+```python
+from wagtail.images.views.bulk_actions.image_bulk_action import ImageBulkAction
+from wagtail import hooks
+
+
+@hooks.register('register_bulk_action')
+class CustomImageBulkAction(ImageBulkAction):
+    ...
+```
+
+## Adding bulk actions to the documents listing
+
+When creating a custom bulk action class for documents, subclass from `wagtail.documents.views.bulk_actions.document_bulk_action.DocumentBulkAction` instead of `wagtail.admin.views.bulk_action.BulkAction`
+
+### Basic example
+
+```python
+from wagtail.documents.views.bulk_actions.document_bulk_action import DocumentBulkAction
+from wagtail import hooks
+
+
+@hooks.register('register_bulk_action')
+class CustomDocumentBulkAction(DocumentBulkAction):
+    ...
+```
+
+## Adding bulk actions to the user listing
+
+When creating a custom bulk action class for users, subclass from `wagtail.users.views.bulk_actions.user_bulk_action.UserBulkAction` instead of `wagtail.admin.views.bulk_action.BulkAction`
+
+### Basic example
+
+```python
+from wagtail.users.views.bulk_actions.user_bulk_action import UserBulkAction
+from wagtail import hooks
+
+
+@hooks.register('register_bulk_action')
+class CustomUserBulkAction(UserBulkAction):
+    ...
+```

+ 0 - 243
docs/extending/custom_bulk_actions.rst

@@ -1,243 +0,0 @@
-.. _custom_bulk_actions:
-
-Adding custom bulk actions
-==========================================
-
-This document describes how to add custom bulk actions to different listings.
-
-
-Registering a custom bulk action
---------------------------------
-
-.. code-block:: python
-
-    from wagtail.admin.views.bulk_action import BulkAction
-    from wagtail import hooks
-
-
-    @hooks.register('register_bulk_action')
-    class CustomDeleteBulkAction(BulkAction):
-        display_name = _("Delete")
-        aria_label = _("Delete selected objects")
-        action_type = "delete"
-        template_name = "/path/to/confirm_bulk_delete.html"
-        models = [...]
-
-        @classmethod
-        def execute_action(cls, objects, **kwargs):
-            for obj in objects:
-                do_something(obj)
-            return num_parent_objects, num_child_objects  # return the count of updated objects
-
-The attributes are as follows:
-
-- ``display_name`` - The label that will be displayed on the button in the user interface
-- ``aria_label`` - The ``aria-label`` attribute that will be applied to the button in the user interface
-- ``action_type`` - A unique identifier for the action. Will be required in the url for bulk actions
-- ``template_name`` - The path to the confirmation template
-- ``models`` - A list of models on which the bulk action can act
-- ``action_priority`` (optional) - A number that is used to determine the placement of the button in the list of buttons
-- ``classes`` (optional) - A set of CSS classnames that will be used on the button in the user interface
-
-An example for a confirmation template is as follows:
-
-.. code-block:: django
-
-  <!-- /path/to/confirm_bulk_delete.html -->
-
-  {% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
-  {% load i18n wagtailadmin_tags %}
-
-  {% block titletag %}{% blocktrans trimmed count counter=items|length %}Delete 1 item{% plural %}Delete {{ counter }} items{% endblocktrans %}{% endblock %}
-
-  {% block header %}
-      {% trans "Delete" as del_str %}
-      {% include "wagtailadmin/shared/header.html" with title=del_str icon="doc-empty-inverse" %}
-  {% endblock header %}
-
-  {% block items_with_access %}
-          {% if items %}
-          <p>{% trans "Are you sure you want to delete these items?" %}</p>
-          <ul>
-              {% for item in items %}
-              <li>
-                  <a href="" target="_blank" rel="noreferrer">{{ item.item.title }}</a>
-              </li>
-              {% endfor %}
-          </ul>
-          {% endif %}
-  {% endblock items_with_access %}
-
-  {% block items_with_no_access %}
-
-  {% blocktrans trimmed asvar no_access_msg count counter=items_with_no_access|length %}You don't have permission to delete this item{% plural %}You don't have permission to delete these items{% endblocktrans %}
-  {% include './list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
-
-  {% endblock items_with_no_access %}
-
-  {% block form_section %}
-  {% if items %}
-      {% trans 'Yes, delete' as action_button_text %}
-      {% trans "No, don't delete" as no_action_button_text %}
-      {% include 'wagtailadmin/bulk_actions/confirmation/form.html' with action_button_class="serious" %}
-  {% else %}
-      {% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
-  {% endif %}
-  {% endblock form_section %}
-
-
-.. code-block:: django
-
-  <!-- ./list_items_with_no_access.html -->
-  {% extends 'wagtailadmin/bulk_actions/confirmation/list_items_with_no_access.html' %}
-  {% load i18n %}
-
-  {% block per_item %}
-      {% if item.can_edit %}
-      <a href="{% url 'wagtailadmin_pages:edit' item.item.id %}" target="_blank" rel="noreferrer">{{ item.item.title }}</a>
-      {% else %}
-      {{ item.item.title }}
-      {% endif %}
-  {% endblock per_item %}
-
-
-The ``execute_action`` classmethod is the only method that must be overridden for the bulk action to work properly. It
-takes a list of objects as the only required argument, and a bunch of keyword arguments that can be supplied by overriding
-the ``get_execution_context`` method. For example.
-
-.. code-block:: python
-
-    @classmethod
-    def execute_action(cls, objects, **kwargs):
-        # the kwargs here is the output of the get_execution_context method
-        user = kwargs.get('user', None)
-        num_parent_objects, num_child_objects = 0, 0
-        # you could run the action per object or run them in bulk using django's bulk update and delete methods
-        for obj in objects:
-            num_child_objects += obj.get_children().count()
-            num_parent_objects += 1
-            obj.delete(user=user)
-            num_parent_objects += 1
-        return num_parent_objects, num_child_objects
-
-
-The ``get_execution_context`` method can be overridden to provide context to the ``execute_action``
-
-.. code-block:: python
-
-    def get_execution_context(self):
-        return {
-            'user': self.request.user
-        }
-
-
-The ``get_context_data`` method can be overridden to pass additional context to the confirmation template.
-
-.. code-block:: python
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-        context['new_key'] = some_value
-        return context
-
-
-The ``check_perm`` method can be overridden to check if an object has some permission or not. objects for which the ``check_perm``
-returns ``False`` will be available in the context under the key ``'items_with_no_access'``.
-
-.. code-block:: python
-
-    def check_perm(self, obj):
-        return obj.has_perm('some_perm')  # returns True or False
-
-
-The success message shown on the admin can be customised by overriding the ``get_success_message`` method.
-
-.. code-block:: python
-
-    def get_success_message(self, num_parent_objects, num_child_objects):
-        return _("{} objects, including {} child objects have been updated".format(num_parent_objects, num_child_objects))
-
-
-
-Adding bulk actions to the page explorer
-----------------------------------------
-
-When creating a custom bulk action class for pages, subclass from ``wagtail.admin.views.pages.bulk_actions.page_bulk_action.PageBulkAction``
-instead of ``wagtail.admin.views.bulk_action.BulkAction``
-
-Basic example
-~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
-    from wagtail import hooks
-
-
-    @hooks.register('register_bulk_action')
-    class CustomPageBulkAction(PageBulkAction):
-        ...
-
-
-
-Adding bulk actions to the Images listing
------------------------------------------
-
-When creating a custom bulk action class for images, subclass from ``wagtail.images.views.bulk_actions.image_bulk_action.ImageBulkAction``
-instead of ``wagtail.admin.views.bulk_action.BulkAction``
-
-Basic example
-~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    from wagtail.images.views.bulk_actions.image_bulk_action import ImageBulkAction
-    from wagtail import hooks
-
-
-    @hooks.register('register_bulk_action')
-    class CustomImageBulkAction(ImageBulkAction):
-        ...
-
-
-
-Adding bulk actions to the documents listing
---------------------------------------------
-
-When creating a custom bulk action class for documents, subclass from ``wagtail.documents.views.bulk_actions.document_bulk_action.DocumentBulkAction``
-instead of ``wagtail.admin.views.bulk_action.BulkAction``
-
-Basic example
-~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    from wagtail.documents.views.bulk_actions.document_bulk_action import DocumentBulkAction
-    from wagtail import hooks
-
-
-    @hooks.register('register_bulk_action')
-    class CustomDocumentBulkAction(DocumentBulkAction):
-        ...
-
-
-
-Adding bulk actions to the user listing
----------------------------------------
-
-When creating a custom bulk action class for users, subclass from ``wagtail.users.views.bulk_actions.user_bulk_action.UserBulkAction``
-instead of ``wagtail.admin.views.bulk_action.BulkAction``
-
-Basic example
-~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    from wagtail.users.views.bulk_actions.user_bulk_action import UserBulkAction
-    from wagtail import hooks
-
-
-    @hooks.register('register_bulk_action')
-    class CustomUserBulkAction(UserBulkAction):
-        ...
-

+ 303 - 0
docs/extending/custom_tasks.md

@@ -0,0 +1,303 @@
+# Adding new Task types
+
+The Workflow system allows users to create tasks, which represent stages of moderation.
+
+Wagtail provides one built in task type: `GroupApprovalTask`, which allows any user in specific groups to approve or reject moderation.
+
+However, it is possible to add your own task types in code. Instances of your custom task can then be created in the `Tasks` section of the Wagtail Admin.
+
+## Task models
+
+All custom tasks must be models inheriting from `wagtailcore.Task`. In this set of examples, we'll set up a task which can be approved by only one specific user.
+
+```python
+# <project>/models.py
+
+from wagtail.models import Task
+
+
+class UserApprovalTask(Task):
+    pass
+```
+
+Subclassed Tasks follow the same approach as Pages: they are concrete models, with the specific subclass instance accessible by calling `Task.specific()`.
+
+You can now add any custom fields. To make these editable in the admin, add the names of the fields into the `admin_form_fields` attribute:
+
+For example:
+
+```python
+# <project>/models.py
+
+from django.conf import settings
+from django.db import models
+from wagtail.models import Task
+
+
+class UserApprovalTask(Task):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
+
+    admin_form_fields = Task.admin_form_fields + ['user']
+```
+
+Any fields that shouldn't be edited after task creation - for example, anything that would fundamentally change the meaning of the task in any history logs - can be added to `admin_form_readonly_on_edit_fields`. For example:
+
+```python
+# <project>/models.py
+
+from django.conf import settings
+from django.db import models
+from wagtail.models import Task
+
+
+class UserApprovalTask(Task):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
+
+    admin_form_fields = Task.admin_form_fields + ['user']
+
+    # prevent editing of `user` after the task is created
+    # by default, this attribute contains the 'name' field to prevent tasks from being renamed
+    admin_form_readonly_on_edit_fields = Task.admin_form_readonly_on_edit_fields + ['user']
+```
+
+Wagtail will choose a default form widget to use based on the field type. But you can override the form widget using the `admin_form_widgets` attribute:
+
+```python
+# <project>/models.py
+
+from django.conf import settings
+from django.db import models
+from wagtail.models import Task
+
+from .widgets import CustomUserChooserWidget
+
+
+class UserApprovalTask(Task):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
+
+    admin_form_fields = Task.admin_form_fields + ['user']
+
+    admin_form_widgets = {
+        'user': CustomUserChooserWidget,
+    }
+```
+
+## Custom TaskState models
+
+You might also need to store custom state information for the task: for example, a rating left by an approving user.
+Normally, this is done on an instance of `TaskState`, which is created when a page starts the task. However, this can
+also be subclassed equivalently to `Task`:
+
+```python
+# <project>/models.py
+
+from wagtail.models import TaskState
+
+
+class UserApprovalTaskState(TaskState):
+    pass
+```
+
+Your custom task must then be instructed to generate an instance of your custom task state on start instead of a plain `TaskState` instance:
+
+```python
+# <project>/models.py
+
+from django.conf import settings
+from django.db import models
+from wagtail.models import Task, TaskState
+
+
+class UserApprovalTaskState(TaskState):
+    pass
+
+
+class UserApprovalTask(Task):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
+
+    admin_form_fields = Task.admin_form_fields + ['user']
+
+    task_state_class = UserApprovalTaskState
+```
+
+## Customising behaviour
+
+Both `Task` and `TaskState` have a number of methods which can be overridden to implement custom behaviour. Here are some of the most useful:
+
+`Task.user_can_access_editor(page, user)`, `Task.user_can_lock(page, user)`, `Task.user_can_unlock(page, user)`:
+
+These methods determine if users usually without permissions can access the editor, lock, or unlock the page, by returning True or False.
+Note that returning `False` will not prevent users who would normally be able to perform those actions. For example, for our `UserApprovalTask`:
+
+```python
+def user_can_access_editor(self, page, user):
+    return user == self.user
+```
+
+`Task.page_locked_for_user(page, user)`:
+
+This returns `True` if the page should be locked and uneditable by the user. It is used by `GroupApprovalTask` to lock the page to any users not in the approval group.
+
+```python
+def page_locked_for_user(self, page, user):
+    return user != self.user
+```
+
+`Task.get_actions(page, user)`:
+
+This returns a list of `(action_name, action_verbose_name, action_requires_additional_data_from_modal)` tuples, corresponding to the actions available for the task in the edit view menu.
+`action_requires_additional_data_from_modal` should be a boolean, returning `True` if choosing the action should open a modal for additional data input - for example, entering a comment.
+
+For example:
+
+```python
+def get_actions(self, page, user):
+    if user == self.user:
+        return [
+            ('approve', "Approve", False),
+            ('reject', "Reject", False),
+            ('cancel', "Cancel", False),
+        ]
+    else:
+        return []
+```
+
+`Task.get_form_for_action(action)`:
+
+Returns a form to be used for additional data input for the given action modal. By default, returns `TaskStateCommentForm`, with a single comment field. The form data returned in `form.cleaned_data` must be fully serializable as JSON.
+
+`Task.get_template_for_action(action)`:
+
+Returns the name of a custom template to be used in rendering the data entry modal for that action.
+
+`Task.on_action(task_state, user, action_name, **kwargs)`:
+
+This performs the actions specified in `Task.get_actions(page, user)`: it is passed an action name, eg `approve`, and the relevant task state. By default, it calls `approve` and `reject` methods on the task state when the corresponding action names are passed through. Any additional data entered in a modal (see `get_form_for_action` and `get_actions`) is supplied as kwargs.
+
+For example, let's say we wanted to add an additional option: cancelling the entire workflow:
+
+```python
+def on_action(self, task_state, user, action_name):
+    if action_name == 'cancel':
+        return task_state.workflow_state.cancel(user=user)
+    else:
+        return super().on_action(task_state, user, workflow_state)
+```
+
+`Task.get_task_states_user_can_moderate(user, **kwargs)`:
+
+This returns a QuerySet of `TaskStates` (or subclasses) the given user can moderate - this is currently used to select pages to display on the user's dashboard.
+
+For example:
+
+```python
+def get_task_states_user_can_moderate(self, user, **kwargs):
+    if user == self.user:
+        # get all task states linked to the (base class of) current task
+        return TaskState.objects.filter(status=TaskState.STATUS_IN_PROGRESS, task=self.task_ptr)
+    else:
+        return TaskState.objects.none()
+```
+
+`Task.get_description()`
+
+A class method that returns the human-readable description for the task.
+
+For example:
+
+```python
+@classmethod
+def get_description(cls):
+    return _("Members of the chosen Wagtail Groups can approve this task")
+```
+
+## Adding notifications
+
+Wagtail's notifications are sent by `wagtail.admin.mail.Notifier` subclasses: callables intended to be connected to a signal.
+
+By default, email notifications are sent upon workflow submission, approval and rejection, and upon submission to a group approval task.
+
+As an example, we'll add email notifications for when our new task is started.
+
+```python
+# <project>/mail.py
+
+from wagtail.admin.mail import EmailNotificationMixin, Notifier
+from wagtail.models import TaskState
+
+from .models import UserApprovalTaskState
+
+
+class BaseUserApprovalTaskStateEmailNotifier(EmailNotificationMixin, Notifier):
+    """A base notifier to send updates for UserApprovalTask events"""
+
+    def __init__(self):
+        # Allow UserApprovalTaskState and TaskState to send notifications
+        super().__init__((UserApprovalTaskState, TaskState))
+
+    def can_handle(self, instance, **kwargs):
+        if super().can_handle(instance, **kwargs) and isinstance(instance.task.specific, UserApprovalTask):
+            # Don't send notifications if a Task has been cancelled and then resumed - ie page was updated to a new revision
+            return not TaskState.objects.filter(workflow_state=instance.workflow_state, task=instance.task, status=TaskState.STATUS_CANCELLED).exists()
+        return False
+
+    def get_context(self, task_state, **kwargs):
+        context = super().get_context(task_state, **kwargs)
+        context['page'] = task_state.workflow_state.page
+        context['task'] = task_state.task.specific
+        return context
+
+    def get_recipient_users(self, task_state, **kwargs):
+
+        # Send emails to the user assigned to the task
+        approving_user = task_state.task.specific.user
+
+        recipients = {approving_user}
+
+        return recipients
+
+
+class UserApprovalTaskStateSubmissionEmailNotifier(BaseUserApprovalTaskStateEmailNotifier):
+    """A notifier to send updates for UserApprovalTask submission events"""
+
+    notification = 'submitted'
+```
+
+Similarly, you could define notifier subclasses for approval and rejection notifications.
+
+Next, you need to instantiate the notifier, and connect it to the `task_submitted` signal.
+
+```python
+# <project>/signal_handlers.py
+
+from wagtail.signals import task_submitted
+from .mail import UserApprovalTaskStateSubmissionEmailNotifier
+
+
+task_submission_email_notifier = UserApprovalTaskStateSubmissionEmailNotifier()
+
+def register_signal_handlers():
+    task_submitted.connect(user_approval_task_submission_email_notifier, dispatch_uid='user_approval_task_submitted_email_notification')
+```
+
+`register_signal_handlers()` should then be run on loading the app: for example, by adding it to the `ready()` method in your `AppConfig`.
+
+```python
+# <project>/apps.py
+from django.apps import AppConfig
+
+
+class MyAppConfig(AppConfig):
+    name = 'myappname'
+    label = 'myapplabel'
+    verbose_name = 'My verbose app name'
+
+    def ready(self):
+        from .signal_handlers import register_signal_handlers
+        register_signal_handlers()
+```
+
+```{note}
+In Django versions before 3.2 your `AppConfig` subclass needs to be set as `default_app_config` in `<project>/__init__.py`.
+See the [relevant section in the Django docs](https://docs.djangoproject.com/en/3.1/ref/applications/#for-application-authors) for the version you are using.
+```

+ 0 - 325
docs/extending/custom_tasks.rst

@@ -1,325 +0,0 @@
-=====================
-Adding new Task types
-=====================
-
-The Workflow system allows users to create tasks, which represent stages of moderation.
-
-Wagtail provides one built in task type: ``GroupApprovalTask``, which allows any user in specific groups to approve or reject moderation.
-
-However, it is possible to add your own task types in code. Instances of your custom task can then be created in the ``Tasks`` section of the Wagtail Admin.
-
-Task models
-~~~~~~~~~~~
-
-All custom tasks must be models inheriting from ``wagtailcore.Task``. In this set of examples, we'll set up a task which can be approved by only one specific user.
-
-.. code-block:: python
-
-    # <project>/models.py
-
-    from wagtail.models import Task
-
-
-    class UserApprovalTask(Task):
-        pass
-
-
-Subclassed Tasks follow the same approach as Pages: they are concrete models, with the specific subclass instance accessible by calling ``Task.specific()``.
-
-You can now add any custom fields. To make these editable in the admin, add the names of the fields into the ``admin_form_fields`` attribute:
-
-For example:
-
-.. code-block:: python
-
-    # <project>/models.py
-
-    from django.conf import settings
-    from django.db import models
-    from wagtail.models import Task
-
-
-    class UserApprovalTask(Task):
-        user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
-
-        admin_form_fields = Task.admin_form_fields + ['user']
-
-
-Any fields that shouldn't be edited after task creation - for example, anything that would fundamentally change the meaning of the task in any history logs -
-can be added to ``admin_form_readonly_on_edit_fields``. For example:
-
-.. code-block:: python
-
-    # <project>/models.py
-
-    from django.conf import settings
-    from django.db import models
-    from wagtail.models import Task
-
-
-    class UserApprovalTask(Task):
-        user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
-
-        admin_form_fields = Task.admin_form_fields + ['user']
-
-        # prevent editing of ``user`` after the task is created
-        # by default, this attribute contains the 'name' field to prevent tasks from being renamed
-        admin_form_readonly_on_edit_fields = Task.admin_form_readonly_on_edit_fields + ['user']
-
-
-Wagtail will choose a default form widget to use based on the field type. But you can override the form widget using the ``admin_form_widgets`` attribute:
-
-
-.. code-block:: python
-
-    # <project>/models.py
-
-    from django.conf import settings
-    from django.db import models
-    from wagtail.models import Task
-
-    from .widgets import CustomUserChooserWidget
-
-
-    class UserApprovalTask(Task):
-        user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
-
-        admin_form_fields = Task.admin_form_fields + ['user']
-
-        admin_form_widgets = {
-            'user': CustomUserChooserWidget,
-        }
-
-
-Custom TaskState models
-~~~~~~~~~~~~~~~~~~~~~~~
-
-You might also need to store custom state information for the task: for example, a rating left by an approving user.
-Normally, this is done on an instance of ``TaskState``, which is created when a page starts the task. However, this can
-also be subclassed equivalently to ``Task``:
-
-.. code-block:: python
-
-    # <project>/models.py
-
-    from wagtail.models import TaskState
-
-
-    class UserApprovalTaskState(TaskState):
-        pass
-
-Your custom task must then be instructed to generate an instance of your custom task state on start instead of a plain ``TaskState`` instance:
-
-.. code-block:: python
-
-    # <project>/models.py
-
-    from django.conf import settings
-    from django.db import models
-    from wagtail.models import Task, TaskState
-
-
-    class UserApprovalTaskState(TaskState):
-        pass
-
-
-    class UserApprovalTask(Task):
-        user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
-
-        admin_form_fields = Task.admin_form_fields + ['user']
-
-        task_state_class = UserApprovalTaskState
-
-
-Customising behaviour
-~~~~~~~~~~~~~~~~~~~~~
-
-Both ``Task`` and ``TaskState`` have a number of methods which can be overridden to implement custom behaviour. Here are some of the most useful:
-
-``Task.user_can_access_editor(page, user)``, ``Task.user_can_lock(page, user)``, ``Task.user_can_unlock(page, user)``:
-
-These methods determine if users usually without permissions can access the editor, lock, or unlock the page, by returning True or False.
-Note that returning ``False`` will not prevent users who would normally be able to perform those actions. For example, for our ``UserApprovalTask``:
-
-.. code-block:: python
-
-    def user_can_access_editor(self, page, user):
-        return user == self.user
-
-``Task.page_locked_for_user(page, user)``:
-
-This returns ``True`` if the page should be locked and uneditable by the user. It is
-used by `GroupApprovalTask` to lock the page to any users not in the approval group.
-
-.. code-block:: python
-
-    def page_locked_for_user(self, page, user):
-        return user != self.user
-
-``Task.get_actions(page, user)``:
-
-This returns a list of ``(action_name, action_verbose_name, action_requires_additional_data_from_modal)`` tuples, corresponding to the actions available for the task in the edit view menu.
-``action_requires_additional_data_from_modal`` should be a boolean, returning ``True`` if choosing the action should open a modal for
-additional data input - for example, entering a comment.
-
-For example:
-
-.. code-block:: python
-
-    def get_actions(self, page, user):
-        if user == self.user:
-            return [
-                ('approve', "Approve", False),
-                ('reject', "Reject", False),
-                ('cancel', "Cancel", False),
-            ]
-        else:
-            return []
-
-``Task.get_form_for_action(action)``:
-
-Returns a form to be used for additional data input for the given action modal. By default,
-returns ``TaskStateCommentForm``, with a single comment field. The form data returned in
-``form.cleaned_data`` must be fully serializable as JSON.
-
-``Task.get_template_for_action(action)``:
-
-Returns the name of a custom template to be used in rendering the data entry modal for that action.
-
-``Task.on_action(task_state, user, action_name, **kwargs)``:
-
-This performs the actions specified in ``Task.get_actions(page, user)``: it is passed an action name, eg ``approve``, and the relevant task state. By default,
-it calls ``approve`` and ``reject`` methods on the task state when the corresponding action names are passed through. Any additional data entered in a modal
-(see ``get_form_for_action`` and ``get_actions``) is supplied as kwargs.
-
-For example,  let's say we wanted to add an additional option: cancelling the entire workflow:
-
-.. code-block:: python
-
-    def on_action(self, task_state, user, action_name):
-        if action_name == 'cancel':
-            return task_state.workflow_state.cancel(user=user)
-        else:
-            return super().on_action(task_state, user, workflow_state)
-
-``Task.get_task_states_user_can_moderate(user, **kwargs)``:
-
-This returns a QuerySet of ``TaskStates`` (or subclasses) the given user can moderate - this is currently used to select pages to display on the user's dashboard.
-
-For example:
-
-.. code-block:: python
-
-    def get_task_states_user_can_moderate(self, user, **kwargs):
-        if user == self.user:
-            # get all task states linked to the (base class of) current task
-            return TaskState.objects.filter(status=TaskState.STATUS_IN_PROGRESS, task=self.task_ptr)
-        else:
-            return TaskState.objects.none()
-
-
-``Task.get_description()``
-
-A class method that returns the human-readable description for the task.
-
-For example:
-
-.. code-block:: python
-
-    @classmethod
-    def get_description(cls):
-        return _("Members of the chosen Wagtail Groups can approve this task")
-
-
-Adding notifications
-~~~~~~~~~~~~~~~~~~~~
-
-Wagtail's notifications are sent by ``wagtail.admin.mail.Notifier`` subclasses: callables intended to be connected to a signal.
-
-By default, email notifications are sent upon workflow submission, approval and rejection, and upon submission to a group approval task.
-
-As an example, we'll add email notifications for when our new task is started.
-
-.. code-block:: python
-
-    # <project>/mail.py
-
-    from wagtail.admin.mail import EmailNotificationMixin, Notifier
-    from wagtail.models import TaskState
-
-    from .models import UserApprovalTaskState
-
-
-    class BaseUserApprovalTaskStateEmailNotifier(EmailNotificationMixin, Notifier):
-        """A base notifier to send updates for UserApprovalTask events"""
-
-        def __init__(self):
-            # Allow UserApprovalTaskState and TaskState to send notifications
-            super().__init__((UserApprovalTaskState, TaskState))
-
-        def can_handle(self, instance, **kwargs):
-            if super().can_handle(instance, **kwargs) and isinstance(instance.task.specific, UserApprovalTask):
-                # Don't send notifications if a Task has been cancelled and then resumed - ie page was updated to a new revision
-                return not TaskState.objects.filter(workflow_state=instance.workflow_state, task=instance.task, status=TaskState.STATUS_CANCELLED).exists()
-            return False
-
-        def get_context(self, task_state, **kwargs):
-            context = super().get_context(task_state, **kwargs)
-            context['page'] = task_state.workflow_state.page
-            context['task'] = task_state.task.specific
-            return context
-
-        def get_recipient_users(self, task_state, **kwargs):
-
-            # Send emails to the user assigned to the task
-            approving_user = task_state.task.specific.user
-
-            recipients = {approving_user}
-
-            return recipients
-
-
-    class UserApprovalTaskStateSubmissionEmailNotifier(BaseUserApprovalTaskStateEmailNotifier):
-        """A notifier to send updates for UserApprovalTask submission events"""
-
-        notification = 'submitted'
-
-
-Similarly, you could define notifier subclasses for approval and rejection notifications.
-
-Next, you need to instantiate the notifier, and connect it to the ``task_submitted`` signal.
-
-.. code-block:: python
-
-    # <project>/signal_handlers.py
-
-    from wagtail.signals import task_submitted
-    from .mail import UserApprovalTaskStateSubmissionEmailNotifier
-
-
-    task_submission_email_notifier = UserApprovalTaskStateSubmissionEmailNotifier()
-
-    def register_signal_handlers():
-        task_submitted.connect(user_approval_task_submission_email_notifier, dispatch_uid='user_approval_task_submitted_email_notification')
-
-``register_signal_handlers()`` should then be run on loading the app: for example, by adding it to the ``ready()`` method in your ``AppConfig``.
-
-.. code-block:: python
-
-    # <project>/apps.py
-    from django.apps import AppConfig
-
-
-    class MyAppConfig(AppConfig):
-        name = 'myappname'
-        label = 'myapplabel'
-        verbose_name = 'My verbose app name'
-
-        def ready(self):
-            from .signal_handlers import register_signal_handlers
-            register_signal_handlers()
-
-.. note::
-   In Django versions before 3.2 your ``AppConfig`` subclass needs to be set as ``default_app_config`` in ``<project>/__init__.py``.
-   See the `relevant section in the Django docs <https://docs.djangoproject.com/en/3.1/ref/applications/#for-application-authors>`_ for the version you are using.
-

+ 108 - 0
docs/extending/customising_group_views.md

@@ -0,0 +1,108 @@
+(customising_group_views)=
+
+# Customising group edit/create views
+
+The views for managing groups within the app are collected into a 'viewset' class, which acts as a single point of reference for all shared components of those views, such as forms. By subclassing the viewset, it is possible to override those components and customise the behaviour of the group management interface.
+
+## Custom edit/create forms
+
+This example shows how to customise forms on the 'edit group' and 'create group' views in the Wagtail admin.
+
+Let's say you need to connect Active Directory groups with Django groups.
+We create a model for Active Directory groups as follows:
+
+```python
+from django.contrib.auth.models import Group
+from django.db import models
+
+
+class ADGroup(models.Model):
+    guid = models.CharField(verbose_name="GUID", max_length=64, db_index=True, unique=True)
+    name = models.CharField(verbose_name="Group", max_length=255)
+    domain = models.CharField(verbose_name="Domain", max_length=255, db_index=True)
+    description = models.TextField(verbose_name="Description", blank=True, null=True)
+    roles = models.ManyToManyField(Group, verbose_name="Role", related_name="adgroups", blank=True)
+
+class Meta:
+    verbose_name = "AD group"
+    verbose_name_plural = "AD groups"
+```
+
+However, there is no role field on the Wagtail group 'edit' or 'create' view.
+To add it, inherit from `wagtail.users.forms.GroupForm` and add a new field:
+
+```python
+from django import forms
+
+from wagtail.users.forms import GroupForm as WagtailGroupForm
+
+from .models import ADGroup
+
+
+class GroupForm(WagtailGroupForm):
+    adgroups = forms.ModelMultipleChoiceField(
+        label="AD groups",
+        required=False,
+        queryset=ADGroup.objects.order_by("name"),
+    )
+
+    class Meta(WagtailGroupForm.Meta):
+        fields = WagtailGroupForm.Meta.fields + ("adgroups",)
+
+    def __init__(self, initial=None, instance=None, **kwargs):
+        if instance is not None:
+            if initial is None:
+                initial = {}
+            initial["adgroups"] = instance.adgroups.all()
+        super().__init__(initial=initial, instance=instance, **kwargs)
+
+    def save(self, commit=True):
+        instance = super().save()
+        instance.adgroups.set(self.cleaned_data["adgroups"])
+        return instance
+```
+
+Now add your custom form into the group viewset by inheriting the default Wagtail `GroupViewSet` class and overriding the `get_form_class` method.
+
+```python
+from wagtail.users.views.groups import GroupViewSet as WagtailGroupViewSet
+
+from .forms import GroupForm
+
+
+class GroupViewSet(WagtailGroupViewSet):
+    def get_form_class(self, for_update=False):
+        return GroupForm
+```
+
+Add the field to the group 'edit'/'create' templates:
+
+```html+django
+{% extends "wagtailusers/groups/edit.html" %}
+{% load wagtailusers_tags wagtailadmin_tags i18n %}
+
+{% block extra_fields %}
+    {% include "wagtailadmin/shared/field_as_li.html" with field=form.adgroups %}
+{% endblock extra_fields %}
+```
+
+Finally we configure the `wagtail.users` application to use the custom viewset, by setting up a custom `AppConfig` class. Within your project folder (i.e. the package containing the top-level settings and urls modules), create `apps.py` (if it does not exist already) and add:
+
+```python
+from wagtail.users.apps import WagtailUsersAppConfig
+
+
+class CustomUsersAppConfig(WagtailUsersAppConfig):
+    group_viewset = "myapplication.someapp.viewsets.GroupViewSet"
+```
+
+Replace `wagtail.users` in `settings.INSTALLED_APPS` with the path to `CustomUsersAppConfig`.
+
+```python
+INSTALLED_APPS = [
+    ...,
+    "myapplication.apps.CustomUsersAppConfig",
+    # "wagtail.users",
+    ...,
+]
+```

+ 0 - 116
docs/extending/customising_group_views.rst

@@ -1,116 +0,0 @@
-.. _customising_group_views:
-
-Customising group edit/create views
-===================================
-
-The views for managing groups within the app are collected into a 'viewset' class, which acts as a single point of reference for all shared components of those views, such as forms. By subclassing the viewset, it is possible to override those components and customise the behaviour of the group management interface.
-
-Custom edit/create forms
-^^^^^^^^^^^^^^^^^^^^^^^^
-
-This example shows how to customise forms on the 'edit group' and 'create group' views in the Wagtail
-admin.
-
-Let's say you need to connect Active Directory groups with Django groups.
-We create a model for Active Directory groups as follows:
-
-.. code-block:: python
-
-  from django.contrib.auth.models import Group
-  from django.db import models
-
-
-  class ADGroup(models.Model):
-      guid = models.CharField(verbose_name="GUID", max_length=64, db_index=True, unique=True)
-      name = models.CharField(verbose_name="Group", max_length=255)
-      domain = models.CharField(verbose_name="Domain", max_length=255, db_index=True)
-      description = models.TextField(verbose_name="Description", blank=True, null=True)
-      roles = models.ManyToManyField(Group, verbose_name="Role", related_name="adgroups", blank=True)
-
-    class Meta:
-        verbose_name = "AD group"
-        verbose_name_plural = "AD groups"
-
-However, there is no role field on the Wagtail group 'edit' or 'create' view.
-To add it, inherit from ``wagtail.users.forms.GroupForm`` and add a new field:
-
-.. code-block:: python
-
-  from django import forms
-
-  from wagtail.users.forms import GroupForm as WagtailGroupForm
-
-  from .models import ADGroup
-
-
-  class GroupForm(WagtailGroupForm):
-      adgroups = forms.ModelMultipleChoiceField(
-          label="AD groups",
-          required=False,
-          queryset=ADGroup.objects.order_by("name"),
-      )
-
-      class Meta(WagtailGroupForm.Meta):
-          fields = WagtailGroupForm.Meta.fields + ("adgroups",)
-
-      def __init__(self, initial=None, instance=None, **kwargs):
-          if instance is not None:
-              if initial is None:
-                  initial = {}
-              initial["adgroups"] = instance.adgroups.all()
-          super().__init__(initial=initial, instance=instance, **kwargs)
-
-      def save(self, commit=True):
-          instance = super().save()
-          instance.adgroups.set(self.cleaned_data["adgroups"])
-          return instance
-
-Now add your custom form into the group viewset by inheriting the default Wagtail
-``GroupViewSet`` class and overriding the ``get_form_class`` method.
-
-.. code-block:: python
-
-  from wagtail.users.views.groups import GroupViewSet as WagtailGroupViewSet
-
-  from .forms import GroupForm
-
-
-  class GroupViewSet(WagtailGroupViewSet):
-      def get_form_class(self, for_update=False):
-          return GroupForm
-
-Add the field to the group 'edit'/'create' templates:
-
-.. code-block:: html+Django
-
-  {% extends "wagtailusers/groups/edit.html" %}
-  {% load wagtailusers_tags wagtailadmin_tags i18n %}
-
-  {% block extra_fields %}
-      {% include "wagtailadmin/shared/field_as_li.html" with field=form.adgroups %}
-  {% endblock extra_fields %}
-
-Finally we configure the ``wagtail.users`` application to use the custom viewset,
-by setting up a custom ``AppConfig`` class. Within your project folder (i.e. the
-package containing the top-level settings and urls modules), create ``apps.py``
-(if it does not exist already) and add:
-
-.. code-block:: python
-
-  from wagtail.users.apps import WagtailUsersAppConfig
-
-
-  class CustomUsersAppConfig(WagtailUsersAppConfig):
-      group_viewset = "myapplication.someapp.viewsets.GroupViewSet"
-
-Replace ``wagtail.users`` in ``settings.INSTALLED_APPS`` with the path to
-``CustomUsersAppConfig``.
-
-.. code-block:: python
-
-  INSTALLED_APPS = [
-      ...,
-      "myapplication.apps.CustomUsersAppConfig",
-      # "wagtail.users",
-      ...,
-  ]

+ 346 - 0
docs/extending/extending_draftail.md

@@ -0,0 +1,346 @@
+# Extending the Draftail Editor
+
+Wagtail’s rich text editor is built with [Draftail](https://www.draftail.org/), and its functionality can be extended through plugins.
+
+Plugins come in three types:
+
+-   Inline styles – To format a portion of a line, eg. `bold`, `italic`, `monospace`.
+-   Blocks – To indicate the structure of the content, eg. `blockquote`, `ol`.
+-   Entities – To enter additional data/metadata, eg. `link` (with a URL), `image` (with a file).
+
+All of these plugins are created with a similar baseline, which we can demonstrate with one of the simplest examples – a custom feature for an inline style of `mark`. Place the following in a `wagtail_hooks.py` file in any installed app:
+
+```python
+import wagtail.admin.rich_text.editors.draftail.features as draftail_features
+from wagtail.admin.rich_text.converters.html_to_contentstate import InlineStyleElementHandler
+from wagtail import hooks
+
+# 1. Use the register_rich_text_features hook.
+@hooks.register('register_rich_text_features')
+def register_mark_feature(features):
+    """
+    Registering the `mark` feature, which uses the `MARK` Draft.js inline style type,
+    and is stored as HTML with a `<mark>` tag.
+    """
+    feature_name = 'mark'
+    type_ = 'MARK'
+    tag = 'mark'
+
+    # 2. Configure how Draftail handles the feature in its toolbar.
+    control = {
+        'type': type_,
+        'label': '☆',
+        'description': 'Mark',
+        # This isn’t even required – Draftail has predefined styles for MARK.
+        # 'style': {'textDecoration': 'line-through'},
+    }
+
+    # 3. Call register_editor_plugin to register the configuration for Draftail.
+    features.register_editor_plugin(
+        'draftail', feature_name, draftail_features.InlineStyleFeature(control)
+    )
+
+    # 4.configure the content transform from the DB to the editor and back.
+    db_conversion = {
+        'from_database_format': {tag: InlineStyleElementHandler(type_)},
+        'to_database_format': {'style_map': {type_: tag}},
+    }
+
+    # 5. Call register_converter_rule to register the content transformation conversion.
+    features.register_converter_rule('contentstate', feature_name, db_conversion)
+
+    # 6. (optional) Add the feature to the default features list to make it available
+    # on rich text fields that do not specify an explicit 'features' list
+    features.default_features.append('mark')
+```
+
+These steps will always be the same for all Draftail plugins. The important parts are to:
+
+-   Consistently use the feature’s Draft.js type or Wagtail feature names where appropriate.
+-   Give enough information to Draftail so it knows how to make a button for the feature, and how to render it (more on this later).
+-   Configure the conversion to use the right HTML element (as they are stored in the DB).
+
+For detailed configuration options, head over to the [Draftail documentation](https://www.draftail.org/docs/formatting-options) to see all of the details. Here are some parts worth highlighting about controls:
+
+-   The `type` is the only mandatory piece of information.
+-   To display the control in the toolbar, combine `icon`, `label` and `description`.
+-   The controls’ `icon` can be a string to use an icon font with CSS classes, say `'icon': 'fas fa-user',`. It can also be an array of strings, to use SVG paths, or SVG symbol references eg. `'icon': ['M100 100 H 900 V 900 H 100 Z'],`. The paths need to be set for a 1024x1024 viewbox.
+
+## Creating new inline styles
+
+In addition to the initial example, inline styles take a `style` property to define what CSS rules will be applied to text in the editor. Be sure to read the [Draftail documentation](https://www.draftail.org/docs/formatting-options) on inline styles.
+
+Finally, the DB to/from conversion uses an `InlineStyleElementHandler` to map from a given tag (`<mark>` in the example above) to a Draftail type, and the inverse mapping is done with [Draft.js exporter configuration](https://github.com/springload/draftjs_exporter) of the `style_map`.
+
+## Creating new blocks
+
+Blocks are nearly as simple as inline styles:
+
+```python
+import wagtail.admin.rich_text.editors.draftail.features as draftail_features
+from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler
+
+@hooks.register('register_rich_text_features')
+def register_help_text_feature(features):
+    """
+    Registering the `help-text` feature, which uses the `help-text` Draft.js block type,
+    and is stored as HTML with a `<div class="help-text">` tag.
+    """
+    feature_name = 'help-text'
+    type_ = 'help-text'
+
+    control = {
+        'type': type_,
+        'label': '?',
+        'description': 'Help text',
+        # Optionally, we can tell Draftail what element to use when displaying those blocks in the editor.
+        'element': 'div',
+    }
+
+    features.register_editor_plugin(
+        'draftail', feature_name, draftail_features.BlockFeature(control, css={'all': ['help-text.css']})
+    )
+
+    features.register_converter_rule('contentstate', feature_name, {
+        'from_database_format': {'div[class=help-text]': BlockElementHandler(type_)},
+        'to_database_format': {'block_map': {type_: {'element': 'div', 'props': {'class': 'help-text'}}}},
+    })
+```
+
+Here are the main differences:
+
+-   We can configure an `element` to tell Draftail how to render those blocks in the editor.
+-   We register the plugin with `BlockFeature`.
+-   We set up the conversion with `BlockElementHandler` and `block_map`.
+
+Optionally, we can also define styles for the blocks with the `Draftail-block--help-text` (`Draftail-block--<block type>`) CSS class.
+
+That’s it! The extra complexity is that you may need to write CSS to style the blocks in the editor.
+
+## Creating new entities
+
+```{warning}
+This is an advanced feature. Please carefully consider whether you really need this.
+```
+
+Entities aren’t simply formatting buttons in the toolbar. They usually need to be much more versatile, communicating to APIs or requesting further user input. As such,
+
+-   You will most likely need to write a **hefty dose of JavaScript**, some of it with React.
+-   The API is very **low-level**. You will most likely need some **Draft.js knowledge**.
+-   Custom UIs in rich text can be brittle. Be ready to spend time **testing in multiple browsers**.
+
+The good news is that having such a low-level API will enable third-party Wagtail plugins to innovate on rich text features, proposing new kinds of experiences.
+But in the meantime, consider implementing your UI through [StreamField](../topics/streamfield.rst) instead, which has a battle-tested API meant for Django developers.
+
+Here are the main requirements to create a new entity feature:
+
+-   Like for inline styles and blocks, register an editor plugin.
+-   The editor plugin must define a `source`: a React component responsible for creating new entity instances in the editor, using the Draft.js API.
+-   The editor plugin also needs a `decorator` (for inline entities) or `block` (for block entities): a React component responsible for displaying entity instances within the editor.
+-   Like for inline styles and blocks, set up the to/from DB conversion.
+-   The conversion usually is more involved, since entities contain data that needs to be serialised to HTML.
+
+To write the React components, Wagtail exposes its own React, Draft.js and Draftail dependencies as global variables. Read more about this in [ectending clientside components](extending_clientside_components).
+To go further, please look at the [Draftail documentation](https://www.draftail.org/docs/formatting-options) as well as the [Draft.js exporter documentation](https://github.com/springload/draftjs_exporter).
+
+Here is a detailed example to showcase how those tools are used in the context of Wagtail.
+For the sake of our example, we can imagine a news team working at a financial newspaper.
+They want to write articles about the stock market, refer to specific stocks anywhere inside of their content (eg. "$TSLA" tokens in a sentence), and then have their article automatically enriched with the stock’s information (a link, a number, a sparkline).
+
+The editor toolbar could contain a "stock chooser" that displays a list of available stocks, then inserts the user’s selection as a textual token. For our example, we will just pick a stock at random:
+
+![Draftail entity stock source](../_static/images/draftail_entity_stock_source.gif)
+
+Those tokens are then saved in the rich text on publish. When the news article is displayed on the site, we then insert live market data coming from an API next to each token:
+
+![Draftail entity stock rendering](../_static/images/draftail_entity_stock_rendering.png)
+
+In order to achieve this, we start with registering the rich text feature like for inline styles and blocks:
+
+```python
+@hooks.register('register_rich_text_features')
+def register_stock_feature(features):
+    features.default_features.append('stock')
+    """
+    Registering the `stock` feature, which uses the `STOCK` Draft.js entity type,
+    and is stored as HTML with a `<span data-stock>` tag.
+    """
+    feature_name = 'stock'
+    type_ = 'STOCK'
+
+    control = {
+        'type': type_,
+        'label': '$',
+        'description': 'Stock',
+    }
+
+    features.register_editor_plugin(
+        'draftail', feature_name, draftail_features.EntityFeature(
+            control,
+            js=['stock.js'],
+            css={'all': ['stock.css']}
+        )
+    )
+
+    features.register_converter_rule('contentstate', feature_name, {
+        # Note here that the conversion is more complicated than for blocks and inline styles.
+        'from_database_format': {'span[data-stock]': StockEntityElementHandler(type_)},
+        'to_database_format': {'entity_decorators': {type_: stock_entity_decorator}},
+    })
+```
+
+The `js` and `css` keyword arguments on `EntityFeature` can be used to specify additional JS and CSS files to load when this feature is active. Both are optional. Their values are added to a `Media` object, more documentation on these objects is available in the [Django Form Assets documentation](django:topics/forms/media).
+
+Since entities hold data, the conversion to/from database format is more complicated. We have to create the two handlers:
+
+```python
+from draftjs_exporter.dom import DOM
+from wagtail.admin.rich_text.converters.html_to_contentstate import InlineEntityElementHandler
+
+def stock_entity_decorator(props):
+    """
+    Draft.js ContentState to database HTML.
+    Converts the STOCK entities into a span tag.
+    """
+    return DOM.create_element('span', {
+        'data-stock': props['stock'],
+    }, props['children'])
+
+
+class StockEntityElementHandler(InlineEntityElementHandler):
+    """
+    Database HTML to Draft.js ContentState.
+    Converts the span tag into a STOCK entity, with the right data.
+    """
+    mutability = 'IMMUTABLE'
+
+    def get_attribute_data(self, attrs):
+        """
+        Take the `stock` value from the `data-stock` HTML attribute.
+        """
+        return { 'stock': attrs['data-stock'] }
+```
+
+Note how they both do similar conversions, but use different APIs. `to_database_format` is built with the [Draft.js exporter](https://github.com/springload/draftjs_exporter) components API, whereas `from_database_format` uses a Wagtail API.
+
+The next step is to add JavaScript to define how the entities are created (the `source`), and how they are displayed (the `decorator`). Within `stock.js`, we define the source component:
+
+```javascript
+const React = window.React;
+const Modifier = window.DraftJS.Modifier;
+const EditorState = window.DraftJS.EditorState;
+
+const DEMO_STOCKS = ['AMD', 'AAPL', 'TWTR', 'TSLA', 'BTC'];
+
+// Not a real React component – just creates the entities as soon as it is rendered.
+class StockSource extends React.Component {
+    componentDidMount() {
+        const { editorState, entityType, onComplete } = this.props;
+
+        const content = editorState.getCurrentContent();
+        const selection = editorState.getSelection();
+
+        const randomStock =
+            DEMO_STOCKS[Math.floor(Math.random() * DEMO_STOCKS.length)];
+
+        // Uses the Draft.js API to create a new entity with the right data.
+        const contentWithEntity = content.createEntity(
+            entityType.type,
+            'IMMUTABLE',
+            {
+                stock: randomStock,
+            },
+        );
+        const entityKey = contentWithEntity.getLastCreatedEntityKey();
+
+        // We also add some text for the entity to be activated on.
+        const text = `$${randomStock}`;
+
+        const newContent = Modifier.replaceText(
+            content,
+            selection,
+            text,
+            null,
+            entityKey,
+        );
+        const nextState = EditorState.push(
+            editorState,
+            newContent,
+            'insert-characters',
+        );
+
+        onComplete(nextState);
+    }
+
+    render() {
+        return null;
+    }
+}
+```
+
+This source component uses data and callbacks provided by [Draftail](https://www.draftail.org/docs/api).
+It also uses dependencies from global variables – see [Extending clientside components](extending_clientside_components).
+
+We then create the decorator component:
+
+```javascript
+const Stock = (props) => {
+    const { entityKey, contentState } = props;
+    const data = contentState.getEntity(entityKey).getData();
+
+    return React.createElement(
+        'a',
+        {
+            role: 'button',
+            onMouseUp: () => {
+                window.open(`https://finance.yahoo.com/quote/${data.stock}`);
+            },
+        },
+        props.children,
+    );
+};
+```
+
+This is a straightforward React component. It does not use JSX since we do not want to have to use a build step for our JavaScript.
+
+Finally, we register the JS components of our plugin:
+
+```javascript
+window.draftail.registerPlugin({
+    type: 'STOCK',
+    source: StockSource,
+    decorator: Stock,
+});
+```
+
+And that’s it! All of this setup will finally produce the following HTML on the site’s front-end:
+
+```html
+<p>
+    Anyone following Elon Musk’s <span data-stock="TSLA">$TSLA</span> should
+    also look into <span data-stock="BTC">$BTC</span>.
+</p>
+```
+
+To fully complete the demo, we can add a bit of JavaScript to the front-end in order to decorate those tokens with links and a little sparkline.
+
+```javascript
+document.querySelectorAll('[data-stock]').forEach((elt) => {
+    const link = document.createElement('a');
+    link.href = `https://finance.yahoo.com/quote/${elt.dataset.stock}`;
+    link.innerHTML = `${elt.innerHTML}<svg width="50" height="20" stroke-width="2" stroke="blue" fill="rgba(0, 0, 255, .2)"><path d="M4 14.19 L 4 14.19 L 13.2 14.21 L 22.4 13.77 L 31.59 13.99 L 40.8 13.46 L 50 11.68 L 59.19 11.35 L 68.39 10.68 L 77.6 7.11 L 86.8 7.85 L 96 4" fill="none"></path><path d="M4 14.19 L 4 14.19 L 13.2 14.21 L 22.4 13.77 L 31.59 13.99 L 40.8 13.46 L 50 11.68 L 59.19 11.35 L 68.39 10.68 L 77.6 7.11 L 86.8 7.85 L 96 4 V 20 L 4 20 Z" stroke="none"></path></svg>`;
+
+    elt.innerHTML = '';
+    elt.appendChild(link);
+});
+```
+
+Custom block entities can also be created (have a look at the separate [Draftail documentation](https://www.draftail.org/docs/blocks)), but these are not detailed here since [StreamField](streamfield) is the go-to way to create block-level rich text in Wagtail.
+
+## Integration of the Draftail widgets
+
+To further customise how the Draftail widgets are integrated into the UI, there are additional extension points for CSS and JS:
+
+-   In JavaScript, use the `[data-draftail-input]` attribute selector to target the input which contains the data, and `[data-draftail-editor-wrapper]` for the element which wraps the editor.
+-   The editor instance is bound on the input field for imperative access. Use `document.querySelector('[data-draftail-input]').draftailEditor`.
+-   In CSS, use the classes prefixed with `Draftail-`.

+ 0 - 338
docs/extending/extending_draftail.rst

@@ -1,338 +0,0 @@
-Extending the Draftail Editor
-=============================
-
-Wagtail’s rich text editor is built with `Draftail <https://www.draftail.org/>`__, and its functionality can be extended through plugins.
-
-Plugins come in three types:
-
-* Inline styles – To format a portion of a line, eg. ``bold``, ``italic``, ``monospace``.
-* Blocks – To indicate the structure of the content, eg. ``blockquote``, ``ol``.
-* Entities – To enter additional data/metadata, eg. ``link`` (with a URL), ``image`` (with a file).
-
-All of these plugins are created with a similar baseline, which we can demonstrate with one of the simplest examples – a custom feature for an inline style of ``mark``. Place the following in a ``wagtail_hooks.py`` file in any installed app:
-
-.. code-block:: python
-
-    import wagtail.admin.rich_text.editors.draftail.features as draftail_features
-    from wagtail.admin.rich_text.converters.html_to_contentstate import InlineStyleElementHandler
-    from wagtail import hooks
-
-    # 1. Use the register_rich_text_features hook.
-    @hooks.register('register_rich_text_features')
-    def register_mark_feature(features):
-        """
-        Registering the `mark` feature, which uses the `MARK` Draft.js inline style type,
-        and is stored as HTML with a `<mark>` tag.
-        """
-        feature_name = 'mark'
-        type_ = 'MARK'
-        tag = 'mark'
-
-        # 2. Configure how Draftail handles the feature in its toolbar.
-        control = {
-            'type': type_,
-            'label': '☆',
-            'description': 'Mark',
-            # This isn’t even required – Draftail has predefined styles for MARK.
-            # 'style': {'textDecoration': 'line-through'},
-        }
-
-        # 3. Call register_editor_plugin to register the configuration for Draftail.
-        features.register_editor_plugin(
-            'draftail', feature_name, draftail_features.InlineStyleFeature(control)
-        )
-
-        # 4.configure the content transform from the DB to the editor and back.
-        db_conversion = {
-            'from_database_format': {tag: InlineStyleElementHandler(type_)},
-            'to_database_format': {'style_map': {type_: tag}},
-        }
-
-        # 5. Call register_converter_rule to register the content transformation conversion.
-        features.register_converter_rule('contentstate', feature_name, db_conversion)
-
-        # 6. (optional) Add the feature to the default features list to make it available
-        # on rich text fields that do not specify an explicit 'features' list
-        features.default_features.append('mark')
-
-These steps will always be the same for all Draftail plugins. The important parts are to:
-
-* Consistently use the feature’s Draft.js type or Wagtail feature names where appropriate.
-* Give enough information to Draftail so it knows how to make a button for the feature, and how to render it (more on this later).
-* Configure the conversion to use the right HTML element (as they are stored in the DB).
-
-For detailed configuration options, head over to the `Draftail documentation <https://www.draftail.org/docs/formatting-options>`__ to see all of the details. Here are some parts worth highlighting about controls:
-
-* The ``type`` is the only mandatory piece of information.
-* To display the control in the toolbar, combine ``icon``, ``label`` and ``description``.
-* The controls’ ``icon`` can be a string to use an icon font with CSS classes, say ``'icon': 'fas fa-user',``. It can also be an array of strings, to use SVG paths, or SVG symbol references eg. ``'icon': ['M100 100 H 900 V 900 H 100 Z'],``. The paths need to be set for a 1024x1024 viewbox.
-
-Creating new inline styles
-~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-In addition to the initial example, inline styles take a ``style`` property to define what CSS rules will be applied to text in the editor. Be sure to read the `Draftail documentation <https://www.draftail.org/docs/formatting-options>`__ on inline styles.
-
-Finally, the DB to/from conversion uses an ``InlineStyleElementHandler`` to map from a given tag (``<mark>`` in the example above) to a Draftail type, and the inverse mapping is done with `Draft.js exporter configuration <https://github.com/springload/draftjs_exporter>`_ of the ``style_map``.
-
-Creating new blocks
-~~~~~~~~~~~~~~~~~~~
-
-Blocks are nearly as simple as inline styles:
-
-.. code-block:: python
-
-    import wagtail.admin.rich_text.editors.draftail.features as draftail_features
-    from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler
-
-    @hooks.register('register_rich_text_features')
-    def register_help_text_feature(features):
-        """
-        Registering the `help-text` feature, which uses the `help-text` Draft.js block type,
-        and is stored as HTML with a `<div class="help-text">` tag.
-        """
-        feature_name = 'help-text'
-        type_ = 'help-text'
-
-        control = {
-            'type': type_,
-            'label': '?',
-            'description': 'Help text',
-            # Optionally, we can tell Draftail what element to use when displaying those blocks in the editor.
-            'element': 'div',
-        }
-
-        features.register_editor_plugin(
-            'draftail', feature_name, draftail_features.BlockFeature(control, css={'all': ['help-text.css']})
-        )
-
-        features.register_converter_rule('contentstate', feature_name, {
-            'from_database_format': {'div[class=help-text]': BlockElementHandler(type_)},
-            'to_database_format': {'block_map': {type_: {'element': 'div', 'props': {'class': 'help-text'}}}},
-        })
-
-Here are the main differences:
-
-* We can configure an ``element`` to tell Draftail how to render those blocks in the editor.
-* We register the plugin with ``BlockFeature``.
-* We set up the conversion with ``BlockElementHandler`` and ``block_map``.
-
-Optionally, we can also define styles for the blocks with the ``Draftail-block--help-text`` (``Draftail-block--<block type>``) CSS class.
-
-That’s it! The extra complexity is that you may need to write CSS to style the blocks in the editor.
-
-Creating new entities
-~~~~~~~~~~~~~~~~~~~~~
-
-.. warning::
-    This is an advanced feature. Please carefully consider whether you really need this.
-
-Entities aren’t simply formatting buttons in the toolbar. They usually need to be much more versatile, communicating to APIs or requesting further user input. As such,
-
-* You will most likely need to write a **hefty dose of JavaScript**, some of it with React.
-* The API is very **low-level**. You will most likely need some **Draft.js knowledge**.
-* Custom UIs in rich text can be brittle. Be ready to spend time **testing in multiple browsers**.
-
-The good news is that having such a low-level API will enable third-party Wagtail plugins to innovate on rich text features, proposing new kinds of experiences.
-But in the meantime, consider implementing your UI through :doc:`StreamField <../../topics/streamfield>` instead, which has a battle-tested API meant for Django developers.
-
-----
-
-Here are the main requirements to create a new entity feature:
-
-* Like for inline styles and blocks, register an editor plugin.
-* The editor plugin must define a ``source``: a React component responsible for creating new entity instances in the editor, using the Draft.js API.
-* The editor plugin also needs a ``decorator`` (for inline entities) or ``block`` (for block entities): a React component responsible for displaying entity instances within the editor.
-* Like for inline styles and blocks, set up the to/from DB conversion.
-* The conversion usually is more involved, since entities contain data that needs to be serialised to HTML.
-
-To write the React components, Wagtail exposes its own React, Draft.js and Draftail dependencies as global variables. Read more about this in :ref:`extending_clientside_components`.
-To go further, please look at the `Draftail documentation <https://www.draftail.org/docs/formatting-options>`__ as well as the `Draft.js exporter documentation <https://github.com/springload/draftjs_exporter>`_.
-
-Here is a detailed example to showcase how those tools are used in the context of Wagtail.
-For the sake of our example, we can imagine a news team working at a financial newspaper.
-They want to write articles about the stock market, refer to specific stocks anywhere inside of their content (eg. "$TSLA" tokens in a sentence), and then have their article automatically enriched with the stock’s information (a link, a number, a sparkline).
-
-The editor toolbar could contain a "stock chooser" that displays a list of available stocks, then inserts the user’s selection as a textual token. For our example, we will just pick a stock at random:
-
-.. image:: ../_static/images/draftail_entity_stock_source.gif
-
-Those tokens are then saved in the rich text on publish. When the news article is displayed on the site, we then insert live market data coming from an API next to each token:
-
-.. image:: ../_static/images/draftail_entity_stock_rendering.png
-
-In order to achieve this, we start with registering the rich text feature like for inline styles and blocks:
-
-.. code-block:: python
-
-    @hooks.register('register_rich_text_features')
-    def register_stock_feature(features):
-        features.default_features.append('stock')
-        """
-        Registering the `stock` feature, which uses the `STOCK` Draft.js entity type,
-        and is stored as HTML with a `<span data-stock>` tag.
-        """
-        feature_name = 'stock'
-        type_ = 'STOCK'
-
-        control = {
-            'type': type_,
-            'label': '$',
-            'description': 'Stock',
-        }
-
-        features.register_editor_plugin(
-            'draftail', feature_name, draftail_features.EntityFeature(
-                control,
-                js=['stock.js'],
-                css={'all': ['stock.css']}
-            )
-        )
-
-        features.register_converter_rule('contentstate', feature_name, {
-            # Note here that the conversion is more complicated than for blocks and inline styles.
-            'from_database_format': {'span[data-stock]': StockEntityElementHandler(type_)},
-            'to_database_format': {'entity_decorators': {type_: stock_entity_decorator}},
-        })
-
-The ``js`` and ``css`` keyword arguments on ``EntityFeature`` can be used to specify additional
-JS and CSS files to load when this feature is active. Both are optional. Their values are added to a ``Media`` object, more documentation on these objects
-is available in the :doc:`Django Form Assets documentation <django:topics/forms/media>`.
-
-Since entities hold data, the conversion to/from database format is more complicated. We have to create the two handlers:
-
-.. code-block:: python
-
-    from draftjs_exporter.dom import DOM
-    from wagtail.admin.rich_text.converters.html_to_contentstate import InlineEntityElementHandler
-
-    def stock_entity_decorator(props):
-        """
-        Draft.js ContentState to database HTML.
-        Converts the STOCK entities into a span tag.
-        """
-        return DOM.create_element('span', {
-            'data-stock': props['stock'],
-        }, props['children'])
-
-
-    class StockEntityElementHandler(InlineEntityElementHandler):
-        """
-        Database HTML to Draft.js ContentState.
-        Converts the span tag into a STOCK entity, with the right data.
-        """
-        mutability = 'IMMUTABLE'
-
-        def get_attribute_data(self, attrs):
-            """
-            Take the ``stock`` value from the ``data-stock`` HTML attribute.
-            """
-            return {
-                'stock': attrs['data-stock'],
-            }
-
-Note how they both do similar conversions, but use different APIs. ``to_database_format`` is built with the `Draft.js exporter <https://github.com/springload/draftjs_exporter>`_ components API, whereas ``from_database_format`` uses a Wagtail API.
-
-The next step is to add JavaScript to define how the entities are created (the ``source``), and how they are displayed (the ``decorator``). Within ``stock.js``, we define the source component:
-
-.. code-block:: javascript
-
-    const React = window.React;
-    const Modifier = window.DraftJS.Modifier;
-    const EditorState = window.DraftJS.EditorState;
-
-    const DEMO_STOCKS = ['AMD', 'AAPL', 'TWTR', 'TSLA', 'BTC'];
-
-    // Not a real React component – just creates the entities as soon as it is rendered.
-    class StockSource extends React.Component {
-        componentDidMount() {
-            const { editorState, entityType, onComplete } = this.props;
-
-            const content = editorState.getCurrentContent();
-            const selection = editorState.getSelection();
-
-            const randomStock = DEMO_STOCKS[Math.floor(Math.random() * DEMO_STOCKS.length)];
-
-            // Uses the Draft.js API to create a new entity with the right data.
-            const contentWithEntity = content.createEntity(entityType.type, 'IMMUTABLE', {
-                stock: randomStock,
-            });
-            const entityKey = contentWithEntity.getLastCreatedEntityKey();
-
-            // We also add some text for the entity to be activated on.
-            const text = `$${randomStock}`;
-
-            const newContent = Modifier.replaceText(content, selection, text, null, entityKey);
-            const nextState = EditorState.push(editorState, newContent, 'insert-characters');
-
-            onComplete(nextState);
-        }
-
-        render() {
-            return null;
-        }
-    }
-
-This source component uses data and callbacks provided by `Draftail <https://www.draftail.org/docs/api>`_.
-It also uses dependencies from global variables – see :ref:`extending_clientside_components`.
-
-We then create the decorator component:
-
-.. code-block:: javascript
-
-    const Stock = (props) => {
-        const { entityKey, contentState } = props;
-        const data = contentState.getEntity(entityKey).getData();
-
-        return React.createElement('a', {
-            role: 'button',
-            onMouseUp: () => {
-                window.open(`https://finance.yahoo.com/quote/${data.stock}`);
-            },
-        }, props.children);
-    };
-
-This is a straightforward React component. It does not use JSX since we do not want to have to use a build step for our JavaScript.
-
-Finally, we register the JS components of our plugin:
-
-.. code-block:: javascript
-
-    window.draftail.registerPlugin({
-        type: 'STOCK',
-        source: StockSource,
-        decorator: Stock,
-    });
-
-And that’s it! All of this setup will finally produce the following HTML on the site’s front-end:
-
-.. code-block:: html
-
-    <p>
-        Anyone following Elon Musk’s <span data-stock="TSLA">$TSLA</span> should also look into <span data-stock="BTC">$BTC</span>.
-    </p>
-
-To fully complete the demo, we can add a bit of JavaScript to the front-end in order to decorate those tokens with links and a little sparkline.
-
-.. code-block:: javascript
-
-    document.querySelectorAll('[data-stock]').forEach((elt) => {
-        const link = document.createElement('a');
-        link.href = `https://finance.yahoo.com/quote/${elt.dataset.stock}`;
-        link.innerHTML = `${elt.innerHTML}<svg width="50" height="20" stroke-width="2" stroke="blue" fill="rgba(0, 0, 255, .2)"><path d="M4 14.19 L 4 14.19 L 13.2 14.21 L 22.4 13.77 L 31.59 13.99 L 40.8 13.46 L 50 11.68 L 59.19 11.35 L 68.39 10.68 L 77.6 7.11 L 86.8 7.85 L 96 4" fill="none"></path><path d="M4 14.19 L 4 14.19 L 13.2 14.21 L 22.4 13.77 L 31.59 13.99 L 40.8 13.46 L 50 11.68 L 59.19 11.35 L 68.39 10.68 L 77.6 7.11 L 86.8 7.85 L 96 4 V 20 L 4 20 Z" stroke="none"></path></svg>`;
-
-        elt.innerHTML = '';
-        elt.appendChild(link);
-    });
-
-----
-
-Custom block entities can also be created (have a look at the separate `Draftail documentation <https://www.draftail.org/docs/blocks>`__), but these are not detailed here since :ref:`StreamField <streamfield>` is the go-to way to create block-level rich text in Wagtail.
-
-Integration of the Draftail widgets
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-To further customise how the Draftail widgets are integrated into the UI, there are additional extension points for CSS and JS:
-
-* In JavaScript, use the ``[data-draftail-input]`` attribute selector to target the input which contains the data, and ``[data-draftail-editor-wrapper]`` for the element which wraps the editor.
-* The editor instance is bound on the input field for imperative access. Use ``document.querySelector('[data-draftail-input]').draftailEditor``.
-* In CSS, use the classes prefixed with ``Draftail-``.

+ 8 - 10
docs/extending/forms.md

@@ -21,7 +21,6 @@ class FeaturedImageForm(WagtailAdminModelForm):
 
 the `date` and `image` fields on the form will use a date picker and image chooser widget respectively.
 
-
 ## Defining admin form widgets
 
 If you have implemented a form widget of your own, you can configure `WagtailAdminModelForm` to select it for a given model field type. This is done by calling the `wagtail.admin.forms.models.register_form_field_override` function, typically in an `AppConfig.ready` method.
@@ -57,7 +56,6 @@ class WagtailVideosAppConfig(AppConfig):
 
 Wagtail's edit views for pages, snippets and ModelAdmin use `WagtailAdminModelForm` as standard, so this change will take effect across the Wagtail admin; a foreign key to `Video` on a page model will automatically use the `VideoChooser` widget, with no need to specify this explicitly.
 
-
 ## Panels
 
 Panels (also known as edit handlers until Wagtail 3.0) are Wagtail's mechanism for specifying the content and layout of a model form without having to write a template. They are used for the editing interface for pages and snippets, as well as the [ModelAdmin](/reference/contrib/modeladmin/index) and [site settings](/reference/contrib/settings) contrib modules.
@@ -66,13 +64,13 @@ See [](/reference/pages/panels) for the set of panel types provided by Wagtail.
 
 A view performs the following steps to render a model form through the panels mechanism:
 
-* The top-level panel object for the model is retrieved. Usually this is done by looking up the model's `edit_handler` property and falling back on an `ObjectList` consisting of children given by the model's `panels` property. However, it may come from elsewhere - for example, the ModelAdmin module allows defining it on the ModelAdmin configuration object.
-* The view calls `bind_to_model` on the top-level panel, passing the model class, and this returns a clone of the panel with a `model` property. As part of this process the `on_model_bound` method is invoked on each child panel, to allow it to perform additional initialisation that requires access to the model (for example, this is where `FieldPanel` retrieves the model field definition).
-* The view then calls `get_form_class` on the top-level panel to retrieve a ModelForm subclass that can be used to edit the model. This proceeds as follows:
-    - Retrieve a base form class from the model's `base_form_class` property, falling back on `wagtail.admin.forms.WagtailAdminModelForm`
-    - Call `get_form_options` on each child panel - which returns a dictionary of properties including `fields` and `widgets` - and merge the results into a single dictionary
-    - Construct a subclass of the base form class, with the options dict forming the attributes of the inner `Meta` class.
-* An instance of the form class is created as per a normal Django form view.
-* The view then calls `get_bound_panel` on the top-level panel, passing `instance`, `form` and `request` as keyword arguments. This returns a `BoundPanel` object, which follows [the template component API](/extending/template_components). Finally, the `BoundPanel` object (and its media definition) is rendered onto the template.
+-   The top-level panel object for the model is retrieved. Usually this is done by looking up the model's `edit_handler` property and falling back on an `ObjectList` consisting of children given by the model's `panels` property. However, it may come from elsewhere - for example, the ModelAdmin module allows defining it on the ModelAdmin configuration object.
+-   The view calls `bind_to_model` on the top-level panel, passing the model class, and this returns a clone of the panel with a `model` property. As part of this process the `on_model_bound` method is invoked on each child panel, to allow it to perform additional initialisation that requires access to the model (for example, this is where `FieldPanel` retrieves the model field definition).
+-   The view then calls `get_form_class` on the top-level panel to retrieve a ModelForm subclass that can be used to edit the model. This proceeds as follows:
+    -   Retrieve a base form class from the model's `base_form_class` property, falling back on `wagtail.admin.forms.WagtailAdminModelForm`
+    -   Call `get_form_options` on each child panel - which returns a dictionary of properties including `fields` and `widgets` - and merge the results into a single dictionary
+    -   Construct a subclass of the base form class, with the options dict forming the attributes of the inner `Meta` class.
+-   An instance of the form class is created as per a normal Django form view.
+-   The view then calls `get_bound_panel` on the top-level panel, passing `instance`, `form` and `request` as keyword arguments. This returns a `BoundPanel` object, which follows [the template component API](/extending/template_components). Finally, the `BoundPanel` object (and its media definition) is rendered onto the template.
 
 New panel types can be defined by subclassing `wagtail.admin.panels.Panel` - see [](/reference/panel_api).

+ 3 - 7
docs/extending/generic_views.md

@@ -1,10 +1,8 @@
-Generic views
-=============
+# Generic views
 
 Wagtail provides a number of generic views for handling common tasks such as creating / editing model instances, and chooser modals. Since these often involve several related views with shared properties (such as the model that we're working with, and its associated icon) Wagtail also implements the concept of a _viewset_, which allows a bundle of views to be defined collectively, and their URLs to be registered with the admin app as a single operation through the `register_admin_viewset` hook.
 
-ModelViewSet
-------------
+## ModelViewSet
 
 The `wagtail.admin.viewsets.model.ModelViewSet` class provides the views for listing, creating, editing and deleting model instances. For example, if we have the following model:
 
@@ -47,9 +45,7 @@ def register_viewset():
 
 Various additional attributes are available to customise the viewset - see [](../reference/viewsets).
 
-
-ChooserViewSet
---------------
+## ChooserViewSet
 
 The `wagtail.admin.viewsets.chooser.ChooserViewSet` class provides the views that make up a modal chooser interface, allowing users to select from a list of model instances to populate a ForeignKey field. Using the same `Person` model, the following definition (to be placed in `views.py`) will generate the views for a person chooser modal:
 

+ 23 - 0
docs/extending/index.md

@@ -0,0 +1,23 @@
+# Extending Wagtail
+
+The Wagtail admin interface is a suite of Django apps, and so the familiar concepts from Django development - views, templates, URL routes and so on - can be used to add new functionality to Wagtail. Numerous [third-party packages](https://wagtail.org/packages/) can be installed to extend Wagtail's capabilities.
+
+This section describes the various mechanisms that can be used to integrate your own code into Wagtail's admin interface.
+
+```{toctree}
+---
+maxdepth: 2
+---
+admin_views
+generic_views
+template_components
+forms
+adding_reports
+custom_tasks
+audit_log
+custom_account_settings
+customising_group_views
+rich_text_internals
+extending_draftail
+custom_bulk_actions
+```

+ 0 - 23
docs/extending/index.rst

@@ -1,23 +0,0 @@
-Extending Wagtail
-=================
-
-The Wagtail admin interface is a suite of Django apps, and so the familiar concepts from Django development - views, templates, URL routes and so on - can be used to add new functionality to Wagtail. Numerous `third-party packages <https://wagtail.org/packages/>`_ can be installed to extend Wagtail's capabilities.
-
-This section describes the various mechanisms that can be used to integrate your own code into Wagtail's admin interface.
-
-
-.. toctree::
-    :maxdepth: 2
-
-    admin_views
-    generic_views
-    template_components
-    forms
-    adding_reports
-    custom_tasks
-    audit_log
-    custom_account_settings
-    customising_group_views
-    rich_text_internals
-    extending_draftail
-    custom_bulk_actions

+ 277 - 0
docs/extending/rich_text_internals.md

@@ -0,0 +1,277 @@
+# Rich text internals
+
+At first glance, Wagtail's rich text capabilities appear to give editors direct control over a block of HTML content. In reality, it's necessary to give editors a representation of rich text content that is several steps removed from the final HTML output, for several reasons:
+
+-   The editor interface needs to filter out certain kinds of unwanted markup; this includes malicious scripting, font styles pasted from an external word processor, and elements which would break the validity or consistency of the site design (for example, pages will generally reserve the `<h1>` element for the page title, and so it would be inappropriate to allow users to insert their own additional `<h1>` elements through rich text).
+-   Rich text fields can specify a `features` argument to further restrict the elements permitted in the field - see [Rich Text Features](rich_text_features).
+-   Enforcing a subset of HTML helps to keep presentational markup out of the database, making the site more maintainable, and making it easier to repurpose site content (including, potentially, producing non-HTML output such as [LaTeX](https://www.latex-project.org/)).
+-   Elements such as page links and images need to preserve metadata such as the page or image ID, which is not present in the final HTML representation.
+
+This requires the rich text content to go through a number of validation and conversion steps; both between the editor interface and the version stored in the database, and from the database representation to the final rendered HTML.
+
+For this reason, extending Wagtail's rich text handling to support a new element is more involved than simply saying (for example) "enable the `<blockquote>` element", since various components of Wagtail - both client and server-side - need to agree on how to handle that feature, including how it should be exposed in the editor interface, how it should be represented within the database, and (if appropriate) how it should be translated when rendered on the front-end.
+
+The components involved in Wagtail's rich text handling are described below.
+
+## Data format
+
+Rich text data (as handled by [RichTextField](rich-text), and `RichTextBlock` within [StreamField](../topics/streamfield.rst)) is stored in the database in a format that is similar, but not identical, to HTML. For example, a link to a page might be stored as:
+
+```html
+<p><a linktype="page" id="3">Contact us</a> for more information.</p>
+```
+
+Here, the `linktype` attribute identifies a rule that shall be used to rewrite the tag. When rendered on a template through the `|richtext` filter (see [rich text filter](rich-text-filter)), this is converted into valid HTML:
+
+```html
+<p><a href="/contact-us/">Contact us</a> for more information.</p>
+```
+
+In the case of `RichTextBlock`, the block's value is a `RichText` object which performs this conversion automatically when rendered as a string, so the `|richtext` filter is not necessary.
+
+Likewise, an image inside rich text content might be stored as:
+
+```html
+<embed embedtype="image" id="10" alt="A pied wagtail" format="left" />
+```
+
+which is converted into an `img` element when rendered:
+
+```html
+<img
+    alt="A pied wagtail"
+    class="richtext-image left"
+    height="294"
+    src="/media/images/pied-wagtail.width-500_ENyKffb.jpg"
+    width="500"
+/>
+```
+
+Again, the `embedtype` attribute identifies a rule that shall be used to rewrite the tag. All tags other than `<a linktype="...">` and `<embed embedtype="..." />` are left unchanged in the converted HTML.
+
+A number of additional constraints apply to `<a linktype="...">` and `<embed embedtype="..." />` tags, to allow the conversion to be performed efficiently via string replacement:
+
+-   The tag name and attributes must be lower-case
+-   Attribute values must be quoted with double-quotes
+-   `embed` elements must use XML self-closing tag syntax (i.e. end in `/>` instead of a closing `</embed>` tag)
+-   The only HTML entities permitted in attribute values are `&lt;`, `&gt;`, `&amp;` and `&quot;`
+
+## The feature registry
+
+Any app within your project can define extensions to Wagtail's rich text handling, such as new `linktype` and `embedtype` rules. An object known as the _feature registry_ serves as a central source of truth about how rich text should behave. This object can be accessed through the [Register Rich Text Features](register_rich_text_features) hook, which is called on startup to gather all definitions relating to rich text:
+
+```python
+
+    # my_app/wagtail_hooks.py
+
+    from wagtail import hooks
+
+    @hooks.register('register_rich_text_features')
+    def register_my_feature(features):
+        # add new definitions to 'features' here
+```
+
+(rich_text_rewrite_handlers)=
+
+## Rewrite handlers
+
+Rewrite handlers are classes that know how to translate the content of rich text tags like `<a linktype="...">` and `<embed embedtype="..." />` into front-end HTML. For example, the `PageLinkHandler` class knows how to convert the rich text tag `<a linktype="page" id="123">` into the HTML tag `<a href="/path/to/page/123">`.
+
+Rewrite handlers can also provide other useful information about rich text tags. For example, given an appropriate tag, `PageLinkHandler` can be used to extract which page is being referred to. This can be useful for downstream code that may want information about objects being referenced in rich text.
+
+You can create custom rewrite handlers to support your own new `linktype` and `embedtype` tags. New handlers must be Python classes that inherit from either `wagtail.richtext.LinkHandler` or `wagtail.richtext.EmbedHandler`. Your new classes should override at least some of the following methods (listed here for `LinkHandler`, although `EmbedHandler` has an identical signature):
+
+```{eval-rst}
+.. class:: LinkHandler
+
+    .. attribute:: identifier
+
+        Required. The ``identifier`` attribute is a string that indicates which rich text tags should be handled by this handler.
+
+        For example, ``PageLinkHandler.identifier`` is set to the string ``"page"``, indicating that any rich text tags with ``<a linktype="page">`` should be handled by it.
+
+    .. method:: expand_db_attributes(attrs)
+
+        Required. The ``expand_db_attributes`` method is expected to take a dictionary of attributes from a database rich text ``<a>`` tag (``<embed>`` for ``EmbedHandler``) and use it to generate valid frontend HTML.
+
+        For example, ``PageLinkHandler.expand_db_attributes`` might receive ``{'id': 123}``, use it to retrieve the Wagtail page with ID 123, and render a link to its URL like ``<a href="/path/to/page/123">``.
+
+    .. method:: get_model()
+
+        Optional. The static ``get_model`` method only applies to those handlers that are used to render content related to Django models. This method allows handlers to expose the type of content that they know how to handle.
+
+        For example, ``PageLinkHandler.get_model`` returns the Wagtail class ``Page``.
+
+        Handlers that aren't related to Django models can leave this method undefined, and calling it will raise ``NotImplementedError``.
+
+    .. method:: get_instance(attrs)
+
+        Optional. The static or classmethod ``get_instance`` method also only applies to those handlers that are used to render content related to Django models. This method is expected to take a dictionary of attributes from a database rich text ``<a>`` tag (``<embed>`` for ``EmbedHandler``) and use it to return the specific Django model instance being referred to.
+
+        For example, ``PageLinkHandler.get_instance`` might receive ``{'id': 123}`` and return the instance of the Wagtail ``Page`` class with ID 123.
+
+        If left undefined, a default implementation of this method will query the ``id`` model field on the class returned by ``get_model`` using the provided ``id`` attribute; this can be overridden in your own handlers should you want to use some other model field.
+```
+
+Below is an example custom rewrite handler that implements these methods to add support for rich text linking to user email addresses. It supports the conversion of rich text tags like `<a linktype="user" username="wagtail">` to valid HTML like `<a href="mailto:hello@wagtail.org">`. This example assumes that equivalent front-end functionality has been added to allow users to insert these kinds of links into their rich text editor.
+
+```python
+from django.contrib.auth import get_user_model
+from wagtail.rich_text import LinkHandler
+
+class UserLinkHandler(LinkHandler):
+    identifier = 'user'
+
+    @staticmethod
+    def get_model():
+        return get_user_model()
+
+    @classmethod
+    def get_instance(cls, attrs):
+        model = cls.get_model()
+        return model.objects.get(username=attrs['username'])
+
+    @classmethod
+    def expand_db_attributes(cls, attrs):
+        user = cls.get_instance(attrs)
+        return '<a href="mailto:%s">' % user.email
+```
+
+### Registering rewrite handlers
+
+Rewrite handlers must also be registered with the feature registry via the [register rich text features](register_rich_text_features) hook. Independent methods for registering both link handlers and embed handlers are provided.
+
+```{eval-rst}
+.. method:: FeatureRegistry.register_link_type(handler)
+
+This method allows you to register a custom handler deriving from ``wagtail.rich_text.LinkHandler``, and adds it to the list of link handlers available during rich text conversion.
+```
+
+```python
+# my_app/wagtail_hooks.py
+
+from wagtail import hooks
+from my_app.handlers import MyCustomLinkHandler
+
+@hooks.register('register_rich_text_features')
+def register_link_handler(features):
+    features.register_link_type(MyCustomLinkHandler)
+```
+
+It is also possible to define link rewrite handlers for Wagtail’s built-in `external` and `email` links, even though they do not have a predefined `linktype`. For example, if you want external links to have a `rel="nofollow"` attribute for SEO purposes:
+
+```python
+from django.utils.html import escape
+from wagtail import hooks
+from wagtail.rich_text import LinkHandler
+
+class NoFollowExternalLinkHandler(LinkHandler):
+    identifier = 'external'
+
+    @classmethod
+    def expand_db_attributes(cls, attrs):
+        href = attrs["href"]
+        return '<a href="%s" rel="nofollow">' % escape(href)
+
+@hooks.register('register_rich_text_features')
+def register_external_link(features):
+    features.register_link_type(NoFollowExternalLinkHandler)
+```
+
+Similarly you can use `email` linktype to add a custom rewrite handler for email links (e.g. to obfuscate emails in rich text).
+
+```{eval-rst}
+.. method:: FeatureRegistry.register_embed_type(handler)
+
+This method allows you to register a custom handler deriving from ``wagtail.rich_text.EmbedHandler``, and adds it to the list of embed handlers available during rich text conversion.
+```
+
+```python
+# my_app/wagtail_hooks.py
+
+from wagtail import hooks
+from my_app.handlers import MyCustomEmbedHandler
+
+@hooks.register('register_rich_text_features')
+def register_embed_handler(features):
+    features.register_embed_type(MyCustomEmbedHandler)
+```
+
+## Editor widgets
+
+The editor interface used on rich text fields can be configured with the [WAGTAILADMIN_RICH_TEXT_EDITORS](WAGTAILADMIN_RICH_TEXT_EDITORS) setting. Wagtail provides an implementation: `wagtail.admin.rich_text.DraftailRichTextArea` (the [Draftail](https://www.draftail.org/) editor based on [Draft.js](https://draftjs.org/)).
+
+It is possible to create your own rich text editor implementation. At minimum, a rich text editor is a Django **_class_ django.forms.Widget** subclass whose constructor accepts an `options` keyword argument (a dictionary of editor-specific configuration options sourced from the `OPTIONS` field in `WAGTAILADMIN_RICH_TEXT_EDITORS`), and which consumes and produces string data in the HTML-like format described above.
+
+Typically, a rich text widget also receives a `features` list, passed from either `RichTextField` / `RichTextBlock` or the `features` option in `WAGTAILADMIN_RICH_TEXT_EDITORS`, which defines the features available in that instance of the editor (see [rich text features](rich_text_features)). To opt in to supporting features, set the attribute `accepts_features = True` on your widget class; the widget constructor will then receive the feature list as a keyword argument `features`.
+
+There is a standard set of recognised feature identifiers as listed under [rich text features](rich_text_features), but this is not a definitive list; feature identifiers are only defined by convention, and it is up to each editor widget to determine which features it will recognise, and adapt its behaviour accordingly. Individual editor widgets might implement fewer or more features than the default set, either as built-in functionality or through a plugin mechanism if the editor widget has one.
+
+For example, a third-party Wagtail extension might introduce `table` as a new rich text feature, and provide implementations for the Draftail editor (which provides a plugin mechanism). In this case, the third-party extension will not be aware of your custom editor widget, and so the widget will not know how to handle the `table` feature identifier. Editor widgets should silently ignore any feature identifiers that they do not recognise.
+
+The `default_features` attribute of the feature registry is a list of feature identifiers to be used whenever an explicit feature list has not been given in `RichTextField` / `RichTextBlock` or `WAGTAILADMIN_RICH_TEXT_EDITORS`. This list can be modified within the `register_rich_text_features` hook to make new features enabled by default, and retrieved by calling `get_default_features()`.
+
+```python
+@hooks.register('register_rich_text_features')
+def make_h1_default(features):
+    features.default_features.append('h1')
+```
+
+Outside of the `register_rich_text_features` hook - for example, inside a widget class - the feature registry can be imported as the object `wagtail.rich_text.features`. A possible starting point for a rich text editor with feature support would be:
+
+```python
+from django.forms import widgets
+from wagtail.rich_text import features
+
+class CustomRichTextArea(widgets.TextArea):
+    accepts_features = True
+
+    def __init__(self, *args, **kwargs):
+        self.options = kwargs.pop('options', None)
+
+        self.features = kwargs.pop('features', None)
+        if self.features is None:
+            self.features = features.get_default_features()
+
+        super().__init__(*args, **kwargs)
+```
+
+## Editor plugins
+
+```{eval-rst}
+.. method:: FeatureRegistry.register_editor_plugin(editor_name, feature_name, plugin_definition)
+
+Rich text editors often provide a plugin mechanism to allow extending the editor with new functionality. The ``register_editor_plugin`` method provides a standardised way for ``register_rich_text_features`` hooks to define plugins to be pulled in to the editor when a given rich text feature is enabled.
+
+``register_editor_plugin`` is passed an editor name (a string uniquely identifying the editor widget - Wagtail uses the identifier ``draftail`` for the built-in editor), a feature identifier, and a plugin definition object. This object is specific to the editor widget and can be any arbitrary value, but will typically include a :doc:`Django form media <django:topics/forms/media>` definition referencing the plugin's JavaScript code - which will then be merged into the editor widget's own media definition - along with any relevant configuration options to be passed when instantiating the editor.
+
+.. method:: FeatureRegistry.get_editor_plugin(editor_name, feature_name)
+
+Within the editor widget, the plugin definition for a given feature can be retrieved via the ``get_editor_plugin`` method, passing the editor's own identifier string and the feature identifier. This will return ``None`` if no matching plugin has been registered.
+
+For details of the plugin formats for Wagtail's built-in editors, see :doc:`./extending_draftail`.
+```
+
+(rich_text_format_converters)=
+
+## Format converters
+
+Editor widgets will often be unable to work directly with Wagtail's rich text format, and require conversion to their own native format. For Draftail, this is a JSON-based format known as ContentState (see [How Draft.js Represents Rich Text Data](https://medium.com/@rajaraodv/how-draft-js-represents-rich-text-data-eeabb5f25cf2)). Editors based on HTML's `contentEditable` mechanism require valid HTML, and so Wagtail uses a convention referred to as "editor HTML", where the additional data required on link and embed elements is stored in `data-` attributes, for example: `<a href="/contact-us/" data-linktype="page" data-id="3">Contact us</a>`.
+
+Wagtail provides two utility classes, `wagtail.admin.rich_text.converters.contentstate.ContentstateConverter` and `wagtail.admin.rich_text.converters.editor_html.EditorHTMLConverter`, to perform conversions between rich text format and the native editor formats. These classes are independent of any editor widget, and distinct from the rewriting process that happens when rendering rich text onto a template.
+
+Both classes accept a `features` list as an argument to their constructor, and implement two methods, `from_database_format(data)` which converts Wagtail rich text data to the editor's format, and `to_database_format(data)` which converts editor data to Wagtail rich text format.
+
+As with editor plugins, the behaviour of a converter class can vary according to the feature list passed to it. In particular, it can apply whitelisting rules to ensure that the output only contains HTML elements corresponding to the currently active feature set. The feature registry provides a `register_converter_rule` method to allow `register_rich_text_features` hooks to define conversion rules that will be activated when a given feature is enabled.
+
+```{eval-rst}
+.. method:: FeatureRegistry.register_converter_rule(converter_name, feature_name, rule_definition)
+
+``register_editor_plugin`` is passed a converter name (a string uniquely identifying the converter class - Wagtail uses the identifiers ``contentstate`` and ``editorhtml``), a feature identifier, and a rule definition object. This object is specific to the converter and can be any arbitrary value.
+
+For details of the rule definition format for the ``contentstate`` converter, see :doc:`./extending_draftail`.
+
+.. method:: FeatureRegistry.get_converter_rule(converter_name, feature_name)
+
+Within a converter class, the rule definition for a given feature can be retrieved via the ``get_converter_rule`` method, passing the converter's own identifier string and the feature identifier. This will return ``None`` if no matching rule has been registered.
+```

+ 0 - 278
docs/extending/rich_text_internals.rst

@@ -1,278 +0,0 @@
-Rich text internals
-===================
-
-At first glance, Wagtail's rich text capabilities appear to give editors direct control over a block of HTML content. In reality, it's necessary to give editors a representation of rich text content that is several steps removed from the final HTML output, for several reasons:
-
-* The editor interface needs to filter out certain kinds of unwanted markup; this includes malicious scripting, font styles pasted from an external word processor, and elements which would break the validity or consistency of the site design (for example, pages will generally reserve the ``<h1>`` element for the page title, and so it would be inappropriate to allow users to insert their own additional ``<h1>`` elements through rich text).
-* Rich text fields can specify a ``features`` argument to further restrict the elements permitted in the field - see :ref:`rich_text_features`.
-* Enforcing a subset of HTML helps to keep presentational markup out of the database, making the site more maintainable, and making it easier to repurpose site content (including, potentially, producing non-HTML output such as `LaTeX <https://www.latex-project.org/>`_).
-* Elements such as page links and images need to preserve metadata such as the page or image ID, which is not present in the final HTML representation.
-
-This requires the rich text content to go through a number of validation and conversion steps; both between the editor interface and the version stored in the database, and from the database representation to the final rendered HTML.
-
-For this reason, extending Wagtail's rich text handling to support a new element is more involved than simply saying (for example) "enable the ``<blockquote>`` element", since various components of Wagtail - both client and server-side - need to agree on how to handle that feature, including how it should be exposed in the editor interface, how it should be represented within the database, and (if appropriate) how it should be translated when rendered on the front-end.
-
-The components involved in Wagtail's rich text handling are described below.
-
-
-Data format
------------
-
-Rich text data (as handled by :ref:`RichTextField <rich-text>`, and ``RichTextBlock`` within :doc:`StreamField </topics/streamfield>`) is stored in the database in a format that is similar, but not identical, to HTML. For example, a link to a page might be stored as:
-
-.. code-block:: html
-
-    <p><a linktype="page" id="3">Contact us</a> for more information.</p>
-
-Here, the ``linktype`` attribute identifies a rule that shall be used to rewrite the tag. When rendered on a template through the ``|richtext`` filter (see :ref:`rich-text-filter`), this is converted into valid HTML:
-
-.. code-block:: html
-
-    <p><a href="/contact-us/">Contact us</a> for more information.</p>
-
-In the case of ``RichTextBlock``, the block's value is a ``RichText`` object which performs this conversion automatically when rendered as a string, so the ``|richtext`` filter is not necessary.
-
-Likewise, an image inside rich text content might be stored as:
-
-.. code-block:: html
-
-    <embed embedtype="image" id="10" alt="A pied wagtail" format="left" />
-
-which is converted into an ``img`` element when rendered:
-
-.. code-block:: html
-
-    <img alt="A pied wagtail" class="richtext-image left" height="294" src="/media/images/pied-wagtail.width-500_ENyKffb.jpg" width="500">
-
-Again, the ``embedtype`` attribute identifies a rule that shall be used to rewrite the tag. All tags other than ``<a linktype="...">`` and ``<embed embedtype="..." />`` are left unchanged in the converted HTML.
-
-A number of additional constraints apply to ``<a linktype="...">`` and ``<embed embedtype="..." />`` tags, to allow the conversion to be performed efficiently via string replacement:
-
-* The tag name and attributes must be lower-case
-* Attribute values must be quoted with double-quotes
-* ``embed`` elements must use XML self-closing tag syntax (i.e. end in ``/>`` instead of a closing ``</embed>`` tag)
-* The only HTML entities permitted in attribute values are ``&lt;``, ``&gt;``, ``&amp;`` and ``&quot;``
-
-
-The feature registry
---------------------
-
-Any app within your project can define extensions to Wagtail's rich text handling, such as new ``linktype`` and ``embedtype`` rules. An object known as the *feature registry* serves as a central source of truth about how rich text should behave. This object can be accessed through the :ref:`register_rich_text_features` hook, which is called on startup to gather all definitions relating to rich text:
-
-.. code-block:: python
-
-    # my_app/wagtail_hooks.py
-
-    from wagtail import hooks
-
-    @hooks.register('register_rich_text_features')
-    def register_my_feature(features):
-        # add new definitions to 'features' here
-
-
-.. _rich_text_rewrite_handlers:
-
-Rewrite handlers
-----------------
-
-Rewrite handlers are classes that know how to translate the content of rich text tags like ``<a linktype="...">`` and ``<embed embedtype="..." />`` into front-end HTML. For example, the ``PageLinkHandler`` class knows how to convert the rich text tag ``<a linktype="page" id="123">`` into the HTML tag ``<a href="/path/to/page/123">``.
-
-Rewrite handlers can also provide other useful information about rich text tags. For example, given an appropriate tag, ``PageLinkHandler`` can be used to extract which page is being referred to. This can be useful for downstream code that may want information about objects being referenced in rich text.
-
-You can create custom rewrite handlers to support your own new ``linktype`` and ``embedtype`` tags. New handlers must be Python classes that inherit from either ``wagtail.richtext.LinkHandler`` or ``wagtail.richtext.EmbedHandler``. Your new classes should override at least some of the following methods (listed here for ``LinkHandler``, although ``EmbedHandler`` has an identical signature):
-
-.. class:: LinkHandler
-
-    .. attribute:: identifier
-
-        Required. The ``identifier`` attribute is a string that indicates which rich text tags should be handled by this handler.
-
-        For example, ``PageLinkHandler.identifier`` is set to the string ``"page"``, indicating that any rich text tags with ``<a linktype="page">`` should be handled by it.
-
-    .. method:: expand_db_attributes(attrs)
-
-        Required. The ``expand_db_attributes`` method is expected to take a dictionary of attributes from a database rich text ``<a>`` tag (``<embed>`` for ``EmbedHandler``) and use it to generate valid frontend HTML.
-
-        For example, ``PageLinkHandler.expand_db_attributes`` might receive ``{'id': 123}``, use it to retrieve the Wagtail page with ID 123, and render a link to its URL like ``<a href="/path/to/page/123">``.
-
-    .. method:: get_model()
-
-        Optional. The static ``get_model`` method only applies to those handlers that are used to render content related to Django models. This method allows handlers to expose the type of content that they know how to handle.
-
-        For example, ``PageLinkHandler.get_model`` returns the Wagtail class ``Page``.
-
-        Handlers that aren't related to Django models can leave this method undefined, and calling it will raise ``NotImplementedError``.
-
-    .. method:: get_instance(attrs)
-
-        Optional. The static or classmethod ``get_instance`` method also only applies to those handlers that are used to render content related to Django models. This method is expected to take a dictionary of attributes from a database rich text ``<a>`` tag (``<embed>`` for ``EmbedHandler``) and use it to return the specific Django model instance being referred to.
-
-        For example, ``PageLinkHandler.get_instance`` might receive ``{'id': 123}`` and return the instance of the Wagtail ``Page`` class with ID 123.
-
-        If left undefined, a default implementation of this method will query the ``id`` model field on the class returned by ``get_model`` using the provided ``id`` attribute; this can be overridden in your own handlers should you want to use some other model field.
-
-Below is an example custom rewrite handler that implements these methods to add support for rich text linking to user email addresses. It supports the conversion of rich text tags like ``<a linktype="user" username="wagtail">`` to valid HTML like ``<a href="mailto:hello@wagtail.org">``. This example assumes that equivalent front-end functionality has been added to allow users to insert these kinds of links into their rich text editor.
-
-.. code-block:: python
-
-    from django.contrib.auth import get_user_model
-    from wagtail.rich_text import LinkHandler
-
-    class UserLinkHandler(LinkHandler):
-        identifier = 'user'
-
-        @staticmethod
-        def get_model():
-            return get_user_model()
-
-        @classmethod
-        def get_instance(cls, attrs):
-            model = cls.get_model()
-            return model.objects.get(username=attrs['username'])
-
-        @classmethod
-        def expand_db_attributes(cls, attrs):
-            user = cls.get_instance(attrs)
-            return '<a href="mailto:%s">' % user.email
-
-
-Registering rewrite handlers
-----------------------------
-
-Rewrite handlers must also be registered with the feature registry via the :ref:`register_rich_text_features` hook. Independent methods for registering both link handlers and embed handlers are provided.
-
-.. method:: FeatureRegistry.register_link_type(handler)
-
-This method allows you to register a custom handler deriving from ``wagtail.rich_text.LinkHandler``, and adds it to the list of link handlers available during rich text conversion.
-
-.. code-block:: python
-
-    # my_app/wagtail_hooks.py
-
-    from wagtail import hooks
-    from my_app.handlers import MyCustomLinkHandler
-
-    @hooks.register('register_rich_text_features')
-    def register_link_handler(features):
-        features.register_link_type(MyCustomLinkHandler)
-
-
-It is also possible to define link rewrite handlers for Wagtail’s built-in ``external`` and ``email`` links, even though they do not have a predefined ``linktype``. For example, if you want external links to have a ``rel="nofollow"`` attribute for SEO purposes:
-
-.. code-block:: python
-
-    from django.utils.html import escape
-    from wagtail import hooks
-    from wagtail.rich_text import LinkHandler
-
-    class NoFollowExternalLinkHandler(LinkHandler):
-        identifier = 'external'
-
-        @classmethod
-        def expand_db_attributes(cls, attrs):
-            href = attrs["href"]
-            return '<a href="%s" rel="nofollow">' % escape(href)
-
-    @hooks.register('register_rich_text_features')
-    def register_external_link(features):
-        features.register_link_type(NoFollowExternalLinkHandler)
-
-Similarly you can use ``email`` linktype to add a custom rewrite handler for email links (e.g. to obfuscate emails in rich text).
-
-
-.. method:: FeatureRegistry.register_embed_type(handler)
-
-This method allows you to register a custom handler deriving from ``wagtail.rich_text.EmbedHandler``, and adds it to the list of embed handlers available during rich text conversion.
-
-.. code-block:: python
-
-    # my_app/wagtail_hooks.py
-
-    from wagtail import hooks
-    from my_app.handlers import MyCustomEmbedHandler
-
-    @hooks.register('register_rich_text_features')
-    def register_embed_handler(features):
-        features.register_embed_type(MyCustomEmbedHandler)
-
-
-Editor widgets
---------------
-
-The editor interface used on rich text fields can be configured with the :ref:`WAGTAILADMIN_RICH_TEXT_EDITORS <WAGTAILADMIN_RICH_TEXT_EDITORS>` setting. Wagtail provides an implementation: ``wagtail.admin.rich_text.DraftailRichTextArea`` (the `Draftail <https://www.draftail.org/>`_ editor based on `Draft.js <https://draftjs.org/>`_).
-
-It is possible to create your own rich text editor implementation. At minimum, a rich text editor is a Django :class:`~django.forms.Widget` subclass whose constructor accepts an ``options`` keyword argument (a dictionary of editor-specific configuration options sourced from the ``OPTIONS`` field in ``WAGTAILADMIN_RICH_TEXT_EDITORS``), and which consumes and produces string data in the HTML-like format described above.
-
-Typically, a rich text widget also receives a ``features`` list, passed from either ``RichTextField`` / ``RichTextBlock`` or the ``features`` option in ``WAGTAILADMIN_RICH_TEXT_EDITORS``, which defines the features available in that instance of the editor (see :ref:`rich_text_features`). To opt in to supporting features, set the attribute ``accepts_features = True`` on your widget class; the widget constructor will then receive the feature list as a keyword argument ``features``.
-
-There is a standard set of recognised feature identifiers as listed under :ref:`rich_text_features`, but this is not a definitive list; feature identifiers are only defined by convention, and it is up to each editor widget to determine which features it will recognise, and adapt its behaviour accordingly. Individual editor widgets might implement fewer or more features than the default set, either as built-in functionality or through a plugin mechanism if the editor widget has one.
-
-For example, a third-party Wagtail extension might introduce ``table`` as a new rich text feature, and provide implementations for the Draftail editor (which provides a plugin mechanism). In this case, the third-party extension will not be aware of your custom editor widget, and so the widget will not know how to handle the ``table`` feature identifier. Editor widgets should silently ignore any feature identifiers that they do not recognise.
-
-The ``default_features`` attribute of the feature registry is a list of feature identifiers to be used whenever an explicit feature list has not been given in ``RichTextField`` / ``RichTextBlock`` or ``WAGTAILADMIN_RICH_TEXT_EDITORS``. This list can be modified within the ``register_rich_text_features`` hook to make new features enabled by default, and retrieved by calling ``get_default_features()``.
-
-.. code-block:: python
-
-    @hooks.register('register_rich_text_features')
-    def make_h1_default(features):
-        features.default_features.append('h1')
-
-
-Outside of the ``register_rich_text_features`` hook - for example, inside a widget class - the feature registry can be imported as the object ``wagtail.rich_text.features``. A possible starting point for a rich text editor with feature support would be:
-
-.. code-block:: python
-
-    from django.forms import widgets
-    from wagtail.rich_text import features
-
-    class CustomRichTextArea(widgets.TextArea):
-        accepts_features = True
-
-        def __init__(self, *args, **kwargs):
-            self.options = kwargs.pop('options', None)
-
-            self.features = kwargs.pop('features', None)
-            if self.features is None:
-                self.features = features.get_default_features()
-
-            super().__init__(*args, **kwargs)
-
-
-Editor plugins
---------------
-
-.. method:: FeatureRegistry.register_editor_plugin(editor_name, feature_name, plugin_definition)
-
-Rich text editors often provide a plugin mechanism to allow extending the editor with new functionality. The ``register_editor_plugin`` method provides a standardised way for ``register_rich_text_features`` hooks to define plugins to be pulled in to the editor when a given rich text feature is enabled.
-
-``register_editor_plugin`` is passed an editor name (a string uniquely identifying the editor widget - Wagtail uses the identifier ``draftail`` for the built-in editor), a feature identifier, and a plugin definition object. This object is specific to the editor widget and can be any arbitrary value, but will typically include a :doc:`Django form media <django:topics/forms/media>` definition referencing the plugin's JavaScript code - which will then be merged into the editor widget's own media definition - along with any relevant configuration options to be passed when instantiating the editor.
-
-.. method:: FeatureRegistry.get_editor_plugin(editor_name, feature_name)
-
-Within the editor widget, the plugin definition for a given feature can be retrieved via the ``get_editor_plugin`` method, passing the editor's own identifier string and the feature identifier. This will return ``None`` if no matching plugin has been registered.
-
-For details of the plugin formats for Wagtail's built-in editors, see :doc:`./extending_draftail`.
-
-
-.. _rich_text_format_converters:
-
-Format converters
------------------
-
-Editor widgets will often be unable to work directly with Wagtail's rich text format, and require conversion to their own native format. For Draftail, this is a JSON-based format known as ContentState (see `How Draft.js Represents Rich Text Data <https://medium.com/@rajaraodv/how-draft-js-represents-rich-text-data-eeabb5f25cf2>`_). Editors based on HTML's ``contentEditable`` mechanism require valid HTML, and so Wagtail uses a convention referred to as "editor HTML", where the additional data required on link and embed elements is stored in ``data-`` attributes, for example: ``<a href="/contact-us/" data-linktype="page" data-id="3">Contact us</a>``.
-
-Wagtail provides two utility classes, ``wagtail.admin.rich_text.converters.contentstate.ContentstateConverter`` and ``wagtail.admin.rich_text.converters.editor_html.EditorHTMLConverter``, to perform conversions between rich text format and the native editor formats. These classes are independent of any editor widget, and distinct from the rewriting process that happens when rendering rich text onto a template.
-
-Both classes accept a ``features`` list as an argument to their constructor, and implement two methods, ``from_database_format(data)`` which converts Wagtail rich text data to the editor's format, and ``to_database_format(data)`` which converts editor data to Wagtail rich text format.
-
-As with editor plugins, the behaviour of a converter class can vary according to the feature list passed to it. In particular, it can apply whitelisting rules to ensure that the output only contains HTML elements corresponding to the currently active feature set. The feature registry provides a ``register_converter_rule`` method to allow ``register_rich_text_features`` hooks to define conversion rules that will be activated when a given feature is enabled.
-
-.. method:: FeatureRegistry.register_converter_rule(converter_name, feature_name, rule_definition)
-
-``register_editor_plugin`` is passed a converter name (a string uniquely identifying the converter class - Wagtail uses the identifiers ``contentstate`` and ``editorhtml``), a feature identifier, and a rule definition object. This object is specific to the converter and can be any arbitrary value.
-
-For details of the rule definition format for the ``contentstate`` converter, see :doc:`./extending_draftail`.
-
-.. method:: FeatureRegistry.get_converter_rule(converter_name, feature_name)
-
-Within a converter class, the rule definition for a given feature can be retrieved via the ``get_converter_rule`` method, passing the converter's own identifier string and the feature identifier. This will return ``None`` if no matching rule has been registered.

+ 1 - 4
docs/extending/template_components.md

@@ -51,7 +51,6 @@ class WelcomePanel(Component):
         return format_html("<h1>{}</h1>", "Welcome to my app!")
 ```
 
-
 ## Passing context to the template
 
 The `get_context_data` method can be overridden to pass context variables to the template. As with `render_html`, this receives the context dictionary from the calling template:
@@ -74,7 +73,6 @@ class WelcomePanel(Component):
 <h1>Welcome to my app, {{ username }}!</h1>
 ```
 
-
 ## Adding media definitions
 
 Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property:
@@ -89,7 +87,6 @@ class WelcomePanel(Component):
         }
 ```
 
-
 ## Using components on your own templates
 
 The `wagtailadmin_tags` tag library provides a `{% component %}` tag for including components on a template. This takes care of passing context variables from the calling template to the component (which would not be the case for a basic `{{ ... }}` variable tag). For example, given the view:
@@ -137,7 +134,7 @@ def welcome_page(request):
     })
 ```
 
-``my_app/welcome.html``:
+`my_app/welcome.html`:
 
 ```html+django
 {% extends "wagtailadmin/base.html" %}

+ 3 - 8
docs/reference/contrib/typed_table_block.md

@@ -1,11 +1,8 @@
-Typed table block
-=================
+# Typed table block
 
 The `typed_table_block` module provides a StreamField block type for building tables consisting of mixed data types. Developers can specify a set of block types (such as `RichTextBlock` or `FloatBlock`) to be available as column types; page authors can then build up tables of any size by choosing column types from that list, in much the same way that they would insert blocks into a StreamField. Within each column, authors enter data using the standard editing control for that field (such as the Draftail editor for rich text cells).
 
-
-Installation
-------------
+## Installation
 
 Add `"wagtail.contrib.typed_table_block"` to your INSTALLED_APPS:
 
@@ -16,9 +13,7 @@ INSTALLED_APPS = [
 ]
 ```
 
-
-Usage
------
+## Usage
 
 `TypedTableBlock` can be imported from the module `wagtail.contrib.typed_table_block.blocks` and used within a StreamField definition. Just like `StructBlock` and `StreamBlock`, it accepts a list of `(name, block_type)` tuples to use as child blocks:
 

+ 7 - 4
docs/reference/panel_api.md

@@ -2,10 +2,11 @@
 
 ```{eval-rst}
 .. module:: wagtail.admin.panels
+```
 
-Panel
------
+## `Panel`
 
+```{eval-rst}
 .. autoclass:: Panel
 
    .. automethod:: bind_to_model
@@ -15,9 +16,11 @@ Panel
    .. automethod:: get_form_options
    .. automethod:: get_form_class
    .. automethod:: get_bound_panel
+```
 
-BoundPanel
-----------
+## `BoundPanel`
+
+```{eval-rst}
 
 .. autoclass:: wagtail.admin.panels.Panel.BoundPanel
 

+ 1 - 1
docs/releases/4.0.md

@@ -16,7 +16,7 @@ When using a queryset to render a list of images, you can now use the ``prefetch
 ### Other features
 
  * Add clarity to confirmation when being asked to convert an external link to an internal one (Thijs Kramer)
- * Convert various pages in the documentation to Markdown (Khanh Hoang, Vu Pham, Daniel Kirkham, LB (Ben) Johnston, Thiago Costa de Souza, Benedict Faw)
+ * Convert various pages in the documentation to Markdown (Khanh Hoang, Vu Pham, Daniel Kirkham, LB (Ben) Johnston, Thiago Costa de Souza, Benedict Faw, Noble Mittal)
  * Add `base_url_path` to `ModelAdmin` so that the default URL structure of app_label/model_name can be overridden (Vu Pham, Khanh Hoang)
  * Add `full_url` to the API output of `ImageRenditionField` (Paarth Agarwal)
  * Use `InlinePanel`'s label when available for field comparison label (Sandil Ranasinghe)