feat(exclusions): Implement component exclusion system (Plan 99.11)

Adds ability to exclude components from specific:
- Categories (by slug or term_id)
- Post/Page IDs
- URL patterns (substring or regex)

Architecture:
- Domain: Value Objects (CategoryExclusion, PostIdExclusion,
  UrlPatternExclusion, ExclusionRuleSet) + Contracts
- Application: EvaluateExclusionsUseCase +
  EvaluateComponentVisibilityUseCase (orchestrator)
- Infrastructure: WordPressExclusionRepository,
  WordPressPageContextProvider, WordPressServerRequestProvider
- Admin: ExclusionFormPartial (reusable UI),
  ExclusionFieldProcessor, JS toggle

The PageVisibilityHelper now uses the orchestrator UseCase that
combines page-type visibility (Plan 99.10) with exclusion rules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-03 10:51:00 -06:00
parent 8735962f52
commit 14138e7762
19 changed files with 1407 additions and 5 deletions

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Services;
/**
* Servicio para procesar campos de exclusion antes de guardar en BD
*
* Convierte formatos de UI a JSON para almacenamiento.
*
* v1.1: Extraido de AdminAjaxHandler (SRP)
*
* @package ROITheme\Admin\Shared\Infrastructure\Services
*/
final class ExclusionFieldProcessor
{
/**
* Procesa un valor de campo de exclusion segun su tipo
*
* @param string $value Valor del campo (desde UI)
* @param string $type Tipo de campo: json_array, json_array_int, json_array_lines
* @return string JSON string para almacenar en BD
*/
public function process(string $value, string $type): string
{
return match ($type) {
'json_array' => $this->processJsonArray($value),
'json_array_int' => $this->processJsonArrayInt($value),
'json_array_lines' => $this->processJsonArrayLines($value),
default => $value,
};
}
/**
* "a, b, c" -> ["a", "b", "c"]
*/
private function processJsonArray(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_UNICODE);
}
/**
* "1, 2, 3" -> [1, 2, 3]
*/
private function processJsonArrayInt(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, 'is_numeric');
$items = array_map('intval', $items);
return json_encode(array_values($items));
}
/**
* Lineas separadas -> array
*/
private function processJsonArrayLines(string $value): string
{
$items = preg_split('/\r\n|\r|\n/', $value);
$items = array_map('trim', $items);
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}

View File

