/** * FormCanvasHelper * * Clase para orquestar la interacción entre un formulario dentro de un Offcanvas * de Bootstrap y el estado de Livewire (modo create/edit/delete), además de * manipular ciertos componentes externos como Select2. * * Se diseñó teniendo en cuenta que el DOM del Offcanvas puede reconstruirse * (re-render) de manera frecuente, por lo que muchos getters reacceden al DOM * dinámicamente. */ export default class FormCanvasHelper { /** * @param {string} offcanvasId - ID del elemento Offcanvas en el DOM. * @param {object} liveWireInstance - Instancia de Livewire asociada al formulario. */ constructor(offcanvasId, liveWireInstance) { this.offcanvasId = offcanvasId; this.liveWireInstance = liveWireInstance; // Validamos referencias mínimas para evitar errores tempranos // Si alguna falta, se mostrará un error en consola. this.validateInitialDomRefs(); } /** * Verifica la existencia básica de elementos en el DOM. * Muestra errores en consola si faltan elementos críticos. */ validateInitialDomRefs() { const offcanvasEl = document.getElementById(this.offcanvasId); if (!offcanvasEl) { console.error(`❌ No se encontró el contenedor Offcanvas con ID: ${this.offcanvasId}`); return; } const formEl = offcanvasEl.querySelector('form'); if (!formEl) { console.error(`❌ No se encontró el formulario dentro de #${this.offcanvasId}`); return; } const offcanvasTitle = offcanvasEl.querySelector('.offcanvas-title'); const submitButtons = formEl.querySelectorAll('.btn-submit'); const resetButtons = formEl.querySelectorAll('.btn-reset'); if (!offcanvasTitle || !submitButtons.length || !resetButtons.length) { console.error(`❌ Faltan el título, botones de submit o reset dentro de #${this.offcanvasId}`); } } /** * Getter para el contenedor Offcanvas actual. * Retorna siempre la referencia más reciente del DOM. */ get offcanvasEl() { return document.getElementById(this.offcanvasId); } /** * Getter para el formulario dentro del Offcanvas. */ get formEl() { return this.offcanvasEl?.querySelector('form') ?? null; } /** * Getter para el título del Offcanvas. */ get offcanvasTitleEl() { return this.offcanvasEl?.querySelector('.offcanvas-title') ?? null; } /** * Getter para la instancia de Bootstrap Offcanvas. * Siempre retorna la instancia más reciente en caso de re-render. */ get offcanvasInstance() { if (!this.offcanvasEl) return null; return bootstrap.Offcanvas.getOrCreateInstance(this.offcanvasEl); } /** * Retorna todos los botones de submit en el formulario. */ get submitButtons() { return this.formEl?.querySelectorAll('.btn-submit') ?? []; } /** * Retorna todos los botones de reset en el formulario. */ get resetButtons() { return this.formEl?.querySelectorAll('.btn-reset') ?? []; } /** * Método principal para manejar la recarga del Offcanvas según los estados en Livewire. * Se encarga de resetear el formulario, limpiar errores y cerrar/abrir el Offcanvas * según sea necesario. * * @param {string|null} triggerMode - Forzar la acción (e.g., 'reset', 'create'). Si no se especifica, se verifica según Livewire. */ reloadOffcanvas(triggerMode = null) { setTimeout(() => { const mode = this.liveWireInstance.get('mode'); const successProcess = this.liveWireInstance.get('successProcess'); const validationError = this.liveWireInstance.get('validationError'); // Si se completa la acción o triggerMode = 'reset', // reseteamos completamente y cerramos el Offcanvas. if (triggerMode === 'reset' || successProcess) { this.resetFormAndState('create'); return; } // Forzar modo create si se solicita explícitamente if (triggerMode === 'create') { // Evitamos re-reset si ya estamos en 'create' if (mode === 'create') return; this.resetFormAndState('create'); this.focusOnOpen(); return; } // Si no, simplemente preparamos la UI según el modo actual. this.prepareOffcanvasUI(mode); // Si hay errores de validación, reabrimos el Offcanvas para mostrarlos. if (validationError) { this.liveWireInstance.set('validationError', null, false); return; } // Si estamos en edit o delete, solo abrimos el Offcanvas. if (mode === 'edit' || mode === 'delete') { this.clearErrors(); if(mode === 'edit') { this.focusOnOpen(); } return; } }, 20); } /** * Reabre o fuerza la apertura del Offcanvas si hay errores de validación * o si el modo de Livewire es 'edit' o 'delete'. * * Normalmente se llama cuando hay un dispatch/evento de Livewire, * por ejemplo si el servidor devuelve un error de validación (para mostrarlo) * o si se acaba de cargar un registro para editar o eliminar. * * - Si hay `validationError`, forzamos la reapertura con `toggleOffcanvas(true, true)` * para que se refresque correctamente y el usuario vea los errores. * - Si el modo es 'edit' o 'delete', simplemente mostramos el Offcanvas sin forzar * un refresco de la interfaz. */ refresh() { setTimeout(() => { const mode = this.liveWireInstance.get('mode'); const successProcess = this.liveWireInstance.get('successProcess'); const validationError = this.liveWireInstance.get('validationError'); // cerramos el Offcanvas. if (successProcess) { this.toggleOffcanvas(false); this.resetFormAndState('create'); return; } if (validationError) { // Forzamos la reapertura para que se rendericen this.toggleOffcanvas(true, true); return; } if (mode === 'edit' || mode === 'delete') { // Abrimos el Offcanvas para edición o eliminación this.toggleOffcanvas(true); return; } }, 10); } /** * Prepara la UI del Offcanvas según el modo actual: cambia texto de botones, título, * habilita o deshabilita campos, etc. * * @param {string} mode - Modo actual en Livewire: 'create', 'edit' o 'delete' */ prepareOffcanvasUI(mode) { // Configura el texto y estilo de botones this.configureButtons(mode); // Ajusta el título del Offcanvas this.configureTitle(mode); // Activa o desactiva campos según el modo this.configureReadonlyMode(mode === 'delete'); } /** * Cierra o muestra el Offcanvas. * * @param {boolean} show - true para mostrar, false para ocultar. * @param {boolean} force - true para forzar el refresco rápido del Offcanvas. */ toggleOffcanvas(show = false, force = false) { const instance = this.offcanvasInstance; if (!instance) return; if (show) { if (force) { // "Force" hace un hide + show para asegurar un nuevo render instance.hide(); setTimeout(() => instance.show(), 10); } else { instance.show(); } } else { instance.hide(); } } /** * Resetea el formulario y el estado en Livewire (modo, id, errores). * * @param {string} targetMode - Modo al que queremos resetear, típicamente 'create'. */ resetFormAndState(targetMode) { if (!this.formEl) return; // Restablecemos en Livewire this.liveWireInstance.set('successProcess', null, false); this.liveWireInstance.set('validationError', null, false); this.liveWireInstance.set('mode', targetMode, false); this.liveWireInstance.set('id', null, false); // Limpiamos el formulario this.formEl.reset(); this.clearErrors(); // Restablecemos valores por defecto del formulario const defaults = this.liveWireInstance.get('defaultValues'); if (defaults && typeof defaults === 'object') { Object.entries(defaults).forEach(([key, value]) => { this.liveWireInstance.set(key, value, false); }); } // Limpiar select2 automáticamente $(this.formEl) .find('select.select2-hidden-accessible') .each(function () { $(this).val(null).trigger('change'); }); // Desactivamos el modo lectura this.configureReadonlyMode(false); // Reconfiguramos el Offcanvas UI this.prepareOffcanvasUI(targetMode); } /** * Configura el texto y estilo de los botones de submit y reset * según el modo de Livewire. * * @param {string} mode - 'create', 'edit' o 'delete' */ configureButtons(mode) { const singularName = this.liveWireInstance.get('singularName'); // Limpiar clases previas this.submitButtons.forEach(button => { button.classList.remove('btn-danger', 'btn-primary'); }); this.resetButtons.forEach(button => { button.classList.remove('btn-text-secondary', 'btn-label-secondary'); }); // Configurar botón de submit según el modo this.submitButtons.forEach(button => { switch (mode) { case 'create': button.classList.add('btn-primary'); button.textContent = `Crear ${singularName.toLowerCase()}`; break; case 'edit': button.classList.add('btn-primary'); button.textContent = `Guardar cambios`; break; case 'delete': button.classList.add('btn-danger'); button.textContent = `Eliminar ${singularName.toLowerCase()}`; break; } }); // Configurar botones de reset según el modo this.resetButtons.forEach(button => { // Cambia la clase dependiendo si se trata de un modo 'delete' o no const buttonClass = (mode === 'delete') ? 'btn-text-secondary' : 'btn-label-secondary'; button.classList.add(buttonClass); }); } /** * Ajusta el título del Offcanvas según el modo y la propiedad configurada en Livewire. * * @param {string} mode - 'create', 'edit' o 'delete' */ configureTitle(mode) { if (!this.offcanvasTitleEl) return; const capitalizeFirstLetter =(str) => { return str.charAt(0).toUpperCase() + str.slice(1); } const singularName = this.liveWireInstance.get('singularName'); const columnNameLabel = this.liveWireInstance.get('columnNameLabel'); const editName = this.liveWireInstance.get(columnNameLabel); switch (mode) { case 'create': this.offcanvasTitleEl.innerHTML = ` ${capitalizeFirstLetter(singularName)} `; break; case 'edit': this.offcanvasTitleEl.innerHTML = `${editName} `; break; case 'delete': this.offcanvasTitleEl.innerHTML = `${editName} `; break; } } /** * Configura el modo de solo lectura/edición en los campos del formulario. * Deshabilita inputs y maneja el "readonly" en checkboxes/radios. * * @param {boolean} readOnly - true si queremos modo lectura, false para edición. */ configureReadonlyMode(readOnly) { if (!this.formEl) return; const inputs = this.formEl.querySelectorAll('input, textarea, select'); inputs.forEach(el => { // Saltar campos marcados como "data-always-enabled" if (el.hasAttribute('data-always-enabled')) return; // Para selects if (el.tagName === 'SELECT') { if ($(el).hasClass('select2-hidden-accessible')) { // Deshabilitar select2 $(el).prop('disabled', readOnly).trigger('change.select2'); } else { this.toggleSelectReadonly(el, readOnly); } return; } // Para checkboxes / radios if (el.type === 'checkbox' || el.type === 'radio') { this.toggleCheckboxReadonly(el, readOnly); return; } // Para inputs de texto / textarea el.readOnly = readOnly; }); } /** * Alterna modo "readonly" en un checkbox/radio simulando la inhabilitación * sin marcarlo como 'disabled' (para mantener su apariencia). * * @param {HTMLElement} checkbox - Elemento checkbox o radio. * @param {boolean} enabled - true si se quiere modo lectura, false en caso contrario. */ toggleCheckboxReadonly(checkbox, enabled) { if (enabled) { checkbox.setAttribute('readonly-mode', 'true'); checkbox.style.pointerEvents = 'none'; checkbox.onclick = function (event) { event.preventDefault(); }; } else { checkbox.removeAttribute('readonly-mode'); checkbox.style.pointerEvents = ''; checkbox.onclick = null; } } /** * Alterna modo "readonly" para un