Adiciona widget de seleção em cascata para o vocabulário MSC 2020

Implementa MscTermSelectWidget, um FieldWidget para campos entity_reference
apontando para o vocabulário msc_2020. Dois selects encadeados: o primeiro
lista as 63 categorias pai; o segundo é reconstruído via Drupal AJAX (#ajax)
ao mudar o pai, listando os subcampos da categoria selecionada mais a opção
"área geral (sem subcampo)".

O valor salvo no campo é o TID do segundo select. O estado dos selects é
preservado em rebuilds AJAX (ex.: "Add another item") lendo getUserInput(),
que não é afetado pelo #limit_validation_errors do botão.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 14:46:16 -03:00
parent 38ff0019f4
commit 7631ef0484
3 changed files with 229 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
/**
* @file
* Reservado para melhorias futuras do widget MSC 2020.
* A lógica de cascata entre os selects é gerida via Drupal AJAX (#ajax).
*/

View File

@@ -0,0 +1,2 @@
msc_term_select_widget:
version: VERSION

View File

@@ -0,0 +1,222 @@
<?php
namespace Drupal\site_tools_msc_2020\Plugin\Field\FieldWidget;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Widget de seleção em cascata para o vocabulário MSC 2020.
*
* O primeiro select (categorias pai) dispara um AJAX que reconstrói o segundo
* select (subcampos) via servidor. O valor armazenado no campo é o TID
* selecionado no segundo select: o TID do pai para "área geral", ou o TID do
* filho para um subcampo específico.
*
* @FieldWidget(
* id = "msc_term_select",
* label = @Translation("MSC 2020 — Seleção em cascata"),
* field_types = {
* "entity_reference"
* }
* )
*/
class MscTermSelectWidget extends WidgetBase {
public function __construct(
$plugin_id,
$plugin_definition,
FieldDefinitionInterface $field_definition,
array $settings,
array $third_party_settings,
protected EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('entity_type.manager'),
);
}
public static function isApplicable(FieldDefinitionInterface $field_definition): bool {
$storage = $field_definition->getFieldStorageDefinition();
if ($storage->getSetting('target_type') !== 'taxonomy_term') {
return FALSE;
}
$handler_settings = $field_definition->getSetting('handler_settings');
return !empty($handler_settings['target_bundles']['msc_2020']);
}
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state): array {
$data = $this->buildTermData();
// getUserInput() contém os dados crus submetidos e NÃO é afetado pelo
// #limit_validation_errors do botão "Add another item" (que descarta
// valores fora do caminho do campo em $form_state->getValues()).
$field_name = $this->fieldDefinition->getName();
$field_parents = $element['#field_parents'] ?? $form['#parents'] ?? [];
$base = array_merge($field_parents, [$field_name, $delta]);
$raw_input = $form_state->getUserInput();
$parent_exists = FALSE;
$submitted_parent = NestedArray::getValue(
$raw_input,
array_merge($base, ['parent_select']),
$parent_exists,
);
if ($parent_exists) {
// Rebuild pós-submissão (AJAX de parent_select, "Add another item", etc.)
$initial_parent = $submitted_parent !== '' ? (int) $submitted_parent : NULL;
$child_exists = FALSE;
$submitted_child = NestedArray::getValue(
$raw_input,
array_merge($base, ['child_select']),
$child_exists,
);
$initial_child = ($child_exists && $submitted_child !== '' && $submitted_child !== NULL)
? (int) $submitted_child
: $initial_parent;
}
else {
// Carga inicial: deriva do valor persistido na entidade.
$current_value = $items[$delta]->target_id ? (int) $items[$delta]->target_id : NULL;
[$initial_parent, $initial_child] = $this->resolveInitialValues($current_value, $data);
}
// Opções do segundo select: pré-populadas para a categoria pai atual.
$child_options = ['' => $this->t('— selecione uma categoria primeiro —')];
if ($initial_parent !== NULL) {
$child_options = [];
$child_options[$initial_parent] = $this->t('— área geral (sem subcampo) —');
$child_options += $data['children'][$initial_parent] ?? [];
}
$field_id = $field_name . '--' . $delta;
$wrapper_id = Html::getId('msc-child-' . $field_id);
$element['parent_select'] = [
'#type' => 'select',
'#title' => $element['#title'] ?? $this->t('Área MSC 2020'),
'#title_display' => $element['#title_display'] ?? 'before',
'#options' => ['' => $this->t('— selecione uma categoria —')] + $data['parents'],
'#default_value' => $initial_parent ?? '',
'#required' => !empty($element['#required']),
'#attributes' => ['class' => ['msc-parent-select']],
'#ajax' => [
'callback' => [static::class, 'rebuildChildSelect'],
'wrapper' => $wrapper_id,
'event' => 'change',
],
];
// O child_select é o elemento cujo valor é salvo no campo.
// #validated => TRUE: aceita o valor submetido sem verificar #options,
// necessário pois as opções podem ter mudado entre submissões.
$element['child_select'] = [
'#type' => 'select',
'#title' => $this->t('Subcampo'),
'#prefix' => '<div id="' . $wrapper_id . '">',
'#suffix' => '</div>',
'#options' => $child_options,
'#default_value' => $initial_child ?? '',
'#validated' => TRUE,
'#attributes' => ['class' => ['msc-child-select']],
];
return $element;
}
/**
* Callback AJAX: reconstrói o child_select quando o parent_select muda.
*
* Drupal faz o rebuild completo do formulário antes de chamar este método,
* então formElement() já calculou as opções corretas para o novo pai.
*/
public static function rebuildChildSelect(array &$form, FormStateInterface $form_state): array {
$parents = $form_state->getTriggeringElement()['#array_parents'];
array_pop($parents);
$parents[] = 'child_select';
return NestedArray::getValue($form, $parents);
}
/**
* Carrega todos os termos msc_2020 e retorna arrays de pais e filhos.
*
* @return array{parents: array<int,string>, children: array<int,array<int,string>>}
*/
protected function buildTermData(): array {
$tree = $this->entityTypeManager
->getStorage('taxonomy_term')
->loadTree('msc_2020', 0, NULL, TRUE);
$code_to_tid = [];
$parents = [];
$children = [];
foreach ($tree as $term) {
$code = $term->get('field_msc_code')->value;
$tid = (int) $term->id();
$code_to_tid[$code] = $tid;
if (strlen($code) === 2) {
$parents[$tid] = $code . ' — ' . $term->label();
}
}
foreach ($tree as $term) {
$code = $term->get('field_msc_code')->value;
if (strlen($code) > 2) {
$parent_code = substr($code, 0, 2);
$parent_tid = $code_to_tid[$parent_code] ?? NULL;
if ($parent_tid) {
$tid = (int) $term->id();
$children[$parent_tid][$tid] = $code . ' — ' . $term->label();
}
}
}
return ['parents' => $parents, 'children' => $children];
}
/**
* Resolve o par (parent_tid, child_tid) a partir do TID persistido.
*
* @return array{int|null, int|null}
*/
protected function resolveInitialValues(?int $current_value, array $data): array {
if ($current_value === NULL) {
return [NULL, NULL];
}
if (isset($data['parents'][$current_value])) {
return [$current_value, $current_value];
}
foreach ($data['children'] as $parent_tid => $kids) {
if (isset($kids[$current_value])) {
return [$parent_tid, $current_value];
}
}
return [NULL, NULL];
}
public function massageFormValues(array $values, array $form, FormStateInterface $form_state): array {
foreach ($values as &$value) {
$child = $value['child_select'] ?? '';
$value = ['target_id' => $child !== '' ? (int) $child : NULL];
}
return $values;
}
}