Altera lógica de Site Section e adiciona redirect em content_page

- Adiciona campo field_redirect_link (link) no bundle content_page;
  EventSubscriber emite redirect 301 quando o campo está preenchido
- field_site_section passa a ser obrigatório
- Formulário de content_page: AJAX no site section, select hierárquico
  de página pai filtrado por seção, validação customizada
- StructuralPagesMenuBlock: MAX_DEPTH 10→50, nova lógica de raiz via
  field_site_section, variável ancestor_url no render array
- Template do menu: novas classes BEM/Gva, suporte a is_redirect,
  usa ancestor_url em vez de chamada Twig direta
- CSS do menu reescrito com estilos flyout/sidebar; Select2 adicionado
  para o campo de página pai no formulário admin
- display_submitted desabilitado no tipo content_page

Co-Authored-By: Bauer <henrique@webcontent.com.br>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 08:04:43 -03:00
parent eff3c0122f
commit 36c3a2e9c0
16 changed files with 746 additions and 102 deletions

View File

@@ -1,15 +1,19 @@
langcode: en
uuid: 37d8e6ba-7a46-4b21-b38f-b98a1aaddfed
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.content_page.body
- field.field.node.content_page.field_parent_page
- field.field.node.content_page.field_redirect_link
- field.field.node.content_page.field_site_section
- node.type.content_page
module:
- dynamic_entity_reference
- path
- link
- text
_core:
default_config_hash: tmbpOtX26Afqxu9LxKtH1hZgFGb0MHl1vPy6gR2U8BQ
id: node.content_page.default
targetEntityType: node
bundle: content_page
@@ -17,7 +21,7 @@ mode: default
content:
body:
type: text_textarea_with_summary
weight: 2
weight: 4
region: content
settings:
rows: 9
@@ -27,13 +31,13 @@ content:
third_party_settings: { }
created:
type: datetime_timestamp
weight: 10
weight: 6
region: content
settings: { }
third_party_settings: { }
field_parent_page:
type: dynamic_entity_reference_default
weight: 0
weight: 2
region: content
settings:
match_operator: CONTAINS
@@ -41,42 +45,37 @@ content:
size: 60
placeholder: ''
third_party_settings: { }
field_redirect_link:
type: link_default
weight: 26
region: content
settings:
placeholder_url: ''
placeholder_title: ''
third_party_settings: { }
field_site_section:
type: options_select
weight: 1
region: content
settings: { }
third_party_settings: { }
path:
type: path
weight: 30
region: content
settings: { }
third_party_settings: { }
promote:
type: boolean_checkbox
weight: 15
langcode:
type: language_select
weight: 3
region: content
settings:
display_label: true
include_locked: true
third_party_settings: { }
status:
type: boolean_checkbox
weight: 120
region: content
settings:
display_label: true
third_party_settings: { }
sticky:
type: boolean_checkbox
weight: 16
weight: 7
region: content
settings:
display_label: true
third_party_settings: { }
title:
type: string_textfield
weight: -5
weight: 0
region: content
settings:
size: 60
@@ -92,4 +91,14 @@ content:
size: 60
placeholder: ''
third_party_settings: { }
hidden: { }
hidden:
gva_box_layout: true
gva_breadcrumb: true
gva_header: true
gva_node_class: true
gva_node_layout: true
gva_pagebuilder_content: true
gva_pagebuilder_enable: true
path: true
promote: true
sticky: true

View File

@@ -1,13 +1,24 @@
uuid: dd8e1e8f-7596-4196-a8ae-1bb7c8e5da0a
langcode: en
status: true
dependencies:
config:
- field.field.node.content_page.body
- field.field.node.content_page.field_parent_page
- field.field.node.content_page.field_redirect_link
- field.field.node.content_page.field_site_section
- node.type.content_page
module:
- layout_builder
- link
- text
- user
third_party_settings:
layout_builder:
enabled: false
allow_custom: false
_core:
default_config_hash: yOaQdWIbNqm6bN5B-vKu-rkQvPlfYxQkeHYNXmGlkio
id: node.content_page.default
targetEntityType: node
bundle: content_page
@@ -20,11 +31,20 @@ content:
third_party_settings: { }
weight: 0
region: content
links:
settings: { }
field_redirect_link:
type: link
label: above
settings:
trim_length: 80
url_only: false
url_plain: false
rel: ''
target: ''
third_party_settings: { }
weight: 100
weight: 1
region: content
hidden:
field_parent_page: true
field_site_section: true
langcode: true
links: true

