Business

Avoiding Drupal 7 #AJAX Pitfalls

Rather than provide a basic how-to tutorial on Drupal's form API #AJAX functionality, I decided to address a few pitfalls that often frustrate developers, both junior and senior alike. To me, it seems that most of the problems arise from...


Filed under:

This article is one of Metal Toad's Top 20 Drupal Tips. Enjoy!

Rather than provide a basic how-to tutorial on Drupal's form API #AJAX functionality, I decided to address a few pitfalls that often frustrate developers, both junior and senior alike. To me, it seems that most of the problems arise from the approach rather than the direct implementation of the individual elements.

TL;DR
  • Try to find a reasonable argument for not using #ajax.
  • Do not do any processing in the callback function, it's too late, I'm sorry.
  • Force button names that are semantic and scalable.
  • Template buttons and remove unnecessary validation from #ajax actions.
  • Use '#theme_wrappers' => array('container') rather than '#preffix' and '#suffix'.

Is AJAX Even Needed?

Since #ajax hinders accessibility and adds that much more complexity, before continuing on with the approach, reconsider others. Drupal will automatically handle the "no js" accessibility issue, providing full page refreshes with unsubmitted forms, but issues will still exist for those using screen readers. Because the time to request and receive the new content is indeterminate, screen readers will fail at providing the users with audible descriptions of the new content. Simply by choosing to use #ajax, you will automatically exclude those needing visual assistance. So, if it is simply hiding/showing another field or sets of fields, then #states would be a better fit. If the task is to select something out of a large selection, a multiple page approach or even an entity reference with an autocomplete field could suffice.

This example is a simplified version of a new field type used to select data from a Solr index of another site's products. The number of products was in the 200k's and the details needed to decide on a selection was more than just the product names, so building checkboxes/radios/select box would be too unwieldy and an autocomplete could not provide enough information. Also, the desired UX was to use a modal rather than multiple pages.

Callback is a Lie

An misconception that many developers, including past myself, have is that the AJAX callback function is the place to perform bulk of the logic. I have come to approach this function as just one that returns the portion of the form that I want. Any logic that changes the structure or data of a form should be handled in the form building function, because there it will be persistent as Drupal will store those changes but ignore any done within AJAX callback. So, the role of the callback function is simply a getter for a portion of the $form array. At first, it may seem easier to just hardcode the logic to return the sub array, but I recommend a dynamic solution that relies on the trigger's nested position relative to the AJAX container.

function product_details_selector_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
  ...
  // Add a property to nested buttons to declare the relative depth
  // of the trigger to the AJAX targeted container
  $form['container']['modal']['next_page']['#nested_depth'] = 1;
  ...
}

Then, for the callback, some "blind" logic can easily return the portion of form to render and return.

/**
 * AJAX callback to replace the container of the product_details_selector.
 */
function product_details_selector_ajax_return($form, $form_state) {
  // Trim the array of array parents for the trigger down to the container
  $array_parents = $form_state['triggering_element']['#array_parents'];
  $pop_count      = 1; // The trigger is always included, so always have to pop
  if (isset($form_state['triggering_element']['#nested_depth'])) {
    $pop_count += $form_state['triggering_element']['#nested_depth'];
  }
  for ($i = 0; $i < $pop_count; $i++) {
    if (empty($array_parents)) {
      break; // Halt the loop whenever there are no more items to pop
    }
    array_pop($array_parents);
  }
  // Return the nested array 
  return drupal_array_get_nested_value($form, $array_parents); // This function is so awesome
}

With this approach, any future modifications to the $form array outside of the container are inconsequential to this widget. And if this widget's array is modified outside of the module, the modifier will just have to double check the #nested_depth values rather than completely overriding the callback function.

Name the Names

For clarity, from here on name will refer to what will be used for the HTML attributes id and name for containers (divs) and buttons, respectively.

