Modals (XMLView)
Los formularios modales son vistas complementarias a la vista principal, que permanecen ocultas hasta que se pulsa su botón de tipo modal. Estos formularios se declaran de manera muy similar a lo detallado en la sección COLUMNS. Podemos definir todos los modals que necesitemos, simplemente añadiendo grupos (etiqueta group) dentro de la etiqueta modals del XMLView.
Ejemplo de modal:
<modals>
<group name="test" title="other-data" icon="fas fa-users">
<column name="name" numcolumns="12" description="desc-customer-name">
<widget type="text" fieldname="nombre" required="true" />
</column>
<column name="create-date" numcolumns="6">
<widget type="date" fieldname="fechaalta" readonly="true" />
</column>
<column name="blocked-date" numcolumns="6">
<widget type="date" fieldname="fechabaja" />
</column>
</group>
</modals>
Mostrar un modal
Para mostrar un modal que ya hayamos definido en modals debemos definir un botón de tipo modal en un row de tipo actions, header o footer. Además este botón debe indicar el nombre del modal en su propiedad action.
Ejemplo:
<rows>
<row type="actions">
<button type="modal" label="mostrar" color="warning" action="test" />
</row>
</rows>
Modal de distinto tamaño
Podemos mostrar una ventana de modal más pequeña añadiendo class="modal-sm" al grupo del modal. También podemos mostrar una ventana más grande con class="modal-lg" o class="modal-xl".
ModalInsert
También podemos hacer que al pulsar el botón nuevo en un listado aparezca un modal elegido, en lugar de redirigir al controlador del modelo. Para lograr esto solamente debemos indicar en el ajuste modalInsert el name del modal.
$this->setSettings($viewName, 'modalInsert', 'add-lote');
// en este caso al hacer clic en el botón nuevo se mostrará el modal con name 'add-lote'
Personalización de un modal (uso avanzado)
Los modales declarados de la forma indicada anteriormente son muy cómodos porque FacturaScripts se encarga de todo: crea la ventana, el formulario, el botón de aceptar y el envío de la acción al controlador. A cambio, su contenido está limitado a columnas y widgets concretos, y no siempre es suficiente. A veces necesitamos botones de tipo radio agrupados, tablas dinámicas rellenadas por JavaScript, bloques informativos, o simplemente un diseño que no encaja en el sistema de columnas.
Este apartado explica cómo mantener toda la maquinaria del modal XML (formulario, token, acción, integración con el controlador) pero sustituyendo su cuerpo por una plantilla o vista Twig propia que podemos personalizar.
Qué genera FacturaScripts con un modal XML
Antes de entrar en el cómo aplicar esta técnica es conveniente entender como funciona internamente el Core cuando definimos un modal. Al declarar un grupo dentro de modals, la clase GroupItem::modal() genera esta estructura:
<form id="formModal{uniqueId}" method="post" enctype="multipart/form-data">
<input type="hidden" name="activetab" value="{viewName}"/>
<input type="hidden" name="multireqtoken" value="{token}"/>
<div class="modal" id="modal{name}" tabindex="-1" role="dialog">
<div class="modal-dialog {class}" role="document">
<div class="modal-content">
<div class="modal-header">...título, descripción e icono...</div>
<div class="modal-body">
<div class="row g-2">...columnas del grupo...</div>
</div>
<div class="modal-footer">
<button type="button" data-bs-dismiss="modal">Cancelar</button>
<input type="hidden" name="action" value="{name}"/>
<button type="submit">Aceptar</button>
</div>
</div>
</div>
</div>
</form>
Hay tres detalles de esta estructura que son la base de toda la técnica:
- El
idde la ventana es siempremodal+ elnamedel grupo. Con ese identificador se abre el modal, tanto desde un botón (data-bs-target) como desde JavaScript. - El atributo
classdel grupo se aplica a.modal-dialog, no al.modal. Además de los tamaños de Bootstrap (modal-sm,modal-lg,modal-xl) podemos añadir ahí una clase marcadora propia que nos sirva de selector inequívoco desde CSS y JavaScript. - Todo lo que esté dentro de
.modal-bodyviaja en el<form>del modal, que ya lleva elactioncorrecto y el token de seguridad. Si conseguimos meter nuestro HTML ahí dentro, sus campos llegan al controlador sin escribir ni una línea extra de fontanería.
La técnica en una frase
Declaramos el grupo del modal vacío (solo la carcasa) y colocamos el contenido real en una plantilla Twig incluida en un panel oculto del pie de la vista; un pequeño script traslada ese contenido al .modal-body cuando la página termina de cargar.
Paso 1: declarar el modal vacío con una clase marcadora
En el XMLView, dentro de modals, declaramos el grupo sin columnas. Añadimos una clase propia que identifique el modal, y el tamaño de Bootstrap si lo necesitamos.
<modals>
<group name="add-receipts-auto" class="autopaymentmodal" />
<group name="invoice-info" class="invoiceinfomodal modal-xl" />
</modals>
El grupo puede llevar también title, description e icon, que se renderizan en la cabecera del modal con normalidad. Si no ponemos title, la cabecera queda vacía (útil cuando el propio contenido ya incluye su encabezado).
El name es la pieza clave: define el id de la ventana (modaladd-receipts-auto, modalinvoice-info) y el valor de action que recibirá el controlador al pulsar Aceptar.
Paso 2: crear la plantilla Twig con el contenido
Creamos la plantilla en View/Block/ del plugin. El contenido debe ir dentro de un div con un id propio: es el "paquete" que después moveremos.
Como el contenedor del core que vamos a vaciar es un div.row.g-2, conviene que nuestro div lleve la clase row, así las columnas de Bootstrap siguen funcionando igual.
View/Block/PaymentAutoModal.html.twig:
{% set mainView = fsc.views[fsc.getMainViewName()] %}
{% set primaryKeyValue = mainView.model.primaryColumnValue() %}
<div id="autopaymentmodalbody" class="row">
<input type="hidden" name="idpayment" value="{{ primaryKeyValue }}"/>
<div class="mb-3 col-12">
<label class="d-block">{{ trans('company') }}</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="company" id="company_all" value="-1" checked autocomplete="off">
<label class="btn btn-outline-primary" for="company_all">{{ trans('all-feminine') }}</label>
{% for company in fsc.getCompanyList() %}
<input type="radio" class="btn-check" name="company" id="company_{{ company.idempresa }}" value="{{ company.idempresa }}" autocomplete="off">
<label class="btn btn-outline-primary" for="company_{{ company.idempresa }}">{{ company.nombrecorto }}</label>
{% endfor %}
</div>
</div>
<div class="mb-3 col-6">
<label for="amount">{{ trans('amount') }}</label>
<input type="number" name="amount" class="form-control" placeholder="0.00" step="0.01" min="0" required>
</div>
<div class="mb-3 col-6">
<label class="d-block">{{ trans('payment-method') }}</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="paymentMethod" id="pm_cash" value="0" checked autocomplete="off">
<label class="btn btn-outline-info" for="pm_cash">
<i class="fa-solid fa-sack-dollar me-1"></i> {{ trans('cash') }}
</label>
<input type="radio" class="btn-check" name="paymentMethod" id="pm_check" value="1" autocomplete="off">
<label class="btn btn-outline-info" for="pm_check">
<i class="fa-solid fa-money-check me-1"></i> {{ trans('bank-check-short') }}
</label>
</div>
</div>
</div>
La única variable que el core pasa a estas plantillas es fsc, el controlador. Desde ella se accede a las vistas (fsc.views), al modelo principal (fsc.views[fsc.getMainViewName()].model) y a cualquier método público que hayamos añadido al controlador, como en el ejemplo fsc.getCompanyList(). La función trans() está disponible con normalidad.
Paso 3: incluir la plantilla en un panel oculto del pie
En el mismo XMLView, dentro de rows, declaramos un panel de tipo footer que incluya la plantilla y que esté oculto con d-none.
<rows>
<row type="footer">
<group name="autoPaymentBlock" id="auto-payment-block" class="d-none" html="Block/PaymentAutoModal.html.twig" />
<group name="invoiceInfoBlock" id="invoice-info-block" class="d-none" html="Block/InvoiceInfoModal.html.twig" />
</row>
</rows>
El atributo html es el que carga la plantilla dentro del card del panel. El class="d-none" oculta ese panel para que el usuario nunca lo vea: solo actúa como contenedor temporal del HTML mientras se carga la página. El id es opcional, pero facilita depurar.
Paso 4: trasladar el contenido al cuerpo del modal
Al final de la plantilla Twig añadimos el script que hace el traslado. Vacía el div.row.g-2 que el core generó dentro del .modal-body y le inserta nuestro div.
<script>
document.addEventListener('DOMContentLoaded', function () {
const modalBody = document.querySelector('.modal-dialog.autopaymentmodal .modal-body');
const sourceDiv = document.getElementById('autopaymentmodalbody');
if (modalBody && sourceDiv) {
modalBody.innerHTML = '';
modalBody.appendChild(sourceDiv);
}
});
</script>
Aquí es donde la clase marcadora del paso 1 gana su sentido: .modal-dialog.autopaymentmodal apunta a un modal concreto aunque la pestaña tenga varios.
El DOMContentLoaded no es opcional. En las plantillas del core (ListView.html.twig, EditView.html.twig, EditListView.html.twig) las filas de tipo footer se renderizan antes que los modales, así que en el momento en que el navegador ejecuta nuestro script en línea el .modal-body de destino todavía no existe en el DOM. Hay que esperar a que el documento esté completo.
Una vez trasladado, el contenido queda dentro del <form id="formModal…"> del modal. Sus campos (amount, company, paymentMethod, idpayment…) se envían junto al action del modal al pulsar Aceptar, exactamente igual que si fueran columnas declaradas en el XML.
Paso 5: abrir el modal
La forma estándar es un botón de tipo modal cuyo action coincida con el name del grupo. Se puede declarar en el XMLView o desde el controlador:
$this->addButton($viewName, [
'action' => 'add-receipts-auto',
'color' => 'success',
'icon' => 'fa-solid fa-folder-plus',
'label' => 'auto',
'type' => 'modal',
]);
Estos botones llaman además a setModalParentForm(), que copia al formulario del modal el code del registro o los codes[] de las filas marcadas del listado. Si abrimos el modal por otras vías, esa copia no ocurre y hay que enviar los identificadores por nuestra cuenta (en el ejemplo anterior, mediante el input oculto idpayment).
También podemos abrirlo desde JavaScript, que es lo habitual cuando el modal es puramente informativo y se dispara al pulsar una fila:
const modal = document.getElementById('modalinvoice-info');
new bootstrap.Modal(modal, { backdrop: 'static', focus: true }).show();
Paso 6: procesar la acción en el controlador
Nada cambia respecto a un modal normal: la acción llega a execPreviousAction() con el name del grupo y los campos se leen del request.
protected function execPreviousAction($action)
{
switch ($action) {
case 'add-receipts-auto':
[ .... ]
return true;
default:
return parent::execPreviousAction($action);
}
}
Variantes del proceso (otros ejemplos de uso)
Modal informativo de solo lectura
Cuando el modal solo muestra información no tiene sentido el botón Aceptar que genera el core. Lo eliminamos en el mismo script del traslado.
<script>
document.addEventListener('DOMContentLoaded', function () {
const modalDialog = document.querySelector('.modal-dialog.invoiceinfomodal');
if (!modalDialog) return;
const modalBody = modalDialog.querySelector('.modal-body');
const sourceDiv = document.getElementById('invoiceinfomodalbody');
if (modalBody && sourceDiv) {
modalBody.innerHTML = '';
modalBody.appendChild(sourceDiv);
}
const modalFooter = modalDialog.querySelector('.modal-footer');
if (modalFooter) {
const acceptButton = modalFooter.querySelector('button[type="submit"], input[type="submit"]');
if (acceptButton) {
acceptButton.remove();
}
}
});
</script>
El botón Cancelar se mantiene y hace de botón de cierre.
Contenido dinámico rellenado por JSON
El contenido trasladado sigue siendo HTML normal, así que podemos dejar contenedores vacíos y rellenarlos desde JavaScript antes de mostrar el modal. Es el patrón para modales de detalle, donde los datos dependen de la fila pulsada.
La plantilla define el esqueleto:
<style>
.modal-dialog.invoiceinfomodal .modal-body .modal-body-scroll {
max-height: 65vh;
overflow-y: auto;
padding-right: 1rem;
}
</style>
<div id="invoiceinfomodalbody" class="row">
<div class="col-12 mb-3">
<div class="p-3 bg-light border border-info rounded">
<strong>{{ trans('customer') }}:</strong> <span id="fact-client">-</span>
<strong>{{ trans('invoice') }}:</strong> <span id="fact-number">-</span>
<strong>{{ trans('date') }}:</strong> <span id="fact-date">-</span>
<strong>{{ trans('total') }}:</strong> <span id="fact-total">-</span>
</div>
</div>
<div class="modal-body-scroll col-12">
<table class="table table-striped table-bordered mb-0 w-100">
<thead>...</thead>
<tbody id="modal-invoice-lines"></tbody>
</table>
</div>
</div>
El script del controlador (cargado con AssetManager::add('js', …)) pide los datos y abre el modal:
const data = new FormData();
data.append("action", "invoice-info");
data.append("idreceipt", params.code);
fetch(window.location.href, { method: "POST", body: data })
.then(response => response.json())
.then(result => showModalInvoice(result.lines, result.header));
Y en el controlador respondemos con JSON en lugar de recargar la vista:
case 'invoice-info':
$this->setTemplate(false);
$results = [ .... ]
$this->response->setContent(json_encode($results));
return false;
Fíjate en que el mismo name del modal (invoice-info) se reutiliza como nombre de acción para el endpoint JSON. No es obligatorio, pero mantiene el código agrupado.
Por qué no declarar el modal completo dentro de la plantilla
La tentación es escribir directamente todo el <div class="modal"> dentro de la plantilla Twig del pie y olvidarse de la etiqueta modals. No funciona bien, por dos motivos:
- El panel del pie se renderiza dentro del
<form>principal de la vista. Un formulario anidado es HTML inválido: el navegador lo descarta y los campos del modal acaban enviándose con el formulario y la acción equivocados. - El modal queda anidado dentro de varios contenedores posicionados (
container-fluid,row,col,card). Bootstrap inserta el fondo oscuro (backdrop) como hijo directo debody, así que el modal se dibuja por debajo de ese fondo. El efecto es desconcertante: la pantalla se oscurece y parece que el modal no se ha abierto, cuando en realidad está ahí, tapado.
Si aun así se necesita un modal totalmente artesanal, sin usar la etiqueta modals, el remedio es sacarlo del árbol y colgarlo directamente del body antes de mostrarlo:
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('miModalPropio');
if (modal && modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
});
Aun así, la técnica recomendada es la de esta guía: dejar que el core cree la carcasa en el lugar correcto del DOM y limitarnos a sustituir su contenido. Así conservamos el formulario, el token de seguridad, la propagación de code y codes[], la traducción del título y la integración natural con execPreviousAction().
Resumen
- Declara el grupo en
modalssin columnas, con una clase marcadora propia (ymodal-xl,modal-lg… si hace falta). - Escribe el contenido en
View/Block/…html.twig, envuelto en undivconidpropio y claserow. - Inclúyelo con
html="Block/…html.twig"en ungroupderow type="footer"conclass="d-none". - Traslada el
dival.modal-bodyenDOMContentLoaded, vaciando antes su contenido. - Abre el modal con un botón
type="modal"cuyoactionsea elnamedel grupo, o conbootstrap.Modaldesde JavaScript. - Procesa los datos en
execPreviousAction()leyendo los campos del request.