Fantástica UX modal con un estado de carga
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeSigamos 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:
| // ... 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:
| // ... 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:
| // ... 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:
| // ... 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:
| // ... 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:
| <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:
| <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:
| <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:
| // ... 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:
| // ... 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:
| // ... 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.
15 Comments
Hi,
Thanks for the tutorial! :)
I believe there’s an issue with the recent update regarding async CSRF. Since I’m using the StimulusBundle Flex recipe, I’m encountering problems with the new csrf_protection_controller.js. Now, every form inside a Turbo modal stops working (even though it works fine with a regular request outside the modal).
The issue might stem from the
document.addEventListener('submit', function (event) { … })found in the controller. This event never gets triggered when using the modal, as described in the tutorial. I suspect it could be something related to the modal Stimulus controller of the tuto, but I can’t figure out what exactly.hey @sylvaindeloux
yeah, that's pretty weird, but I'd recommend disabling stateless csrf the code here is not configured to use it, so it causes issues, and the problem is not in this controller, but in new
csrf.yamlwhere stateless csrf is configured. To solve issue just disable it.Cheers!
Looks like this can be fixed by removing / stimulusFetch: 'lazy' / from the csrf_protection_controller.js
https://github.com/symfony/symfony/issues/59571#issuecomment-2610555055
Thanks for the tip
Hello !
I'm having a really weird issue and I found nothing about it in the Symfony UX or the Turbo documentation, GitHub repos or internet at all.
I have CSRF protection enabled on my website and when sumitting the form in the turbo frame I have a form validation error "Invalid CSRF Token" and this log "CSRF validation failed: double-submit info was used in a previous request but is now missing.''
The form is working perfectly on the dedicated page, but inside a frame this happen. Also the form in the frame works again for a short period when deleting the PHPSESSID cookie.
Is there something to know about turbo frame and submitting form with CSRF protection ? Or should I write an issue in symfony UX or turbo in GitHub ?
Hey Hugo,
As a workaround, you can just turn off the CSRF protection for some forms that are handled in turbo frames. Yeah, CSRF may indeed cause some issues in some cases, especially when we're talking about async requests.
But actually, even with Turbo frames it should work well I think, though probably you have a more complex setup? Maybe the problem in Turbo drive cache. You should try to find steps to reproduce. When you will have them, and when you will be 100% sure that following those steps you will get the issue with CSRF protection - analyze the steps to see the problem, I bet it will be more obvious to see the problem and a possible fixes when you take a look at the steps to reproduce.
I hope that helps!
Cheers!
Hi, I'm confusing about "loadingContent" and "loadingTemplate".
For example, I read: "Over in the modal controller, add a new target called loadingContent:" but in the code the target is "loadingTemplate".
Also in other parts of tutorial there are these little mistakes... or am I missing something?
Hey @Fedale
Thanks for reporting it. Indeed this is a little mistake. I'll look for a way to make it right
Cheers!
Hi Ryan,
Cool stuff, really! I do however have a question, I have implemented the ajax modal and use the openModal way so we have the out-of-the-box escape close handler. I also have the stimulus action to listen to the native close event so we can close everything.
But.... (there's always one), how do you handle the following:
Let's say the user clicks a link, modal with turbo frame opens but the loading time is longer then expected. During this process the user presses the Escape key, the modal closes BUT (there it is) the fetch call is still running, when it's finished it still replaces the turbo-frame and through the MutationObserver the modal gets re-opened.
I tried watching if the turbo-frame has a aria-busy when the close method is being called, but it always is undefined. So my guess these two events are triggered passed each other.
The ultimate solution would be to maybe abort the turbo fetch request all together, or have a way to block the modal from opening again.
Maybe I missed something in the other tutorials and there is a solutions somewhere.
Keep up the nice work!
Sometimes you spend a good amount of time typing a message and find a solution a few minutes after it, I stumbled upon this tutorial, which has the answer for now:
https://symfonycasts.com/screencast/turbo/prevent-frame-rendering
Putting the code below in the controller now prevents the frame rendering from taking place if the modal isn't open (anymore).
If there is more to it, maybe you can let us now.
Hey @webstack
Ha! Yea, sometimes you just need to put your problem into words. Thank you for sharing your solution, and by the way, you can use our search form at the top of the page to look for course-related topics (choose the "In this course" option)
Cheers!
Ah Thanks! Have a good one.
This might just be my favourite episode so far. So many things coming together!
Woo! It might be mine too :)
Hey Hauke,
Thank you for your feedback! We're super happy you liked it :)
Cheers!
"Houston: no signs of life"
Start the conversation!