From 7631ef0484ce9b719429fd6ae5fdbe97a70ddd6d Mon Sep 17 00:00:00 2001 From: "Quintino A. G. Souza" Date: Fri, 13 Mar 2026 14:46:16 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20widget=20de=20sele=C3=A7=C3=A3o=20em?= =?UTF-8?q?=20cascata=20para=20o=20vocabul=C3=A1rio=20MSC=202020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/site_tools_msc_2020/js/msc-select.js | 5 + .../site_tools_msc_2020.libraries.yml | 2 + .../Field/FieldWidget/MscTermSelectWidget.php | 222 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 modules/site_tools_msc_2020/js/msc-select.js create mode 100644 modules/site_tools_msc_2020/site_tools_msc_2020.libraries.yml create mode 100644 modules/site_tools_msc_2020/src/Plugin/Field/FieldWidget/MscTermSelectWidget.php diff --git a/modules/site_tools_msc_2020/js/msc-select.js b/modules/site_tools_msc_2020/js/msc-select.js new file mode 100644 index 0000000..28732dc --- /dev/null +++ b/modules/site_tools_msc_2020/js/msc-select.js @@ -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). + */ diff --git a/modules/site_tools_msc_2020/site_tools_msc_2020.libraries.yml b/modules/site_tools_msc_2020/site_tools_msc_2020.libraries.yml new file mode 100644 index 0000000..5bc43ad --- /dev/null +++ b/modules/site_tools_msc_2020/site_tools_msc_2020.libraries.yml @@ -0,0 +1,2 @@ +msc_term_select_widget: + version: VERSION diff --git a/modules/site_tools_msc_2020/src/Plugin/Field/FieldWidget/MscTermSelectWidget.php b/modules/site_tools_msc_2020/src/Plugin/Field/FieldWidget/MscTermSelectWidget.php new file mode 100644 index 0000000..3413bc7 --- /dev/null +++ b/modules/site_tools_msc_2020/src/Plugin/Field/FieldWidget/MscTermSelectWidget.php @@ -0,0 +1,222 @@ +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' => '
', + '#suffix' => '
', + '#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, children: array>} + */ + 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; + } + +}