Drupal 9: Cascading Ajax Select Forms

14th February 2021 - 14 minutes read time

Tying together different select elements in a form is done with very little effort thanks to the ajax and states system built into Drupal 9. This means that any Drupal form can have a select element that shows and updates options into another select element within the same form. Using this system you can create a hierarchical system where one select will show and populate another select element with items dependent on the first. This is great for giving the user the ability to drill down into options that are dependent on each other. As the user selects the first select element the second select element will populate with data and be shown on the screen.

It does, however, require a few things to be in place first and so takes a little while to set up. There are also two different ways to set up this kind of system, both of which have their limitations. I found a lot of information out there on how to implement one system, but not the other. I thought I would create a post to show how each system can be set up and where it can be used.

The Form

Before jumping into the ajax components we need a form. The simplest, independent and most universally accepted set of select elements I could think of is a date picker. You wouldn't normally display the date as a set of select elements (although I have seen this before), but the data behind it is simple enough to understand and doesn't require any other aspects from Drupal to use.

Here is the form we will generate here.

Date picker select elements in Drupal 9

In this state the user can technically select the year, month and day, but because the form is static the number of days will always be the same. If we implement this using Drupal's ajax form update mechanisms we can show the correct number of days depending on the month selected. This also allows for things like leap years to be taken into account so that the number of days in February can be dynamically altered.

