dependent_vocabularies
A config entity, a form alter, and a submit handler. Drupal 10 and 11.
Taxonomy decided where content showed up across urban.org, and we wanted content writers to have full access to it. Many terms came with rules about which other terms they could go with, and those rules changed whenever a program was reorganized. Hard-coding them would have meant a deploy every time.
So we hard-coded the relationship instead of the rules. A secondary term carries a field that points at its primary term. A content manager links terms to each other in the taxonomy UI, and the node form follows: two separate reference fields become one widget that only offers the secondary terms belonging to the chosen primary. The business rules live in content, owned by the people who change them.
Where to look
- The config entitysrc/Entity/DependentVocabulary.php
- Its add and edit formsrc/Form/DependentVocabularyForm.php
- The widget and the delete guarddependent_vocabularies.module
// Build one secondary select per primary term and use #states to show
// only the one that matches the chosen primary. This trades a little
// extra markup for zero AJAX round trips and no custom JavaScript.
$primary_selector = ':input[name="' . $widget_key . '[primary]"]';
$secondary_selects = [];
foreach ($primary_terms as $term) {
$tids = $term_storage->getQuery()
->condition('vid', $secondary_vid)
->condition($relationship_field, $term->tid)
->sort('weight')
->sort('name')
->accessCheck(TRUE)
->execute();
$options = ['_none' => t('- Select -')];
foreach ($term_storage->loadMultiple($tids) as $secondary_term) {
$options[$secondary_term->id()] = $secondary_term->label();
}
$secondary_selects['secondary_' . $term->tid] = [
'#type' => 'container',
'#attributes' => [
'class' => ['dv-secondary-item'],
'data-primary-id' => $term->tid,
],
'#states' => [
'visible' => [
$primary_selector => ['value' => $term->tid],
],
],
'select' => [
'#type' => 'select',
'#title' => $config->get('secondary_form_label') ?: t('Secondary term'),
'#options' => $options,
'#default_value' => ($primary_selected == $term->tid) ? $secondary_selected : '_none',
'#attributes' => ['class' => ['dv-secondary-select']],
],
];
}
Every possible secondary select is rendered up front. Core's #states shows the one whose primary matches the current selection. No AJAX callback, no JavaScript file, and the browser does the work.
$form[$widget_key] = [
'#type' => 'container',
'#tree' => TRUE,
'#prefix' => '<div id="' . $widget_key . '-wrapper">',
'#suffix' => '</div>',
'primary' => [
'#type' => 'select',
'#title' => $config->get('primary_form_label') ?: t('Primary term'),
'#options' => $primary_options,
'#default_value' => $primary_selected,
],
'secondary_group' => [
'#type' => 'container',
'#attributes' => ['class' => ['dv-secondary-group']],
] + $secondary_selects,
];
// Copy widget values into the real fields before the entity is built.
array_unshift($form['actions']['submit']['#submit'], 'dependent_vocabularies_node_form_submit');
array_unshift puts the copy step ahead of the entity form's own submit handlers, so by the time Drupal builds the node, the real fields already hold the widget's values.
function dependent_vocabularies_node_form_submit($form, FormStateInterface $form_state) {
foreach (DependentVocabulary::loadMultiple() as $config) {
$values = $form_state->getValue('dv_' . $config->id());
if (!$values) {
continue;
}
$primary_value = (!empty($values['primary']) && $values['primary'] !== '_none') ? $values['primary'] : NULL;
$secondary_value = NULL;
if ($primary_value) {
$selected = $values['secondary_group']['secondary_' . $primary_value]['select'] ?? '_none';
$secondary_value = ($selected !== '_none') ? $selected : NULL;
}
$form_state->setValue([$config->get('primary_field'), 0, 'target_id'], $primary_value);
$form_state->setValue([$config->get('secondary_field'), 0, 'target_id'], $secondary_value);
}
}
// Sanity check the pair. The secondary term is the more specific choice,
// so if it disagrees with the primary we trust the secondary and move the
// primary to match. This happens when a secondary term is re-parented
// after nodes were tagged with it.
if ($secondary_selected !== '_none') {
$primary_term = $term_storage->load($primary_selected);
$secondary_term = $term_storage->load($secondary_selected);
if (!$primary_term || !$secondary_term) {
$secondary_selected = '_none';
}
elseif ((int) $secondary_term->get($relationship_field)->target_id !== (int) $primary_selected) {
$primary_selected = $secondary_term->get($relationship_field)->target_id;
}
}
A secondary term can be re-parented after nodes were tagged with it. The secondary is the more specific choice, so the primary is moved to match rather than the other way round.
This is the module I would show first. It is small, it uses the APIs the way core intends, and every line is there for a reason. If I were writing it today I would move the option building out of the .module file into a service so it could be cached and tested, and I would guard the no-AJAX approach with a term count check, since it pre-renders a select for every primary term.