From 47df43d722d112d54f5ee4221721700219cef967 Mon Sep 17 00:00:00 2001 From: Antoni Martyniuk Date: Mon, 3 Jul 2023 22:40:18 +0200 Subject: [PATCH] Finish `attrs` support for FieldPanel and other Panels - Closes #10133 - Rework from original PR #10323 - Add documentation --- CHANGELOG.txt | 1 + docs/reference/pages/panels.md | 46 ++++++++ docs/releases/5.1.md | 1 + wagtail/admin/panels/base.py | 1 + .../panels/multi_field_panel_child.html | 2 +- .../wagtailadmin/panels/object_list.html | 4 +- .../wagtailadmin/panels/tabbed_interface.html | 2 +- .../templates/wagtailadmin/shared/panel.html | 27 ++--- wagtail/admin/tests/test_edit_handlers.py | 110 ++++++++++++++++++ 9 files changed, 177 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index edf14163e2..4365f6306d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -18,6 +18,7 @@ Changelog * Auto-select the `StreamField` block when only one block type is declared (Sébastien Corbin) * Add support for more advanced Draftail customisation APIs (Thibaud Colas) * Add the ability to export snippets listing via `SnippetViewSet.list_export` (Sage Abdullah) + * Add support for adding HTML `attrs` on `FieldPanel`, `FieldRowPanel`, `MultiFieldPanel`, and others (Aman Pandey, Antoni Martyniuk, LB (Ben) Johnston) * Fix: Prevent choosers from failing when initial value is an unrecognised ID, e.g. when moving a page from a location where `parent_page_types` would disallow it (Dan Braghis) * Fix: Move comment notifications toggle to the comments side panel (Sage Abdullah) * Fix: Remove comment button on InlinePanel fields (Sage Abdullah) diff --git a/docs/reference/pages/panels.md b/docs/reference/pages/panels.md index 8da2a9352c..6cf2195da0 100644 --- a/docs/reference/pages/panels.md +++ b/docs/reference/pages/panels.md @@ -47,6 +47,10 @@ Here are some built-in panel types that you can use in your panel definitions. T By default, field values from ``StreamField`` or ``RichTextField`` are redacted to prevent rendering of potentially insecure HTML mid-form. You can change this behaviour for custom panel types by overriding ``Panel.format_value_for_display()``. + .. attribute:: FieldPanel.attrs (optional) + + Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute. + ``` ### MultiFieldPanel @@ -63,6 +67,11 @@ Here are some built-in panel types that you can use in your panel definitions. T .. attribute:: MultiFieldPanel.permission (optional) Allows a panel to be selectively shown to users with sufficient permission. Accepts a permission codename such as ``'myapp.change_blog_category'`` - if the logged-in user does not have that permission, the panel will be omitted from the form. Similar to :attr:`FieldPanel.permission`. + + .. attribute:: MultiFieldPanel.attrs (optional) + + Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute. + ``` (inline_panels)= @@ -94,6 +103,10 @@ Here are some built-in panel types that you can use in your panel definitions. T Maximum number of forms a user must submit. + .. attribute:: InlinePanel.attrs (optional) + + Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute. + ``` (multiple_chooser_panel)= @@ -150,6 +163,11 @@ The `MultipleChooserPanel` definition on `BlogPage` would be: .. attribute:: FieldRowPanel.permission (optional) Allows a panel to be selectively shown to users with sufficient permission. Accepts a permission codename such as ``'myapp.change_blog_category'`` - if the logged-in user does not have that permission, the panel will be omitted from the form. Similar to :attr:`FieldPanel.permission`. + + .. attribute:: FieldRowPanel.attrs (optional) + + Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute. + ``` ### HelpPanel @@ -164,6 +182,11 @@ The `MultipleChooserPanel` definition on `BlogPage` would be: .. attribute:: HelpPanel.template Path to a template rendering the full panel HTML. + + .. attribute:: HelpPanel.attrs (optional) + + Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute. + ``` ### PageChooserPanel @@ -323,3 +346,26 @@ To make input or chooser selection mandatory for a field, add [`blank=False`](dj ### Hiding fields Without a top-level panel definition, a `FieldPanel` will be constructed for each field in your model. If you intend to hide a field on the Wagtail page editor, define the field with [`editable=False`](django.db.models.Field.editable). If a field is not present in the panels definition, it will also be hidden. + +(panels_attrs)= + +### Additional HTML attributes + +Use the `attrs` parameter to add custom attributes to the HTML element of the panel. This allows you to specify additional attributes, such as `data-*` attributes. The `attrs` parameter accepts a dictionary where the keys are the attribute names and these will be rendered in the same way as Django's widget `attrs`[https://docs.djangoproject.com/en/stable/ref/forms/widgets/#django.forms.Widget.attrs] where `True` and `False will be treated as HTML5 boolean attributes. + +For example, you can use the `attrs` parameter to integrate your Stimulus controller to the panel: + +```python + content_panels = [ + MultiFieldPanel( + [ + FieldPanel('cover'), + FieldPanel('book_file'), + FieldPanel('publisher', attrs={'data-my-controller-target': 'myTarget'}), + ], + heading="Collection of Book Fields", + classname="collapsed", + attrs={'data-controller': 'my-controller'}, + ), + ] +``` diff --git a/docs/releases/5.1.md b/docs/releases/5.1.md index 05ee58d703..26f4ff450a 100644 --- a/docs/releases/5.1.md +++ b/docs/releases/5.1.md @@ -42,6 +42,7 @@ Thank you to Damilola for his work, and to Google for sponsoring this project. * Auto-select the `StreamField` block when only one block type is declared (Sébastien Corbin) * Add support for more [advanced Draftail customisation APIs](extending_the_draftail_editor_advanced) (Thibaud Colas) * Add the ability to export snippets listing via `SnippetViewSet.list_export` (Sage Abdullah) + * Add support for adding [HTML `attrs`](panels_attrs) on `FieldPanel`, `FieldRowPanel`, `MultiFieldPanel`, and others (Aman Pandey, Antoni Martyniuk, LB (Ben) Johnston) ### Bug fixes diff --git a/wagtail/admin/panels/base.py b/wagtail/admin/panels/base.py index cb576ce528..003b15d20f 100644 --- a/wagtail/admin/panels/base.py +++ b/wagtail/admin/panels/base.py @@ -65,6 +65,7 @@ class Panel: :param help_text: Help text to display within the panel. :param base_form_class: The base form class to use for the panel. Defaults to the model's ``base_form_class``, before falling back to :class:`~wagtail.admin.forms.WagtailAdminModelForm`. This is only relevant for the top-level panel. :param icon: The name of the icon to display next to the panel heading. + :param attrs: A dictionary of HTML attributes to add to the panel's HTML element. """ BASE_ATTRS = {} diff --git a/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html b/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html index 895bb8692c..07674fbe73 100644 --- a/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html +++ b/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html @@ -1,5 +1,5 @@ {% load wagtailadmin_tags %} -
+
{% if child.heading %} {% fragment as label_content %} {{ child.heading }}{% if child.is_required %}*{% endif %} diff --git a/wagtail/admin/templates/wagtailadmin/panels/object_list.html b/wagtail/admin/templates/wagtailadmin/panels/object_list.html index 2fbed0fa3d..f21573a20b 100644 --- a/wagtail/admin/templates/wagtailadmin/panels/object_list.html +++ b/wagtail/admin/templates/wagtailadmin/panels/object_list.html @@ -1,11 +1,11 @@ {% load wagtailadmin_tags %} -
+
{% if self.help_text %} {% help_block status="info" %}{{ self.help_text }}{% endhelp_block %} {% endif %} {% for child, identifier in self.visible_children_with_identifiers %} - {% panel id_prefix=self.prefix id=identifier classname=child.classes|join:' ' heading=child.heading heading_size="label" icon=child.icon id_for_label=child.id_for_label is_required=child.is_required %} + {% panel id_prefix=self.prefix id=identifier classname=child.classes|join:' ' attrs=child.attrs heading=child.heading heading_size="label" icon=child.icon id_for_label=child.id_for_label is_required=child.is_required %} {% component child %} {% endpanel %} {% endfor %} diff --git a/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html b/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html index c9655a5766..458e3893c7 100644 --- a/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html +++ b/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html @@ -4,7 +4,7 @@ {% help_block status="info" %}{{ self.help_text }}{% endhelp_block %} {% endif %} -
+
{% for child, identifier in self.visible_children_with_identifiers %} diff --git a/wagtail/admin/templates/wagtailadmin/shared/panel.html b/wagtail/admin/templates/wagtailadmin/shared/panel.html index 4706cd4428..7f0bb24f2f 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/panel.html +++ b/wagtail/admin/templates/wagtailadmin/shared/panel.html @@ -1,25 +1,26 @@ {% load wagtailadmin_tags i18n %} -{% comment %} +{% comment "text/markdown" %} Variables this template accepts: - id_prefix - A prefix for all id attributes. - classname - String of CSS classes to use for the panel. - id - Unique to the page. - heading - The text of the panel’s heading. - heading_size - The size of the heading. - heading_level - ARIA override to the default heading level (2). - icon - Displayed alongside the heading. - id_for_label - id of an associated field. - is_required - If the panel contains a required field. - children - The panel’s contents. - header_controls - Additional panel buttons to display in the header area. + - `id_prefix` - A prefix for all id attributes. + - `classname` - String of CSS classes to use for the panel. + - `id` - Unique to the page. + - `heading` - The text of the panel’s heading. + - `heading_size` - The size of the heading. + - `heading_level` - ARIA override to the default heading level (2). + - `icon` - Displayed alongside the heading. + - `id_for_label` - id of an associated field. + - `is_required` - If the panel contains a required field. + - `children` - The panel’s contents. + - `header_controls` - Additional panel buttons to display in the header area. + - `attrs` - Additional HTML attributes to render on the panel. {% endcomment %} {% fragment as prefix %}{% if id_prefix %}{{ id_prefix }}-{% endif %}{{ id }}{% endfragment %} {% fragment as panel_id %}{{ prefix }}-section{% endfragment %} {% fragment as heading_id %}{{ prefix }}-heading{% endfragment %} {% fragment as content_id %}{{ prefix }}-content{% endfragment %} -
+
{# If a panel has no heading nor header controls, we don’t want any of the associated UI. #} {% if heading or header_controls %}
diff --git a/wagtail/admin/tests/test_edit_handlers.py b/wagtail/admin/tests/test_edit_handlers.py index d070cee3a3..4965d4b5c5 100644 --- a/wagtail/admin/tests/test_edit_handlers.py +++ b/wagtail/admin/tests/test_edit_handlers.py @@ -20,6 +20,7 @@ from wagtail.admin.panels import ( CommentPanel, FieldPanel, FieldRowPanel, + HelpPanel, InlinePanel, MultiFieldPanel, MultipleChooserPanel, @@ -381,6 +382,115 @@ class TestExtractPanelDefinitionsFromModelClass(TestCase): ) +class TestPanelAttributes(WagtailTestUtils, TestCase): + def setUp(self): + self.request = RequestFactory().get("/") + user = self.create_superuser(username="admin") + self.request.user = user + self.user = self.login() + + # a custom tabbed interface for EventPage + self.event_page_tabbed_interface = TabbedInterface( + [ + ObjectList( + [ + HelpPanel( + "Double-check event details before submit.", + attrs={"data-panel-type": "help"}, + ), + FieldPanel("title", widget=forms.Textarea), + FieldRowPanel( + [ + FieldPanel("date_from"), + FieldPanel( + "date_to", attrs={"data-panel-type": "field"} + ), + ], + attrs={"data-panel-type": "field-row"}, + ), + ], + heading="Event details", + classname="shiny", + attrs={"data-panel-type": "object-list"}, + ), + ObjectList( + [ + InlinePanel( + "speakers", + label="Speakers", + attrs={"data-panel-type": "inline"}, + ), + ], + heading="Speakers", + ), + ObjectList( + [ + MultiFieldPanel( + [ + HelpPanel( + "Double-check cost details before submit.", + attrs={"data-panel-type": "help-cost"}, + ), + FieldPanel("cost"), + FieldRowPanel( + [ + FieldPanel("cost"), + FieldPanel( + "cost", + attrs={ + "data-panel-type": "nested-object_list-multi_field-field_row-field" + }, + ), + ], + attrs={ + "data-panel-type": "nested-object_list-multi_field-field_row" + }, + ), + ], + attrs={"data-panel-type": "multi-field"}, + ) + ], + heading="Secret", + ), + ], + attrs={"data-panel-type": "tabs"}, + ).bind_to_model(EventPage) + + def test_render(self): + EventPageForm = self.event_page_tabbed_interface.get_form_class() + event = EventPage(title="Abergavenny sheepdog trials") + form = EventPageForm(instance=event) + + tabbed_interface = self.event_page_tabbed_interface.get_bound_panel( + instance=event, + form=form, + request=self.request, + ) + + result = tabbed_interface.render_html() + + # result should contain custom data attributes assigned to panels + # each attribute should be rendered exactly once + self.assertEqual(result.count('data-panel-type="tabs"'), 1) + self.assertEqual(result.count('data-panel-type="multi-field"'), 1) + self.assertEqual( + result.count('data-panel-type="nested-object_list-multi_field-field_row"'), + 1, + ) + self.assertEqual( + result.count( + 'data-panel-type="nested-object_list-multi_field-field_row-field"' + ), + 1, + ) + self.assertEqual(result.count('data-panel-type="help-cost"'), 1) + self.assertEqual(result.count('data-panel-type="inline"'), 1) + self.assertEqual(result.count('data-panel-type="object-list"'), 1) + self.assertEqual(result.count('data-panel-type="field-row"'), 1) + self.assertEqual(result.count('data-panel-type="field"'), 1) + self.assertEqual(result.count('data-panel-type="help"'), 1) + + class TestTabbedInterface(WagtailTestUtils, TestCase): def setUp(self): self.request = RequestFactory().get("/")