Here is the form class in its entirety. We'll be adding to this code throughout the rest of this post. The submit handler just prints out the entered data to the screen, and is a simple way of showing the form in action.

  1. <?php
  2.  
  3. namespace Drupal\mymodule\Form;
  4.  
  5. use Drupal\Core\Form\FormBase;
  6. use Drupal\Core\Form\FormStateInterface;
  7.  
  8. class SelectAjaxDemo extends FormBase {
  9.  
  10. /**
  11.   * {@inheritdoc}
  12.   */
  13. public function getFormId() {
  14. return 'select_ajax_demo';
  15. }
  16.  
  17. /**
  18.   * {@inheritdoc}
  19.   */
  20. public function buildForm(array $form, FormStateInterface $form_state) {
  21. $years = range(2019, 2050);
  22. $years = array_combine($years, $years);
  23. $year = $form_state->getValue('year');
  24.  
  25. $form['year'] = [
  26. '#type' => 'select',
  27. '#title' => $this->t('Year'),
  28. '#options' => $years,
  29. '#empty_option' => $this->t('- Select year -'),
  30. '#default_value' => $year,
  31. '#required' => TRUE,
  32. ];
  33.  
  34. $months = [1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'May', 6 => 'Jun', 7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Oct', 11 => 'Nov', 12 => 'Dec'];
  35. $month = $form_state->getValue('month');
  36.  
  37. $form['month'] = [
  38. '#type' => 'select',
  39. '#title' => $this->t('Month'),
  40. '#options' => $months,
  41. '#empty_option' => $this->t('- Select month -'),
  42. '#default_value' => $month,
  43. '#required' => TRUE,
  44. ];
  45.  
  46. $days = range(1, 31);
  47. $day = $form_state->getValue('day');
  48.  
  49. $form['day'] = [
  50. '#type' => 'select',
  51. '#title' => $this->t('Day'),
  52. '#options' => $days,
  53. '#empty_option' => $this->t('- Select day -'),
  54. '#default_value' => $day,
  55. '#required' => TRUE,
  56. ];
  57.  
  58. $form['submit'] = [
  59. '#type' => 'submit',
  60. '#value' => 'Submit',
  61. ];
  62.  
  63. return $form;
  64. }
  65.  
  66. /**
  67.   * {@inheritdoc}
  68.   */
  69. public function submitForm(array &$form, FormStateInterface $form_state) {
  70. $year = $form_state->getValue('year');
  71. $month = $form_state->getValue('month');
  72. $day = $form_state->getValue('day');
  73. $messenger = \Drupal::messenger();
  74. $messenger->addMessage("Year:" . $year . " Month:" . $month . " Day:" . $day);
  75. }
  76.  
  77. }

Method One - Single Select Callback

The first method to discuss the simplest to implement and involves just tying each select element to an ajax callback to the form.

The first ajax implementation we need is on the year select element. Setting the ajax event attribute to change means that when the select element is changed the ajax callback is triggered and the month element is changed. The callback attribute is the function we will call in this function and the wrapper is the element that will be replaced.

  1. $form['year'] = [
  2. '#type' => 'select',
  3. '#title' => $this->t('Year'),
  4. '#options' => $years,
  5. '#empty_option' => $this->t('- Select year -'),
  6. '#default_value' => $year,
  7. '#required' => TRUE,
  8. '#ajax' => [
  9. 'event' => 'change',
  10. 'callback' => '::yearSelectCallback',
  11. 'wrapper' => 'field-month',
  12. ],
  13. ];

Before adding the ajax callback we first need to add a couple of items to the month select element so that it can react correctly to the callback. Because we do not want the month select element to display before the year has been selected we first add a states attribute. This is set so that the current element is not visible until the year select element has something set in it. This isn't technically part of the ajax callback, but creates a nicer user experience with this kind of hierarchical form.

To get the ajax callback to work we set a prefix and suffix on the month form element to wrap the element in a div. This allows us to replace the whole field by simply returning the field from the ajax callback.

  1. $form['month'] = [
  2. '#type' => 'select',
  3. '#title' => $this->t('Month'),
  4. '#options' => $months,
  5. '#empty_option' => $this->t('- Select month -'),
  6. '#default_value' => $month,
  7. '#required' => TRUE,
  8. '#states' => [
  9. '!visible' => [
  10. ':input[name="year"]' => ['value' => ''],
  11. ],
  12. ],
  13. '#prefix' => '<div id="field-month">',
  14. '#suffix' => '</div>',
  15. ];

Finally, we set the ajax callback for the years field. The years field callback will return the month field and replace field-month div with the contents returned in the callback.

  1. public function yearSelectCallback(array $form, FormStateInterface $form_state) {
  2. return $form['month'];
  3. }

Here are the sequence of events called when the years select is changed.

  • The select element is changed.
  • The states API is triggered, and because the year select contains data the month select will be displayed.
  • The ajax callback is triggered.
  • After bootstrapping Drupal, the entire form() method is run again, generating the form elements.
  • The yearSelectCallback() method is called, returning just the month form element.
  • Drupal renders the element returned so that it is in HTML.
  • The ajax success function (on the JavaScript side) replaces the field-month div with the contents of the returned HTML from Drupal.

Taking this a step further we can now update the day field to do the same.

In order to do this we need to add an ajax callback to the month field. This callback will be triggered by a change to the month select and will call the monthSelectCallback() method in the form class.

  1. $form['month'] = [
  2. '#type' => 'select',
  3. '#title' => $this->t('Month'),
  4. '#options' => $months,
  5. '#empty_option' => $this->t('- Select month -'),
  6. '#default_value' => $month,
  7. '#required' => TRUE,
  8. '#states' => [
  9. '!visible' => [
  10. ':input[name="year"]' => ['value' => ''],
  11. ],
  12. ],
  13. '#ajax' => [
  14. 'event' => 'change',
  15. 'callback' => '::monthSelectCallback',
  16. 'wrapper' => 'field-day',
  17. ],
  18. '#prefix' => '<div id="field-month">',
  19. '#suffix' => '</div>',
  20. ];

The day field can now be updated in a couple of different ways, and this is where the power of ajax callbacks starts to become really clear. Month and years select elements will contain the same number of items but not every month has the same number of days[citation needed]. This means that when we select a year and a month combination the number of days in the month might change for February in the case of leap years. This means that we can dynamically take in the month and year values and update the select list with the right number of items that represent the day.

The day field is updated with the same states, prefix and suffix attributes that allow the day field to be displayed by the states API and then replaced by the ajax callback.

  1. // Extract the year and month values from the user input (this is already part of the form, but duplicated here for clarity).
  2. $year = $form_state->getValue('year');
  3. $month = $form_state->getValue('month');
  4.  
  5. // Calculate the number of days in the month.
  6. $number = cal_days_in_month(CAL_GREGORIAN, $month, $year);
  7. $days = range(1, $number);
  8. $day = $form_state->getValue('day');
  9.  
  10. $form['day'] = [
  11. '#type' => 'select',
  12. '#title' => $this->t('Day'),
  13. '#options' => $days,
  14. '#empty_option' => $this->t('- Select day -'),
  15. '#default_value' => $day,
  16. '#required' => TRUE,
  17. '#states' => [
  18. '!visible' => [
  19. ':input[name="month"]' => ['value' => ''],
  20. ],
  21. ],
  22. '#prefix' => '<div id="field-day">',
  23. '#suffix' => '</div>',
  24. ];

The monthSelectCallback() method is pretty simple. All we need to do is return the day field.

  1. public function monthSelectCallback(array $form, FormStateInterface $form_state) {
  2. return $form['day'];
  3. }

This all works nicely. When a user selects the year the month field is shown and when the month is selected the day field (containing the correct number of days) is shown.

One slight problem here is that if the user then selects a different year then the day field isn't updated. This is because the ajax callback for days is not triggered, it is only triggered when the month select element is changed. This is a slight limitation of this method and fixing this requires a slightly different approach, which leads us onto method two.

Method Two - The ReplaceCommand

As the simple callback method has the limitation of not updating the entire form a slight alteration needs to be made. The change will mean that every time a select element on the form is changed all of the elements will be updated with up to date values. This method is slightly more difficult to implement due to the inclusion of a couple of different classes.

The first thing we need to do is change the ajax callbacks to all point at the same method. I've removed some of the detail from the above form setup to reduce the amount of code here as all I am doing is changing the ajax callback attribute.

  1. $form['year'] = [
  2. // ..
  3. '#ajax' => [
  4. 'event' => 'change',
  5. 'callback' => '::formSelectCallback',
  6. 'wrapper' => 'field-month',
  7. ],
  8. ];
  9.  
  10. $form['month'] = [
  11. // ..
  12. '#ajax' => [
  13. 'event' => 'change',
  14. 'callback' => '::formSelectCallback',
  15. 'wrapper' => 'field-day',
  16. ],
  17. // ..
  18. ];

Next we need to add two additional use statements to the top of the form class to introduce the AjaxResponse and ReplaceCommand classes to our form.

  1. use Drupal\Core\Ajax\AjaxResponse;
  2. use Drupal\Core\Ajax\ReplaceCommand;

The single callback function, rather than just returning the form element, needs to return an AjaxResponse object. This object is used by Drupal to run a series of commands, and we use the object to inject a couple of ReplaceCommand objects to replace the form fields. The ReplaceCommand will take an HTML element name and a renderable Drupal item (or just plain HTML) and will replace the element with the supplied HTML. This will run a bunch of JavaScript code without us actually needing to write any JavaScript.

  1. public function formSelectCallback(array $form, FormStateInterface $form_state) {
  2. $response = new AjaxResponse();
  3.  
  4. $response->addCommand(new ReplaceCommand('#field-month', $form['month']));
  5.  
  6. $response->addCommand(new ReplaceCommand('#field-day', $form['day']));
  7.  
  8. return $response;
  9. }

The final output of this is that the form will act in the same way as the simple method with one major difference. When the year field is updated both the method and the day fields are updated at the same time, the upshot of this is that if the year is not a leap year and February is selected as the month then the number of days is automatically updated. The states attributes still hides the month and day fields until they have values in them.

To illustrate this, here is what the form looks like at each step.

Step 1 - The form without any user interaction.

Drupal date picker select elements with year not selected.

Step 2- The form after selecting the year.

Drupal date picker select elements with year selected and the month showing.

Step 3 - The form after selecting a month.

Drupal date picker select elements with year and month selected and the day now showing.

The downside to this method is that when any of the form fields are changed then more of the form is rendered and sent back through the ajax request. This can mean that on large forms there might be a slowdown as everything is reconstructed onto the page through the ajax callback.  It might, however, be better to approach forms with a single callback in mind, and then replace all elements at once as it does create a better user experience. You get more control over what is going on within the form as the user fills things in.

Comments

Permalink

Great and helpful article! Is it possible to make something like this in Entity Forms?

Robert (Mon, 02/15/2021 - 09:05)

Permalink

Thanks Robert! Glad you found it useful.

By Entity Forms, do you mean using hook_form_alter() to inject this sort of thing into a node (or other entity) form? I think that's certainly possible, I think the callback used would just be a stand alone callback function though (ie, not part of a form class).

Permalink

Don't you getting any validation errors on the second approach as all the fields are marked as required? As you wrote, on every AJAX call the form will be processed and validated as well. I'm using Business Rules module to make dependent fields on entity forms and it seems using the same approach with AJAX command to replace the dependant field. I'm facing the issue when there is three or more levels of dependent fields and they are required. During AJAX call on top level changes the dependent fields has empty values and the form validation fails.

Do you have any suggestions how it could be solved? Maybe fields validation could be skipped only on AJAX calls?

Andrew (Tue, 02/23/2021 - 16:02)

Permalink

I think you need to look at the "limit_validation_errors" form attribute. That will prevent validation errors from happening and mucking up your forms during ajax calls.

Add new comment

The content of this field is kept private and will not be shown publicly.