Repository: wagtail/wagtail

Issue:
Validation error for scheduled publishing expiry is not indicated or easy to find
### Issue Summary

If a page has passed it's publishing-schedule expiry date/time, you can no longer save / publish the page, and the validation error is not indicated anywhere except if you know to look inside the **Set Schedule** popup in the "i" section.  You only get a generic "The page could not be saved due to validation errors" message visible.

### Steps to Reproduce
1. set up a blank wagtail project
```sh
wagtail start myproject
cd myproject
./manage.py migrate
./manage.py createsuperuser
./manage.py runserver
```

2. http://localhost:8000/admin/pages/3/edit/
3. "ℹ" icon to get the info-right-sidebar, and in there 'Set Schedule' 
   ![image](https://user-images.githubusercontent.com/1070206/206405325-3961a9b3-7a5c-4b7c-8b19-af84c78cdf4d.png)
4. Set the expires date to the past and click 'Save schedule'  ![image](https://user-images.githubusercontent.com/1070206/206405664-a9636453-2af9-41fb-9118-53a9ff950b31.png)
5. You're now directed back to the page, with the generic error at the top, but no way to know that the validation error is inside the 'Set Schedule' popup. ![image](https://user-images.githubusercontent.com/1070206/206405782-1ec590ac-4292-4a84-b210-d4d32a21f716.png)

### Any other relevant information.

This is especially hard to find on a page which was published, expired eventually, and then you try to edit it later on, not realising that it expired.
eg.
![image](https://user-images.githubusercontent.com/1070206/206406080-14f62f65-1453-45af-a162-4627859387c1.png)

Only after clicking 'i' -> 'Set schedule', can you find the error: 
![image](https://user-images.githubusercontent.com/1070206/206406219-b2b92f92-41cf-42a4-8e28-1ac8ec881baa.png)

At the very least, the "i" icon needs to be highlighted if there's a validation error there - and preferably the "Publishing Schedule" one too?

#### Desired Mockup:

![image](https://user-images.githubusercontent.com/1070206/206407439-73c9dc7f-4532-45ab-8146-d64ac481cb67.png)

Where the "i" is highlighted to show that's where the problem is, likewise the Schedule, and the sidebar is open if there's a problem in that section.


-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: **yes**

### Technical details

-   Python version: 3.8.2
-   Django version: 4.1.4
-   Wagtail version: 4.1.1
-   Browser version: Firefox 107



Discussion:
Thanks for the report! This was mentioned in https://github.com/wagtail/wagtail/discussions/9553#discussioncomment-4029280 but I think this is worth having a separate issue. We'll likely need some feedback from the design team on how to handle this, so I have marked this as such.
The natural course of action would be to open the info panel so that the error is visible. At least that would be my expectation
It's not even visible if you only open the info panel, you need to click "set schedule" to open the schedule modal to see the error.
Hey

Having discussed with @laymonage here are some mockups for how we could make this clearer. Mainly inline with suggestions made above.

In summary:
- Add a red validation error badge to the status icon in the header
- Use the mini alert banner in the panel to reassert there is an error (exact wording tbc)
- Update to the field layout in the modal (stacked rather than side by side fields) to make the layout less busy when there is error text (also a suggested global change to reduce the font-weight of the red error text to match the general help text and achieve better hierarchy between field label and corresponding error message).



![Image](https://github.com/wagtail/wagtail/assets/4907819/582eab1d-0452-4d6e-b82e-4aacc8feae3e)



![Image](https://github.com/wagtail/wagtail/assets/4907819/4d577776-3860-410f-a870-214e0be5cd66)



Ben




Resolving patch:
diff --git a/wagtail/admin/panels/publishing_panel.py b/wagtail/admin/panels/publishing_panel.py
--- a/wagtail/admin/panels/publishing_panel.py
+++ b/wagtail/admin/panels/publishing_panel.py
@@ -5,7 +5,7 @@
 from wagtail.models import Page
 
 from .field_panel import FieldPanel
-from .group import FieldRowPanel, MultiFieldPanel
+from .group import MultiFieldPanel
 
 
 # This allows users to include the publishing panel in their own per-model override
@@ -16,21 +16,17 @@ def __init__(self, **kwargs):
         js_overlay_parent_selector = "#schedule-publishing-dialog"
         updated_kwargs = {
             "children": [
-                FieldRowPanel(
-                    [
-                        FieldPanel(
-                            "go_live_at",
-                            widget=AdminDateTimeInput(
-                                js_overlay_parent_selector=js_overlay_parent_selector,
-                            ),
-                        ),
-                        FieldPanel(
-                            "expire_at",
-                            widget=AdminDateTimeInput(
-                                js_overlay_parent_selector=js_overlay_parent_selector,
-                            ),
-                        ),
-                    ],
+                FieldPanel(
+                    "go_live_at",
+                    widget=AdminDateTimeInput(
+                        js_overlay_parent_selector=js_overlay_parent_selector,
+                    ),
+                ),
+                FieldPanel(
+                    "expire_at",
+                    widget=AdminDateTimeInput(
+                        js_overlay_parent_selector=js_overlay_parent_selector,
+                    ),
                 ),
             ],
             "classname": "publishing",
@@ -50,6 +46,7 @@ def get_context_data(self, parent_context=None):
             context["request"] = self.request
             context["instance"] = self.instance
             context["classname"] = self.classname
+            context["model_opts"] = self.instance._meta
             if isinstance(self.instance, Page):
                 context["page"] = self.instance
             return context
diff --git a/wagtail/admin/ui/side_panels.py b/wagtail/admin/ui/side_panels.py
--- a/wagtail/admin/ui/side_panels.py
+++ b/wagtail/admin/ui/side_panels.py
@@ -10,10 +10,34 @@
 
 
 class BaseSidePanel(Component):
+    class SidePanelToggle(Component):
+        template_name = "wagtailadmin/shared/side_panel_toggle.html"
+        aria_label = ""
+        icon_name = ""
+        has_counter = True
+        counter_classname = ""
+
+        def __init__(self, panel):
+            self.panel = panel
+
+        def get_context_data(self, parent_context):
+            # Inherit classes from fragments defined in slim_header.html
+            inherit = {
+                "nav_icon_button_classes",
+                "nav_icon_classes",
+                "nav_icon_counter_classes",
+            }
+            context = {key: parent_context.get(key) for key in inherit}
+            context["toggle"] = self
+            context["panel"] = self.panel
+            context["count"] = 0
+            return context
+
     def __init__(self, object, request):
         self.object = object
         self.request = request
         self.model = type(self.object)
+        self.toggle = self.SidePanelToggle(panel=self)
 
     def get_context_data(self, parent_context):
         context = {"panel": self, "object": self.object, "request": self.request}
@@ -23,12 +47,23 @@ def get_context_data(self, parent_context):
 
 
 class BaseStatusSidePanel(BaseSidePanel):
+    class SidePanelToggle(BaseSidePanel.SidePanelToggle):
+        aria_label = gettext_lazy("Toggle status")
+        icon_name = "info-circle"
+        counter_classname = "w-bg-critical-200"
+
+        def get_context_data(self, parent_context):
+            context = super().get_context_data(parent_context)
+            form = parent_context.get("form")
+            context["count"] = form and len(
+                form.errors.keys() & {"go_live_at", "expire_at"}
+            )
+            return context
+
     name = "status"
     title = gettext_lazy("Status")
     template_name = "wagtailadmin/shared/side_panels/status.html"
     order = 100
-    toggle_aria_label = gettext_lazy("Toggle status")
-    toggle_icon_name = "info-circle"
 
     def __init__(
         self,
@@ -68,13 +103,15 @@ def get_status_templates(self, context):
 
         return templates
 
-    def get_scheduled_publishing_context(self):
+    def get_scheduled_publishing_context(self, parent_context):
         if not isinstance(self.object, DraftStateMixin):
             return {"draftstate_enabled": False}
 
         context = {
             # Used for hiding the info completely if the model doesn't extend DraftStateMixin
             "draftstate_enabled": True,
+            # Show error message if any of the scheduled publishing fields has errors
+            "schedule_has_errors": False,
             # The dialog toggle can be hidden (e.g. if PublishingPanel is not present)
             # but the scheduled publishing info should still be shown
             "show_schedule_publishing_toggle": self.show_schedule_publishing_toggle,
@@ -92,6 +129,10 @@ def get_scheduled_publishing_context(self):
             "live_expire_at": None,
         }
 
+        # Reuse logic from the toggle to get the count of errors
+        if self.toggle.get_context_data(parent_context)["count"]:
+            context["schedule_has_errors"] = True
+
         # Only consider draft schedule if the object hasn't been created
         # or if there are unpublished changes
         if not self.object.pk or self.object.has_unpublished_changes:
@@ -174,7 +215,7 @@ def get_context_data(self, parent_context):
         context["model_name"] = capfirst(self.model._meta.verbose_name)
         context["base_model_name"] = context["model_name"]
         context["status_templates"] = self.get_status_templates(context)
-        context.update(self.get_scheduled_publishing_context())
+        context.update(self.get_scheduled_publishing_context(parent_context))
         context.update(self.get_lock_context(parent_context))
         if self.object.pk:
             context.update(self.get_usage_context())
@@ -270,12 +311,14 @@ def get_context_data(self, parent_context):
 
 
 class CommentsSidePanel(BaseSidePanel):
+    class SidePanelToggle(BaseSidePanel.SidePanelToggle):
+        aria_label = gettext_lazy("Toggle comments")
+        icon_name = "comment"
+
     name = "comments"
     title = gettext_lazy("Comments")
     template_name = "wagtailadmin/shared/side_panels/comments.html"
     order = 300
-    toggle_aria_label = gettext_lazy("Toggle comments")
-    toggle_icon_name = "comment"
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
@@ -284,12 +327,15 @@ def get_context_data(self, parent_context):
 
 
 class BasePreviewSidePanel(BaseSidePanel):
+    class SidePanelToggle(BaseSidePanel.SidePanelToggle):
+        aria_label = gettext_lazy("Toggle preview")
+        icon_name = "mobile-alt"
+        has_counter = False
+
     name = "preview"
     title = gettext_lazy("Preview")
     template_name = "wagtailadmin/shared/side_panels/preview.html"
     order = 400
-    toggle_aria_label = gettext_lazy("Toggle preview")
-    toggle_icon_name = "mobile-alt"
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
diff --git a/wagtail/snippets/side_panels.py b/wagtail/snippets/side_panels.py
--- a/wagtail/snippets/side_panels.py
+++ b/wagtail/snippets/side_panels.py
@@ -64,7 +64,7 @@ def __init__(
         scheduled_object=None,
     ):
         self.side_panels = []
-        if object.pk or view.locale:
+        if object.pk or view.locale or show_schedule_publishing_toggle:
             self.side_panels += [
                 SnippetStatusSidePanel(
                     object,


Test patch:
diff --git a/wagtail/admin/tests/pages/test_create_page.py b/wagtail/admin/tests/pages/test_create_page.py
--- a/wagtail/admin/tests/pages/test_create_page.py
+++ b/wagtail/admin/tests/pages/test_create_page.py
@@ -32,6 +32,11 @@
 
 
 class TestPageCreation(WagtailTestUtils, TestCase):
+    STATUS_TOGGLE_BADGE_REGEX = (
+        r'data-side-panel-toggle="status"[^<]+<svg[^<]+<use[^<]+</use[^<]+</svg[^<]+'
+        r"<div data-side-panel-toggle-counter[^>]+w-bg-critical-200[^>]+>\s*%(num_errors)s\s*</div>"
+    )
+
     def setUp(self):
         # Find root page
         self.root_page = Page.objects.get(id=2)
@@ -526,6 +531,20 @@ def test_create_simplepage_scheduled_go_live_before_expiry(self):
             "Go live date/time must be before expiry date/time",
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 2
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
         # form should be marked as having unsaved changes for the purposes of the dirty-forms warning
         self.assertContains(response, "alwaysDirty: true")
 
@@ -553,6 +572,20 @@ def test_create_simplepage_scheduled_expire_in_the_past(self):
             response, "form", "expire_at", "Expiry date/time must be in the future"
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 1
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
         # form should be marked as having unsaved changes for the purposes of the dirty-forms warning
         self.assertContains(response, "alwaysDirty: true")
 
diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py
--- a/wagtail/admin/tests/pages/test_edit_page.py
+++ b/wagtail/admin/tests/pages/test_edit_page.py
@@ -49,6 +49,11 @@
 
 
 class TestPageEdit(WagtailTestUtils, TestCase):
+    STATUS_TOGGLE_BADGE_REGEX = (
+        r'data-side-panel-toggle="status"[^<]+<svg[^<]+<use[^<]+</use[^<]+</svg[^<]+'
+        r"<div data-side-panel-toggle-counter[^>]+w-bg-critical-200[^>]+>\s*%(num_errors)s\s*</div>"
+    )
+
     def setUp(self):
         # Find root page
         self.root_page = Page.objects.get(id=2)
@@ -406,7 +411,7 @@ def test_edit_post_scheduled(self):
         # Should show the draft go_live_at and expire_at under the "Once published" label
         self.assertContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
             count=1,
         )
@@ -442,7 +447,7 @@ def test_edit_post_scheduled(self):
 
         self.assertContains(
             response,
-            "This publishing schedule will only take effect after you have published",
+            'This publishing schedule will only take effect after you select the "Publish" option',
         )
 
     def test_edit_post_scheduled_custom_timezone(self):
@@ -499,7 +504,7 @@ def test_edit_post_scheduled_custom_timezone(self):
         # and should be in the user's timezone
         self.assertContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
             count=1,
         )
@@ -536,7 +541,7 @@ def test_edit_post_scheduled_custom_timezone(self):
 
         self.assertContains(
             response,
-            "This publishing schedule will only take effect after you have published",
+            'This publishing schedule will only take effect after you select the "Publish" option',
         )
 
     def test_schedule_panel_without_publish_permission(self):
@@ -583,6 +588,20 @@ def test_edit_scheduled_go_live_before_expiry(self):
             "Go live date/time must be before expiry date/time",
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 2
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
         # form should be marked as having unsaved changes for the purposes of the dirty-forms warning
         self.assertContains(response, "alwaysDirty: true")
 
@@ -606,9 +625,91 @@ def test_edit_scheduled_expire_in_the_past(self):
             response, "form", "expire_at", "Expiry date/time must be in the future"
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 1
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
         # form should be marked as having unsaved changes for the purposes of the dirty-forms warning
         self.assertContains(response, "alwaysDirty: true")
 
+    def test_edit_post_invalid_schedule_with_existing_draft_schedule(self):
+        self.child_page.go_live_at = timezone.now() + datetime.timedelta(days=1)
+        self.child_page.expire_at = timezone.now() + datetime.timedelta(days=2)
+        latest_revision = self.child_page.save_revision()
+
+        go_live_at = timezone.now() + datetime.timedelta(days=10)
+        expire_at = timezone.now() + datetime.timedelta(days=-20)
+        post_data = {
+            "title": "I've been edited!",
+            "content": "Some content",
+            "slug": "hello-world",
+            "go_live_at": submittable_timestamp(go_live_at),
+            "expire_at": submittable_timestamp(expire_at),
+        }
+        edit_url = reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
+        response = self.client.post(edit_url, post_data)
+
+        # Should render the edit page with errors instead of redirecting
+        self.assertEqual(response.status_code, 200)
+
+        child_page_new = SimplePage.objects.get(id=self.child_page.id)
+
+        # The page will still be live
+        self.assertTrue(child_page_new.live)
+
+        # No new revision should have been created
+        self.assertEqual(child_page_new.latest_revision_id, latest_revision.pk)
+
+        # Should not show the draft go_live_at and expire_at under the "Once published" label
+        self.assertNotContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
+            html=True,
+        )
+        self.assertNotContains(
+            response,
+            '<span class="w-text-grey-600">Go-live:</span>',
+            html=True,
+        )
+        self.assertNotContains(
+            response,
+            '<span class="w-text-grey-600">Expiry:</span>',
+            html=True,
+        )
+
+        # Should show the "Edit schedule" button
+        html = response.content.decode()
+        self.assertTagInHTML(
+            '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 2
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
     def test_page_edit_post_publish(self):
         # Connect a mock signal handler to page_published signal
         mock_handler = mock.MagicMock()
@@ -761,7 +862,7 @@ def test_edit_post_publish_scheduled_unpublished_page(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -925,7 +1026,7 @@ def test_edit_post_publish_scheduled_published_page(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -1108,7 +1209,7 @@ def test_edit_post_save_schedule_before_a_scheduled_expire_page(self):
         # Should also show the draft go_live_at and expire_at under the "Once published" label
         self.assertContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
             count=1,
         )
@@ -1218,7 +1319,7 @@ def test_edit_post_publish_schedule_before_a_scheduled_expire_page(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -1322,7 +1423,7 @@ def test_edit_post_publish_schedule_after_a_scheduled_expire_page(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
diff --git a/wagtail/snippets/tests/test_snippets.py b/wagtail/snippets/tests/test_snippets.py
--- a/wagtail/snippets/tests/test_snippets.py
+++ b/wagtail/snippets/tests/test_snippets.py
@@ -790,6 +790,11 @@ def test_locale_selector_not_present_on_non_translatable_snippet(self):
 
 
 class TestCreateDraftStateSnippet(WagtailTestUtils, TestCase):
+    STATUS_TOGGLE_BADGE_REGEX = (
+        r'data-side-panel-toggle="status"[^<]+<svg[^<]+<use[^<]+</use[^<]+</svg[^<]+'
+        r"<div data-side-panel-toggle-counter[^>]+w-bg-critical-200[^>]+>\s*%(num_errors)s\s*</div>"
+    )
+
     def setUp(self):
         self.user = self.login()
 
@@ -817,10 +822,28 @@ def test_get(self):
             response,
             '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning"\n    data-controller="w-progress"\n    data-action="w-progress#activate"\n',
         )
-        # The status side panel should not be shown
-        self.assertNotContains(
+        # The status side panel should be rendered so that the
+        # publishing schedule can be configured
+        self.assertContains(
             response,
-            '<div class="form-side__panel" data-side-panel="status">',
+            '<div class="form-side__panel" data-side-panel="status" hidden>',
+        )
+
+        # The status side panel should show "No publishing schedule set" info
+        self.assertContains(response, "No publishing schedule set")
+
+        # Should show the "Set schedule" button
+        html = response.content.decode()
+        self.assertTagInHTML(
+            '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Set schedule</button>',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        # Should show the correct subtitle in the dialog
+        self.assertContains(
+            response, "Choose when this draft state model should go live and/or expire"
         )
 
         # Should not show the Unpublish action menu item
@@ -1095,6 +1118,20 @@ def test_create_scheduled_go_live_before_expiry(self):
             "Go live date/time must be before expiry date/time",
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 2
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
     def test_create_scheduled_expire_in_the_past(self):
         response = self.post(
             post_data={
@@ -1110,6 +1147,20 @@ def test_create_scheduled_expire_in_the_past(self):
             response, "form", "expire_at", "Expiry date/time must be in the future"
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 1
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
     def test_create_post_publish_scheduled(self):
         go_live_at = now() + datetime.timedelta(days=1)
         expire_at = now() + datetime.timedelta(days=2)
@@ -1506,6 +1557,11 @@ def test_edit_snippet_with_revision(self):
 
 
 class TestEditDraftStateSnippet(BaseTestSnippetEditView):
+    STATUS_TOGGLE_BADGE_REGEX = (
+        r'data-side-panel-toggle="status"[^<]+<svg[^<]+<use[^<]+</use[^<]+</svg[^<]+'
+        r"<div data-side-panel-toggle-counter[^>]+w-bg-critical-200[^>]+>\s*%(num_errors)s\s*</div>"
+    )
+
     def setUp(self):
         super().setUp()
         self.test_snippet = DraftStateCustomPrimaryKeyModel.objects.create(
@@ -1531,6 +1587,21 @@ def test_get(self):
         # The status side panel should show "No publishing schedule set" info
         self.assertContains(response, "No publishing schedule set")
 
+        # Should show the "Set schedule" button
+        html = response.content.decode()
+        self.assertTagInHTML(
+            '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Set schedule</button>',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        # Should show the correct subtitle in the dialog
+        self.assertContains(
+            response,
+            "Choose when this draft state custom primary key model should go live and/or expire",
+        )
+
         # Should not show the Unpublish action menu item
         unpublish_url = reverse(
             "wagtailsnippets_tests_draftstatecustomprimarykeymodel:unpublish",
@@ -2089,7 +2160,7 @@ def test_edit_post_scheduled(self):
         # Should show the draft go_live_at and expire_at under the "Once published" label
         self.assertContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
             count=1,
         )
@@ -2148,6 +2219,20 @@ def test_edit_scheduled_go_live_before_expiry(self):
             "Go live date/time must be before expiry date/time",
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 2
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
     def test_edit_scheduled_expire_in_the_past(self):
         response = self.post(
             post_data={
@@ -2163,6 +2248,86 @@ def test_edit_scheduled_expire_in_the_past(self):
             response, "form", "expire_at", "Expiry date/time must be in the future"
         )
 
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 1
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
+    def test_edit_post_invalid_schedule_with_existing_draft_schedule(self):
+        self.test_snippet.go_live_at = now() + datetime.timedelta(days=1)
+        self.test_snippet.expire_at = now() + datetime.timedelta(days=2)
+        latest_revision = self.test_snippet.save_revision()
+
+        go_live_at = now() + datetime.timedelta(days=10)
+        expire_at = now() + datetime.timedelta(days=-20)
+        response = self.post(
+            post_data={
+                "text": "Some edited content",
+                "go_live_at": submittable_timestamp(go_live_at),
+                "expire_at": submittable_timestamp(expire_at),
+            }
+        )
+
+        # Should render the edit page with errors instead of redirecting
+        self.assertEqual(response.status_code, 200)
+
+        self.test_snippet.refresh_from_db()
+
+        # The snippet will not be live
+        self.assertFalse(self.test_snippet.live)
+
+        # No new revision should have been created
+        self.assertEqual(self.test_snippet.latest_revision_id, latest_revision.pk)
+
+        # Should not show the draft go_live_at and expire_at under the "Once published" label
+        self.assertNotContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
+            html=True,
+        )
+        self.assertNotContains(
+            response,
+            '<span class="w-text-grey-600">Go-live:</span>',
+            html=True,
+        )
+        self.assertNotContains(
+            response,
+            '<span class="w-text-grey-600">Expiry:</span>',
+            html=True,
+        )
+
+        # Should show the "Edit schedule" button
+        html = response.content.decode()
+        self.assertTagInHTML(
+            '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        self.assertContains(
+            response,
+            '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
+            html=True,
+        )
+
+        num_errors = 2
+
+        # Should show the correct number on the badge of the toggle button
+        self.assertRegex(
+            response.content.decode(),
+            self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
+        )
+
     def test_first_published_at_editable(self):
         """Test that we can update the first_published_at via the edit form,
         for models that expose it."""
@@ -2234,7 +2399,7 @@ def test_edit_post_publish_scheduled_unpublished(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -2409,7 +2574,7 @@ def test_edit_post_publish_scheduled_published(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -2599,7 +2764,7 @@ def test_edit_post_save_schedule_before_a_scheduled_expire(self):
         # Should also show the draft go_live_at and expire_at under the "Once published" label
         self.assertContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
             count=1,
         )
@@ -2712,7 +2877,7 @@ def test_edit_post_publish_schedule_before_a_scheduled_expire(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -2827,7 +2992,7 @@ def test_edit_post_publish_schedule_after_a_scheduled_expire(self):
         # Should show the go_live_at and expire_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
         self.assertContains(
@@ -2899,7 +3064,7 @@ def test_edit_get_scheduled_for_publishing_with_publish_permission(self):
         # Should show the go_live_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )
 
@@ -2966,7 +3131,7 @@ def test_edit_get_scheduled_for_publishing_without_publish_permission(self):
         # Should show the go_live_at without the "Once published" label
         self.assertNotContains(
             response,
-            '<div class="w-label-3 w-text-grey-600">Once published:</div>',
+            '<div class="w-label-3 w-text-primary">Once published:</div>',
             html=True,
         )