Buy Access to Course
21.

Fantástica UX modal con un estado de carga

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Sigamos donde lo dejamos ayer. El modal con tecnología Ajax se carga. Intenta enviarlo. Algo ha ido mal. Fue a una página que no tenía <turbo-frame id="modal">... lo cual es extraño, porque ahora todas las páginas tienen uno. Eso es porque... la respuesta fue un error. Si miramos abajo en la barra de herramientas de depuración web, había un código de estado 405. Abre eso. Es interesante:

No se ha encontrado ninguna ruta para POST /voyage/

Eso es raro porque estamos enviando el formulario de nuevo viaje... así que la URL debería ser /voyage/new.

Añadir atributos de acción a los formularios

Éste es el problema: cuando generé la basura de la travesía desde MakerBundle, creó formularios que no tienen un atributo action. Eso está bien cuando el formulario vive en /voyage/new porque sin action significa que se devuelve a la URL actual. Pero en cuanto decidimos incrustar nuestros formularios en otras páginas, tenemos que ser responsables y establecer siempre el atributo action.

Para ello, abre src/Controller/VoyageController.php. En la parte inferior, pegaré un simple método privado. Pulsa Aceptar para añadir la declaración use:

98 lines | src/Controller/VoyageController.php
// ... lines 1 - 9
use Symfony\Component\Form\FormInterface;
// ... lines 11 - 15
class VoyageController extends AbstractController
{
// ... lines 18 - 88
private function createVoyageForm(Voyage $voyage = null): FormInterface
{
$voyage = $voyage ?? new Voyage();
return $this->createForm(VoyageType::class, $voyage, [
'action' => $voyage->getId() ? $this->generateUrl('app_voyage_edit', ['id' => $voyage->getId()]) : $this->generateUrl('app_voyage_new'),
]);
}
}

Podemos pasar un viaje o no... y esto crea el formulario pero establece el action. Si el viaje tiene un id, establece la acción en la página de edición, si no, la establece en la página nueva.

Gracias a esto, arriba en la acción new, podemos decir this->createVoyageForm($voyage). Copia eso... porque necesitamos la línea exacta abajo en edit:

98 lines | src/Controller/VoyageController.php
// ... lines 1 - 15
class VoyageController extends AbstractController
{
// ... lines 18 - 26
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
// ... line 29
$form = $this->createVoyageForm($voyage);
// ... lines 31 - 45
}
// ... lines 47 - 56
public function edit(Request $request, Voyage $voyage, EntityManagerInterface $entityManager): Response
{
$form = $this->createVoyageForm($voyage);
// ... lines 60 - 73
}
// ... lines 75 - 96
}

Encantador. De vuelta, ni siquiera necesitamos actualizar. Abrimos el modal, guardamos y... Ah, ¡es absolutamente encantador! Se ha enviado y recibimos la respuesta justo dentro del modal. Porque... ¡por supuesto! Ese es el objetivo de un marco Turbo. Mantiene la navegación dentro de sí mismo.

Cargar el modal al instante

Antes de hablar de lo que ocurre en caso de éxito, quiero perfeccionar esto. Mi segundo requisito para abrir el modal es que debe abrirse inmediatamente. En la acción new, añade un sleep(2)... para simular que nuestro sitio está siendo asaltado por extraterrestres que planean sus viajes de vacaciones de primavera:

99 lines | src/Controller/VoyageController.php
// ... lines 1 - 15
class VoyageController extends AbstractController
{
// ... lines 18 - 26
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
// ... lines 29 - 31
sleep(2);
// ... lines 33 - 46
}
// ... lines 48 - 97
}

Cuando pulsamos el botón ahora... no pasa nada. No hay respuesta del usuario en absoluto hasta que finaliza la petición Ajax. Eso no es suficiente. En lugar de eso, quiero que el modal se abra inmediatamente con una animación de carga.

En el controlador modal, añade un nuevo objetivo llamado loadingContent:

62 lines | assets/controllers/modal_controller.js
// ... lines 1 - 2
export default class extends Controller {
static targets = ['dialog', 'dynamicContent', 'loadingContent'];
// ... lines 5 - 60
}

Ésta es mi idea: si quieres que se cargue contenido, definirás qué aspecto tiene en Twig y establecerás este objetivo en él. Lo haremos dentro de un momento.

En la parte inferior, crea un nuevo método llamado showLoading(). Si this.dialogTarget.open, es decir, si el diálogo ya está abierto, no necesitamos mostrar la carga, así que devuelve. Si no, digamos this.dynamicContentTarget -para nosotros, ese es el <turbo-frame>en el que se cargará finalmente el contenido Ajax- .innerHTML es igual athis.loadingContentTarget.innerHTML:

62 lines | assets/controllers/modal_controller.js
// ... lines 1 - 2
export default class extends Controller {
// ... lines 4 - 52
showLoading() {
// do nothing if the dialog is already open
if (this.dialogTarget.open) {
return;
}
this.dynamicContentTarget.innerHTML = this.loadingContentTarget.innerHTML;
}
}

Por último, añade ese objetivo. En base.html.twig, después del dialog, añadiré un elementotemplate. Sí, mi querido elemento template: es perfecto para esta situación porque todo lo que haya dentro no será visible ni estará activo en la página. Es una plantilla que podemos robar. Añadiré un data-modal-target="loadingContent". Pondré algo de contenido dentro:

