Preserve required asterisks on validation-deferred fields when re-showing form after an error

pull/12964/head
Matt Westcott 2025-03-04 21:07:52 +00:00 zatwierdzone przez Matt Westcott
rodzic 2ab55ac3c8
commit 5015eddef3
9 zmienionych plików z 179 dodań i 2 usunięć

Wyświetl plik

@ -126,15 +126,27 @@ class WagtailAdminModelForm(
def __init__(self, *args, **kwargs):
# keep hold of the `for_user` kwarg as well as passing it on to PermissionedForm
self.for_user = kwargs.get("for_user")
self.deferred_required_fields = []
super().__init__(*args, **kwargs)
def defer_required_fields(self):
if self.deferred_required_fields:
# defer_required_fields has already been called
return
for field_name in self._meta.defer_required_on_fields:
try:
self.fields[field_name].required = False
if self.fields[field_name].required:
self.fields[field_name].required = False
self.deferred_required_fields.append(field_name)
except KeyError:
pass
def restore_required_fields(self):
for field_name in self.deferred_required_fields:
self.fields[field_name].required = True
self.deferred_required_fields = []
class Meta:
formfield_callback = formfield_for_dbfield

Wyświetl plik

@ -621,6 +621,58 @@ class TestPageCreation(WagtailTestUtils, TestCase):
self.assertEqual(page.date_from, None)
self.assertFalse(page.live)
def test_required_asterisk_on_reshowing_form(self):
"""
If a form is reshown due to a validation error elsewhere, fields whose validation
was deferred should still show the required asterisk.
"""
post_data = {
"title": "Event page",
"date_from": "",
"slug": "event-page",
"audience": "public",
"location": "",
"cost": "Free",
"signup_link": "Not a valid URL",
"carousel_items-TOTAL_FORMS": 0,
"carousel_items-INITIAL_FORMS": 0,
"carousel_items-MIN_NUM_FORMS": 0,
"carousel_items-MAX_NUM_FORMS": 0,
"speakers-TOTAL_FORMS": 0,
"speakers-INITIAL_FORMS": 0,
"speakers-MIN_NUM_FORMS": 0,
"speakers-MAX_NUM_FORMS": 0,
"related_links-TOTAL_FORMS": 0,
"related_links-INITIAL_FORMS": 0,
"related_links-MIN_NUM_FORMS": 0,
"related_links-MAX_NUM_FORMS": 0,
"head_counts-TOTAL_FORMS": 0,
"head_counts-INITIAL_FORMS": 0,
"head_counts-MIN_NUM_FORMS": 0,
"head_counts-MAX_NUM_FORMS": 0,
}
response = self.client.post(
reverse(
"wagtailadmin_pages:add",
args=("tests", "eventpage", self.root_page.id),
),
post_data,
)
self.assertEqual(response.status_code, 200)
# Empty fields should not cause a validation error, but the invalid URL should
self.assertNotContains(response, "This field is required.")
self.assertContains(response, "Enter a valid URL.", count=1)
# Asterisks should still show against required fields
soup = self.get_soup(response.content)
self.assertTrue(
soup.select_one('label[for="id_date_from"] > span.w-required-mark')
)
self.assertTrue(
soup.select_one('label[for="id_location"] > span.w-required-mark')
)
def test_cannot_publish_eventpage_post_with_blank_start_date(self):
"""
EventPage.date_from has null=True and blank=False; the latter is enforced when

Wyświetl plik

@ -578,6 +578,58 @@ class TestPageEdit(WagtailTestUtils, TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "This field is required.")
def test_required_asterisk_on_reshowing_form(self):
"""
If a form is reshown due to a validation error elsewhere, fields whose validation
was deferred should still show the required asterisk.
"""
post_data = {
"title": "Event page",
"date_from": "",
"slug": "event-page",
"audience": "public",
"location": "",
"cost": "Free",
"signup_link": "Not a valid URL",
"carousel_items-TOTAL_FORMS": 0,
"carousel_items-INITIAL_FORMS": 0,
"carousel_items-MIN_NUM_FORMS": 0,
"carousel_items-MAX_NUM_FORMS": 0,
"speakers-TOTAL_FORMS": 0,
"speakers-INITIAL_FORMS": 0,
"speakers-MIN_NUM_FORMS": 0,
"speakers-MAX_NUM_FORMS": 0,
"related_links-TOTAL_FORMS": 0,
"related_links-INITIAL_FORMS": 0,
"related_links-MIN_NUM_FORMS": 0,
"related_links-MAX_NUM_FORMS": 0,
"head_counts-TOTAL_FORMS": 0,
"head_counts-INITIAL_FORMS": 0,
"head_counts-MIN_NUM_FORMS": 0,
"head_counts-MAX_NUM_FORMS": 0,
}
response = self.client.post(
reverse(
"wagtailadmin_pages:edit",
args=[self.event_page.id],
),
post_data,
)
self.assertEqual(response.status_code, 200)
# Empty fields should not cause a validation error, but the invalid URL should
self.assertNotContains(response, "This field is required.")
self.assertContains(response, "Enter a valid URL.", count=1)
# Asterisks should still show against required fields
soup = self.get_soup(response.content)
self.assertTrue(
soup.select_one('label[for="id_date_from"] > span.w-required-mark')
)
self.assertTrue(
soup.select_one('label[for="id_location"] > span.w-required-mark')
)
def test_page_edit_post_when_locked(self):
# Tests that trying to edit a locked page results in an error

Wyświetl plik

@ -56,3 +56,13 @@ class TestDeferRequiredFields(TestCase):
)
form.defer_required_fields()
self.assertTrue(form.is_valid())
form = AdvertForm(
{
"url": "https://www.example.com",
"text": "",
}
)
form.defer_required_fields()
form.restore_required_fields()
self.assertFalse(form.is_valid())

Wyświetl plik

@ -751,8 +751,12 @@ class CreateEditViewOptionalFeaturesMixin:
# If saving as draft, do not enforce full validation
if self.saving_as_draft and isinstance(form, WagtailAdminModelForm):
form.defer_required_fields()
form_is_valid = form.is_valid()
form.restore_required_fields()
else:
form_is_valid = form.is_valid()
if form.is_valid():
if form_is_valid:
return self.form_valid(form)
else:
return self.form_invalid(form)

Wyświetl plik

@ -157,6 +157,7 @@ class CreateView(WagtailAdminTemplateMixin, HookResponseMixin, View):
if self.form.is_valid():
return self.form_valid(self.form)
else:
self.form.restore_required_fields()
return self.form_invalid(self.form)
@cached_property

Wyświetl plik

@ -495,6 +495,7 @@ class EditView(WagtailAdminTemplateMixin, HookResponseMixin, View):
if self.form.is_valid() and not self.locked_for_user:
return self.form_valid(self.form)
else:
self.form.restore_required_fields()
return self.form_invalid(self.form)
def workflow_action_is_valid(self):

Wyświetl plik

@ -1401,6 +1401,25 @@ class TestCreateDraftStateSnippet(WagtailTestUtils, TestCase):
self.assertEqual(log_entry.revision, snippet.latest_revision)
self.assertEqual(log_entry.label, f"DraftStateModel object ({snippet.pk})")
def test_required_asterisk_on_reshowing_form(self):
"""
If a form is reshown due to a validation error elsewhere, fields whose validation
was deferred should still show the required asterisk.
"""
response = self.client.post(
reverse("some_namespace:add"),
{"text": "", "country_code": "UK", "some_number": "meef"},
)
self.assertEqual(response.status_code, 200)
# The empty text should not cause a validation error, but the invalid number should
self.assertNotContains(response, "This field is required.")
self.assertContains(response, "Enter a whole number.", count=1)
soup = self.get_soup(response.content)
self.assertTrue(soup.select_one('label[for="id_text"] > span.w-required-mark'))
def test_create_will_not_publish_invalid_snippet(self):
response = self.post(
post_data={"text": "", "action-publish": "Publish"},
@ -2374,6 +2393,30 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
f"DraftStateCustomPrimaryKeyModel object ({self.test_snippet.pk})",
)
def test_required_asterisk_on_reshowing_form(self):
"""
If a form is reshown due to a validation error elsewhere, fields whose validation
was deferred should still show the required asterisk.
"""
snippet = FullFeaturedSnippet.objects.create(
text="Hello world",
country_code="UK",
some_number=42,
)
response = self.client.post(
reverse("some_namespace:edit", args=[snippet.pk]),
{"text": "", "country_code": "UK", "some_number": "meef"},
)
self.assertEqual(response.status_code, 200)
# The empty text should not cause a validation error, but the invalid number should
self.assertNotContains(response, "This field is required.")
self.assertContains(response, "Enter a whole number.", count=1)
soup = self.get_soup(response.content)
self.assertTrue(soup.select_one('label[for="id_text"] > span.w-required-mark'))
def test_cannot_publish_invalid(self):
# Connect a mock signal handler to published signal
mock_handler = mock.MagicMock()

Wyświetl plik

@ -1255,6 +1255,8 @@ class FullFeaturedSnippet(
for_concrete_model=False,
)
panels = ["text", "country_code", "some_number"]
search_fields = [
index.SearchField("text"),
index.AutocompleteField("text"),