@@ -0,0 +1,31 @@
/**
* Toggle para mostrar/ocultar reglas de exclusion en FormBuilders
*
* Escucha cambios en checkboxes con ID que termine en "ExclusionsEnabled"
* y muestra/oculta el contenedor de reglas correspondiente.
*
* @package ROITheme\Admin
*/
(function() {
'use strict';
function initExclusionToggles() {
document.querySelectorAll('[id$="ExclusionsEnabled"]').forEach(function(checkbox) {
// Handler para cambios
checkbox.addEventListener('change', function() {
const prefix = this.id.replace('ExclusionsEnabled', '');
const rulesContainer = document.getElementById(prefix + 'ExclusionRules');
if (rulesContainer) {
rulesContainer.style.display = this.checked ? 'block' : 'none';
}
});
});
}
// Inicializar cuando DOM este listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initExclusionToggles);
} else {
initExclusionToggles();
}
})();

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* Componente UI parcial reutilizable para reglas de exclusion
*
* Genera el HTML para la seccion de exclusiones en FormBuilders.
* Debe ser incluido despues de la seccion de visibilidad por tipo de pagina.
*
* Uso en FormBuilder:
* ```php
* $exclusionPartial = new ExclusionFormPartial($this->renderer);
* $html .= $exclusionPartial->render($componentId, 'prefijo');
* ```
*
* @package ROITheme\Admin\Shared\Infrastructure\Ui
*/
final class ExclusionFormPartial
{
private const GROUP_NAME = '_exclusions';
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {}
/**
* Renderiza la seccion de exclusiones
*
* @param string $componentId ID del componente (kebab-case)
* @param string $prefix Prefijo para IDs de campos (ej: 'cta' genera 'ctaExclusionsEnabled')
* @return string HTML de la seccion
*/
public function render(string $componentId, string $prefix): string
{
$html = '';
$html .= $this->buildExclusionHeader();
$html .= $this->buildExclusionToggle($componentId, $prefix);
$html .= $this->buildExclusionRules($componentId, $prefix);
return $html;
}
private function buildExclusionHeader(): string
{
$html = '<hr class="my-3">';
$html .= '<p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-funnel me-1" style="color: #FF8600;"></i>';
$html .= ' Reglas de exclusion avanzadas';
$html .= '</p>';
$html .= '<p class="small text-muted mb-2">';
$html .= ' Excluir este componente de categorias, posts o URLs especificos.';
$html .= '</p>';
return $html;
}
private function buildExclusionToggle(string $componentId, string $prefix): string
{
$enabled = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclusions_enabled',
false
);
$checked = $this->toBool($enabled);
$id = $prefix . 'ExclusionsEnabled';
$html = '<div class="mb-3">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= ' <i class="bi bi-filter-circle me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Activar reglas de exclusion</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildExclusionRules(string $componentId, string $prefix): string
{
$enabled = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclusions_enabled',
false
);
$display = $this->toBool($enabled) ? 'block' : 'none';
$html = sprintf(
'<div id="%sExclusionRules" style="display: %s;">',
esc_attr($prefix),
$display
);
$html .= $this->buildCategoryField($componentId, $prefix);
$html .= $this->buildPostIdsField($componentId, $prefix);
$html .= $this->buildUrlPatternsField($componentId, $prefix);
$html .= '</div>';
return $html;
}
private function buildCategoryField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_categories',
'[]'
);
$categories = $this->jsonToCommaList($value);
$id = $prefix . 'ExcludeCategories';
$html = '<div class="mb-3">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-folder me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir en categorias';
$html .= ' </label>';
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="noticias, eventos, tutoriales">',
esc_attr($id),
esc_attr($categories)
);
$html .= ' <small class="text-muted">Slugs de categorias separados por comas</small>';
$html .= '</div>';
return $html;
}
private function buildPostIdsField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_post_ids',
'[]'
);
$postIds = $this->jsonToCommaList($value);
$id = $prefix . 'ExcludePostIds';
$html = '<div class="mb-3">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-hash me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir en posts/paginas';
$html .= ' </label>';
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="123, 456, 789">',
esc_attr($id),
esc_attr($postIds)
);
$html .= ' <small class="text-muted">IDs de posts o paginas separados por comas</small>';
$html .= '</div>';
return $html;
}
private function buildUrlPatternsField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_url_patterns',
'[]'
);
$patterns = $this->jsonToLineList($value);
$id = $prefix . 'ExcludeUrlPatterns';
$html = '<div class="mb-0">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir por patrones URL';
$html .= ' </label>';
$html .= sprintf(
' <textarea id="%s" class="form-control form-control-sm" rows="3" placeholder="/privado/&#10;/landing-especial/&#10;/^\/categoria\/\d+$/">%s</textarea>',
esc_attr($id),
esc_textarea($patterns)
);
$html .= ' <small class="text-muted">Un patron por linea. Soporta texto simple o regex (ej: /^\/blog\/\d+$/)</small>';
$html .= '</div>';
return $html;
}
/**
* Convierte JSON array a lista separada por comas
*/
private function jsonToCommaList(string $json): string
{
$decoded = json_decode($json, true);
if (!is_array($decoded) || empty($decoded)) {
return '';
}
return implode(', ', $decoded);
}
/**
* Convierte JSON array a lista separada por lineas
*/
private function jsonToLineList(string $json): string
{
$decoded = json_decode($json, true);
if (!is_array($decoded) || empty($decoded)) {
return '';
}
return implode("\n", $decoded);
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}