Repository: wagtail/wagtail

Issue:
Pages can be created with 'blank' title/seo_title comprising only space characters
Like the title says.  At the very least, a string consisting of all white space characters should be rejected.  A user clearing out the seo_title with the spacebar instead of the delete key currently makes something like

```
{% if self.seo_title %}{{ self.seo_title|title }}{% else %}{{ self.title|title }}{% endif %}
```

output a blank title.  I cannot think of a situation where a seo_title consisting of all whitespace characters is actually good for seo.  A whitespace title is equally confusing.

Since the user cannot see that they have entered whitespace for the title they are not able to figure out what is going on either.



Discussion:

Resolving patch:
diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py
--- a/wagtail/wagtailadmin/views/pages.py
+++ b/wagtail/wagtailadmin/views/pages.py
@@ -20,6 +20,7 @@
 
 from wagtail.wagtailcore import hooks
 from wagtail.wagtailcore.models import Page, PageRevision, get_navigation_menu_items
+from wagtail.wagtailcore.validators import validate_not_whitespace
 
 from wagtail.wagtailadmin import messages
 
@@ -161,6 +162,19 @@ def clean_slug(slug):
             return slug
         form.fields['slug'].clean = clean_slug
 
+        # Validate title and seo_title are not entirely whitespace
+        def clean_title(title):
+            validate_not_whitespace(title)
+            return title
+        form.fields['title'].clean = clean_title
+
+        def clean_seo_title(seo_title):
+            if not seo_title:
+                return ''
+            validate_not_whitespace(seo_title)
+            return seo_title
+        form.fields['seo_title'].clean = clean_seo_title
+
         # Stick another validator into the form to check that the scheduled publishing settings are set correctly
         def clean():
             cleaned_data = form_class.clean(form)
@@ -277,6 +291,20 @@ def clean_slug(slug):
             return slug
         form.fields['slug'].clean = clean_slug
 
+        # Validate title and seo_title are not entirely whitespace
+        def clean_title(title):
+            validate_not_whitespace(title)
+            return title
+        form.fields['title'].clean = clean_title
+
+        def clean_seo_title(seo_title):
+            if not seo_title:
+                return ''
+            validate_not_whitespace(seo_title)
+            return seo_title
+
+        form.fields['seo_title'].clean = clean_seo_title
+
         # Stick another validator into the form to check that the scheduled publishing settings are set correctly
         def clean():
             cleaned_data = form_class.clean(form)
diff --git a/wagtail/wagtailcore/validators.py b/wagtail/wagtailcore/validators.py
new file mode 100644
--- /dev/null
+++ b/wagtail/wagtailcore/validators.py
@@ -0,0 +1,15 @@
+import re
+
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext as _
+
+WHITESPACE_RE = re.compile(r'^\s+$')
+
+
+def validate_not_whitespace(value):
+    """
+    Validate that a value isn't all whitespace, for example in title and
+    seo_title
+    """
+    if value and WHITESPACE_RE.match(value):
+        raise ValidationError(_('Value cannot be entirely whitespace characters'))


Test patch:
diff --git a/wagtail/wagtailadmin/tests/test_pages_views.py b/wagtail/wagtailadmin/tests/test_pages_views.py
--- a/wagtail/wagtailadmin/tests/test_pages_views.py
+++ b/wagtail/wagtailadmin/tests/test_pages_views.py
@@ -186,7 +186,7 @@ def test_create_simplepage_post(self):
 
         # Should be redirected to edit page
         self.assertRedirects(response, reverse('wagtailadmin_pages_edit', args=(page.id, )))
-       
+
         self.assertEqual(page.title, post_data['title'])
         self.assertIsInstance(page, SimplePage)
         self.assertFalse(page.live)
@@ -325,7 +325,7 @@ def test_create_simplepage_post_submit(self):
 
         # Should be redirected to explorer
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
-       
+
         self.assertEqual(page.title, post_data['title'])
         self.assertIsInstance(page, SimplePage)
         self.assertFalse(page.live)
@@ -389,6 +389,20 @@ def test_preview_on_create(self):
         self.assertTrue(response.context['self'].path.startswith(self.root_page.path))
         self.assertEqual(response.context['self'].get_parent(), self.root_page)
 
+    def test_whitespace_titles(self):
+        post_data = {
+            'title': " ",  # Single space on purpose
+            'content': "Some content",
+            'slug': 'hello-world',
+            'action-submit': "Submit",
+            'seo_title': '\t',
+        }
+        response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data)
+
+        # Check that a form error was raised
+        self.assertFormError(response, 'form', 'title', "Value cannot be entirely whitespace characters")
+        self.assertFormError(response, 'form', 'seo_title', "Value cannot be entirely whitespace characters")
+
 
 class TestPageEdit(TestCase, WagtailTestUtils):
     def setUp(self):
@@ -439,7 +453,7 @@ def test_page_edit_post(self):
             'slug': 'hello-world',
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Should be redirected to edit page
         self.assertRedirects(response, reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )))
 
@@ -461,7 +475,7 @@ def test_page_edit_post_when_locked(self):
             'slug': 'hello-world',
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Shouldn't be redirected
         self.assertContains(response, "The page could not be saved as it is locked")
 
@@ -550,7 +564,7 @@ def page_published_handler(sender, instance, **kwargs):
             'action-publish': "Publish",
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Should be redirected to explorer
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
 
@@ -652,7 +666,7 @@ def test_page_edit_post_submit(self):
             'action-submit': "Submit",
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Should be redirected to explorer
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
 
diff --git a/wagtail/wagtailcore/tests/test_validators.py b/wagtail/wagtailcore/tests/test_validators.py
new file mode 100644
--- /dev/null
+++ b/wagtail/wagtailcore/tests/test_validators.py
@@ -0,0 +1,14 @@
+from django.test import TestCase
+from django.core.exceptions import ValidationError
+
+from wagtail.wagtailcore.validators import validate_not_whitespace
+
+
+class TestValidators(TestCase):
+
+    def test_not_whitespace(self):
+        validate_not_whitespace('bar')
+
+        for test_value in (' ', '\t', '\r', '\n', '\r\n'):
+            with self.assertRaises(ValidationError):
+                validate_not_whitespace(test_value)