Refatora arquitetura de domínio: field_site_section DER, field_parent_page node-only

- field_site_section migrado de entity_reference para dynamic_entity_reference,
  permitindo referenciar taxonomy_term, user ou event como domínio raiz da página
- field_parent_page restrito a content_page nodes apenas (removidos taxonomy_term/user)
- Update hooks 10011–10013: migração de schema/config, limpeza de referências
  inválidas e correção do tipo do storage field_parent_page para DER
- SectionBreadcrumbBuilder reescrito para usar field_site_section como raiz
- StructuralPagesMenuBlock: queries de domínio via DB direto (evita bug do
  entity query com especificador target_type em campos DER)
- Formulário content_page: pre-fill de field_site_section via query params
  ?domain_type/domain_id ou ?parent_section_type/parent_id; atualiza widget
  #default_value diretamente para refletir o valor na renderização inicial
- Parâmetro de URL parent_type renomeado para parent_section_type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 09:41:17 -03:00
parent 6ab880045a
commit d2ef439227
9 changed files with 894 additions and 370 deletions

View File

@@ -10,20 +10,20 @@ declare(strict_types=1);
use Drupal\Core\Entity\EntityInterface;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\TermInterface;
use Drupal\user\UserInterface;
/**
* Implements hook_entity_presave().
*
* Inherits field_site_section from parent to content_page.
* Validates circular reference in field_parent_page.
* For content_page nodes with a node parent: inherits field_site_section from
* the parent content_page. For root pages (no parent) field_site_section is
* set directly by the user in the form and is left untouched here.
*/
function structural_pages_entity_presave(EntityInterface $entity): void {
if (!$entity instanceof NodeInterface || $entity->bundle() !== 'content_page') {
return;
}
// Check if has parent page.
// Root page: field_site_section is managed by the editor, nothing to inherit.
if (!$entity->hasField('field_parent_page') || $entity->get('field_parent_page')->isEmpty()) {
return;
}
@@ -33,38 +33,35 @@ function structural_pages_entity_presave(EntityInterface $entity): void {
return;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
if (!$parent_id) {
return;
}
// Circular reference validation (only for node parents).
if ($parent_entity_type === 'node' && !$entity->isNew() && $parent_id) {
// Circular reference validation.
if (!$entity->isNew()) {
if (_structural_pages_creates_circular_reference($entity->id(), $parent_id)) {
\Drupal::messenger()->addError(t('Circular reference detected. A page cannot be a parent of itself or its ancestors.'));
// Remove invalid reference.
$entity->set('field_parent_page', NULL);
return;
}
}
// Handle site section based on parent type.
// Some entity types (like user and group) act as context containers themselves.
/** @var \Drupal\structural_pages\ParentEntityHandler\ParentEntityHandlerManagerInterface $handler_manager */
$handler_manager = \Drupal::service('plugin.manager.parent_entity_handler');
if ($handler_manager->clearsSiteSection($parent_entity_type)) {
// Clear field_site_section as the context is the parent entity, not a site section.
$entity->set('field_site_section', NULL);
// Inherit field_site_section (DER value: target_type + target_id) from the
// parent content_page so sibling pages always share the same domain root.
$parent_node = \Drupal::entityTypeManager()->getStorage('node')->load($parent_id);
if (!$parent_node instanceof NodeInterface) {
return;
}
if (!$parent_node->hasField('field_site_section') || $parent_node->get('field_site_section')->isEmpty()) {
return;
}
// Inherit field_site_section based on parent type (node or taxonomy_term).
$site_section_id = _structural_pages_get_section_from_parent($parent_entity_type, $parent_id);
if ($site_section_id) {
$entity->set('field_site_section', $site_section_id);
}
$parent_fss = $parent_node->get('field_site_section')->first();
$entity->set('field_site_section', [
'target_type' => $parent_fss->target_type ?? 'taxonomy_term',
'target_id' => $parent_fss->target_id,
]);
}
/**
@@ -78,84 +75,6 @@ function _structural_pages_get_vocabulary(): string {
->get('site_section_vocabulary') ?? 'site_sections';
}
/**
* Gets the site section ID from a parent entity.
*
* @param string $parent_entity_type
* The parent entity type (node or taxonomy_term).
* @param int|string $parent_id
* The parent entity ID.
*
* @return int|string|null
* The site section term ID, or NULL if not found.
*/
function _structural_pages_get_section_from_parent(string $parent_entity_type, int|string $parent_id): int|string|null {
$entity_type_manager = \Drupal::entityTypeManager();
if ($parent_entity_type === 'taxonomy_term') {
// If parent is a taxonomy term, verify it's from the configured vocabulary.
$term = $entity_type_manager->getStorage('taxonomy_term')->load($parent_id);
if ($term instanceof TermInterface && $term->bundle() === _structural_pages_get_vocabulary()) {
return $term->id();
}
return NULL;
}
if ($parent_entity_type === 'node') {
$parent_node = $entity_type_manager->getStorage('node')->load($parent_id);
if (!$parent_node instanceof NodeInterface) {
return NULL;
}
// If parent has field_site_section, use it.
if ($parent_node->hasField('field_site_section') && !$parent_node->get('field_site_section')->isEmpty()) {
return $parent_node->get('field_site_section')->target_id;
}
}
return NULL;
}
/**
* Gets the parent context information for a content_page.
*
* @param \Drupal\node\NodeInterface $node
* The content_page node.
*
* @return array|null
* An array with 'type' and 'entity' keys, or NULL if no parent.
*/
function _structural_pages_get_parent_context(NodeInterface $node): ?array {
if (!$node->hasField('field_parent_page') || $node->get('field_parent_page')->isEmpty()) {
return NULL;
}
$parent_field = $node->get('field_parent_page')->first();
if (!$parent_field) {
return NULL;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
return NULL;
}
$parent = \Drupal::entityTypeManager()
->getStorage($parent_entity_type)
->load($parent_id);
if (!$parent) {
return NULL;
}
return [
'type' => $parent_entity_type,
'entity' => $parent,
];
}
/**
* Checks if setting parent_id as parent of node_id would create circular reference.
*
@@ -288,7 +207,13 @@ function _structural_pages_get_section_path(NodeInterface $node): string {
return '';
}
$term_id = $node->get('field_site_section')->target_id;
$fss = $node->get('field_site_section')->first();
// Only taxonomy_term domains have a hierarchical term path.
if (($fss->target_type ?? 'taxonomy_term') !== 'taxonomy_term') {
return '';
}
$term_id = $fss->target_id;
$term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
$term = $term_storage->load($term_id);
@@ -350,121 +275,191 @@ function structural_pages_form_node_content_page_edit_form_alter(&$form, \Drupal
/**
* Helper to alter the content_page forms for parent page filtering.
*
* Architecture:
* - field_site_section (DER): domain root — taxonomy_term, user, or event.
* - field_parent_page (DER, node-only): parent content_page within the domain,
* or empty for a root page.
*
* The native field_parent_page widget is replaced by a custom hierarchical
* select that lists only content_pages sharing the same field_site_section
* value. A sentinel option '__root__' represents an empty parent (root page).
* Query params ?domain_type=<type>&domain_id=<id> (or the aliases
* ?parent_section_type=<type>&parent_id=<id>) pre-fill field_site_section
* when creating a new page from an external domain context (e.g., user).
*/
function _structural_pages_alter_parent_page_form(&$form, \Drupal\Core\Form\FormStateInterface $form_state) {
// Hide the native Drupal menu settings tab, since this module generates
// the structural pages menu automatically.
// Hide the native Drupal menu settings tab.
if (isset($form['menu'])) {
$form['menu']['#access'] = FALSE;
}
if (isset($form['field_site_section']) && isset($form['field_parent_page'])) {
// 1. Add AJAX behavior to update parent page options when section changes.
if (isset($form['field_site_section']['widget'])) {
$form['field_site_section']['widget']['#ajax'] = [
'callback' => 'structural_pages_parent_page_ajax_callback',
'wrapper' => 'parent-page-wrapper',
];
if (!isset($form['field_site_section']) || !isset($form['field_parent_page'])) {
return;
}
$current_entity = $form_state->getFormObject()->getEntity();
// Pre-fill field_site_section for new nodes created from a domain context.
if ($current_entity->isNew()) {
$request = \Drupal::request();
$qp_type = $request->query->get('domain_type') ?? $request->query->get('parent_section_type');
$qp_id = $request->query->get('domain_id') ?? $request->query->get('parent_id');
if ($qp_type && $qp_id && $qp_type !== 'node') {
$domain_entity = \Drupal::entityTypeManager()->getStorage($qp_type)->load($qp_id);
if ($domain_entity) {
// Set the entity value (used on AJAX rebuild and on save).
$current_entity->set('field_site_section', [
'target_type' => $qp_type,
'target_id' => $qp_id,
]);
// Also update the widget elements directly: hook_form_alter runs after
// the DER widget has already been built from the original (empty) entity,
// so #default_value in the rendered elements needs to be set explicitly.
if (isset($form['field_site_section']['widget'][0]['target_type'])) {
$form['field_site_section']['widget'][0]['target_type']['#default_value'] = $qp_type;
}
if (isset($form['field_site_section']['widget'][0]['target_id'])) {
$form['field_site_section']['widget'][0]['target_id']['#default_value'] = $domain_entity;
}
}
}
}
// Add AJAX so changing field_site_section rebuilds the parent page options.
$ajax = [
'callback' => 'structural_pages_parent_page_ajax_callback',
'wrapper' => 'parent-page-wrapper',
'event' => 'change',
];
if (isset($form['field_site_section']['widget'][0]['target_type'])) {
$form['field_site_section']['widget'][0]['target_type']['#ajax'] = $ajax;
}
if (isset($form['field_site_section']['widget'][0]['target_id'])) {
$form['field_site_section']['widget'][0]['target_id']['#ajax'] = $ajax;
}
// Fallback for single-type widgets (entity_reference-style DER or pre-DER).
if (!isset($form['field_site_section']['widget'][0]['target_type']) &&
!isset($form['field_site_section']['widget'][0]['target_id'])) {
$form['field_site_section']['widget']['#ajax'] = $ajax;
}
// Wrapper container replaced by AJAX.
$form['structural_pages_wrapper'] = [
'#type' => 'container',
'#attributes' => ['id' => 'parent-page-wrapper'],
'#weight' => $form['field_parent_page']['#weight'] ?? 1,
'#attached' => ['library' => ['structural_pages/select2']],
];
// Hide the native DER widget for field_parent_page; we replace it below.
$form['field_parent_page']['#access'] = FALSE;
// Determine the active domain (type + id) from field_site_section.
[$domain_type, $domain_id] = _structural_pages_get_form_domain($form, $form_state, $current_entity);
if ($domain_type && $domain_id) {
$domain_entity = \Drupal::entityTypeManager()->getStorage($domain_type)->load($domain_id);
$domain_label = $domain_entity ? $domain_entity->label() : t('Domínio');
$current_nid = (!$current_entity->isNew()) ? (int) $current_entity->id() : NULL;
$options = _structural_pages_build_parent_tree_options($domain_type, $domain_id, $current_nid);
$options = ['__root__' => '< ' . t('Raiz (@label)', ['@label' => $domain_label]) . ' >'] + $options;
// Determine default selection.
$default_parent = '__root__';
if (!$current_entity->isNew() && !$current_entity->get('field_parent_page')->isEmpty()) {
$pf = $current_entity->get('field_parent_page')->first();
if ($pf && $pf->target_id) {
$key = 'node:' . $pf->target_id;
if (isset($options[$key])) {
$default_parent = $key;
}
}
}
// Wrap the container where our custom select will go.
$form['structural_pages_wrapper'] = [
'#type' => 'container',
'#attributes' => ['id' => 'parent-page-wrapper'],
'#weight' => $form['field_parent_page']['#weight'] ?? 1,
'#attached' => [
'library' => [
'structural_pages/select2',
],
$form['structural_pages_wrapper']['custom_parent_page'] = [
'#type' => 'select',
'#title' => t('Parent Page'),
'#options' => $options,
'#default_value' => $default_parent,
'#description' => t('Select the parent page within this domain. Select Root to create a top-level page.'),
'#attributes' => [
'class' => ['select2-widget', 'chosen-enable'],
'data-placeholder' => t('Search for a parent page...'),
],
];
// 3. Retrieve the currently selected site_section ID.
$site_section_id = NULL;
// From AJAX state (user just changed it).
$site_section_value = $form_state->getValue('field_site_section');
if (!empty($site_section_value[0]['target_id'])) {
$site_section_id = $site_section_value[0]['target_id'];
}
// From initial form load.
elseif (isset($form['field_site_section']['widget']['#default_value'])) {
$default = $form['field_site_section']['widget']['#default_value'];
if (is_array($default) && !empty($default[0])) {
$site_section_id = $default[0];
}
elseif (is_scalar($default)) {
$site_section_id = $default;
}
}
$current_entity = $form_state->getFormObject()->getEntity();
$current_nid = $current_entity ? $current_entity->id() : NULL;
if ($site_section_id) {
$options = _structural_pages_build_parent_tree_options($site_section_id, $current_nid);
$default_parent = '';
if ($current_entity && !$current_entity->isNew() && !$current_entity->get('field_parent_page')->isEmpty()) {
$default_parent = $current_entity->get('field_parent_page')->target_type . ':' . $current_entity->get('field_parent_page')->target_id;
}
// Add the Root (Taxonomy term) as the default option at the top of the tree.
$term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load($site_section_id);
$term_label = $term ? $term->label() : t('Taxonomia');
$root_key = 'taxonomy_term:' . $site_section_id;
$options = [$root_key => '< ' . t('Raiz (@term)', ['@term' => $term_label]) . ' >'] + $options;
if (empty($default_parent)) {
$default_parent = $root_key;
}
// Hide the original field.
$form['field_parent_page']['#access'] = FALSE;
// Create a new visual field.
$form['structural_pages_wrapper']['custom_parent_page'] = [
'#type' => 'select',
'#title' => t('Parent Page'),
'#options' => $options,
'#default_value' => $default_parent,
'#empty_option' => t('- Selecione a Página Pai -'),
'#description' => t('Select the parent page within this site section. Select Root if this is a top-level page.'),
// Attach a select2/chosen class if available in standard themes
'#attributes' => [
'class' => ['select2-widget', 'chosen-enable'],
'data-placeholder' => t('Search for a parent page...'),
],
];
// We must add a custom submit/validate handler to map our select back to field_parent_page.
array_unshift($form['#validate'], 'structural_pages_custom_parent_validate');
} else {
// If no section chosen yet, hide parent page completely so they pick a section first.
$form['field_parent_page']['#access'] = FALSE;
$form['structural_pages_wrapper']['custom_parent_page'] = [
'#type' => 'markup',
'#markup' => '<p><em>' . t('Por favor, selecione uma Site Section primeiro.') . '</em></p>',
];
}
array_unshift($form['#validate'], 'structural_pages_custom_parent_validate');
}
else {
$form['structural_pages_wrapper']['custom_parent_page'] = [
'#type' => 'markup',
'#markup' => '<p><em>' . t('Por favor, selecione a Seção do Site primeiro.') . '</em></p>',
];
}
}
/**
* Validation callback to map the select list back into dynamic_entity_reference.
* Returns the domain [type, id] from the form state or the entity being edited.
*
* Priority:
* 1. AJAX rebuild state (user just changed field_site_section).
* 2. Saved field_site_section value on the entity being edited.
* 3. Legacy widget #default_value (pre-DER fallback).
*
* @return array{0: string|null, 1: int|string|null}
*/
function _structural_pages_get_form_domain(array &$form, \Drupal\Core\Form\FormStateInterface $form_state, \Drupal\Core\Entity\EntityInterface $entity): array {
// 1. AJAX state.
$fss_value = $form_state->getValue('field_site_section');
if (!empty($fss_value[0]['target_id'])) {
return [
$fss_value[0]['target_type'] ?? 'taxonomy_term',
$fss_value[0]['target_id'],
];
}
// 2. Saved entity value.
if ($entity->hasField('field_site_section') && !$entity->get('field_site_section')->isEmpty()) {
$fss = $entity->get('field_site_section')->first();
return [
$fss->target_type ?? 'taxonomy_term',
$fss->target_id,
];
}
// 3. Legacy widget default value (entity_reference before DER migration).
if (isset($form['field_site_section']['widget']['#default_value'])) {
$default = $form['field_site_section']['widget']['#default_value'];
if (is_array($default) && !empty($default[0])) {
return ['taxonomy_term', $default[0]];
}
if (is_scalar($default) && $default) {
return ['taxonomy_term', $default];
}
}
return [NULL, NULL];
}
/**
* Validation callback: maps the custom select back to field_parent_page.
*
* '__root__' or empty → field_parent_page = [] (root page, no parent).
* 'node:<id>' → field_parent_page = [{target_id: id, target_type: node}].
*/
function structural_pages_custom_parent_validate(&$form, \Drupal\Core\Form\FormStateInterface $form_state) {
$custom_val = $form_state->getValue('custom_parent_page');
if (!empty($custom_val)) {
list($type, $id) = explode(':', $custom_val);
$form_state->setValue('field_parent_page', [
['target_id' => $id, 'target_type' => $type]
]);
} else {
if (empty($custom_val) || $custom_val === '__root__') {
$form_state->setValue('field_parent_page', []);
}
else {
[, $id] = explode(':', $custom_val, 2);
$form_state->setValue('field_parent_page', [
['target_id' => $id, 'target_type' => 'node'],
]);
}
}
/**
@@ -475,74 +470,118 @@ function structural_pages_parent_page_ajax_callback(array &$form, \Drupal\Core\F
}
/**
* Builds a hierarchical tree array of content_pages for a given section.
* Builds a hierarchical tree array of content_pages for a given domain.
*
* Queries all content_pages with field_site_section matching [$domain_type,
* $domain_id], then arranges them in a tree keyed as 'node:<nid>'.
*
* @param string $domain_type
* The domain entity type (e.g. 'taxonomy_term', 'user').
* @param int|string $domain_id
* The domain entity ID.
* @param int|string|null $current_node_id
* The node being edited (excluded from options to prevent self-reference).
*
* @return array
* Associative array 'node:<nid>' => indented title.
*/
function _structural_pages_build_parent_tree_options($site_section_id, $current_node_id = NULL) {
function _structural_pages_build_parent_tree_options(string $domain_type, int|string $domain_id, $current_node_id = NULL): array {
$options = [];
$query = \Drupal::entityQuery('node')
->condition('type', 'content_page')
->condition('field_site_section', $site_section_id)
->accessCheck(TRUE)
->sort('title', 'ASC');
_structural_pages_apply_domain_condition($query, $domain_type, $domain_id);
$nids = $query->execute();
if (empty($nids)) {
return $options;
}
$nodes = \Drupal::entityTypeManager()->getStorage('node')->loadMultiple($nids);
$children = [];
$roots = [];
$roots = [];
foreach ($nodes as $nid => $node) {
if ($nid == $current_node_id) {
continue; // Skip self
continue;
}
$parent_id = NULL;
$parent_type = NULL;
if (!$node->get('field_parent_page')->isEmpty()) {
$parent_id = $node->get('field_parent_page')->target_id;
$parent_type = $node->get('field_parent_page')->target_type;
$parent_id = $node->get('field_parent_page')->target_id;
}
// If the parent is another content_page in our list, it's a child.
if ($parent_type === 'node' && isset($nodes[$parent_id])) {
if ($parent_id && isset($nodes[$parent_id])) {
$children[$parent_id][] = $nid;
} else {
// It's a root (parent is a taxonomy term, a section_page, or empty)
}
else {
$roots[] = $nid;
}
}
// Try to sort roots alphabetically
usort($roots, function($a, $b) use ($nodes) {
return strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle());
});
$build_options = function($nids, $depth) use (&$build_options, &$options, $nodes, $children) {
// We use non-breaking spaces and hyphens for a tree view look
usort($roots, fn($a, $b) => strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle()));
$build_options = function ($nids, $depth) use (&$build_options, &$options, $nodes, $children) {
$prefix = str_repeat('— ', $depth);
foreach ($nids as $nid) {
$options['node:' . $nid] = $prefix . $nodes[$nid]->getTitle();
if (isset($children[$nid])) {
// Sort children alphabetically too
$child_nids = $children[$nid];
usort($child_nids, function($a, $b) use ($nodes) {
return strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle());
});
usort($child_nids, fn($a, $b) => strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle()));
$build_options($child_nids, $depth + 1);
}
}
};
$build_options($roots, 0);
return $options;
}
/**
* Applies domain conditions to a content_page entity query.
*
* Uses a direct database subquery on node__field_site_section to filter by
* both target_type and target_id. This avoids relying on DER's entity query
* integration (which may throw "Invalid specifier 'target_type'" depending on
* the installed DER version and Drupal's internal caching).
*
* @param \Drupal\Core\Entity\Query\QueryInterface $query
* The entity query to modify.
* @param string $domain_type
* The domain entity type (e.g. 'taxonomy_term', 'user').
* @param int|string $domain_id
* The domain entity ID.
*/
function _structural_pages_apply_domain_condition(\Drupal\Core\Entity\Query\QueryInterface $query, string $domain_type, int|string $domain_id): void {
$database = \Drupal::database();
$schema = $database->schema();
if ($schema->fieldExists('node__field_site_section', 'field_site_section_target_type')) {
// DER storage: query the field table directly to filter by both columns.
$nids = $database->select('node__field_site_section', 'fss')
->fields('fss', ['entity_id'])
->condition('fss.field_site_section_target_type', $domain_type)
->condition('fss.field_site_section_target_id', $domain_id)
->condition('fss.deleted', 0)
->execute()
->fetchCol();
// Add a condition that will never match if no results found.
$query->condition('nid', empty($nids) ? [0] : $nids, 'IN');
}
else {
// Legacy entity_reference storage (before update hook 10011 runs):
// all references are taxonomy_terms, target_id alone is sufficient.
$query->condition('field_site_section', $domain_id);
}
}
/**
* Implements hook_preprocess_node().
*/
@@ -582,17 +621,18 @@ function structural_pages_views_post_execute(\Drupal\views\ViewExecutable $view)
if ($parent_id) {
// Has a node parent. Siblings are nodes with the same parent.
// field_parent_page only points to nodes (post-migration), so
// filtering by target_id alone is sufficient.
$query->condition('field_parent_page.target_id', $parent_id);
$query->condition('field_parent_page.target_type', 'node');
} else {
// Root page. Siblings are other root pages in the same site section.
$orGroup = $query->orConditionGroup()
->notExists('field_parent_page')
->condition('field_parent_page.target_type', 'taxonomy_term');
$query->condition($orGroup);
$query->notExists('field_parent_page');
if (!$current_node->get('field_site_section')->isEmpty()) {
$query->condition('field_site_section.target_id', $current_node->get('field_site_section')->target_id);
$fss = $current_node->get('field_site_section')->first();
$domain_type = $fss->target_type ?? 'taxonomy_term';
$domain_id = $fss->target_id;
_structural_pages_apply_domain_condition($query, $domain_type, $domain_id);
}
}