Conditional fields in Paragraphs using the Javascript States API for Drupal 8
While creating content, there are pieces of information that are only relevant when other fields have a certain value. For example, if we want to allow the user to upload either an image or a video, but not both, you can have another field for the user to select which type of media they want to upload. In these scenarios, the Javascript States API for Drupal 8 can be used to conditionally hide and show the input elements for image and video conditionally.
Note: Do not confuse the Javascript States API with the storage State API.
The basics: conditional fields in node forms
Let’s see how to accomplish the conditional fields behavior in a node form before explaining the implementations for paragraphs. For this example, let’s assume a content type has a machine name of article
with three fields: field_image
, field_video
, and field_media_type
. The field_image_or_video
field is of type List (text)
with the following values: Image
and Video
.
/**
* Implements hook_form_alter().
*/
function nicaragua_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
if ($form_id == 'node_article_form' || $form_id == 'node_article_edit_form') {
$form['field_ image']['#states'] = [
'visible' => [
':input[name="field_image_or_video"]' => ['value' => 'Image'],
],
];
$form['field_ video']['#states'] = [
'visible' => [
':input[name="field_image_or_video"]' => ['value' => 'Video'],
],
];
}
}
Note that in Drupal 8, the node add and edit form have different form ids. Hence, we check for either one before applying the field states. After checking for the right forms to alter, we implement the fields’ states logic as such:
$form[DEPENDEE_FIELD_NAME]['#states'] = [
DEPENDEE_FIELD_STATE => [
DEPENDENT_FIELD_SELECTOR => ['value' => DEPENDENT_FIELD_VALUE],
],
];
DEPENDENT_FIELD_SELECTOR
is a CSS selector to the HTML form element rendered in the browser. Not to be confused with a nested Drupal form structure.
Conditional fields in Drupal 8 paragraphs
Although hook_form_alter could be used in paragraphs as well, their deep nesting nature makes it super complicated. Instead, we can use hook_field_widget_form_alter to alter the paragraph widget before it is added to the form. In fact, we are going to use the widget specific hook_field_widget_WIDGET_TYPE_form_alter to affect paragraphs only.
For this example, let’s assume a content type has a machine name of campaign
with an entity reference field whose machine name is field_sections
. The paragraph where we want to apply the conditional logic has a machine name of embedded_image_or_video
with the following fields: field_image
, field_video
, and field_image_or_video
. The field_image_or_video field is of type List (text)
with the following values: Image
and Video
.
/**
* Implements hook_field_widget_WIDGET_TYPE_form_alter().
*/
function nicaragua_field_widget_paragraphs_form_alter(&$element, \Drupal\Core\Form\FormStateInterface $form_state, $context) {
/** @var \Drupal\field\Entity\FieldConfig $field_definition */
$field_definition = $context['items']->getFieldDefinition();
$paragraph_entity_reference_field_name = $field_definition->getName();
if ($paragraph_entity_reference_field_name == 'field_sections') {
/** @see \Drupal\paragraphs\Plugin\Field\FieldWidget\ParagraphsWidget::formElement() */
$widget_state = \Drupal\Core\Field\WidgetBase::getWidgetState($element['#field_parents'], $paragraph_entity_reference_field_name, $form_state);
/** @var \Drupal\paragraphs\Entity\Paragraph $paragraph */
$paragraph_instance = $widget_state['paragraphs'][$element['#delta']]['entity'];
$paragraph_type = $paragraph_instance->bundle();
// Determine which paragraph type is being embedded.
if ($paragraph_type == 'embedded_image_or_video') {
$dependee_field_name = 'field_image_or_video';
$selector = sprintf('select[name="%s[%d][subform][%s]"]', $paragraph_entity_reference_field_name, $element['#delta'], $dependee_field_name);
// Dependent fields.
$element['subform']['field_image']['#states'] = [
'visible' => [
$selector => ['value' => 'Image'],
],
];
$element['subform']['field_video']['#states'] = [
'visible' => [
$selector => ['value' => 'Video'],
],
];
}
}
}
Paragraphs can be referenced from multiple fields. If you want to limit the conditional behavior you can check the name of the field embedding the paragraph using:
$field_definition = $context['items']->getFieldDefinition();
$paragraph_entity_reference_field_name = $field_definition->getName();
If you need more information on the field or entity where the paragraph is being embedded, the field definition (instance of FieldConfig
) provides some useful methods:
$field_definition->getName(); // Returns the field_name property. Example: 'field_sections'.
$field_definition->getType(); // Returns the field_type property. Example: 'entity_reference_revisions'.
$field_definition->getTargetEntityTypeId(); // Returns the entity_type property. Example: 'node'.
$field_definition->getTargetBundle(); // Returns the bundle property. Example: 'campaign'.
In Drupal 8 it is a common practice to use the paragraph module to replace the body field. When doing so, a single field allows many different paragraph types. In that scenario, it is possible that different paragraph types have fields with the same name. You can add a check to apply the conditional logic only when one specific paragraph type is being embedded.
$widget_state = \Drupal\Core\Field\WidgetBase::getWidgetState($element['#field_parents'], $paragraph_entity_reference_field_name, $form_state);
$paragraph_instance = $widget_state['paragraphs'][$element['#delta']]['entity'];
$paragraph_type = $paragraph_instance->bundle();
The last step is to add the Javascript states API logic. There are two important things to consider:
- The paragraph widgets are added under a
subform
key. - Because multiple paragraphs can be referenced from the same field, we need to consider the order (i.e. the paragraph delta). This is reflected in the DEPENDENT_FIELD_SELECTOR.
$element['subform'][DEPENDEE_FIELD_NAME]['#states'] = [
DEPENDEE_FIELD_STATE => [
DEPENDENT_FIELD_SELECTOR => ['value' => DEPENDENT_FIELD_VALUE],
],
];
When adding the widget, the form API will generate markup similar to this:
<select data-drupal-selector="edit-field-sections-0-subform-field-image-or-video"
id="edit-field-sections-0-subform-field-image-or-video--vtQ4eJfmH7k"
name="field_sections[0][subform][field_image_or_video]"
class="form-select required"
required="required"
aria-required="true">
<option value="Image" selected="selected">Image</option>
<option value="Video">Video>
</select>
So we need a selector like select[name="field_sections[0][subform][field_image_or_video]"]
which can be generated using:
$selector = sprintf('select[name="%s[%d][subform][%s]"]', $paragraph_field_name, $element['#delta'], $dependee_field_name);
By using $element['#delta']
we ensure to apply the conditional field logic to the proper instance of the paragraph. This works when a field allows multiple paragraphs, including multiple instances of the same paragraph type.
You can get the example code here.
Warning: Javascript behavior does not affect user input
It is very important to note that the form elements are hidden and shown via javascript. This does not affect user input. If, for example, a user selects image and uploads one then changes the selection to video and sets one then both the image and video will be stored. Switching the selection from image to video and vice versa does not remove what the user had previous uploaded or set. Once the node is saved, if there are values for the image and the video both will be saved. One way to work around this when rendering the node is to toggle field visibility in the node Twig template. In my session "Twig Recipes: Making Drupal 8 Render the Markup You Want" there is an example on how to do this. Check out the slide deck and the video recording for reference.
What do you think of this approach to add conditional field logic to paragraphs? Let me know in the comments.