Like with everything in programming, naming is the initial task that can make development, current and future, a simple walk through the business logic or a spaghetti mess of "oh yeahs". This is especially true for #ajax which requires the use of HTML ID attributes to place the new content as well as handling user actions (triggers). For most developers, this step is brushed over because the idea of their work being used in an unconventional or altered way is completely out of their purview. But a solid approach will reduce the frustration of future developers including yourself for this #ajax widget right now.

In this example and most cases these triggers will be buttons, but Drupal 7 also allowed for other triggering elements, such as the select box or radio buttons. Now, this leaves a weird situation where these other triggers have semantic names, but buttons will simply be named 'op'. For a simple form, this is no big deal, but for something complex, determining which action to take relies on the comparison of the button values. This gets much harder to do when you have multiple fields of the same type, bring in translation, and/or the client decides to change the wording later on in the project. So, my suggestion is to override the button names and base the logic on them.

// drupal_html_class() converts _ to - as well as removing dangerous characters
$trigger_prefix = drupal_html_class($field['field_name'] . '-' . $langcode . '-' . $delta);
// Short trigger names
$show_trigger   = $trigger_prefix . '-modal-open';
$next_trigger   = $trigger_prefix . '-modal-next';
$prev_trigger   = $trigger_prefix . '-modal-prev';
$search_trigger = $trigger_prefix . '-modal-search';
$add_trigger    = $trigger_prefix . '-add';
$remove_trigger = $trigger_prefix . '-remove';
$cancel_trigger = $trigger_prefix . '-cancel';
// Div wrapper
$ajax_container = $trigger_prefix . '-ajax-container';

The prefix in the example is built as a field form widget example. It is unique to field's name, language, and delta so that multiple instances can exist in the same form. But if your widget is not a field, it is still best to start with someting that is dynamically unique. Then, semantics are used to fill out the rest of the trigger names needed as well as the container's ID.

Button Structure

Ideally, every button within the #ajax widget should simply cause a rebuild of the same container, regardless of the changes triggered within the nested array. Since the callback is reduced to a simple getter for the container's render array, the majority of trigger properties can be templated. Now, all buttons that are built off of this template, barring intentional overrides, will prevent validation of elements outside of the widget, prevent submission, and have the same #ajax command to run.

$ajax_button_template = array(
  '#type'  => 'button',                    // Not 'submit'
  '#value' => t('Button Template'), // To be replaced
  '#name'  => 'button-name',        // To be replaced
  '#ajax'  => array(
    'callback' => 'product_details_selector_ajax_return',
    'wrapper'  => $ajax_container,
    'method'   => 'replace',
    'effect'   => 'fade',
  ),
  '#validate'                => array(),
  '#submit'                  => array(),
  '#limit_validation_errors' => array(array()), // Prevent standard Drupal validation
  '#access'                  => TRUE,           // Display will be conditional based on the button and the state
);
 
// Limit the validation errors down to the specific item's AJAX container
// Once again, the field could be nested in multiple entity forms
// and the errors array must be exact. If the widget is not a field, 
// then use the '#parents' key if available.
if (!empty($element['#field_parents'])) {
  foreach ($element['#field_parents'] as $field_parent) {
    $ajax_button_template['#limit_validation_errors'][0][] = $field_parent;
  }
}
$ajax_button_template['#limit_validation_errors'][0][] = $field['field_name'];
$ajax_button_template['#limit_validation_errors'][0][] = $langcode;
$ajax_button_template['#limit_validation_errors'][0][] = $delta;
$ajax_button_template['#limit_validation_errors'][0][] = 'container';

Limiting the validation errors will prevent other, unrelated fields from affecting the modal's functionality. Though, if certain fields are a requirement they can be specified here. This example will validate any defaults, such as required fields, that exist within the container.

$form['container']['modal']['page_list_next'] = array(
  '#value'  => t('Next'),
  '#name'   => $next_trigger,
  '#access' => FALSE,
  '#page'   => 1, // For page navigation of Solr results
) + $ajax_button_template; // Keys not defined in the first array will be set from the values in the second
 
