Browse Source

Add TitleFieldPanel to support shared usage of title field sync

- Avoid a widget approach used on default content_panels on Page, instead allow a shared TitleFieldPanel to be used
- Fixes #10517
LB Johnston 1 year ago
parent
commit
c85eaae5a7

+ 13 - 0
docs/reference/pages/panels.md

@@ -222,6 +222,19 @@ The `MultipleChooserPanel` definition on `BlogPage` would be:
             ]
 ```
 
+(title_field_panel)=
+
+### TitleFieldPanel
+
+```{eval-rst}
+.. module:: wagtail.admin.panels
+
+.. autoclass:: TitleFieldPanel
+
+    This is the panel to use for Page title fields or main titles on other models. It provides a default classname, placeholder and widget attributes to enable the automatic sync with the slug field in the form. Many of these defaults can be customised by passing additional arguments to the constructor. All the same `FieldPanel` arguments are supported including a custom widget. For more details, see :ref:`customising_panels`.
+
+```
+
 (customising_panels)=
 
 ## Panel customisation

+ 1 - 0
wagtail/admin/panels/__init__.py

@@ -18,3 +18,4 @@ from .page_chooser_panel import *  # NOQA: F403
 from .page_utils import *  # NOQA: F403
 from .publishing_panel import *  # NOQA: F403
 from .signal_handlers import *  # NOQA: F403
+from .title_field_panel import *  # NOQA: F403

+ 2 - 17
wagtail/admin/panels/page_utils.py

@@ -1,6 +1,4 @@
-from django import forms
 from django.conf import settings
-from django.utils.text import format_lazy
 from django.utils.translation import gettext_lazy
 
 from wagtail.admin.forms.pages import WagtailAdminPageForm
@@ -12,25 +10,12 @@ from .comment_panel import CommentPanel
 from .field_panel import FieldPanel
 from .group import MultiFieldPanel, ObjectList, TabbedInterface
 from .publishing_panel import PublishingPanel
+from .title_field_panel import TitleFieldPanel
 
 
 def set_default_page_edit_handlers(cls):
     cls.content_panels = [
-        FieldPanel(
-            "title",
-            classname="title",
-            widget=forms.TextInput(
-                attrs={
-                    "placeholder": format_lazy(
-                        "{title}*", title=gettext_lazy("Page title")
-                    ),
-                    "data-controller": "w-sync",
-                    "data-action": "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
-                    # ensure that if the page is live, the slug field does not receive updates from changes to the title field
-                    "data-w-sync-target-value": "body:not(.page-is-live) [data-edit-form] #id_slug",
-                }
-            ),
-        ),
+        TitleFieldPanel("title"),
     ]
 
     cls.promote_panels = [

+ 129 - 0
wagtail/admin/panels/title_field_panel.py

@@ -0,0 +1,129 @@
+from django.utils.text import format_lazy
+from django.utils.translation import gettext_lazy
+
+from wagtail.models import Page
+
+from .field_panel import FieldPanel
+
+
+class TitleFieldPanel(FieldPanel):
+    """
+    Prepares the default widget attributes that are used on Page title fields.
+    Can be used outside of pages to easily enable the slug field sync functionality.
+
+    :param apply_if_live: (optional) If ``True``, the built in slug sync behaviour will apply irrespective of the published state.
+        The default is ``False``, where the slug sync will only apply when the instance is not live (or does not have a live property).
+    :param classname: (optional) A CSS class name to add to the panel's HTML element. Default is ``"title"``.
+    :param placeholder: (optional) If a value is provided, it will be used as the field's placeholder, if ``False`` is provided no placeholder will be shown.
+        If ``True``, a placeholder value of ``"Title*"`` will be used or ``"Page Title*"`` if the model is a ``Page`` model.
+        The default is ``True``. If a widget is provided with a placeholder, the widget's value will be used instead.
+    :param targets: (optional) This allows you to override the default target of the field named `slug` on the form.
+        Accepts a list of field names, default is ``["slug"]``.
+        Note that the slugify/urlify behaviour relies on usage of the ``wagtail.admin.widgets.slug`` widget on the slug field.
+    """
+
+    def __init__(
+        self,
+        *args,
+        apply_if_live=False,
+        classname="title",
+        placeholder=True,
+        targets=["slug"],
+        **kwargs,
+    ):
+        kwargs["classname"] = classname
+        self.apply_if_live = apply_if_live
+        self.placeholder = placeholder
+        self.targets = targets
+        super().__init__(*args, **kwargs)
+
+    def clone_kwargs(self):
+        return {
+            **super().clone_kwargs(),
+            "apply_if_live": self.apply_if_live,
+            "placeholder": self.placeholder,
+            "targets": self.targets,
+        }
+
+    class BoundPanel(FieldPanel.BoundPanel):
+
+        apply_actions = [
+            "focus->w-sync#check",
+            "blur->w-sync#apply",
+            "change->w-sync#apply",
+            "keyup->w-sync#apply",
+        ]
+
+        def get_context_data(self, parent_context=None):
+            field = self.bound_field.field
+            if field and not self.read_only:
+                field.widget.attrs.update(**self.get_attrs())
+            return super().get_context_data(parent_context)
+
+        def get_attrs(self):
+            """
+            Generates a dict of widget attributes to be updated on the widget
+            before rendering.
+            """
+
+            panel = self.panel
+            widget = self.bound_field.field.widget
+
+            attrs = {}
+
+            controllers = [widget.attrs.get("data-controller", None), "w-sync"]
+            attrs["data-controller"] = " ".join(filter(None, controllers))
+
+            if self.get_should_apply():
+                actions = [widget.attrs.get("data-action", None)] + self.apply_actions
+                attrs["data-action"] = " ".join(filter(None, actions))
+
+            targets = [self.get_target_selector(target) for target in panel.targets]
+            attrs["data-w-sync-target-value"] = ", ".join(filter(None, targets))
+
+            placeholder = self.get_placeholder()
+            if placeholder and "placeholder" not in widget.attrs:
+                attrs["placeholder"] = placeholder
+
+            return attrs
+
+        def get_placeholder(self):
+            """
+            If placeholder is falsey, return None. Otherwise allow a valid placeholder
+            to be resolved.
+            """
+            placeholder = self.panel.placeholder
+
+            if not placeholder:
+                return None
+
+            if placeholder is True:
+                title = gettext_lazy("Title")
+
+                if issubclass(self.panel.model, Page):
+                    title = gettext_lazy("Page title")
+
+                return format_lazy("{title}*", title=title)
+
+            return placeholder
+
+        def get_should_apply(self):
+            """
+            Check that the title field should apply the sync with the target fields.
+            """
+            if self.panel.apply_if_live:
+                return True
+
+            instance = self.instance
+            if not instance:
+                return True
+
+            is_live = instance.pk and getattr(instance, "live", False)
+            return not is_live
+
+        def get_target_selector(self, target):
+            """
+            Prepare a selector for an individual target field.
+            """
+            field = self.form[target]
+            return f"#{field.id_for_label}"

+ 34 - 0
wagtail/admin/tests/pages/test_create_page.py

@@ -2,6 +2,7 @@ import datetime
 import unittest
 from unittest import mock
 
+from bs4 import BeautifulSoup
 from django.contrib.auth.models import Group, Permission
 from django.http import HttpRequest, HttpResponse
 from django.test import TestCase
@@ -974,6 +975,39 @@ class TestPageCreation(WagtailTestUtils, TestCase):
             "Ensure this value has at most 255 characters (it has 287).",
         )
 
+    def test_title_field_attrs(self):
+        """
+        Should correctly add the sync field and placeholder attributes to the title field.
+        Note: Many test Page models use a FieldPanel for 'title', StandardChild does not
+        override content_panels (uses the default).
+        """
+
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:add",
+                args=("tests", "standardchild", self.root_page.id),
+            )
+        )
+
+        html = BeautifulSoup(response.content, "html5lib")
+
+        actual_attrs = html.find("input", {"name": "title"}).attrs
+
+        expected_attrs = {
+            "aria-describedby": "panel-child-content-child-title-helptext",
+            "data-action": "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
+            "data-controller": "w-sync",
+            "data-w-sync-target-value": "#id_slug",
+            "id": "id_title",
+            "maxlength": "255",
+            "name": "title",
+            "placeholder": "Page title*",
+            "required": "",
+            "type": "text",
+        }
+
+        self.assertEqual(actual_attrs, expected_attrs)
+
     def test_before_create_page_hook(self):
         def hook_func(request, parent_page, page_class):
             self.assertIsInstance(request, HttpRequest)

+ 320 - 0
wagtail/admin/tests/test_edit_handlers.py

@@ -3,6 +3,7 @@ from functools import wraps
 from typing import Any, List, Mapping, Optional
 from unittest import mock
 
+from bs4 import BeautifulSoup
 from django import forms
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -27,6 +28,7 @@ from wagtail.admin.panels import (
     Panel,
     PublishingPanel,
     TabbedInterface,
+    TitleFieldPanel,
     extract_panel_definitions_from_model_class,
     get_form_for_model,
 )
@@ -43,6 +45,7 @@ from wagtail.images import get_image_model
 from wagtail.models import Comment, CommentReply, Page, Site
 from wagtail.test.testapp.forms import ValidatedPageForm
 from wagtail.test.testapp.models import (
+    Advert,
     EventPage,
     EventPageChooserModel,
     EventPageSpeaker,
@@ -2063,3 +2066,320 @@ class TestPanelIcons(WagtailTestUtils, TestCase):
             with self.subTest(panel_type=type(panel)):
                 self.assertEqual(bound_panel.icon, expected_icon)
                 self.assertIn(f"#icon-{expected_icon}", html)
+
+
+class TestTitleFieldPanel(WagtailTestUtils, TestCase):
+    fixtures = ["test.json"]
+
+    def setUp(self):
+        self.user = self.login()
+        self.request = get_dummy_request()
+        self.request.user = self.user
+
+    def get_edit_handler_html(
+        self,
+        edit_handler,
+        model=EventPage,
+        instance=None,
+    ):
+        edit_handler = edit_handler.bind_to_model(model)
+        form_class = edit_handler.get_form_class()
+        bound_edit_handler = edit_handler.get_bound_panel(
+            request=self.request,
+            form=form_class(),
+            instance=instance,
+        )
+        html = bound_edit_handler.render_form_content()
+        return BeautifulSoup(html, "html5lib")
+
+    @clear_edit_handler(Page)
+    def test_default_page_content_panels_uses_title_field(self):
+        edit_handler = Page.get_edit_handler()
+        first_inner_panel_child = edit_handler.children[0].children[0]
+        self.assertTrue(isinstance(first_inner_panel_child, TitleFieldPanel))
+
+    def test_default_title_field_panel(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title"), FieldPanel("slug")])
+        )
+
+        # check default classname is used
+        self.assertIsNotNone(html.find(attrs={"class": "w-panel title"}))
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["placeholder"], "Page title*")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_slug")
+        self.assertEqual(
+            attrs["data-action"],
+            "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
+        )
+
+    def test_not_using_apply_actions_if_live(self):
+        """
+        If the Page (or any model) has `live = True`, do not apply the actions by default.
+        Allow this to be overridden though.
+        """
+
+        event_live = EventPage.objects.get(slug="christmas")
+
+        self.assertEqual(event_live.live, True)
+
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title"), FieldPanel("slug")]),
+            instance=event_live,
+        )
+
+        self.assertIsNone(html.find("input").attrs.get("data-action"))
+
+        # allow to be overridden
+
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("title", apply_if_live=True), FieldPanel("slug")]
+            ),
+            instance=event_live,
+        )
+
+        self.assertIsNotNone(html.find("input").attrs.get("data-action"))
+
+    def test_using_apply_actions_if_non_page_model(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("text", targets=["url"]), FieldPanel("url")]),
+            model=Advert,
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_url")
+        self.assertIsNotNone(attrs["data-action"])
+
+    def test_using_apply_actions_if_non_page_model_with_live_property(self):
+        """
+        Check for instance being live should be agnostic to how that is implemented.
+        """
+
+        advert_live = Advert(text="Free sheepdog", url="https://example.com", id=5000)
+        advert_live.live = True
+
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("text", targets=["url"]), FieldPanel("url")]),
+            model=Advert,
+            instance=advert_live,
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_url")
+        self.assertIsNone(attrs.get("data-action"))
+
+        # apply_if_live should work the same when apply_if_live is True
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "text",
+                        targets=["url"],
+                        apply_if_live=True,
+                    ),
+                    FieldPanel("url"),
+                ]
+            ),
+            model=Advert,
+            instance=advert_live,
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertIsNotNone(attrs.get("data-action"))
+
+    def test_targets_override_with_empty(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", targets=[]), FieldPanel("slug")]),
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-w-sync-target-value"], "")
+
+    def test_targets_override_with_non_slug_field(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("location", targets=["title"]), FieldPanel("title")]
+            ),
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_title")
+
+    def test_targets_override_with_multiple_fields(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel("title", targets=["cost", "location"]),
+                    FieldPanel("cost"),
+                    FieldPanel("location"),
+                ]
+            ),
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_cost, #id_location")
+
+    def test_classname_override(self):
+
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("title", classname="super-title"), FieldPanel("slug")]
+            )
+        )
+
+        # check default classname is not used
+        self.assertIsNone(html.find(attrs={"class": "w-panel title"}))
+
+        # check custom one is used
+        self.assertIsNotNone(html.find(attrs={"class": "w-panel super-title"}))
+
+    def test_merging_data_attrs(self):
+        widget = forms.TextInput(
+            attrs={
+                "data-controller": "w-clean",
+                "data-action": "w-clean#clean blur->w-clean#clean",
+                "data-w-clean-filters-value": "trim upper",
+                "data-w-sync-target-value": ".will-be-ignored",
+            }
+        )
+
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", widget=widget), FieldPanel("slug")])
+        )
+
+        attrs = html.find("input").attrs
+
+        # data-controller should be merged
+        self.assertEqual(attrs["data-controller"], "w-clean w-sync")
+
+        # data-action should be merged
+        self.assertEqual(
+            attrs["data-action"],
+            " ".join(
+                [
+                    "w-clean#clean blur->w-clean#clean",
+                    "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
+                ]
+            ),
+        )
+
+        # "data-w-sync-target-value" should be ignored if supplied in widget attrs
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_slug")
+
+        # other data attributes should be appended
+        self.assertEqual(attrs["data-w-clean-filters-value"], "trim upper")
+
+    def test_placeholder_override_false(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("title", placeholder=False), FieldPanel("slug")]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertNotIn("placeholder", attrs)
+
+    def test_placeholder_override_none(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", placeholder=None), FieldPanel("slug")])
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertNotIn("placeholder", attrs)
+
+    def test_placeholder_override_empty_string(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", placeholder=""), FieldPanel("slug")])
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertNotIn("placeholder", attrs)
+
+    def test_placeholder_override_via_widget(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "title",
+                        widget=forms.TextInput(
+                            attrs={"placeholder": "My custom placeholder"}
+                        ),
+                    ),
+                    FieldPanel("slug"),
+                ]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["placeholder"], "My custom placeholder")
+
+    def test_placeholder_override_via_widget_over_kwarg(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "title",
+                        placeholder="PANEL placeholder",
+                        widget=forms.TextInput(
+                            attrs={"placeholder": "WIDGET placeholder"}
+                        ),
+                    ),
+                    FieldPanel("slug"),
+                ]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["placeholder"], "WIDGET placeholder")
+
+    def test_placeholder_override_via_widget_over_false_kwarg(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "title",
+                        placeholder=False,
+                        widget=forms.TextInput(
+                            attrs={"placeholder": "WIDGET placeholder"}
+                        ),
+                    ),
+                    FieldPanel("slug"),
+                ]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["placeholder"], "WIDGET placeholder")