bundle() !== 'content_page') { return; } // Check if has parent page. if (!$entity->hasField('field_parent_page') || $entity->get('field_parent_page')->isEmpty()) { return; } $parent_field = $entity->get('field_parent_page')->first(); if (!$parent_field) { return; } $parent_entity_type = $parent_field->target_type ?? NULL; $parent_id = $parent_field->target_id ?? NULL; if (!$parent_entity_type || !$parent_id) { return; } // Circular reference validation (only for node parents). if ($parent_entity_type === 'node' && !$entity->isNew() && $parent_id) { 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); 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); } } /** * Returns the configured site section vocabulary machine name. * * @return string * The vocabulary ID (e.g., 'site_sections'). */ function _structural_pages_get_vocabulary(): string { return \Drupal::config('structural_pages.settings') ->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. * * @param int|string $node_id * The ID of the node being edited. * @param int|string $parent_id * The ID of the potential parent. * * @return bool * TRUE if it would create circular reference, FALSE otherwise. */ function _structural_pages_creates_circular_reference(int|string $node_id, int|string $parent_id): bool { $visited = []; $current_id = $parent_id; $node_storage = \Drupal::entityTypeManager()->getStorage('node'); while ($current_id) { // If we find the original node in the parent chain, it's circular. if ($current_id == $node_id) { return TRUE; } // Avoid infinite loops in case of corrupted data. if (isset($visited[$current_id])) { return TRUE; } $visited[$current_id] = TRUE; // Load the current node and get its parent. $current_node = $node_storage->load($current_id); if (!$current_node instanceof NodeInterface || !$current_node->hasField('field_parent_page') || $current_node->get('field_parent_page')->isEmpty()) { break; } $parent_field = $current_node->get('field_parent_page')->first(); // Only continue checking if parent is also a node. if (!$parent_field || ($parent_field->target_type ?? NULL) !== 'node') { break; } $current_id = $parent_field->target_id; } return FALSE; } /** * Implements hook_theme(). */ function structural_pages_theme(): array { return [ 'structural_pages_menu' => [ 'variables' => [ 'ancestor' => NULL, 'ancestor_url' => '', 'tree' => [], 'active_trail' => [], 'show_ancestor_title' => TRUE, ], ], 'structural_pages_menu_tree' => [ 'variables' => [ 'items' => [], 'active_trail' => [], 'depth' => 0, ], ], ]; } /** * Implements hook_token_info(). */ function structural_pages_token_info(): array { $info = []; $info['tokens']['node']['site-section-path'] = [ 'name' => t('Site Section Path'), 'description' => t('The hierarchical path of the site section taxonomy (e.g., undergraduate/courses).'), ]; $info['tokens']['term']['hierarchy-path'] = [ 'name' => t('Hierarchy Path'), 'description' => t('The hierarchical path of the term including ancestors (e.g., institutional/news).'), ]; return $info; } /** * Implements hook_tokens(). */ function structural_pages_tokens(string $type, array $tokens, array $data, array $options, $bubbleable_metadata): array { $replacements = []; if ($type === 'node' && !empty($data['node'])) { $node = $data['node']; foreach ($tokens as $name => $original) { if ($name === 'site-section-path') { $replacements[$original] = _structural_pages_get_section_path($node); } } } if ($type === 'term' && !empty($data['term'])) { $term = $data['term']; foreach ($tokens as $name => $original) { if ($name === 'hierarchy-path') { $replacements[$original] = _structural_pages_get_term_hierarchy_path($term); } } } return $replacements; } /** * Gets the hierarchical path of the site section for a node. * * @param \Drupal\node\NodeInterface $node * The node. * * @return string * The section path (e.g., "undergraduate/courses") or empty string. */ function _structural_pages_get_section_path(NodeInterface $node): string { if (!$node->hasField('field_site_section') || $node->get('field_site_section')->isEmpty()) { return ''; } $term_id = $node->get('field_site_section')->target_id; $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); $term = $term_storage->load($term_id); if (!$term) { return ''; } // Get all ancestors of the term. $ancestors = $term_storage->loadAllParents($term_id); $ancestors = array_reverse($ancestors); // Build the path using the term names converted to URL. $path_parts = []; foreach ($ancestors as $ancestor) { $path_parts[] = \Drupal::service('pathauto.alias_cleaner')->cleanString($ancestor->getName()); } return implode('/', $path_parts); } /** * Gets the hierarchical path of a taxonomy term. * * @param \Drupal\taxonomy\TermInterface $term * The taxonomy term. * * @return string * The hierarchical path (e.g., "institutional/news") or empty string. */ function _structural_pages_get_term_hierarchy_path(TermInterface $term): string { $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); // Get all ancestors of the term (including itself). $ancestors = $term_storage->loadAllParents($term->id()); $ancestors = array_reverse($ancestors); // Build the path using the term names converted to URL. $path_parts = []; foreach ($ancestors as $ancestor) { $path_parts[] = \Drupal::service('pathauto.alias_cleaner')->cleanString($ancestor->getName()); } return implode('/', $path_parts); } /** * Implements hook_form_FORM_ID_alter() for node_content_page_form. */ function structural_pages_form_node_content_page_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { _structural_pages_alter_parent_page_form($form, $form_state); } /** * Implements hook_form_FORM_ID_alter() for node_content_page_edit_form. */ function structural_pages_form_node_content_page_edit_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { _structural_pages_alter_parent_page_form($form, $form_state); } /** * Helper to alter the content_page forms for parent page filtering. */ 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. 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', ]; } // 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', ], ], ]; // 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' => '
' . t('Por favor, selecione uma Site Section primeiro.') . '
', ]; } } } /** * Validation callback to map the select list back into dynamic_entity_reference. */ 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 { $form_state->setValue('field_parent_page', []); } } /** * AJAX callback to replace the parent page wrapper. */ function structural_pages_parent_page_ajax_callback(array &$form, \Drupal\Core\Form\FormStateInterface $form_state) { return $form['structural_pages_wrapper']; } /** * Builds a hierarchical tree array of content_pages for a given section. */ function _structural_pages_build_parent_tree_options($site_section_id, $current_node_id = NULL) { $options = []; $query = \Drupal::entityQuery('node') ->condition('type', 'content_page') ->condition('field_site_section', $site_section_id) ->accessCheck(TRUE) ->sort('title', 'ASC'); $nids = $query->execute(); if (empty($nids)) { return $options; } $nodes = \Drupal::entityTypeManager()->getStorage('node')->loadMultiple($nids); $children = []; $roots = []; foreach ($nodes as $nid => $node) { if ($nid == $current_node_id) { continue; // Skip self } $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; } // If the parent is another content_page in our list, it's a child. if ($parent_type === 'node' && isset($nodes[$parent_id])) { $children[$parent_id][] = $nid; } else { // It's a root (parent is a taxonomy term, a section_page, or empty) $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 $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()); }); $build_options($child_nids, $depth + 1); } } }; $build_options($roots, 0); return $options; } /** * Implements hook_preprocess_node(). */ function structural_pages_preprocess_node(array &$variables): void { // Forcefully remove the "submitted by" author and date information // for content pages, regardless of theme settings. if (isset($variables['node']) && $variables['node']->bundle() === 'content_page') { $variables['display_submitted'] = FALSE; } } /** * Implements hook_views_post_execute(). */ function structural_pages_views_post_execute(\Drupal\views\ViewExecutable $view) { // Fallback for child_pages view: if empty, show sibling pages instead. if ($view->id() === 'child_pages' && empty($view->result)) { $args = $view->args; if (!empty($args[0]) && is_numeric($args[0])) { $node_storage = \Drupal::entityTypeManager()->getStorage('node'); $current_node = $node_storage->load($args[0]); if ($current_node instanceof \Drupal\node\NodeInterface && $current_node->bundle() === 'content_page') { $query = \Drupal::entityQuery('node') ->condition('type', 'content_page') ->condition('status', 1) ->accessCheck(TRUE) ->sort('title', 'ASC'); $parent_id = NULL; if (!$current_node->get('field_parent_page')->isEmpty()) { $parent_field = $current_node->get('field_parent_page')->first(); if (($parent_field->target_type ?? '') === 'node') { $parent_id = $parent_field->target_id; } } if ($parent_id) { // Has a node parent. Siblings are nodes with the same parent. $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); if (!$current_node->get('field_site_section')->isEmpty()) { $query->condition('field_site_section.target_id', $current_node->get('field_site_section')->target_id); } } $nids = $query->execute(); if (!empty($nids)) { // Load the sibling nodes $siblings = $node_storage->loadMultiple($nids); $index = 0; $view->result = []; foreach ($siblings as $nid => $sibling) { $row = new \Drupal\views\ResultRow(); $row->_entity = $sibling; $row->nid = $nid; $row->index = $index++; $view->result[] = $row; } $view->total_rows = count($view->result); // Optionally alter the title to indicate these are siblings $view->setTitle(t('Páginas do mesmo nível')); // Remove empty text since we now have results $view->empty = []; } } } } }