2025-03-05 20:28:54 -06:00

478 lines
16 KiB
JavaScript

/**
* 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 = `<i class="ti ti-plus ml-2"></i> ${capitalizeFirstLetter(singularName)} `;
break;
case 'edit':
this.offcanvasTitleEl.innerHTML = `${editName} <i class="ti ti-lg ti-pencil ml-2 text-success"></i>`;
break;
case 'delete':
this.offcanvasTitleEl.innerHTML = `${editName} <i class="ti ti-lg ti-eraser ml-2 text-danger"></i>`;
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 <select> convencional.
*
* @param {HTMLElement} select - Elemento select.
* @param {boolean} enabled - true si queremos readonly, false si editable.
*/
toggleSelectReadonly(select, enabled) {
if (enabled) {
select.setAttribute('readonly-mode', 'true');
select.style.pointerEvents = 'none';
select.tabIndex = -1;
} else {
select.removeAttribute('readonly-mode');
select.style.pointerEvents = '';
select.tabIndex = '';
}
}
/**
* Hace focus en el elemento con el selector dado.
*/
focusOnOpen() {
const focusSelector = this.liveWireInstance.get('focusOnOpen'); // Obtiene el selector de Livewire
if (!focusSelector) return;
setTimeout(() => {
// Buscar el elemento real en el DOM
const focusElement = document.getElementById(focusSelector);
// Si existe, hacer focus
if (focusElement) {
focusElement.focus();
} else {
console.warn(`Elemento no encontrado: ${focusSelector}`);
}
}, 250);
}
/**
* Limpia mensajes de error y la clase 'is-invalid' en el formulario.
*/
clearErrors() {
if (!this.formEl) return;
// Remover mensajes de error en texto
this.formEl.querySelectorAll('.text-danger').forEach(el => el.remove());
// Remover la clase 'is-invalid' de los inputs afectados
this.formEl.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
// Remover las notificaciones
this.formEl.querySelectorAll('.notification-container').forEach(el => el.innerHTML = '');
// Removemos el checkbox de confirmación de eliminar
const confirmDeletion = this.formEl.querySelector('.confirm-deletion');
if (confirmDeletion) {
confirmDeletion.remove();
}
}
}
// Exponemos la clase en window para acceso global (si fuese necesario)
window.FormCanvasHelper = FormCanvasHelper;