diff --git a/core/core.services.yml b/core/core.services.yml
index 12f3a3c..c73a3ca 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -115,7 +115,7 @@ services:
arguments: [default]
form_builder:
class: Drupal\Core\Form\FormBuilder
- arguments: ['@module_handler', '@keyvalue.expirable', '@event_dispatcher', '@url_generator', '@string_translation', '@?csrf_token', '@?http_kernel']
+ arguments: ['@module_handler', '@keyvalue.expirable', '@event_dispatcher', '@url_generator', '@string_translation', '@link_generator', '@?csrf_token', '@?http_kernel']
calls:
- [setRequest, ['@?request']]
keyvalue:
diff --git a/core/includes/form.inc b/core/includes/form.inc
index 2e8e9e1..40103cd 100644
--- a/core/includes/form.inc
+++ b/core/includes/form.inc
@@ -1105,6 +1105,7 @@ function form_process_password_confirm($element) {
'#value' => empty($element['#value']) ? NULL : $element['#value']['pass1'],
'#required' => $element['#required'],
'#attributes' => array('class' => array('password-field')),
+ '#error_use_parent' => TRUE,
);
$element['pass2'] = array(
'#type' => 'password',
@@ -1112,6 +1113,7 @@ function form_process_password_confirm($element) {
'#value' => empty($element['#value']) ? NULL : $element['#value']['pass2'],
'#required' => $element['#required'],
'#attributes' => array('class' => array('password-confirm')),
+ '#error_use_parent' => TRUE,
);
$element['#element_validate'] = array('password_confirm_validate');
$element['#tree'] = TRUE;
@@ -1215,6 +1217,8 @@ function form_process_radios($element) {
'#parents' => $element['#parents'],
'#id' => drupal_html_id('edit-' . implode('-', $parents_for_id)),
'#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+ // Errors should only be shown on the parent radios element.
+ '#error_use_parent' => TRUE,
'#weight' => $weight,
);
}
@@ -1365,6 +1369,8 @@ function form_process_checkboxes($element) {
'#default_value' => isset($value[$key]) ? $key : NULL,
'#attributes' => $element['#attributes'],
'#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+ // Errors should only be shown on the parent checkboxes element.
+ '#error_use_parent' => TRUE,
'#weight' => $weight,
);
}
@@ -2771,8 +2777,11 @@ function theme_form_element($variables) {
// may not necessarily have been processed by form_builder().
$element += array(
'#title_display' => 'before',
+ '#parents' => array(),
);
+ $variables['errors'] = \Drupal::formBuilder()->getError($element);
+
// Take over any #wrapper_attributes defined by the element.
// @todo Temporary hack for #type 'item'.
// @see http://drupal.org/node/1829202
@@ -2795,8 +2804,17 @@ function theme_form_element($variables) {
if (!empty($element['#attributes']['disabled'])) {
$attributes['class'][] = 'form-disabled';
}
+ // Add a class if an error exists.
+ if (!empty($variables['errors'])) {
+ $attributes['class'][] = 'form-error';
+ }
$output = '
' . "\n";
+ // Display any error messages.
+ if ($variables['errors'] && !$element['#error_use_parent']) {
+ $output .= ' ' . theme('form_error_message', $variables);
+ }
+
// If #title is not set, we don't display any label or required marker.
if (!isset($element['#title'])) {
$element['#title_display'] = 'none';
@@ -2907,6 +2925,25 @@ function theme_form_element_label($variables) {
}
/**
+ * Returns HTML for an inline error associated with a specific form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: error.
+ * - errors: The errors associated with the current element as returned by
+ * form_get_error($element).
+ *
+ * @ingroup themeable
+ */
+function theme_form_error_message($variables) {
+ $output = '
';
+ $output .= '' . t('Error') . ': ' . $variables['errors'] . '';
+ $output .= '
';
+ return $output;
+}
+
+/**
* Sets a form element's class attribute.
*
* Adds 'required' and 'error' classes as needed.
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 9a0d713..357e7c4 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -3143,6 +3143,9 @@ function drupal_common_theme() {
'form_element_label' => array(
'render element' => 'element',
),
+ 'form_error_message' => array(
+ 'render element' => 'element',
+ ),
'vertical_tabs' => array(
'render element' => 'element',
),
diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php
index 38bfab7..d7db162 100644
--- a/core/lib/Drupal/Core/Form/FormBuilder.php
+++ b/core/lib/Drupal/Core/Form/FormBuilder.php
@@ -17,6 +17,7 @@
use Drupal\Core\KeyValueStore\KeyValueExpirableFactory;
use Drupal\Core\Routing\UrlGeneratorInterface;
use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Utility\LinkGeneratorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -24,6 +25,7 @@
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
/**
* Provides form building and processing.
@@ -59,6 +61,13 @@ class FormBuilder implements FormBuilderInterface {
protected $urlGenerator;
/**
+ * The link generator.
+ *
+ * @var \Drupal\Core\Utility\LinkGeneratorInterface
+ */
+ protected $linkGenerator;
+
+ /**
* The translation manager service.
*
* @var \Drupal\Core\StringTranslation\TranslationInterface
@@ -137,16 +146,19 @@ class FormBuilder implements FormBuilderInterface {
* The URL generator.
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation_manager
* The translation manager.
+ * @param \Drupal\Core\Utility\LinkGeneratorInterface $link_generator
+ * The link generator.
* @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
* The CSRF token generator.
* @param \Drupal\Core\HttpKernel $http_kernel
* The HTTP kernel.
*/
- public function __construct(ModuleHandlerInterface $module_handler, KeyValueExpirableFactory $key_value_expirable_factory, EventDispatcherInterface $event_dispatcher, UrlGeneratorInterface $url_generator, TranslationInterface $translation_manager, CsrfTokenGenerator $csrf_token = NULL, HttpKernel $http_kernel = NULL) {
+ public function __construct(ModuleHandlerInterface $module_handler, KeyValueExpirableFactory $key_value_expirable_factory, EventDispatcherInterface $event_dispatcher, UrlGeneratorInterface $url_generator, TranslationInterface $translation_manager, LinkGeneratorInterface $link_generator, $csrf_token = NULL, HttpKernel $http_kernel = NULL) {
$this->moduleHandler = $module_handler;
$this->keyValueExpirableFactory = $key_value_expirable_factory;
$this->eventDispatcher = $event_dispatcher;
$this->urlGenerator = $url_generator;
+ $this->linkGenerator = $link_generator;
$this->translationManager = $translation_manager;
$this->csrfToken = $csrf_token;
$this->httpKernel = $http_kernel;
@@ -905,6 +917,9 @@ public function validateForm($form_id, &$form, &$form_state) {
}
$form_state['values'] = $values;
}
+ if (!$form_state['programmed']) {
+ $this->displayErrors($form);
+ }
}
/**
@@ -1284,6 +1299,7 @@ public function doBuildForm($form_id, &$element, &$form_state) {
'#required' => FALSE,
'#attributes' => array(),
'#title_display' => 'before',
+ '#error_use_parent' => FALSE,
);
// Special handling if we're on the top level form element.
@@ -1760,6 +1776,13 @@ protected function drupalStaticReset($name = NULL) {
}
/**
+ * Wraps format_plural().
+ */
+ protected function formatPlural($count, $singular, $plural, array $args = array(), array $options = array()) {
+ format_plural($count, $singular, $plural, $args, $options);
+ }
+
+ /**
* Gets the current active user.
*
* @return \Drupal\Core\Session\AccountInterface
@@ -1793,4 +1816,82 @@ public function setRequest(Request $request) {
$this->request = $request;
}
+ /**
+ * Displays the given form's errors and links each error to the form element
+ * in question.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ */
+ protected function displayErrors($form) {
+ if ($errors = $this->getErrors()) {
+ $error_links = array();
+ foreach ($errors as $key => $error) {
+ $element = $this->getElement($key, $form);
+ if ($element) {
+ $title = $this->getElementTitle($element);
+ $error_links[] = $this->linkGenerator->generate($title, $this->request->get(RouteObjectInterface::ROUTE_NAME), $this->request->query->all(), array('fragment' => 'edit-' . str_replace('_', '-', $key), 'external' => TRUE));
+ }
+ else {
+ $this->drupalSetMessage($error, 'error');
+ unset($errors[$key]);
+ }
+ }
+
+ if (!empty($error_links)) {
+ $this->drupalSetMessage($this->formatPlural(count($error_links), '1 error has been found', '@count errors have been found') . ': ' . implode(', ', $error_links), 'error');
+ }
+ }
+ }
+
+ /**
+ * Given a form and an element key, this function returns the element no matter
+ * how deep within the form array the key exists. If the key is not found an
+ * empty array is returned.
+ *
+ * @param string $element_key
+ * The key to search for.
+ *
+ * @param array $form
+ * A structured form array to search.
+ *
+ * @return array
+ */
+ protected function getElement($element_key, $form) {
+ $element = array();
+ foreach ($this->elementChildren($form) as $key) {
+ if ($key === $element_key) {
+ $element = $form[$key];
+ break;
+ }
+ else {
+ if (is_array($form[$key])) {
+ $element = $this->getElement($element_key, $form[$key]);
+ if (!empty($element)) {
+ break;
+ }
+ }
+ }
+ }
+ return $element;
+ }
+
+ /**
+ * Returns the title the highest element in the hierarchy that has a title. If
+ * no title is found, then NULL is returned.
+ */
+ protected function getElementTitle(array $element) {
+ if (isset($element['#title'])) {
+ return $element['#title'];
+ }
+ else {
+ foreach ($this->elementChildren($element) as $key) {
+ $title = $this->getElementTitle($element[$key]);
+ if (isset($title)) {
+ return $title;
+ }
+ }
+ }
+ }
+
}
diff --git a/core/modules/system/css/system.theme.css b/core/modules/system/css/system.theme.css
index c6ed012..e2a6504 100644
--- a/core/modules/system/css/system.theme.css
+++ b/core/modules/system/css/system.theme.css
@@ -44,6 +44,16 @@ td.active {
/**
* Markup generated by Form API.
*/
+div.form-error {
+ background-color: #fef5f1;
+ border: 1px solid #ed541d;
+ color: #8c2e0b;
+ padding: 10px;
+}
+div.form-error-message {
+ margin-bottom: 10px;
+ min-height: 25px;
+}
.form-item,
.form-actions {
margin-top: 1em;
diff --git a/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php b/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php
index b0779bd..b8f4ae8 100644
--- a/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php
+++ b/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php
@@ -37,6 +37,13 @@ class FormBuilderTest extends UnitTestCase {
protected $urlGenerator;
/**
+ * The mocked link generator.
+ *
+ * @var \PHPUnit_Framework_MockObject_MockObject|\Drupal\Core\Utility\LinkGeneratorInterface
+ */
+ protected $linkGenerator;
+
+ /**
* The mocked module handler.
*
* @var \PHPUnit_Framework_MockObject_MockObject|\Drupal\Core\Extension\ModuleHandlerInterface
@@ -99,6 +106,7 @@ public function setUp() {
$event_dispatcher = $this->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface');
$this->urlGenerator = $this->getMock('Drupal\Core\Routing\UrlGeneratorInterface');
+ $this->linkGenerator = $this->getMock('Drupal\Core\Utility\LinkGeneratorInterface');
$translation_manager = $this->getStringTranslationStub();
$this->csrfToken = $this->getMockBuilder('Drupal\Core\Access\CsrfTokenGenerator')
->disableOriginalConstructor()
@@ -107,7 +115,7 @@ public function setUp() {
->disableOriginalConstructor()
->getMock();
- $this->formBuilder = new TestFormBuilder($this->moduleHandler, $key_value_expirable_factory, $event_dispatcher, $this->urlGenerator, $translation_manager, $this->csrfToken, $http_kernel);
+ $this->formBuilder = new TestFormBuilder($this->moduleHandler, $key_value_expirable_factory, $event_dispatcher, $this->urlGenerator, $translation_manager, $this->linkGenerator, $this->csrfToken, $http_kernel);
$this->formBuilder->setRequest(new Request());
$this->account = $this->getMock('Drupal\Core\Session\AccountInterface');
@@ -689,6 +697,12 @@ protected function drupalHtmlId($id) {
protected function drupalStaticReset($name = NULL) {
}
+ /**
+ * {@inheritdoc}
+ */
+ protected function formatPlural($count, $singular, $plural, array $args = array(), array $options = array()) {
+ }
+
}
class TestForm implements FormInterface {