// Fade effect within the modal is disorienting
$element['container']['modal']['search_button']['#ajax']['effect']  = 'none';
$element['container']['modal']['page_list_prev']['#ajax']['effect'] = 'none';
$element['container']['modal']['page_list_next']['#ajax']['effect'] = 'none';

The #page key is arbitrary and simply used to keep track of the page state without having to clutter up the $form_state, especially since the entire array of the triggering element is already stored in that variable. Other buttons within the widget do not need to track the page other than previous and next. Clicking the search button should result in the first page of a new search while cancel and selection buttons will close the modal anyway.

Smoking Gun

Determining the widget's state can now start easily with checks on the name and data of the trigger.

$trigger = FALSE;
if (!empty($form_state['triggering_element']['#name'])) {
  $trigger = $form_state['triggering_element'];
}
$trigger_name = $trigger ? $trigger['#name'] : FALSE;
 
$open_modal = FALSE;
if (strpos($trigger_prefix . '-modal', $trigger_name) === 0)) {
  $open_modal = TRUE;
}
 
...
// Hide or show modal
$form['container']['modal']['#access'] = $open_modal;
 
...
// Obtain page number regardless of next or previous
$search_page = 1;
if (isset($trigger['#page'])) {
  $search_page = $trigger['#page'];
}
 
...
// Calculate if a next page button should be shown
$next_offset = ($search_page + 1) * $per_page;
if ($next_offset > $search_results['total']) {
  $form['container']['modal']['next_page']['#access'] = TRUE; // or '#disabled' if the action is too jerky
}

Theming

Now, to where most developers start their problem solving, how to build the AJAX-able portion. Drupal requires an element with an ID attribute to target where the new HTML is inserted. Ideally, it is best to make the target element and the AJAX content one and the same. There are a couple of ways for doing this, the most common that I see is far too static and therefore difficult to modify or extend.

// Div wrapper for AJAX replacing
$element['contianer'] = array(
  '#prefix' => '<div id="' . $ajax_container . '">',
  '#suffix' => '</div>',
);

This does solve the solution for the time being. It renders any child elements properly while wrapping with the appropriate HTML. But if another module, function, or developer wants to add other information, classes for instance, they would have to recreate the entire #prefix string. What I propose is to use the #theme_wrappers key instead.

// Div wrapper for AJAX replacing
$element['container'] = array(
  '#theme_wrappers' => array('container'),
  '#attributes'     => array(
    'id' => $ajax_container,
  ),
);
 
if (in_array($trigger_name, $list_triggers)) {
  $element['container']['#attributes']['class'][] = 'product-details-selector-active-modal';
}
 
// Div inner-wrapper for modal styling
$element['container']['modal'] = array(
  '#theme_wrappers' => array('container'),
  '#attributes'     => array(
    'class' => array('dialog-box'),
  ),
);
 
$element['container']['product_details'] = array(
  '#theme_wrappers' => array('container'),
  '#attributes'     => array(
    'class' => array('product-details'),
  ),
  '#access' => TRUE,
);

I have experienced in the past that using #theme, causes the form elements to be rendered "wrong," losing their names and their relationships with the data. The themes declared within #theme_wrappers will render later in the pipeline, so form elements will not lose their identity and the div container can be built dynamically. That is, simply to add a class, one just needs to add another array element to $element['container']['#attributes']['class'].

Conclusion

I do not propose the above to be hard-set rules to follow, but they should be helpful ideas that allow for more focus to be put into the important logic rather than basic functional logistics. View the form as transforming throughout time as the user navigates while the AJAX functionality is simply a way to refresh a portion of that form and the complexity of building your form widget will reduce down to the business logic needed.

Similar posts

Get notified on new marketing insights

Be the first to know about new B2B SaaS Marketing insights to build or refine your marketing function with the tools and knowledge of today’s industry.