Browse Source

Merge pull request #11837 from jeremyharris/html5-error-messages

[RFC] HTML5 validation error messages
Mark Story 7 years ago
parent
commit
bf70253576

+ 53 - 14
src/Validation/Validator.php

@@ -104,14 +104,6 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable
     {
         $errors = [];
 
-        $requiredMessage = 'This field is required';
-        $emptyMessage = 'This field cannot be left empty';
-
-        if ($this->_useI18n) {
-            $requiredMessage = __d('cake', 'This field is required');
-            $emptyMessage = __d('cake', 'This field cannot be left empty');
-        }
-
         foreach ($this->_fields as $name => $field) {
             $keyPresent = array_key_exists($name, $data);
 
@@ -119,9 +111,7 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable
             $context = compact('data', 'newRecord', 'field', 'providers');
 
             if (!$keyPresent && !$this->_checkPresence($field, $context)) {
-                $errors[$name]['_required'] = isset($this->_presenceMessages[$name])
-                    ? $this->_presenceMessages[$name]
-                    : $requiredMessage;
+                $errors[$name]['_required'] = $this->getRequiredMessage($name);
                 continue;
             }
             if (!$keyPresent) {
@@ -132,9 +122,7 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable
             $isEmpty = $this->_fieldIsEmpty($data[$name]);
 
             if (!$canBeEmpty && $isEmpty) {
-                $errors[$name]['_empty'] = isset($this->_allowEmptyMessages[$name])
-                    ? $this->_allowEmptyMessages[$name]
-                    : $emptyMessage;
+                $errors[$name]['_empty'] = $this->getNotEmptyMessage($name);
                 continue;
             }
 
@@ -1957,6 +1945,57 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable
     }
 
     /**
+     * Gets the required message for a field
+     *
+     * @param string $field Field name
+     * @return string|null
+     */
+    public function getRequiredMessage($field)
+    {
+        if (!isset($this->_fields[$field])) {
+            return null;
+        }
+
+        $defaultMessage = 'This field is required';
+        if ($this->_useI18n) {
+            $defaultMessage = __d('cake', 'This field is required');
+        }
+
+        return isset($this->_presenceMessages[$field])
+            ? $this->_presenceMessages[$field]
+            : $defaultMessage;
+    }
+
+    /**
+     * Gets the notEmpty message for a field
+     *
+     * @param string $field Field name
+     * @return string|null
+     */
+    public function getNotEmptyMessage($field)
+    {
+        if (!isset($this->_fields[$field])) {
+            return null;
+        }
+
+        $defaultMessage = 'This field cannot be left empty';
+        if ($this->_useI18n) {
+            $defaultMessage = __d('cake', 'This field cannot be left empty');
+        }
+
+        $notBlankMessage = null;
+        foreach ($this->_fields[$field] as $rule) {
+            if ($rule->get('rule') === 'notBlank' && $rule->get('message')) {
+                return $rule->get('message');
+            }
+        }
+
+        return isset($this->_allowEmptyMessages[$field])
+            ? $this->_allowEmptyMessages[$field]
+            : $defaultMessage;
+    }
+
+    /**
      * Returns false if any validation for the passed rule set should be stopped
      * due to the field missing in the data array
      *

+ 27 - 4
src/View/Form/ArrayContext.php

@@ -16,6 +16,7 @@ namespace Cake\View\Form;
 
 use Cake\Http\ServerRequest;
 use Cake\Utility\Hash;
+use Cake\Validation\Validator;
 
 /**
  * Provides a basic array based context provider for FormHelper.
@@ -29,7 +30,8 @@ use Cake\Utility\Hash;
  *   will be used when there is no request data set. Data should be nested following
  *   the dot separated paths you access your fields with.
  * - `required` A nested array of fields, relationships and boolean
- *   flags to indicate a field is required.
+ *   flags to indicate a field is required. The value can also be a string to be used
+ *   as the required error message
  * - `schema` An array of data that emulate the column structures that
  *   Cake\Database\Schema\Schema uses. This array allows you to control
  *   the inferred type for fields and allows auto generation of attributes
@@ -53,7 +55,12 @@ use Cake\Utility\Hash;
  *    'defaults' => [
  *      'id' => 1,
  *      'title' => 'First post!',
- *    ]
+ *    ],
+ *    'required' => [
+ *      'id' => true, // will use default required message
+ *      'title' => 'Please enter a title',
+ *      'body' => false,
+ *    ],
  *  ];
  *  ```
  */
@@ -195,15 +202,31 @@ class ArrayContext implements ContextInterface
      */
     public function isRequired($field)
     {
+        return (bool)$this->getRequiredMessage($field);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getRequiredMessage($field)
+    {
         if (!is_array($this->_context['required'])) {
-            return false;
+            return null;
         }
         $required = Hash::get($this->_context['required'], $field);
         if ($required === null) {
             $required = Hash::get($this->_context['required'], $this->stripNesting($field));
         }
 
-        return (bool)$required;
+        if ($required === false) {
+            return null;
+        }
+
+        if ($required === true) {
+            $required = __d('cake', 'This field is required');
+        }
+
+        return $required;
     }
 
     /**

+ 2 - 0
src/View/Form/ContextInterface.php

@@ -16,6 +16,8 @@ namespace Cake\View\Form;
 
 /**
  * Interface for FormHelper context implementations.
+ *
+ * @method string|null getRequiredMessage($field) Gets the default "required" error message for a field
  */
 interface ContextInterface
 {

+ 28 - 0
src/View/Form/EntityContext.php

@@ -427,6 +427,34 @@ class EntityContext implements ContextInterface
     }
 
     /**
+     * {@inheritDoc}
+     */
+    public function getRequiredMessage($field)
+    {
+        $parts = explode('.', $field);
+
+        $validator = $this->_getValidator($parts);
+        $fieldName = array_pop($parts);
+        if (!$validator->hasField($fieldName)) {
+            return null;
+        }
+
+        $ruleset = $validator->field($fieldName);
+
+        $requiredMessage = $validator->getRequiredMessage($fieldName);
+        $emptyMessage = $validator->getNotEmptyMessage($fieldName);
+
+        if ($ruleset->isPresenceRequired() && $requiredMessage) {
+            return $requiredMessage;
+        }
+        if (!$ruleset->isEmptyAllowed() && $emptyMessage) {
+            return $emptyMessage;
+        }
+
+        return null;
+    }
+
+    /**
      * Get the field names from the top level entity.
      *
      * If the context is for an array of entities, the 0th index will be used.

+ 28 - 0
src/View/Form/FormContext.php

@@ -142,6 +142,34 @@ class FormContext implements ContextInterface
     /**
      * {@inheritDoc}
      */
+    public function getRequiredMessage($field)
+    {
+        $parts = explode('.', $field);
+
+        $validator = $this->_form->getValidator();
+        $fieldName = array_pop($parts);
+        if (!$validator->hasField($fieldName)) {
+            return null;
+        }
+
+        $ruleset = $validator->field($fieldName);
+
+        $requiredMessage = $validator->getRequiredMessage($fieldName);
+        $emptyMessage = $validator->getNotEmptyMessage($fieldName);
+
+        if ($ruleset->isPresenceRequired() && $requiredMessage) {
+            return $requiredMessage;
+        }
+        if (!$ruleset->isEmptyAllowed() && $emptyMessage) {
+            return $emptyMessage;
+        }
+
+        return null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
     public function fieldNames()
     {
         return $this->_form->schema()->fields();

+ 8 - 0
src/View/Form/NullContext.php

@@ -86,6 +86,14 @@ class NullContext implements ContextInterface
     /**
      * {@inheritDoc}
      */
+    public function getRequiredMessage($field)
+    {
+        return null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
     public function fieldNames()
     {
         return [];

+ 21 - 1
src/View/Helper/FormHelper.php

@@ -165,7 +165,9 @@ class FormHelper extends Helper
             'submitContainer' => '<div class="submit">{{content}}</div>',
             //Confirm javascript template for postLink()
             'confirmJs' => '{{confirm}}',
-        ]
+        ],
+        // set HTML5 validation message to custom required/empty messages
+        'autoSetCustomValidity' => false,
     ];
 
     /**
@@ -1443,10 +1445,28 @@ class FormHelper extends Helper
     {
         $context = $this->_getContext();
 
+        $options += [
+            'templateVars' => []
+        ];
+
         if (!isset($options['required']) && $options['type'] !== 'hidden') {
             $options['required'] = $context->isRequired($fieldName);
         }
 
+        if (method_exists($context, 'getRequiredMessage')) {
+            $message = $context->getRequiredMessage($fieldName);
+            $message = h($message);
+
+            if ($options['required'] && $message) {
+                $options['templateVars']['customValidityMessage'] = $message;
+
+                if ($this->getConfig('autoSetCustomValidity')) {
+                    $options['oninvalid'] = "this.setCustomValidity('$message')";
+                    $options['onvalid'] = "this.setCustomValidity('')";
+                }
+            }
+        }
+
         $type = $context->type($fieldName);
         $fieldDef = $context->attributes($fieldName);
 

+ 62 - 1
tests/TestCase/ORM/RulesCheckerIntegrationTest.php

@@ -32,7 +32,8 @@ class RulesCheckerIntegrationTest extends TestCase
      */
     public $fixtures = [
         'core.articles', 'core.articles_tags', 'core.authors', 'core.tags',
-        'core.special_tags', 'core.categories', 'core.site_articles', 'core.site_authors'
+        'core.special_tags', 'core.categories', 'core.site_articles', 'core.site_authors',
+        'core.comments',
     ];
 
     /**
@@ -653,6 +654,66 @@ class RulesCheckerIntegrationTest extends TestCase
     }
 
     /**
+     * Tests existsIn does not prevent new entities from saving if parent entity is new
+     *
+     * @return void
+     */
+    public function testExistsInHasManyNewEntities()
+    {
+        $table = $this->getTableLocator()->get('Articles');
+        $table->hasMany('Comments');
+        $table->Comments->belongsTo('Articles');
+
+        $rules = $table->Comments->rulesChecker();
+        $rules->add($rules->existsIn(['article_id'], $table));
+
+        $article = $table->newEntity([
+            'title' => 'new article',
+            'comments' => [
+                $table->Comments->newEntity([
+                    'user_id' => 1,
+                    'comment' => 'comment 1',
+                ]),
+                $table->Comments->newEntity([
+                    'user_id' => 1,
+                    'comment' => 'comment 2',
+                ]),
+            ]
+        ]);
+
+        $this->assertNotFalse($table->save($article));
+    }
+
+    /**
+     * Tests existsIn does not prevent new entities from saving if parent entity is new,
+     * getting the parent entity from the association
+     *
+     * @return void
+     */
+    public function testExistsInHasManyNewEntitiesViaAssociation()
+    {
+        $table = $this->getTableLocator()->get('Articles');
+        $table->hasMany('Comments');
+        $table->Comments->belongsTo('Articles');
+
+        $rules = $table->Comments->rulesChecker();
+        $rules->add($rules->existsIn(['article_id'], 'Articles'));
+
+        $article = $table->newEntity([
+            'title' => 'test',
+        ]);
+
+        $article->comments = [
+            $table->Comments->newEntity([
+                'user_id' => 1,
+                'comment' => 'test',
+            ])
+        ];
+
+        $this->assertNotFalse($table->save($article));
+    }
+
+    /**
      * Tests the checkRules save option
      *
      * @group save

+ 47 - 0
tests/TestCase/Validation/ValidatorTest.php

@@ -27,6 +27,53 @@ class ValidatorTest extends TestCase
 {
 
     /**
+     * tests getRequiredMessage
+     *
+     * @return void
+     */
+    public function testGetRequiredMessage()
+    {
+        $validator = new Validator();
+        $this->assertNull($validator->getRequiredMessage('field'));
+
+        $validator = new Validator();
+        $validator->requirePresence('field');
+        $this->assertSame('This field is required', $validator->getRequiredMessage('field'));
+
+        $validator = new Validator();
+        $validator->requirePresence('field', true, 'Custom message');
+        $this->assertSame('Custom message', $validator->getRequiredMessage('field'));
+    }
+
+    /**
+     * tests getNotEmptyMessage
+     *
+     * @return void
+     */
+    public function testGetNotEmptyMessage()
+    {
+        $validator = new Validator();
+        $this->assertNull($validator->getNotEmptyMessage('field'));
+
+        $validator = new Validator();
+        $validator->requirePresence('field');
+        $this->assertSame('This field cannot be left empty', $validator->getNotEmptyMessage('field'));
+
+        $validator = new Validator();
+        $validator->notEmpty('field', 'Custom message');
+        $this->assertSame('Custom message', $validator->getNotEmptyMessage('field'));
+
+        $validator = new Validator();
+        $validator->notBlank('field', 'Cannot be blank');
+        $this->assertSame('Cannot be blank', $validator->getNotEmptyMessage('field'));
+
+        $validator = new Validator();
+        $validator->notEmpty('field', 'Cannot be empty');
+        $validator->notBlank('field', 'Cannot be blank');
+        $this->assertSame('Cannot be blank', $validator->getNotEmptyMessage('field'));
+    }
+
+    /**
      * Testing you can dynamically add rules to a field
      *
      * @return void

+ 17 - 0
tests/TestCase/View/Form/ArrayContextTest.php

@@ -35,6 +35,23 @@ class ArrayContextTest extends TestCase
         $this->request = new ServerRequest();
     }
 
+    public function testGetRequiredMessage()
+    {
+        $context = new ArrayContext($this->request, [
+            'required' => [
+                'Comments' => [
+                    'required' => 'My custom message',
+                    'nope' => false,
+                    'tags' => true
+                ]
+            ]
+        ]);
+
+        $this->assertSame('My custom message', $context->getRequiredMessage('Comments.required'));
+        $this->assertSame('This field is required', $context->getRequiredMessage('Comments.tags'));
+        $this->assertSame(null, $context->getRequiredMessage('Comments.nope'));
+    }
+
     /**
      * Test getting the primary key.
      *

+ 20 - 0
tests/TestCase/View/Form/EntityContextTest.php

@@ -67,6 +67,25 @@ class EntityContextTest extends TestCase
     }
 
     /**
+     * tests getRequiredMessage
+     *
+     * @return void
+     */
+    public function testGetRequiredMessage()
+    {
+        $this->_setupTables();
+
+        $context = new EntityContext($this->request, [
+            'entity' => new Article(),
+            'table' => 'Articles',
+            'validator' => 'create',
+        ]);
+
+        $this->assertNull($context->getRequiredMessage('body'));
+        $this->assertSame('Don\'t forget a title!', $context->getRequiredMessage('title'));
+    }
+
+    /**
      * Test getting entity back from context.
      *
      * @return void
@@ -1283,6 +1302,7 @@ class EntityContextTest extends TestCase
         ]);
 
         $validator = new Validator();
+        $validator->requirePresence('title', true, 'Don\'t forget a title!');
         $validator->add('title', 'minlength', [
             'rule' => ['minlength', 10]
         ])

+ 21 - 0
tests/TestCase/View/Form/FormContextTest.php

@@ -44,6 +44,27 @@ class FormContextTest extends TestCase
     }
 
     /**
+     * tests getRequiredMessage
+     *
+     * @return void
+     */
+    public function testGetRequiredMessage()
+    {
+        $validator = new Validator();
+        $validator->requirePresence('title', true, 'Don\'t forget a title!');
+
+        $form = new Form();
+        $form->setValidator(Form::DEFAULT_VALIDATOR, $validator);
+
+        $context = new FormContext($this->request, [
+            'entity' => $form,
+        ]);
+
+        $this->assertNull($context->getRequiredMessage('body'));
+        $this->assertSame('Don\'t forget a title!', $context->getRequiredMessage('title'));
+    }
+
+    /**
      * Test getting the primary key.
      *
      * @return void

+ 160 - 1
tests/TestCase/View/Helper/FormHelperTest.php

@@ -8375,7 +8375,7 @@ class FormHelperTest extends TestCase
                     'name',
                     'required' => 'required',
                     'id' => '0-comments-1-comment',
-                    'rows' => 5
+                    'rows' => 5,
                 ],
                 '/textarea',
             '/div'
@@ -8454,6 +8454,165 @@ class FormHelperTest extends TestCase
     }
 
     /**
+     * tests fields that are required use custom validation messages
+     *
+     * @return void
+     */
+    public function testHtml5ErrorMessage()
+    {
+        $this->Form->setConfig('autoSetCustomValidity', true);
+
+        $validator = (new \Cake\Validation\Validator())
+            ->requirePresence('email', true, 'Custom error message')
+            ->requirePresence('password')
+            ->alphaNumeric('password')
+            ->notBlank('phone');
+
+        $table = $this->getTableLocator()->get('Contacts', [
+            'className' => __NAMESPACE__ . '\ContactsTable'
+        ]);
+        $table->setValidator('default', $validator);
+        $contact = new Entity();
+
+        $this->Form->create($contact, ['context' => ['table' => 'Contacts']]);
+        $this->Form->setTemplates(['inputContainer' => '{{content}}']);
+
+        $result = $this->Form->control('password');
+        $expected = [
+            'label' => ['for' => 'password'],
+            'Password',
+            '/label',
+            'input' => [
+                'id' => 'password',
+                'name' => 'password',
+                'type' => 'password',
+                'value' => '',
+                'required' => 'required',
+                'onvalid' => 'this.setCustomValidity(&#039;&#039;)',
+                'oninvalid' => 'this.setCustomValidity(&#039;This field is required&#039;)',
+            ]
+        ];
+        $this->assertHtml($expected, $result);
+
+        $result = $this->Form->control('phone');
+        $expected = [
+            'label' => ['for' => 'phone'],
+            'Phone',
+            '/label',
+            'input' => [
+                'id' => 'phone',
+                'name' => 'phone',
+                'type' => 'tel',
+                'value' => '',
+                'maxlength' => 255,
+                'required' => 'required',
+                'onvalid' => 'this.setCustomValidity(&#039;&#039;)',
+                'oninvalid' => 'this.setCustomValidity(&#039;This field cannot be left empty&#039;)',
+            ]
+        ];
+        $this->assertHtml($expected, $result);
+
+        $result = $this->Form->control('email');
+        $expected = [
+            'label' => ['for' => 'email'],
+            'Email',
+            '/label',
+            'input' => [
+                'id' => 'email',
+                'name' => 'email',
+                'type' => 'email',
+                'value' => '',
+                'maxlength' => 255,
+                'required' => 'required',
+                'onvalid' => 'this.setCustomValidity(&#039;&#039;)',
+                'oninvalid' => 'this.setCustomValidity(&#039;Custom error message&#039;)',
+            ]
+        ];
+        $this->assertHtml($expected, $result);
+    }
+
+    /**
+     * tests that custom validation messages are in templateVars
+     *
+     * @return void
+     */
+    public function testHtml5ErrorMessageInTemplateVars()
+    {
+        $validator = (new \Cake\Validation\Validator())
+            ->requirePresence('email', true, 'Custom error "message" & entities')
+            ->requirePresence('password')
+            ->alphaNumeric('password')
+            ->notBlank('phone');
+
+        $table = $this->getTableLocator()->get('Contacts', [
+            'className' => __NAMESPACE__ . '\ContactsTable'
+        ]);
+        $table->setValidator('default', $validator);
+        $contact = new Entity();
+
+        $this->Form->create($contact, ['context' => ['table' => 'Contacts']]);
+        $this->Form->setTemplates([
+            'input' => '<input type="{{type}}" name="{{name}}"{{attrs}} data-message="{{customValidityMessage}}" {{custom}}/>',
+            'inputContainer' => '{{content}}'
+        ]);
+
+        $result = $this->Form->control('password');
+        $expected = [
+            'label' => ['for' => 'password'],
+            'Password',
+            '/label',
+            'input' => [
+                'id' => 'password',
+                'name' => 'password',
+                'type' => 'password',
+                'value' => '',
+                'required' => 'required',
+                'data-message' => 'This field is required',
+            ]
+        ];
+        $this->assertHtml($expected, $result);
+
+        $result = $this->Form->control('phone');
+        $expected = [
+            'label' => ['for' => 'phone'],
+            'Phone',
+            '/label',
+            'input' => [
+                'id' => 'phone',
+                'name' => 'phone',
+                'type' => 'tel',
+                'value' => '',
+                'maxlength' => 255,
+                'required' => 'required',
+                'data-message' => 'This field cannot be left empty',
+            ],
+        ];
+        $this->assertHtml($expected, $result);
+
+        $result = $this->Form->control('email', [
+            'templateVars' => [
+                'custom' => 'data-custom="1"'
+            ]
+        ]);
+        $expected = [
+            'label' => ['for' => 'email'],
+            'Email',
+            '/label',
+            'input' => [
+                'id' => 'email',
+                'name' => 'email',
+                'type' => 'email',
+                'value' => '',
+                'maxlength' => 255,
+                'required' => 'required',
+                'data-message' => 'Custom error &quot;message&quot; &amp; entities',
+                'data-custom' => '1',
+            ],
+        ];
+        $this->assertHtml($expected, $result);
+    }
+
+    /**
      * testRequiredAttribute method
      *
      * Tests that formhelper sets required attributes.