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 {