Files
roi-theme/Admin/CustomCSSManager/Infrastructure/Ui/CustomCSSManagerFormBuilder.php
FrankZamora a33c43a104 fix(admin): corregir guardado customcssmanager con toast
- crear bootstrap para handler post en admin_init
- ocultar botones globales para custom-css-manager
- simplificar formbuilder eliminando handler duplicado
- reemplazar alert por toast para notificaciones

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 15:02:06 -06:00

422 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
/**
* FormBuilder para gestión de CSS snippets en Admin Panel
*
* Sigue el patrón estándar de FormBuilders del tema:
* - Constructor recibe AdminDashboardRenderer
* - Método buildForm() genera el HTML del formulario
*
* NOTA: El handler de formulario POST está en CustomCSSManagerBootstrap
* para que se ejecute en admin_init ANTES de que se envíen headers HTTP.
*
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
*/
final class CustomCSSManagerFormBuilder
{
private const COMPONENT_ID = 'custom-css-manager';
private const NONCE_ACTION = 'roi_custom_css_manager';
private WordPressSnippetRepository $repository;
private GetAllSnippetsUseCase $getAllUseCase;
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {
// Crear repositorio y Use Case para listar snippets
global $wpdb;
$this->repository = new WordPressSnippetRepository($wpdb);
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
// NOTA: El handler POST está en CustomCSSManagerBootstrap (admin_init)
}
/**
* Construye el formulario del componente
*
* @param string $componentId ID del componente (custom-css-manager)
* @return string HTML del formulario
*/
public function buildForm(string $componentId): string
{
$snippets = $this->getAllUseCase->execute();
$message = $this->getFlashMessage();
$html = '';
// Header
$html .= $this->buildHeader($componentId, count($snippets));
// Toast para mensajes (usa el sistema existente de admin-dashboard.js)
if ($message) {
$html .= $this->buildToastTrigger($message);
}
// Lista de snippets existentes
$html .= $this->buildSnippetsList($snippets);
// Formulario de creación/edición
$html .= $this->buildSnippetForm();
// JavaScript
$html .= $this->buildJavaScript();
return $html;
}
/**
* Construye el header del componente
*/
private function buildHeader(string $componentId, int $snippetCount): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-header d-flex justify-content-between align-items-center" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%);">';
$html .= ' <h3 class="mb-0 text-white"><i class="bi bi-file-earmark-code me-2"></i>Gestor de CSS Personalizado</h3>';
$html .= ' <span class="badge bg-light text-dark">' . esc_html($snippetCount) . ' snippet(s)</span>';
$html .= ' </div>';
$html .= ' <div class="card-body">';
$html .= ' <p class="text-muted mb-0">Gestiona snippets de CSS personalizados. Los snippets críticos se cargan en el head, los diferidos en el footer.</p>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Construye la lista de snippets existentes
*/
private function buildSnippetsList(array $snippets): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>';
$html .= ' Snippets Configurados';
$html .= ' </h5>';
if (empty($snippets)) {
$html .= ' <p class="text-muted">No hay snippets configurados.</p>';
} else {
$html .= ' <div class="table-responsive">';
$html .= ' <table class="table table-hover">';
$html .= ' <thead>';
$html .= ' <tr>';
$html .= ' <th>Nombre</th>';
$html .= ' <th>Tipo</th>';
$html .= ' <th>Páginas</th>';
$html .= ' <th>Estado</th>';
$html .= ' <th>Acciones</th>';
$html .= ' </tr>';
$html .= ' </thead>';
$html .= ' <tbody>';
foreach ($snippets as $snippet) {
$html .= $this->renderSnippetRow($snippet);
}
$html .= ' </tbody>';
$html .= ' </table>';
$html .= ' </div>';
}
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Renderiza una fila de snippet en la tabla
*/
private function renderSnippetRow(array $snippet): string
{
$id = esc_attr($snippet['id']);
$name = esc_html($snippet['name']);
$type = $snippet['type'] === 'critical' ? 'Crítico' : 'Diferido';
$typeBadge = $snippet['type'] === 'critical' ? 'bg-danger' : 'bg-info';
$pages = implode(', ', $snippet['pages'] ?? ['all']);
$enabled = ($snippet['enabled'] ?? false) ? 'Activo' : 'Inactivo';
$enabledBadge = ($snippet['enabled'] ?? false) ? 'bg-success' : 'bg-secondary';
// Usar data-attribute para JSON seguro
$snippetJson = esc_attr(wp_json_encode($snippet, JSON_HEX_APOS | JSON_HEX_QUOT));
$nonce = wp_create_nonce(self::NONCE_ACTION);
return <<<HTML
<tr>
<td><strong>{$name}</strong></td>
<td><span class="badge {$typeBadge}">{$type}</span></td>
<td><small>{$pages}</small></td>
<td><span class="badge {$enabledBadge}">{$enabled}</span></td>
<td>
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-snippet"
data-snippet="{$snippetJson}">
<i class="bi bi-pencil"></i>
</button>
<form method="post" class="d-inline"
onsubmit="return confirm('¿Eliminar este snippet?');">
<input type="hidden" name="_wpnonce" value="{$nonce}">
<input type="hidden" name="roi_css_action" value="delete">
<input type="hidden" name="snippet_id" value="{$id}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
HTML;
}
/**
* Construye el formulario de creación/edición de snippets
*/
private function buildSnippetForm(): string
{
$nonce = wp_create_nonce(self::NONCE_ACTION);
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-plus-circle me-2" style="color: #FF8600;"></i>';
$html .= ' Agregar/Editar Snippet';
$html .= ' </h5>';
$html .= ' <form method="post" id="roi-snippet-form">';
$html .= ' <input type="hidden" name="_wpnonce" value="' . esc_attr($nonce) . '">';
$html .= ' <input type="hidden" name="roi_css_action" value="save">';
$html .= ' <input type="hidden" name="snippet_id" id="snippet_id" value="">';
$html .= ' <div class="row g-3">';
// Nombre
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Nombre *</label>';
$html .= ' <input type="text" name="snippet_name" id="snippet_name" class="form-control" required maxlength="100" placeholder="Ej: Estilos Tablas APU">';
$html .= ' </div>';
// Tipo
$html .= ' <div class="col-md-3">';
$html .= ' <label class="form-label">Tipo *</label>';
$html .= ' <select name="snippet_type" id="snippet_type" class="form-select">';
$html .= ' <option value="critical">Crítico (head)</option>';
$html .= ' <option value="deferred" selected>Diferido (footer)</option>';
$html .= ' </select>';
$html .= ' </div>';
// Orden
$html .= ' <div class="col-md-3">';
$html .= ' <label class="form-label">Orden</label>';
$html .= ' <input type="number" name="snippet_order" id="snippet_order" class="form-control" value="100" min="1" max="999">';
$html .= ' </div>';
// Descripción
$html .= ' <div class="col-12">';
$html .= ' <label class="form-label">Descripción</label>';
$html .= ' <input type="text" name="snippet_description" id="snippet_description" class="form-control" maxlength="255" placeholder="Descripción breve del propósito del CSS">';
$html .= ' </div>';
// Páginas
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Aplicar en páginas</label>';
$html .= ' <div class="d-flex flex-wrap gap-2">';
foreach ($this->getPageOptions() as $value => $label) {
$checked = $value === 'all' ? 'checked' : '';
$html .= sprintf(
'<div class="form-check"><input type="checkbox" name="snippet_pages[]" value="%s" id="page_%s" class="form-check-input" %s><label class="form-check-label" for="page_%s">%s</label></div>',
esc_attr($value),
esc_attr($value),
$checked,
esc_attr($value),
esc_html($label)
);
}
$html .= ' </div>';
$html .= ' </div>';
// Estado
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Estado</label>';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input type="checkbox" name="snippet_enabled" id="snippet_enabled" class="form-check-input" checked>';
$html .= ' <label class="form-check-label" for="snippet_enabled">Habilitado</label>';
$html .= ' </div>';
$html .= ' </div>';
// Código CSS
$html .= ' <div class="col-12">';
$html .= ' <label class="form-label">Código CSS *</label>';
$html .= ' <textarea name="snippet_css" id="snippet_css" class="form-control font-monospace" rows="10" required placeholder="/* Tu CSS aquí */"></textarea>';
$html .= ' <small class="text-muted">Crítico: máx 14KB | Diferido: máx 100KB</small>';
$html .= ' </div>';
// Botones
$html .= ' <div class="col-12">';
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">';
$html .= ' <i class="bi bi-check-circle me-1"></i> Guardar Cambios';
$html .= ' </button>';
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
$html .= ' </button>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </form>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Genera el JavaScript necesario para el formulario
*/
private function buildJavaScript(): string
{
return <<<JS
<script>
// Event delegation para botones de edición
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.btn-edit-snippet').forEach(function(btn) {
btn.addEventListener('click', function() {
const snippet = JSON.parse(this.dataset.snippet);
editCssSnippet(snippet);
});
});
});
function editCssSnippet(snippet) {
document.getElementById('snippet_id').value = snippet.id;
document.getElementById('snippet_name').value = snippet.name;
document.getElementById('snippet_description').value = snippet.description || '';
document.getElementById('snippet_type').value = snippet.type;
document.getElementById('snippet_order').value = snippet.order || 100;
document.getElementById('snippet_css').value = snippet.css;
document.getElementById('snippet_enabled').checked = snippet.enabled;
// Actualizar checkboxes de páginas
document.querySelectorAll('input[name="snippet_pages[]"]').forEach(cb => {
cb.checked = (snippet.pages || ['all']).includes(cb.value);
});
document.getElementById('snippet_name').focus();
// Scroll al formulario
document.getElementById('roi-snippet-form').scrollIntoView({ behavior: 'smooth' });
}
function resetCssForm() {
document.getElementById('roi-snippet-form').reset();
document.getElementById('snippet_id').value = '';
}
</script>
JS;
}
/**
* Opciones de páginas disponibles
*/
private function getPageOptions(): array
{
return [
'all' => 'Todas',
'home' => 'Inicio',
'posts' => 'Posts',
'pages' => 'Páginas',
'archives' => 'Archivos',
];
}
/**
* Obtiene mensaje flash de la URL
*/
private function getFlashMessage(): ?array
{
$message = $_GET['roi_message'] ?? null;
if ($message === 'success') {
return ['type' => 'success', 'text' => 'Cambios guardados correctamente'];
}
if ($message === 'error') {
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
return ['type' => 'error', 'text' => $error];
}
return null;
}
/**
* Genera script para mostrar Toast
*/
private function buildToastTrigger(array $message): string
{
$type = esc_js($message['type']);
$text = esc_js($message['text']);
// Mapear tipo a configuración de Bootstrap
$typeMap = [
'success' => ['bg' => 'success', 'icon' => 'bi-check-circle-fill'],
'error' => ['bg' => 'danger', 'icon' => 'bi-x-circle-fill'],
];
$config = $typeMap[$type] ?? $typeMap['success'];
$bg = $config['bg'];
$icon = $config['icon'];
return <<<HTML
<script>
document.addEventListener('DOMContentLoaded', function() {
// Crear container de toasts si no existe
let toastContainer = document.getElementById('roiToastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'roiToastContainer';
toastContainer.className = 'toast-container position-fixed start-50 translate-middle-x';
toastContainer.style.top = '60px';
toastContainer.style.zIndex = '999999';
document.body.appendChild(toastContainer);
}
// Crear toast
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="\${toastId}" class="toast align-items-center text-white bg-{$bg} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi {$icon} me-2"></i>
<strong>{$text}</strong>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
// Mostrar toast
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Eliminar del DOM después de ocultarse
toastElement.addEventListener('hidden.bs.toast', function() {
toastElement.remove();
});
// Limpiar parámetros de URL sin recargar
const url = new URL(window.location.href);
url.searchParams.delete('roi_message');
url.searchParams.delete('roi_error');
window.history.replaceState({}, '', url.toString());
});
</script>
HTML;
}
}