wagtail: Wagtail InlinePanel subpanels don't honor "collapsible collapsed"

Issue Summary

When overriding the wagtail.admin.panels.InlinePanel class’s child_edit_handler property; the returned MultiFieldPanel does not honor the collapsed class in the templates. I’d like to be able to individually collapse the subpanels of the InlinePanel; this provides a better overview while not taking up too much screenspace.

Steps to Reproduce

  1. Override the panel class like so:
class InlineOfficeHoursPanel(InlinePanel):
    def __init__(self, *args, **kwargs):
        kwargs["min_num"] = 7
        kwargs["max_num"] = 7
        super().__init__(*args, **kwargs)

    @cached_property
    def child_edit_handler(self):
        panels = self.panel_definitions
        child_edit_handler = MultiFieldPanel(panels, heading=self.heading, classname="collapsible collapsed")
        return child_edit_handler.bind_to_model(self.db_field.related_model)

    class BoundPanel(InlinePanel.BoundPanel):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            for index, child in enumerate(self.children):
                child: MultiFieldPanel.BoundPanel

                print(child.classname) # does print "collapsible collapsed".

                ....

  1. Define the panel on any model with a relationship suited for InlinePanel.
  2. Go to the wagtailadmin settings section, click on the office menu item and look at the panel.

Edit
Other, but might be same issue: Panels from other models won’t collapse inside the inline panel either:

    panels = [
        MultiFieldPanel([
            FieldPanel("day"),
            FieldPanel("closed"),
        ], classname="collapsible collapse"), # not working
    ]

Would presumably be able to close this panel, but this is not the case. https://github.com/wagtail/wagtail/issues/11130#issuecomment-1783445118

Expected result: image

Actual results: image

Any other relevant information.

Though this might not be the conventional way of customising wagtail; it is and remains a Panel. The expectation would be for this to work. Though I don’t know how complex logic for this would be; I suspect it would be just as easy as with the heading attribute. Going through the components/InlinePanel.ts makes me think more attributes are not supported; but I don’t know Typescript all too well; and don’t understand the intricacies of how panels are set up all too well.

  • I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

Technical details

  • Wagtail version: 5.1.3, 5.2

About this issue

  • Original URL
  • State: open
  • Created 8 months ago
  • Reactions: 1
  • Comments: 15 (9 by maintainers)

Most upvoted comments

@Temidayo32 adding the collapsible collapsed classes translates to “this element should be collapsible, and it should be collapsed (that is closed) when the page loads”. So the issue is that they are not collapsed when you load the page, but they are collapsible manually as you have demonstrated

@zerolab Thanks for the clarification. @Nigel2392 now, I see what the problem is.

@Nigel2392 let me try to reproduce this

Here’s my full code snippet, save you some time.

(imports might be duplicated; i like to split my models into separate files.)

from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property

from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
from wagtail.admin.panels import (
    FieldPanel,
    InlinePanel,
    FieldRowPanel,
    MultiFieldPanel,
    TabbedInterface,
    ObjectList,
)

from modelcluster.models import ClusterableModel
from django.forms.formsets import BaseFormSet

from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from wagtail.admin.panels import (
    FieldPanel,
    FieldRowPanel,
)

from wagtail.models import Orderable
from modelcluster.fields import ParentalKey


class OfficeHours(Orderable):
    class WeekDays(models.TextChoices):
        MONDAY    = "1", _("Monday")
        TUESDAY   = "2", _("Tuesday")
        WEDNESDAY = "3", _("Wednesday")
        THURSDAY  = "4", _("Thursday")
        FRIDAY    = "5", _("Friday")
        SATURDAY  = "6", _("Saturday")
        SUNDAY    = "7", _("Sunday")

    office = ParentalKey(
        "Office",
        verbose_name=_("Office"),
        on_delete=models.CASCADE,
        related_name="hours",
    )

    day    = models.CharField(
        max_length=1,
        verbose_name=_("Day of Week"),
        help_text=_("Day of the week this time applies to."),
        choices=WeekDays.choices, 
        unique=True,
        blank=False,
        null=False,
    )
    open   = models.TimeField(
        verbose_name=_("Open Time"),
        help_text=_("Time the office opens."),
        blank=True, 
        null=True,
    )
    close  = models.TimeField(
        verbose_name=_("Close Time"),
        help_text=_("Time the office closes."),
        blank=True, 
        null=True,
    )
    closed = models.BooleanField(
        verbose_name=_("Closed"),
        help_text=_("Is the office closed on this day?"),
        default=False,
    )

    panels = [
        FieldRowPanel([
            FieldPanel("day"),
            FieldPanel("closed"),
        ]),
        FieldRowPanel([
            FieldPanel("open"),
            FieldPanel("close"),
        ]),
    ]

    class Meta:
        verbose_name = _("Office Hours")
        verbose_name_plural = _("Office Hours")
        ordering = ["day"]
        constraints = [
            # If closed is true, open and close should be null
            # If not closed and open is specified, close should also be specified and vice versa
            # Else they should both be null
            models.CheckConstraint(
                check=(
                    models.Q(closed=True) & models.Q(open__isnull=True) & models.Q(close__isnull=True)
                ) | (
                    models.Q(closed=False) & models.Q(open__isnull=False) & models.Q(close__isnull=False)
                ) | (
                    models.Q(closed=False) & models.Q(open__isnull=True) & models.Q(close__isnull=True)
                ),
                name="open_close",
            ),
        ]

    def save(self, *args, **kwargs):
        self.sort_order = int(self.day)
        return super().save(*args, **kwargs)

    def clean(self) -> None:
        return super().clean()

    def __str__(self):
        day = self.get_day_display()
        if self.closed:
            return f"{day}: Closed"
        if self.open and self.close:
            return f"{day}: {self.open.strftime('%I:%M %p')} - {self.close.strftime('%I:%M %p')}"
        return f"{day}: Unknown"



class InlineOfficeHoursPanel(InlinePanel):
    def __init__(self, *args, **kwargs):
        kwargs["min_num"] = 7
        kwargs["max_num"] = 7
        super().__init__(*args, **kwargs)

    class BoundPanel(InlinePanel.BoundPanel):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            for index, child in enumerate(self.children):
                child: MultiFieldPanel.BoundPanel
                if child.form.instance and not child.form.instance.day:
                    child.form.initial = {"day": index + 1}
                elif not child.form.instance:
                    child.form.initial = {"day": index + 1}
                child.form.fields["day"].widget.attrs["readonly"] = True # fix later

@register_setting
class Office(BaseSiteSetting, ClusterableModel):

    phone_number = models.CharField(
        verbose_name=_("Phone Number"),
        help_text=_("Phone number for the office."),
        max_length=20,
        blank=True,
        null=True,
    )

    fax_number = models.CharField(
        verbose_name=_("Fax Number"),
        help_text=_("Fax number for the office."),
        max_length=20,
        blank=True,
        null=True,
    )

    email_address = models.EmailField(
        verbose_name=_("Email Address"),
        help_text=_("Email address for the office."),
        max_length=255,
        blank=True,
        null=True,
    )

    panels = [
        FieldPanel("email_address"),
        FieldRowPanel([
            FieldPanel("phone_number"),
            FieldPanel("fax_number"),
        ]),
        InlineOfficeHoursPanel("hours", heading=_("Office Hours"), label=_("Day"), min_num=7, max_num=7),
    ]

    edit_handler = TabbedInterface([
        ObjectList(panels, heading=_("Office")),
    ])

    class Meta:
        verbose_name = _("Office")
        verbose_name_plural = _("Offices")


        

@Nigel2392 let me try to reproduce this