Respect the 'limit validation errors' setting on AJAX request

Created on 19 May 2020, about 4 years ago
Updated 12 June 2024, 14 days ago

Problem/Motivation

Bogus errors are displayed and logged in the use case of a form with a 'dependent' element whose 'source' element is changed and this triggers an AJAX request.

Use case: a form with a 'dependent' element whose 'source' element change triggers an AJAX request.
Pre-condition: the 'dependent' element has a non-empty value.
Event: change value of 'source' element to trigger an AJAX request to refresh options in 'dependent' element.
Result: Bogus errors are displayed and logged stating 'An illegal choice has been detected'.

The 8x-9x workflow for an AJAX request has regressed from 7x for this use case.

A 'dependent' select element is one whose options depend on the value of another form element. For example, with automobiles, the list of makes or models depends on the manufacturer. If an AJAX request is tied to a change in the manufacturer element to refresh the list of makes-models, then this will always produce 1+ error messages and log entries. In 7x this does not occur.

A similar situation is described in the related issues.

steps to reproduce
Add two fields to a form in custom module.

  $form['field_list'] = [
    '#type' => 'select',
    '#options' => ['a', 'b', 'c', 'd', 'e'],
    '#default_value' => 'a',
    '#limit_validation_errors' => [
      array_merge($form['#parents'], ['field_list]),
    ],
    '#ajax' => [
      'callback' => 'my_module_dependent_refresh',
      'event' => 'change',
    ],
  ];

  $form['field_dependent_list'] = [
    '#type' => 'select',
    '#options' => my_module_dependent_options(),
  ];

  return $form;

action steps
Display form.
Set value of 'field_dependent_list'.
Change the value of 'field_list' to trigger the AJAX request.
Form refreshes with new list of options on 'field_dependent_list'.

expected result
No errors or log entries.

actual result
Have errors and log entries related to 'An illegal choice has been detected'.

workflow analysis
In 7x the AJAX request goes through these routines:

ajax_form_callback()
  list($form, $form_state, $form_id, $form_build_id, $commands) = ajax_get_form();
  drupal_process_form($form['#form_id'], $form, $form_state);
ajax_get_form()
  $form = form_get_cache($form_build_id, $form_state);
  $form_state['input'] = $_POST;
drupal_process_form()
  drupal_validate_form($form_id, $form, $form_state);
  _form_validate($form, $form_state, $form_id);
  $form = drupal_rebuild_form($form_id, $form_state, $form);

This workflow:

  • omits the form builder routine and
  • uses the cached copy of the form
  • which has the 'field_dependent_list' options based on the original value of 'field_list'
  • so the submitted values for 'field_dependent_list' are valid when drupal_validate_form executes
  • rebuilds the form with options based on the new value of 'field_list'
  • and returns the ajax response

In 8x-9x the AJAX request goes through these routines:

  Drupal\Core\Form\FormBuilder::buildForm
  Drupal\Core\Form\FormBuilder::retrieveForm
  Drupal\Core\Form\FormBuilder::prepareForm
  Drupal\Core\Form\FormBuilder::processForm
  Drupal\Core\Form\FormBuilder::doBuildForm
  Drupal\Core\Form\FormValidator::validateForm
  Drupal\Core\Form\FormValidator::doValidateForm
  if (isset($elements['#needs_validation'])) {
    Drupal\Core\Form\FormValidator::performRequiredValidation
  }
  $form_state->setLimitValidationErrors($this->determineLimitValidationErrors($form_state));
  foreach ($elements['#element_validate'] as $callback)
    call_user_func_array($form_state->prepareCallback($callback), [&$elements, &$form_state, &$complete_form]);
  }

This workflow:

  • is the same as the initial form display
  • builds the options for 'field_dependent_list' based on the submitted value of 'field_list'
  • automatically produces an inconsistent state between
  • the submitted values for 'field_dependent_list' (based on the original value of 'field_list')
  • and the new list of options for 'field_dependent_list'
  • when validateForm executes
  • emits error messages and inserts log entries related to 'An illegal choice has been detected'

A custom module can suppress the error messages but not the log entries.
So, although the user may not see error messages, the log will contain bogus errors.

Proposed resolution

These changes:

  • add routine to test whether errors should be output for an element
  • add this test to the conditions on performRequiredValidation
  • execute the performRequiredValidation block after
  • setting the limit_validation_errors array and
  • calling the '#element_validate' callback routines

To wit:

  Drupal\Core\Form\FormValidator::validateForm
  Drupal\Core\Form\FormValidator::doValidateForm
  $form_state->setLimitValidationErrors($this->determineLimitValidationErrors($form_state));
  foreach ($elements['#element_validate'] as $callback) {
    call_user_func_array($form_state->prepareCallback($callback), [&$elements, &$form_state, &$complete_form]);
  }
  if (isset($elements['#needs_validation']) && $form_state->recordError($elements)) {
    Drupal\Core\Form\FormValidator::performRequiredValidation
  }

With this change in call order the AJAX request workflow:

  • conforms to the comments in FormValidator::determineLimitValidationErrors
  • conforms to the concept of '#limit_validation_errors'

Remaining tasks

Review, add test, clarify documentation.

User interface changes

None other than absence of bogus error message.

API changes

None.

Data model changes

None.

Release notes snippet

Not applicable.

🐛 Bug report
Status

Needs work

Version

11.0 🔥

Component
Form 

Last updated about 13 hours ago

Created by

🇺🇸United States solotandem

Live updates comments and jobs are added and updated live.
  • Needs tests

    The change is currently missing an automated test that fails when run with the original code, and succeeds when the bug has been fixed.

Sign in to follow issues

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • 🇨🇦Canada kiwad

    In case this code snippet helps, here's how we've done it to avoid 'An illegal choice has been detected'.

         // Champ de sélection de termes de taxonomie (uniquement les termes parents).
         $form['taxonomy_select'] = [
          '#type' => 'select',
          '#title' => t('Disciplinary grouping'),
          '#options' => $this->getTaxonomyParentOptions(),
          '#empty_option' => t('- Select -'),
          '#ajax' => [
            'callback' => '::updateChildrenCheckboxes',
            'wrapper' => 'children-checkboxes-wrapper',
          ],
        ];
    
        // Cases à cocher pour les enfants du terme sélectionné.
        $form['children_checkboxes' . $form_state->getValue('taxonomy_select')] = [
          '#type' => 'checkboxes',
          '#title' => t('Field of specialization'),
          '#options' => $this->getTaxonomyChildrenOptions($form_state->getValue('taxonomy_select')),
          '#prefix' => '<div id="children-checkboxes-wrapper">',
          '#suffix' => '</div>',
          '#default_value' => !empty($form_state->getValue('children_checkboxes')) ? $form_state->getValue('children_checkboxes') : [],
          '#states' => [
            'invisible' => [
              ':input[name="taxonomy_select"]' => ['value' => ''],
            ],
          ],
        ];
    
    

    By adding $form_state->getValue('taxonomy_select') inside the children form element makes it work

  • 🇨🇦Canada kiwad

    I had to re-write my code, previous snippet was causing other problems on validation

    I used DependentDropdown from Ajax Examples as base code for my form and patch in #5 did correct the "Illegal choice" problem.

  • 🇦🇺Australia jordan.jamous

    #5 Worked well on 10.1.x

  • 🇦🇺Australia jordan.jamous

    Running tests on 10.1.x

  • Open in Jenkins → Open on Drupal.org →
    Environment: PHP 8.1 & MariaDB 10.3.22
    last update 14 days ago
    29,689 pass
Production build 0.69.0 2024