Handling Errors in Complex Drupal Form Validation
Recently, I have been working on a site with a big multi-step form that is using a lot of custom form elements, custom auto-completes, custom form api states and ajax based sub-forms. It is all built using Drupal form api. The best thing about this form is that all the fields and sub-forms map to a data model.
This gives us the benefit of validating the form fields using the Symfony validation component. This is good because we can write all the validation separately and test it using phpunit without bootstrapping Drupal. To convert this data model into form fields we wrote a form factory and connected it all together using the service container module.
Whenever you are working with complex multistep forms and updating a sub-form based on user input then you can't exactly show the form errors in the order in which the fields appeared on the form. In this post I'll explain a process in which we can achieve just that - keeping the form error messages in the same order as the form fields.
In short, the workflow is:
- initiate the form factory;
- create a model object;
- pass the model object to the form factory, which derives the form API fields; and
- show the form
After form submission the process is:
- hydrate the model objects from user input; and
- validate the objects using Symfony validation component.
- If there are errors set the form errors else store objects in local storage.
This was all working nicely but the problem was that all the validation errors were showing in the order they are validated not in the order in which they are displayed on the form. For most of the fields it was working correctly but when the mutli-value custom form elements were validated the order of all the single value field errors were inconsistent with the order on the form. The reason was that the multi-value fields were loaded using ajax and were validated after the single value fields. This was a poor experience from a usability point of view. One way of fixing this problem was by using inline form errors but we didn't want to go down that road. Another way to fix this is to sort the form errors according to fields weight in the form after all the errors are set.
There is a form_get_errors
function which returns all the errors of the form keyed by their string version of '#parents'
key of the element. The solution was simple, get all the errors recursively parse through all the sorted fapi elements in $form
and create a new list of errors and set them again. I added a new custom validator to all the steps because we have the complete form with all the fields in the validator and as it was the last validator all the form errors were already set.
/**
* Sorts the form errors according to the fields weight in form.
*
* @param array $form
* The form array.
* @param array $form_state
* The form state array.
*/
function sort_form_errors($form, &$form_state) {
$sorted_errors = [];
$errors = form_get_errors();
if ($errors) {
// Clear errors.
form_clear_error();
// Clear error messages.
$error_messages = drupal_get_messages('error');
// Get the sorted errors form the form recursively.
get_sorted_errors($form, $sorted_errors, $errors);
// Initialize an array where removed error messages are stored.
$removed_messages = [];
// Reinstate the errors.
foreach (array_merge($sorted_errors, $errors) as $name => $error) {
form_set_error($name, $error);
// form_set_error() calls drupal_set_message(), so we have to filter out
// these from the error messages as well.
$removed_messages[] = $error;
}
// Reinstate remaining error messages (which, at this point, are messages
// that were originated outside of the validation process).
foreach (array_diff($error_messages['error'], $removed_messages) as $message) {
drupal_set_message($message, 'error');
}
}
}
/**
* Gets the sorted errors from the form recursively.
*
* @param array $form
* The form element array.
* @param array $sorted_errors
* The sorted errors array.
* @param array $errors
* All the errors of the form.
*/
function get_sorted_errors(&$form, &$sorted_errors, &$errors) {
foreach (element_children($form, TRUE) as $child) {
if (is_array($form[$child])) {
$element = $form[$child];
$parents = $element['#parents'];
$name = implode('][', $parents);
if (isset($errors[$name])) {
$sorted_errors[$name] = $errors[$name];
unset($errors[$name]);
}
get_sorted_errors($element, $sorted_errors, $errors);
}
}
}
The original idea of this code was taken from https://api.drupal.org/comment/28464#comment-28464.
In closing
We have plans to port our form factory code to Drupal 8 and release as a contrib module. We've found decoupling our model objects from Drupal makes it much easier to test business logic and allows for rapid iteration with a focus on maximum business value.