View File

@@ -0,0 +1,23 @@
uuid: 426b6b25-a2f2-4020-8b23-01dc9e82f429
langcode: pt-br
status: true
dependencies:
config:
- field.storage.node.field_redirect_link
- node.type.content_page
module:
- link
id: node.content_page.field_redirect_link
field_name: field_redirect_link
entity_type: node
bundle: content_page
label: 'Redirect Link'
description: ''
required: false
translatable: null
default_value: { }
default_value_callback: ''
settings:
title: 0
link_type: 17
field_type: link

View File

@@ -13,7 +13,7 @@ entity_type: node
bundle: content_page
label: 'Site Section'
description: 'For root pages, select the section. For child pages, this field is filled automatically.'
required: false
required: true
translatable: false
default_value: { }
default_value_callback: ''

View File

@@ -0,0 +1,19 @@
uuid: 6212ce1e-3dfb-48b9-b15c-43f0e8c6fd24
langcode: pt-br
status: true
dependencies:
module:
- link
- node
id: node.field_redirect_link
field_name: field_redirect_link
entity_type: node
type: link
settings: { }
module: link
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View File

@@ -10,4 +10,4 @@ description: 'Pages with hierarchical parent-child structure for content organiz
help: 'For root pages, fill in the Site Section manually. For child pages, select the parent page and the section will be inherited automatically.'
new_revision: true
preview_mode: 1
display_submitted: true
display_submitted: false

View File

@@ -17,3 +17,17 @@ structural_pages.settings:
bundle:
type: string
label: 'Bundle'
block.settings.structural_pages_menu:
type: block_settings
label: 'Structural Pages Menu Block'
mapping:
max_depth:
type: integer
label: 'Maximum depth'
show_ancestor_title:
type: boolean
label: 'Show ancestor title'
expand_active_trail:
type: boolean
label: 'Expand active trail'

View File