94 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 55
<div
// ... lines 57 - 58
>
// ... lines 60 - 75
<template data-modal-target="loadingContent">
<div class="bg-space-pattern bg-cover rounded-lg p-8">
<div class="space-y-2">
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
</div>
</div>
</template>
</div>
</body>
</html>

Aquí no hay nada especial: sólo algunas clases de Tailwind con una animación de pulso muy chula.

Si probamos esto ahora... ¡no se carga el contenido! Eso es porque nada está llamando al nuevo método showLoading(). En base.html.twig, busca el fotograma. Lo dividiré en varias líneas. Pensemos: en cuanto turbo-frame empiece a cargarse, queremos llamar a showLoading(). Afortunadamente, Turbo envía un evento cuando inicia una petición AJAX. Y podemos escucharlo.

Añade un data-action para escuchar turbo:before-fetch-request -así se llama el evento- y luego ->modal#showLoading:

94 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 55
<div
// ... lines 57 - 58
>
<dialog
// ... lines 61 - 63
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<turbo-frame
id="modal"
data-modal-target="dynamicContent"
data-action="turbo:before-fetch-request->modal#showLoading"
></turbo-frame>
</div>
</div>
</dialog>
// ... lines 75 - 90
</div>
</body>
</html>

Muy bien, ¡comprobemos el efecto! Actualiza la página y... ¡oh, es maravilloso! Se abre al instante, vemos que se carga el contenido... ¡y se sustituye cuando termina el marco!

Me encanta cómo funciona esto. Cuando esto llama a showLoading(), ese método pone el contenido en dynamicContentTarget. Y... ¿recuerdas lo que ocurre en el momento en que cualquier HTML entra ahí? Nuestro controlador se da cuenta y abre el diálogo. ¡Eso sí que es trabajo en equipo!

Indicación de carga al enviar el formulario

Ya casi lo tenemos perfecto, ¡pero no estoy satisfecho! Mientras aún tenemos el sleep, envía el formulario. ¡No ocurre nada! No hay ninguna indicación mientras se carga.

Tip

Para conseguir un efecto aún más bonito, también puedes cambiar la opacidad sólo si la carga tarda más de, por ejemplo, 700 ms. Para ello, añade una clase aria-busy:delay-700.

Por suerte para nosotros, ya hemos recorrido este camino antes con otro marco Turbo. Añade la clasearia-busy:opacity-50, y transition-opacity:

95 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 55
<div
// ... lines 57 - 58
>
<dialog
// ... lines 61 - 63
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<turbo-frame
// ... lines 68 - 70
class="aria-busy:opacity-50 transition-opacity"
></turbo-frame>
</div>
</div>
</dialog>
// ... lines 76 - 91
</div>
</body>
</html>

Recargaré... clic, animación de carga y enviar. ¡Sí! La baja opacidad nos indica que algo está pasando.

Y con eso, eliminaré alegremente nuestro sleep:

99 lines | src/Controller/VoyageController.php
// ... lines 1 - 15
class VoyageController extends AbstractController
{
// ... lines 18 - 26
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
// ... lines 29 - 31
sleep(2);
// ... lines 33 - 46
}
// ... lines 48 - 97
}

Estilo Modal Condicional

Vale, un último detalle que quiero aclarar: este relleno extra. Existe porque el contenido de la página new tiene un elemento con m-4 y p-4. Así que el modal tiene algo de relleno... y el relleno extra proviene de esa página.

En la página, el margen y el relleno tienen sentido. Viene de aquí, denew.html.twig. Así que queremos esto en la página completa... pero no en el modal.

Para ayudarnos a hacerlo, vamos a utilizar un truco de Tailwind. En tailwind.config.js, añade una variante más. Llámala modal, y actívala siempre que estemos dentro de un elemento dialog:

30 lines | tailwind.config.js
// ... lines 1 - 3
module.exports = {
// ... lines 5 - 22
plugins: [
plugin(function({ addVariant }) {
// ... line 25
addVariant('modal', 'dialog &');
}),
],
}

Ahora, en new.html.twig, mantén el margen y el relleno para la situación normal. Pero si estamos en un modal, utiliza modal:m-0, y modal:p-0:

24 lines | templates/voyage/new.html.twig
// ... lines 1 - 4
{% block body %}
<div class="m-4 p-4 modal:m-0 modal:p-0 bg-gray-800 rounded-lg">
// ... lines 7 - 21
</div>
{% endblock %}

En la nueva página, esto no debería cambiar. ¡Se ve bien! Pero en el modal... eso es lo que queremos.

Nuestro sistema modal ahora se abre instantáneamente, carga el contenido con AJAX, podemos enviarlo ¡e incluso se cierra solo si tiene éxito! Observa: rellena un propósito, selecciona un planeta... y... ¡el modal se cerró!

¿Cómo? ¡Es genial! La acción new redirige a la página índice. Y comoindex.html.twig amplía el base.html.twig normal, sí tiene un marcomodal... pero es ese vacío de la parte inferior. Eso hace que elturbo-frame de la página quede vacío. Y gracias a nuestro controlador modal, nos damos cuenta y cerramos el diálogo.

Lo único que nos falta ahora, si estabas atento, ¡es la notificación del brindis! Mañana hablaremos de cómo manejar el éxito cuando se envía un formulario dentro de un marco... incluyendo hacer cosas chulas como añadir automáticamente la nueva fila a la tabla de esta página. Hasta mañana.