Commit 215b967a authored by alexpott's avatar alexpott
Browse files

Issue #1493324 by tim.plunkett, dmsmidt, mgifford, bleen18, davidhernandez,...

Issue #1493324 by tim.plunkett, dmsmidt, mgifford, bleen18, davidhernandez, crasx, mparker17, stefan.r, YesCT, joelpittet, tstoeckler, larowlan, vijaycs85, swentel, rpayanm, Bojhan, LewisNyman, emma.maria, BarisW, njbarrett, rteijeiro, nod_, sun, joshtaylor, mrjmd, webchick, marcvangend, kattekrab, SKAUGHT, bowersox, andrewmacpherson, Manjit.Singh, RavindraSingh, Wim Leers, BLadwin, aspilicious, mortendk, mausolos, jessebeach, Gábor Hojtsy, anandps, falcon03, franz, andypost, rooby, rootwork, Cottser, Xano: Inline form errors for accessibility and UX
parent 3dd38370
...@@ -285,10 +285,13 @@ services: ...@@ -285,10 +285,13 @@ services:
arguments: ['@form_validator', '@form_submitter', '@form_cache', '@module_handler', '@event_dispatcher', '@request_stack', '@class_resolver', '@element_info', '@theme.manager', '@?csrf_token'] arguments: ['@form_validator', '@form_submitter', '@form_cache', '@module_handler', '@event_dispatcher', '@request_stack', '@class_resolver', '@element_info', '@theme.manager', '@?csrf_token']
form_validator: form_validator:
class: Drupal\Core\Form\FormValidator class: Drupal\Core\Form\FormValidator
arguments: ['@request_stack', '@string_translation', '@csrf_token', '@logger.channel.form'] arguments: ['@request_stack', '@string_translation', '@csrf_token', '@logger.channel.form', '@form_error_handler']
form_submitter: form_submitter:
class: Drupal\Core\Form\FormSubmitter class: Drupal\Core\Form\FormSubmitter
arguments: ['@request_stack', '@url_generator'] arguments: ['@request_stack', '@url_generator']
form_error_handler:
class: Drupal\Core\Form\FormErrorHandler
arguments: ['@string_translation', '@link_generator']
form_cache: form_cache:
class: Drupal\Core\Form\FormCache class: Drupal\Core\Form\FormCache
arguments: ['@app.root', '@keyvalue.expirable', '@module_handler', '@current_user', '@csrf_token', '@logger.channel.form', '@request_stack', '@page_cache_request_policy'] arguments: ['@app.root', '@keyvalue.expirable', '@module_handler', '@current_user', '@csrf_token', '@logger.channel.form', '@request_stack', '@page_cache_request_policy']
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
use Drupal\Component\Utility\Xss; use Drupal\Component\Utility\Xss;
use Drupal\Core\Database\Database; use Drupal\Core\Database\Database;
use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\FormElementHelper;
use Drupal\Core\Form\OptGroup; use Drupal\Core\Form\OptGroup;
use Drupal\Core\Render\Element; use Drupal\Core\Render\Element;
use Drupal\Core\Template\Attribute; use Drupal\Core\Template\Attribute;
...@@ -203,6 +204,12 @@ function template_preprocess_fieldset(&$variables) { ...@@ -203,6 +204,12 @@ function template_preprocess_fieldset(&$variables) {
// Add the description's id to the fieldset aria attributes. // Add the description's id to the fieldset aria attributes.
$variables['attributes']['aria-describedby'] = $description_id; $variables['attributes']['aria-describedby'] = $description_id;
} }
// Display any error messages.
$variables['errors'] = NULL;
if (!empty($element['#errors']) && empty($element['#error_no_message'])) {
$variables['errors'] = $element['#errors'];
}
} }
/** /**
...@@ -407,7 +414,7 @@ function template_preprocess_form_element(&$variables) { ...@@ -407,7 +414,7 @@ function template_preprocess_form_element(&$variables) {
); );
$variables['attributes'] = $element['#wrapper_attributes']; $variables['attributes'] = $element['#wrapper_attributes'];
// Add element #id for #type 'item'. // Add element #id for #type 'item' and 'password_confirm'.
if (isset($element['#markup']) && !empty($element['#id'])) { if (isset($element['#markup']) && !empty($element['#id'])) {
$variables['attributes']['id'] = $element['#id']; $variables['attributes']['id'] = $element['#id'];
} }
...@@ -423,6 +430,12 @@ function template_preprocess_form_element(&$variables) { ...@@ -423,6 +430,12 @@ function template_preprocess_form_element(&$variables) {
// Pass elements disabled status to template. // Pass elements disabled status to template.
$variables['disabled'] = !empty($element['#attributes']['disabled']) ? $element['#attributes']['disabled'] : NULL; $variables['disabled'] = !empty($element['#attributes']['disabled']) ? $element['#attributes']['disabled'] : NULL;
// Display any error messages.
$variables['errors'] = NULL;
if (!empty($element['#errors']) && empty($element['#error_no_message'])) {
$variables['errors'] = $element['#errors'];
}
// If #title is not set, we don't display any label. // If #title is not set, we don't display any label.
if (!isset($element['#title'])) { if (!isset($element['#title'])) {
$element['#title_display'] = 'none'; $element['#title_display'] = 'none';
......
...@@ -500,6 +500,12 @@ function template_preprocess_datetime_wrapper(&$variables) { ...@@ -500,6 +500,12 @@ function template_preprocess_datetime_wrapper(&$variables) {
$variables['title'] = $element['#title']; $variables['title'] = $element['#title'];
} }
// Display any error messages.
$variables['errors'] = NULL;
if (!empty($element['#errors']) && empty($element['#error_no_message'])) {
$variables['errors'] = $element['#errors'];
}
if (!empty($element['#description'])) { if (!empty($element['#description'])) {
$variables['description'] = $element['#description']; $variables['description'] = $element['#description'];
} }
......
...@@ -265,6 +265,7 @@ public static function processDatelist(&$element, FormStateInterface $form_state ...@@ -265,6 +265,7 @@ public static function processDatelist(&$element, FormStateInterface $form_state
'#attributes' => $element['#attributes'], '#attributes' => $element['#attributes'],
'#options' => $options, '#options' => $options,
'#required' => $element['#required'], '#required' => $element['#required'],
'#error_no_message' => TRUE,
); );
} }
......
...@@ -267,6 +267,7 @@ public static function processDatetime(&$element, FormStateInterface $form_state ...@@ -267,6 +267,7 @@ public static function processDatetime(&$element, FormStateInterface $form_state
'#attributes' => $element['#attributes'] + $extra_attributes, '#attributes' => $element['#attributes'] + $extra_attributes,
'#required' => $element['#required'], '#required' => $element['#required'],
'#size' => max(12, strlen($element['#value']['date'])), '#size' => max(12, strlen($element['#value']['date'])),
'#error_no_message' => TRUE,
); );
// Allows custom callbacks to alter the element. // Allows custom callbacks to alter the element.
...@@ -298,6 +299,7 @@ public static function processDatetime(&$element, FormStateInterface $form_state ...@@ -298,6 +299,7 @@ public static function processDatetime(&$element, FormStateInterface $form_state
'#attributes' => $element['#attributes'] + $extra_attributes, '#attributes' => $element['#attributes'] + $extra_attributes,
'#required' => $element['#required'], '#required' => $element['#required'],
'#size' => 12, '#size' => 12,
'#error_no_message' => TRUE,
); );
// Allows custom callbacks to alter the element. // Allows custom callbacks to alter the element.
......
<?php
/**
* @file
* Contains \Drupal\Core\Form\FormElementHelper.
*/
namespace Drupal\Core\Form;
use Drupal\Core\Render\Element;
/**
* Provides common functionality for form elements.
*/
class FormElementHelper {
/**
* Retrieves a form element.
*
* @param string $name
* The name of the form element. If the #parents property of your form
* element is ['foo', 'bar', 'baz'] then the name is 'foo][bar][baz'.
* @param array $form
* An associative array containing the structure of the form.
*
* @return array
* The form element.
*/
public static function getElementByName($name, array $form) {
foreach (Element::children($form) as $key) {
if (implode('][', $form[$key]['#parents']) === $name) {
return $form[$key];
}
elseif ($element = static::getElementByName($name, $form[$key])) {
return $element;
}
}
return [];
}
/**
* Returns the title for the element.
*
* If the element has no title, this will recurse through all children of the
* element until a title is found.
*
* @param array $element
* An associative array containing the properties of the form element.
*
* @return string
* The title of the element, or an empty string if none is found.
*/
public static function getElementTitle(array $element) {
$title = '';
if (isset($element['#title'])) {
$title = $element['#title'];
}
else {
foreach (Element::children($element) as $key) {
if ($title = static::getElementTitle($element[$key])) {
break;
}
}
}
return $title;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Form\FormErrorHandler.
*/
namespace Drupal\Core\Form;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\LinkGeneratorTrait;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Url;
use Drupal\Core\Utility\LinkGeneratorInterface;
/**
* Handles form errors.
*/
class FormErrorHandler implements FormErrorHandlerInterface {
use StringTranslationTrait;
use LinkGeneratorTrait;
/**
* Constructs a new FormErrorHandler.
*
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
* @param \Drupal\Core\Utility\LinkGeneratorInterface $link_generator
* The link generation service.
*/
public function __construct(TranslationInterface $string_translation, LinkGeneratorInterface $link_generator) {
$this->stringTranslation = $string_translation;
$this->linkGenerator = $link_generator;
}
/**
* {@inheritdoc}
*/
public function handleFormErrors(array &$form, FormStateInterface $form_state) {
// After validation check if there are errors.
if ($errors = $form_state->getErrors()) {
// Display error messages for each element.
$this->displayErrorMessages($form, $form_state);
// Loop through and assign each element its errors.
$this->setElementErrorsFromFormState($form, $form_state);
}
return $this;
}
/**
* Loops through and displays all form errors.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function displayErrorMessages(array $form, FormStateInterface $form_state) {
$error_links = [];
$errors = $form_state->getErrors();
// Loop through all form errors and check if we need to display a link.
foreach ($errors as $name => $error) {
$form_element = FormElementHelper::getElementByName($name, $form);
$title = FormElementHelper::getElementTitle($form_element);
// Only show links to erroneous elements that are visible.
$is_visible_element = Element::isVisibleElement($form_element);
// Only show links for elements that have a title themselves or have
// children with a title.
$has_title = !empty($title);
// Only show links for elements with an ID.
$has_id = !empty($form_element['#id']);
// Do not show links to elements with suppressed messages. Most often
// their parent element is used for inline errors.
if (!empty($form_element['#error_no_message'])) {
unset($errors[$name]);
}
elseif ($is_visible_element && $has_title && $has_id) {
// We need to pass this through SafeMarkup::escape() so
// drupal_set_message() does not encode the links.
$error_links[] = SafeMarkup::escape($this->l($title, Url::fromRoute('<none>', [], ['fragment' => $form_element['#id'], 'external' => TRUE])));
unset($errors[$name]);
}
}
// Set normal error messages for all remaining errors.
foreach ($errors as $error) {
$this->drupalSetMessage($error, 'error');
}
if (!empty($error_links)) {
$message = $this->formatPlural(count($error_links), '1 error has been found: !errors', '@count errors have been found: !errors', [
'!errors' => SafeMarkup::set(implode(', ', $error_links)),
]);
$this->drupalSetMessage($message, 'error');
}
}
/**
* Stores the errors of each element directly on the element.
*
* We must provide a way for non-form functions to check the errors for a
* specific element. The most common usage of this is a #pre_render callback.
*
* @param array $elements
* An associative array containing the structure of a form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function setElementErrorsFromFormState(array &$elements, FormStateInterface &$form_state) {
// Recurse through all children.
foreach (Element::children($elements) as $key) {
if (isset($elements[$key]) && $elements[$key]) {
$this->setElementErrorsFromFormState($elements[$key], $form_state);
}
}
// Store the errors for this element on the element directly.
$elements['#errors'] = $form_state->getError($elements);
}
/**
* Wraps drupal_set_message().
*
* @codeCoverageIgnore
*/
protected function drupalSetMessage($message = NULL, $type = 'status', $repeat = FALSE) {
drupal_set_message($message, $type, $repeat);
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Form\FormErrorHandlerInterface.
*/
namespace Drupal\Core\Form;
/**
* Provides an interface for handling form errors.
*/
interface FormErrorHandlerInterface {
/**
* Handles form errors after form validation.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return $this
*/
public function handleFormErrors(array &$form, FormStateInterface $form_state);
}
...@@ -1090,9 +1090,6 @@ public function setErrorByName($name, $message = '') { ...@@ -1090,9 +1090,6 @@ public function setErrorByName($name, $message = '') {
$errors[$name] = $message; $errors[$name] = $message;
$this->errors = $errors; $this->errors = $errors;
static::setAnyErrors(); static::setAnyErrors();
if ($message) {
$this->drupalSetMessage($message, 'error');
}
} }
} }
...@@ -1119,7 +1116,7 @@ public function clearErrors() { ...@@ -1119,7 +1116,7 @@ public function clearErrors() {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function getError(array $element) { public function getError(array $element) {
if ($errors = $this->getErrors($this)) { if ($errors = $this->getErrors()) {
$parents = array(); $parents = array();
foreach ($element['#parents'] as $parent) { foreach ($element['#parents'] as $parent) {
$parents[] = $parent; $parents[] = $parent;
...@@ -1244,15 +1241,6 @@ public function cleanValues() { ...@@ -1244,15 +1241,6 @@ public function cleanValues() {
return $this; return $this;
} }
/**
* Wraps drupal_set_message().
*
* @return array|null
*/
protected function drupalSetMessage($message = NULL, $type = 'status', $repeat = FALSE) {
return drupal_set_message($message, $type, $repeat);
}
/** /**
* Wraps ModuleHandler::loadInclude(). * Wraps ModuleHandler::loadInclude().
*/ */
......
...@@ -417,7 +417,8 @@ public static function hasAnyErrors(); ...@@ -417,7 +417,8 @@ public static function hasAnyErrors();
* indicate which element needs to be changed and provide an error message. * indicate which element needs to be changed and provide an error message.
* This causes the Form API to not execute the form submit handlers, and * This causes the Form API to not execute the form submit handlers, and
* instead to re-display the form to the user with the corresponding elements * instead to re-display the form to the user with the corresponding elements
* rendered with an 'error' CSS class (shown as red by default). * rendered with an 'error' CSS class (shown as red by default) and the error
* message near the element.
* *
* The standard behavior of this method can be changed if a button provides * The standard behavior of this method can be changed if a button provides
* the #limit_validation_errors property. Multistep forms not wanting to * the #limit_validation_errors property. Multistep forms not wanting to
......
...@@ -44,6 +44,13 @@ class FormValidator implements FormValidatorInterface { ...@@ -44,6 +44,13 @@ class FormValidator implements FormValidatorInterface {
*/ */
protected $logger; protected $logger;
/**
* The form error handler.
*
* @var \Drupal\Core\Form\FormErrorHandlerInterface
*/
protected $formErrorHandler;
/** /**
* Constructs a new FormValidator. * Constructs a new FormValidator.
* *
...@@ -55,12 +62,15 @@ class FormValidator implements FormValidatorInterface { ...@@ -55,12 +62,15 @@ class FormValidator implements FormValidatorInterface {
* The CSRF token generator. * The CSRF token generator.
* @param \Psr\Log\LoggerInterface $logger * @param \Psr\Log\LoggerInterface $logger
* A logger instance. * A logger instance.
* @param \Drupal\Core\Form\FormErrorHandlerInterface $form_error_handler
* The form error handler.
*/ */
public function __construct(RequestStack $request_stack, TranslationInterface $string_translation, CsrfTokenGenerator $csrf_token, LoggerInterface $logger) { public function __construct(RequestStack $request_stack, TranslationInterface $string_translation, CsrfTokenGenerator $csrf_token, LoggerInterface $logger, FormErrorHandlerInterface $form_error_handler) {
$this->requestStack = $request_stack; $this->requestStack = $request_stack;
$this->stringTranslation = $string_translation; $this->stringTranslation = $string_translation;
$this->csrfToken = $csrf_token; $this->csrfToken = $csrf_token;
$this->logger = $logger; $this->logger = $logger;
$this->formErrorHandler = $form_error_handler;
} }
/** /**
...@@ -184,8 +194,9 @@ protected function handleErrorsWithLimitedValidation(&$form, FormStateInterface ...@@ -184,8 +194,9 @@ protected function handleErrorsWithLimitedValidation(&$form, FormStateInterface
* The unique string identifying the form. * The unique string identifying the form.
*/ */
protected function finalizeValidation(&$form, FormStateInterface &$form_state, $form_id) { protected function finalizeValidation(&$form, FormStateInterface &$form_state, $form_id) {
// After validation, loop through and assign each element its errors. // Delegate handling of form errors to a service.
$this->setElementErrorsFromFormState($form, $form_state); $this->formErrorHandler->handleFormErrors($form, $form_state);
// Mark this form as validated. // Mark this form as validated.
$form_state->setValidationComplete(); $form_state->setValidationComplete();
} }
...@@ -394,26 +405,4 @@ protected function determineLimitValidationErrors(FormStateInterface &$form_stat ...@@ -394,26 +405,4 @@ protected function determineLimitValidationErrors(FormStateInterface &$form_stat
} }
} }
/**
* Stores the errors of each element directly on the element.
*
* We must provide a way for non-form functions to check the errors for a
* specific element. The most common usage of this is a #pre_render callback.
*
* @param array $elements
* An associative array containing the structure of a form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function setElementErrorsFromFormState(array &$elements, FormStateInterface &$form_state) {
// Recurse through all children.
foreach (Element::children($elements) as $key) {
if (isset($elements[$key]) && $elements[$key]) {
$this->setElementErrorsFromFormState($elements[$key], $form_state);
}
}
// Store the errors for this element on the element directly.
$elements['#errors'] = $form_state->getError($elements);
}
} }
...@@ -142,7 +142,7 @@ public static function getVisibleChildren(array $elements) { ...@@ -142,7 +142,7 @@ public static function getVisibleChildren(array $elements) {
} }
// Skip value and hidden elements, since they are not rendered. // Skip value and hidden elements, since they are not rendered.
if (isset($child['#type']) && in_array($child['#type'], array('value', 'hidden'))) { if (!static::isVisibleElement($child)) {
continue; continue;
} }
...@@ -152,6 +152,19 @@ public static function getVisibleChildren(array $elements) { ...@@ -152,6 +152,19 @@ public static function getVisibleChildren(array $elements) {
return array_keys($visible_children); return array_keys($visible_children);
} }
/**
* Determines if an element is visible.
*
* @param array $element
* The element to check for visibility.
*
* @return bool
* TRUE if the element is visible, otherwise FALSE.
*/
public static function isVisibleElement($element) {
return (!isset($element['#type']) || !in_array($element['#type'], ['value', 'hidden', 'token'])) && (!isset($element['#access']) || $element['#access']);
}
/** /**
* Sets HTML attributes based on element properties. * Sets HTML attributes based on element properties.
* *
......
...@@ -86,6 +86,8 @@ public static function processCheckboxes(&$element, FormStateInterface $form_sta ...@@ -86,6 +86,8 @@ public static function processCheckboxes(&$element, FormStateInterface $form_sta
'#default_value' => isset($value[$key]) ? $key : NULL, '#default_value' => isset($value[$key]) ? $key : NULL,
'#attributes' => $element['#attributes'], '#attributes' => $element['#attributes'],
'#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL, '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
// Errors should only be shown on the parent checkboxes element.
'#error_no_message' => TRUE,
'#weight' => $weight, '#weight' => $weight,
); );
} }
......
...@@ -26,6 +26,7 @@ public function getInfo() { ...@@ -26,6 +26,7 @@ public function getInfo() {
$class = get_class($this); $class = get_class($this);
return array( return array(
'#input' => TRUE, '#input' => TRUE,
'#markup' => '',
'#process' => array( '#process' => array(
array($class, 'processPasswordConfirm'), array($class, 'processPasswordConfirm'),
), ),
...@@ -53,6 +54,7 @@ public static function processPasswordConfirm(&$element, FormStateInterface $for ...@@ -53,6 +54,7 @@ public static function processPasswordConfirm(&$element, FormStateInterface $for
'#value' => empty($element['#value']) ? NULL : $element['#value']['pass1'], '#value' => empty($element['#value']) ? NULL : $element['#value']['pass1'],