@@ -1,63 +1,103 @@
/**
* @file
* Styles for the structural pages menu block.
* Structural Pages Menu - Custom Flyout Styles
* Ensures the nested menu items fly out to the right exactly like the Gavias theme reference menu.
*/
.structural-pages-menu {
font-size: 0.9rem;
.block-structural-pages-menu {
padding: 0 !important;
}
.structural-pages-menu__title {
font-size: 1.1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #ddd;
/* Apply the gray background and padding directly to the nav so it remains styled even if detached by the sticky script */
.sidebar .structural-pages-menu {
padding: 30px;
background-color: #f3f3f3;
border-radius: 10px;
width: 100%;
}
.structural-pages-menu__title a {
text-decoration: none;
color: inherit;
/* Remove the padding and background from the outer Drupal block wrapper to avoid double padding */
.sidebar [id^="block-structuralpagesmenublock"] {
padding: 0 !important;
background: transparent !important;
}
.structural-pages-menu__title a:hover {
text-decoration: underline;
/* Position relative on parent items to anchor the absolute sub-menu */
.structural-pages-menu .structural-pages-menu__item {
position: relative;
}
.structural-pages-menu__list {
/* Hide sub-menus by default and position them to the right */
.structural-pages-menu .sub-menu {
position: absolute;
top: 0;
left: 100%;
visibility: hidden;
opacity: 0;
min-width: 260px;
background-color: #ffffff;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
z-index: 999;
transition: all 0.25s ease-out;
transform: translateY(10px);
pointer-events: none;
border-left: 2px solid var(--notech-theme-color, #db162f);
list-style: none;
margin: 0;
padding: 0;
margin: 0;
}
.structural-pages-menu__list--level-2,
.structural-pages-menu__list--level-3,
.structural-pages-menu__list--level-4 {
padding-left: 1rem;
/* Show submenu on hover or focus */
.structural-pages-menu .structural-pages-menu__item:hover>.sub-menu,
.structural-pages-menu .structural-pages-menu__item:focus-within>.sub-menu {
visibility: visible;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
padding: 5px 30px !important;
}
.structural-pages-menu__item {
margin: 0.25rem 0;
/* Add an indicator icon (arrow) to items that have children */
.structural-pages-menu .structural-pages-menu__item--has-children>a::after {
content: " \203A";
float: right;
font-family: monospace;
font-size: 1.4em;
font-weight: bold;
line-height: 0.8;
opacity: 0.4;
transition: opacity 0.2s;
}
.structural-pages-menu__link {
.structural-pages-menu .structural-pages-menu__item--has-children:hover>a::after {
opacity: 1;
}
/* Styles for the links inside the flyout menus */
.structural-pages-menu .sub-menu>li>a {
display: block;
padding: 0.25rem 0.5rem;
padding: 12px 20px;
font-size: 14px;
color: #333333;
text-decoration: none;
color: #333;
border-radius: 3px;
transition: background-color 0.15s ease;
border-bottom: 1px solid #f2f2f2;
transition: all 0.2s ease;
background-color: transparent;
font-weight: 500;
}
.structural-pages-menu__link:hover {
background-color: #f5f5f5;
text-decoration: none;
.structural-pages-menu .sub-menu>li:last-child>a {
border-bottom: none;
}
.structural-pages-menu__link--active-trail {
font-weight: 600;
color: #0073bd;
.structural-pages-menu .sub-menu>li>a:hover {
color: var(--notech-theme-color, #db162f);
background-color: #fcfcfc;
padding-left: 25px;
/* Subtle interactively indication */
}
.structural-pages-menu__item--active-trail > .structural-pages-menu__link {
background-color: #e8f4fc;
}
/* Ensure active trail items inside submenu remain highlighted */
.structural-pages-menu .sub-menu .menu-item--active-trail>a,
.structural-pages-menu .sub-menu .is-active {
color: var(--notech-theme-color, #db162f);
}

View File

@@ -0,0 +1,60 @@
/* Custom styles to make Select2 match Drupal 10 Claro Theme's native select */
.select2-container--default .select2-selection--single {
height: 3rem; /* 48px matches Claro's form elements */
border: 1px solid #8e929c; /* Claro default border */
border-radius: 2px;
background-color: #f3f4f9; /* Claro default input background */
display: flex;
align-items: center;
transition: border-color 0.2s, box-shadow 0.2s;
}
.select2-container--default .select2-selection--single .select2-selection__rendered {
line-height: normal;
color: #222330;
padding-left: 1rem;
font-size: 1rem;
}
.select2-container--default .select2-selection--single .select2-selection__arrow {
height: 100%;
right: 0.5rem;
display: flex;
align-items: center;
}
/* Hover effect */
.select2-container--default .select2-selection--single:hover {
border-color: #55565b;
}
/* Focus and Open effect */
.select2-container--default.select2-container--focus .select2-selection--single,
.select2-container--default.select2-container--open .select2-selection--single {
border-color: #003cc5;
box-shadow: 0 0 0 2px #26a769; /* Claro green-ish or blue-ish focus ring */
outline: none;
}
.select2-container--default.select2-container--open .select2-selection--single {
box-shadow: 0 0 0 2px #003cc5; /* Active blue focus */
}
/* Adjust dropdown list styling slightly */
.select2-dropdown {
border: 1px solid #003cc5;
border-radius: 2px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}
.select2-container--default .select2-search--dropdown .select2-search__field {
border: 1px solid #8e929c;
border-radius: 2px;
padding: 0.5rem;
height: 2.5rem;
}
.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable {
background-color: #003cc5;
color: white;
}

View File

@@ -0,0 +1,34 @@
(function ($, Drupal, once) {
Drupal.behaviors.structuralPagesSelect2 = {
attach: function (context, settings) {
function initSelect2() {
var elements = once('structural-pages-select2', 'select.select2-widget', context);
if (elements.length) {
var $select = $(elements);
// If the select2 jQuery plugin is loaded (by another module/theme), use it.
// Otherwise, try chosen if it's there.
if (typeof $.fn.select2 !== 'undefined') {
$select.select2({
width: '100%',
dropdownAutoWidth: true
});
}
else if (typeof $.fn.chosen !== 'undefined') {
$select.chosen({
width: '100%',
search_contains: true
});
}
}
}
// Initialize on load
initSelect2();
// Some admin themes replace select elements entirely. This ensures we hook in.
$(document).on('ajaxComplete', function () {
initSelect2();
});
}
};
})(jQuery, Drupal, once);

View File

@@ -0,0 +1,79 @@
<?php
namespace Drupal\structural_pages\EventSubscriber;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\node\NodeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Redirects content pages to their configured link if set.
*/
class StructuralPagesRedirectSubscriber implements EventSubscriberInterface
{
/**
* The route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructs a new StructuralPagesRedirectSubscriber object.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*/
public function __construct(RouteMatchInterface $route_match)
{
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
$events[KernelEvents::REQUEST][] = ['onKernelRequest', 30];
return $events;
}
/**
* Redirects if the node has a redirect link.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*/
public function onKernelRequest(RequestEvent $event)
{
if (!$event->isMainRequest()) {
return;
}
$route_name = $this->routeMatch->getRouteName();
if ($route_name === 'entity.node.canonical') {
$node = $this->routeMatch->getParameter('node');
if ($node instanceof NodeInterface && $node->bundle() === 'content_page') {
if ($node->hasField('field_redirect_link') && !$node->get('field_redirect_link')->isEmpty()) {
try {
/** @var \Drupal\link\LinkItemInterface $link_item */
$link_item = $node->get('field_redirect_link')->first();
$url = $link_item->getUrl()->toString();
// Cacheable redirect response.
$response = new TrustedRedirectResponse($url, 301);
$response->addCacheableDependency($node);
$event->setResponse($response);
} catch (\Exception $e) {
// If the link is invalid, proceed normally.
}
}
}
}
}
}

View File

@@ -27,12 +27,13 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
* }
* )
*/
class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPluginInterface {
class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPluginInterface
{
/**
* Maximum depth to prevent infinite loops.
* Maximum depth to prevent infinite loops. We set it very high (50) to allow unlimited sublevels.
*/
protected const MAX_DEPTH = 10;
protected const MAX_DEPTH = 50;
/**
* The entity type manager.
@@ -82,7 +83,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static
{
return new static(
$configuration,
$plugin_id,
@@ -96,9 +98,10 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
public function defaultConfiguration(): array
{
return [
'max_depth' => 3,
'max_depth' => 50,
'show_ancestor_title' => TRUE,
'expand_active_trail' => TRUE,
] + parent::defaultConfiguration();
@@ -107,7 +110,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state): array {
public function blockForm($form, FormStateInterface $form_state): array
{
$form = parent::blockForm($form, $form_state);
$form['max_depth'] = [
@@ -139,7 +143,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state): void {
public function blockSubmit($form, FormStateInterface $form_state): void
{
$this->configuration['max_depth'] = $form_state->getValue('max_depth');
$this->configuration['show_ancestor_title'] = $form_state->getValue('show_ancestor_title');
$this->configuration['expand_active_trail'] = $form_state->getValue('expand_active_trail');
@@ -148,7 +153,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
/**
* {@inheritdoc}
*/
public function build(): array {
public function build(): array
{
$this->cacheTags = [];
// Get current context.
@@ -177,6 +183,7 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
$build = [
'#theme' => 'structural_pages_menu',
'#ancestor' => $ancestor,
'#ancestor_url' => $ancestor->hasLinkTemplate('canonical') ? $ancestor->toUrl()->toString() : '',
'#tree' => $tree,
'#active_trail' => $active_trail,
'#show_ancestor_title' => $this->configuration['show_ancestor_title'],
@@ -195,15 +202,15 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return \Drupal\node\NodeInterface|null
* The current node or NULL.
*/
protected function getCurrentNode(): ?NodeInterface {
protected function getCurrentNode(): ?NodeInterface
{
// Try to get from block context first.
try {
$node = $this->getContextValue('node');
if ($node instanceof NodeInterface) {
return $node;
}
}
catch (\Exception $e) {
} catch (\Exception $e) {
// Context not available.
}
@@ -221,7 +228,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return \Drupal\Core\Entity\EntityInterface|null
* The ancestor entity (term, user, or group) or NULL.
*/
protected function findAncestor(?NodeInterface $node): ?EntityInterface {
protected function findAncestor(?NodeInterface $node): ?EntityInterface
{
if (!$node) {
// Check if we're on a taxonomy term, user, or group page.
return $this->getAncestorFromRoute();
@@ -278,7 +286,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return \Drupal\Core\Entity\EntityInterface|null
* The ancestor entity or NULL.
*/
protected function getAncestorFromRoute(): ?EntityInterface {
protected function getAncestorFromRoute(): ?EntityInterface
{
return $this->handlerManager->getEntityFromRoute($this->routeMatch);
}
@@ -291,7 +300,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return array
* The menu tree structure.
*/
protected function buildTree(EntityInterface $ancestor): array {
protected function buildTree(EntityInterface $ancestor): array
{
$root_pages = $this->getChildPages($ancestor->getEntityTypeId(), (string) $ancestor->id());
if (empty($root_pages)) {
@@ -321,14 +331,29 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return array
* The branch structure.
*/
protected function buildBranch(NodeInterface $node, int $current_depth, int $max_depth): array {
protected function buildBranch(NodeInterface $node, int $current_depth, int $max_depth): array
{
$this->cacheTags[] = 'node:' . $node->id();
$url = $node->toUrl()->toString();
$is_redirect = FALSE;
if ($node->hasField('field_redirect_link') && !$node->get('field_redirect_link')->isEmpty()) {
try {
/** @var \Drupal\link\LinkItemInterface $link_item */
$link_item = $node->get('field_redirect_link')->first();
$url = $link_item->getUrl()->toString();
$is_redirect = TRUE;
} catch (\Exception $e) {
// Fallback to node url if the redirect link is invalid.
}
}
$branch = [
'entity' => $node,
'id' => $node->id(),
'title' => $node->label(),
'url' => $node->toUrl()->toString(),
'url' => $url,
'is_redirect' => $is_redirect,
'depth' => $current_depth,
'children' => [],
];
@@ -354,15 +379,24 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return \Drupal\node\NodeInterface[]
* Array of child nodes.
*/
protected function getChildPages(string $parent_type, string $parent_id): array {
protected function getChildPages(string $parent_type, string $parent_id): array
{
$query = $this->entityTypeManager->getStorage('node')->getQuery()
->accessCheck(TRUE)
->condition('type', 'content_page')
->condition('status', 1)
->condition('field_parent_page.target_type', $parent_type)
->condition('field_parent_page.target_id', $parent_id)
->sort('title', 'ASC');
if ($parent_type === 'taxonomy_term') {
// Root pages of a section have no parent page, but belong to the section.
$query->notExists('field_parent_page');
$query->condition('field_site_section.target_id', $parent_id);
} else {
// Child pages belong to a specific parent entity (node).
$query->condition('field_parent_page.target_type', $parent_type);
$query->condition('field_parent_page.target_id', $parent_id);
}
$nids = $query->execute();
if (empty($nids)) {
@@ -386,7 +420,8 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
* @return array
* Array of node IDs in the active trail.
*/
protected function getActiveTrail(NodeInterface $node): array {
protected function getActiveTrail(NodeInterface $node): array
{
$trail = [$node->id()];
$visited = [$node->id() => TRUE];
$current = $node;
@@ -427,14 +462,16 @@ class StructuralPagesMenuBlock extends BlockBase implements ContainerFactoryPlug
/**
* {@inheritdoc}
*/
public function getCacheTags(): array {
public function getCacheTags(): array
{
return Cache::mergeTags(parent::getCacheTags(), ['node_list:content_page']);
}
/**
* {@inheritdoc}
*/
public function getCacheContexts(): array {
public function getCacheContexts(): array
{
return Cache::mergeContexts(parent::getCacheContexts(), ['route', 'url.path']);
}

View File

@@ -3,3 +3,17 @@ menu:
css:
component:
css/structural-pages-menu.css: {}
select2:
version: 4.1.0-rc.0
css:
component:
https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css: { type: external }
css/structural_pages_select2.css: {}
js:
https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js: { type: external }
js/structural_pages_select2.js: {}
dependencies:
- core/jquery
- core/drupal
- core/once

View File

@@ -212,6 +212,7 @@ function structural_pages_theme(): array {
'structural_pages_menu' => [
'variables' => [
'ancestor' => NULL,
'ancestor_url' => '',
'tree' => [],
'active_trail' => [],
'show_ancestor_title' => TRUE,
@@ -332,3 +333,285 @@ function _structural_pages_get_term_hierarchy_path(TermInterface $term): string
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;
}
// 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('- Raiz da Seção -'),
'#description' => t('Select the parent page within this site section.'),
// 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>',
];
}
}
}
/**
* 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 = [];
// Add the Section's Taxonomy Term as a Root option
$term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load($site_section_id);
if ($term) {
$options['taxonomy_term:' . $site_section_id] = '< Raiz (' . $term->getName() . ') >';
}
$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.
$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);
}
}
$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 = [];
}
}
}
}
}

View File

@@ -14,3 +14,10 @@ services:
- '@plugin.manager.parent_entity_handler'
tags:
- { name: breadcrumb_builder, priority: 100 }
structural_pages.redirect_subscriber:
class: Drupal\structural_pages\EventSubscriber\StructuralPagesRedirectSubscriber
arguments:
- '@current_route_match'
tags:
- { name: event_subscriber }

View File

@@ -18,22 +18,26 @@
#}
{{ attach_library('structural_pages/menu') }}
{% if tree is not empty %}
<nav class="structural-pages-menu" aria-label="{{ 'Site structure navigation'|t }}">
<nav class="block-menu navigation structural-pages-menu" aria-label="{{ 'Site structure navigation'|t }}">
{% if show_ancestor_title and ancestor %}
<h2 class="structural-pages-menu__title">
{% if ancestor.toUrl is defined %}
<a href="{{ ancestor.toUrl }}">{{ ancestor.label }}</a>
<h2 class="structural-pages-menu__title block-title">
<span>
{% if ancestor_url %}
<a href="{{ ancestor_url }}">{{ ancestor.label }}</a>
{% else %}
{{ ancestor.label }}
{% endif %}
</span>
</h2>
{% endif %}
<ul class="structural-pages-menu__list structural-pages-menu__list--level-1">
{% for item in tree %}
{{ _self.menu_item(item, active_trail, 1) }}
{% endfor %}
</ul>
<div class="block-content">
<ul class="gva_menu structural-pages-menu__list structural-pages-menu__list--level-1">
{% for item in tree %}
{{ _self.menu_item(item, active_trail, 1) }}
{% endfor %}
</ul>
</div>
</nav>
{% endif %}
@@ -41,19 +45,20 @@
{% set is_active = item.id in active_trail %}
{% set has_children = item.children is not empty %}
{% set classes = [
'menu-item',
'structural-pages-menu__item',
'structural-pages-menu__item--level-' ~ depth,
is_active ? 'structural-pages-menu__item--active-trail',
has_children ? 'structural-pages-menu__item--has-children',
is_active ? 'menu-item--active-trail structural-pages-menu__item--active-trail',
has_children ? 'menu-item--expanded structural-pages-menu__item--has-children',
] %}
<li{{ create_attribute().addClass(classes) }}>
<a href="{{ item.url }}" class="structural-pages-menu__link{% if is_active %} structural-pages-menu__link--active-trail{% endif %}">
<a href="{{ item.url }}"{% if item.is_redirect %} target="_blank"{% endif %} class="structural-pages-menu__link{% if is_active %} is-active structural-pages-menu__link--active-trail{% endif %}">
{{- item.title -}}
</a>
{% if has_children %}
<ul class="structural-pages-menu__list structural-pages-menu__list--level-{{ depth + 1 }}">
<ul class="menu sub-menu structural-pages-menu__list structural-pages-menu__list--level-{{ depth + 1 }}">
{% for child in item.children %}
{{ _self.menu_item(child, active_trail, depth + 1) }}
{% endfor %}