Componentes en vivo
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 Subscribe¡Feliz día 27 de LAST Stack! Hemos conseguido mucho durante los primeros 26 días con sólo tres letras de LAST Stack: Mapeador de Activos, Stimulus y Turbo. Hoy desciframos el código de la L de LAST Stack: Componentes en vivo. Los componentes en vivo nos permiten tomar un componente Twig... y volver a renderizarlo mediante Ajax cuando el usuario interactúa con él.
Nuestro objetivo es esta búsqueda global. Cuando hago clic, ¡no pasa nada! Lo que quiero hacer es abrir un modal con un cuadro de búsqueda que, a medida que escribimos, cargue una búsqueda en vivo.
Abrir el modal de búsqueda
Comienza dentro de templates/base.html.twig. ¡Busca la búsqueda! Perfecto: esta es la entrada de búsqueda falsa que acabamos de ver. Añade un <twig:Modal> con :closeButton="true", luego un <twig:block> con name="trigger". Para que esto abra el modal, necesitamos data-action="modal#open":
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true"> | |
| <twig:block name="trigger"> | |
| <div | |
| class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer" | |
| data-action="modal#open" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| <span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span> | |
| </div> | |
| </twig:block> | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 45 - 54 | |
| </div> | |
| // ... lines 56 - 84 | |
| </body> | |
| </html> |
¡Genial! Si actualizamos, no cambia nada: la única parte visible del modal es el activador. Para el contenido del modal, después del bloque Twig, pegaré un div:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true"> | |
| <twig:block name="trigger"> | |
| <div | |
| class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer" | |
| data-action="modal#open" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| <span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span> | |
| </div> | |
| </twig:block> | |
| <div class="relative"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| </div> | |
| <input | |
| type="search" | |
| aria-label="Search site" | |
| placeholder="Search for anything" | |
| class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none" | |
| /> | |
| </div> | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 57 - 66 | |
| </div> | |
| // ... lines 68 - 96 | |
| </body> | |
| </html> |
Aquí no hay nada especial: sólo una entrada de búsqueda real.
De vuelta al navegador, cuando hago clic... uh oh. ¡no pasa nada! La depuración se inicia siempre en la consola. No hay errores, pero cuando hago clic, mira: no hay ningún registro que diga que se está lanzando la acción. ¿Hay algo que no funciona y tal vez hayas visto mi error? Añadimos el data-action a un div. A diferencia de un buttono un form, Stimulus no tiene un evento predeterminado para un div. Añade click->:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true"> | |
| <twig:block name="trigger"> | |
| <div | |
| // ... line 35 | |
| data-action="click->modal#open" | |
| > | |
| // ... lines 38 - 39 | |
| </div> | |
| </twig:block> | |
| // ... lines 42 - 53 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 57 - 66 | |
| </div> | |
| // ... lines 68 - 96 | |
| </body> | |
| </html> |
Y ahora... ¡ya está!
Ah, ¡y autoenfoca la entrada! Eso es.... ¡una característica de los diálogos! Funcionan como una minipágina dentro de una página: autoenfoca el primer elemento tabulable... o puedes utilizar el atributo normal autofocus para tener más control. Funciona como tú quieras.
Modal: Controla el relleno
De todos modos, soy quisquilloso: esto tiene más relleno del que quiero. Pero ¡no pasa nada! Podemos hacer que nuestro componente Modal sea un poco más flexible. En components/Modal.html.twig, el relleno extra es este p-5. En la parte superior, añade un tercer prop: padding='p-5'. Cópialo. Y aquí abajo, renderiza padding:
| {% props allowSmallWidth=false, closeButton=false, padding="p-5" %} | |
| <div | |
| // ... lines 3 - 6 | |
| > | |
| // ... lines 8 - 9 | |
| <dialog | |
| // ... lines 11 - 13 | |
| > | |
| <div class="flex grow {{ padding }}"> | |
| // ... lines 16 - 18 | |
| </div> | |
| // ... lines 20 - 28 | |
| </dialog> | |
| // ... lines 30 - 35 | |
| </div> |
En base.html.twig, en el modal, añade padding igual a comillas vacías:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding=""> | |
| // ... lines 33 - 53 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 57 - 66 | |
| </div> | |
| // ... lines 68 - 96 | |
| </body> | |
| </html> |
¡Vamos a comprobarlo! Y... mucho más ordenado.
Crear el componente Twig
Para dar vida a los resultados, podríamos repetir la configuración de las tablas de datos de la página de inicio. Podríamos añadir un <turbo-frame> con los resultados justo aquí y hacer que la entrada se autoenvíe a ese marco.
Otra opción es construir esto con un componente en vivo. Pero antes de hablar de eso, organicemos primero el contenido del modal en un componente Twig.
En templates/components/, crea un nuevo archivo llamado SearchSite.html.twig. Añadiré un div con {{ attributes }}. Luego ve a robar todo el cuerpo del modal, y pégalo aquí:
| <div {{ attributes }}> | |
| <div class="relative"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| </div> | |
| <input | |
| type="search" | |
| aria-label="Search site" | |
| placeholder="Search for anything" | |
| class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none" | |
| /> | |
| </div> | |
| </div> |
En base.html.twig, es fácil, ¿verdad? <twig:SearchSite /> y listo:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding=""> | |
| <twig:block name="trigger"> | |
| // ... lines 34 - 40 | |
| </twig:block> | |
| <twig:SearchSite /> | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 47 - 56 | |
| </div> | |
| // ... lines 58 - 86 | |
| </body> | |
| </html> |
En el navegador, obtenemos el mismo resultado.
Obtención de datos con un componente Twig
La búsqueda del sitio va a ser realmente una búsqueda de viaje. Para obtener los resultados, tenemos dos opciones. En primer lugar, podríamos... obtener de algún modo los viajes que queremos mostrar dentro de base.html.twig y pasarlos a SearchSite como prop. Pero... obtener datos de nuestro diseño base es complicado... probablemente necesitaríamos una función Twig personalizada.
La segunda opción es aprovechar nuestro componente Twig Uno de sus superpoderes es la capacidad de obtener sus propios datos: ser autónomo.
Para ello, este componente Twig necesita ahora una clase PHP. En src/Twig/Components/, crea una nueva clase PHP llamada SearchSite. Lo único que necesita para ser reconocido como componente Twig es un atributo: #[AsTwigComponent]:
| // ... lines 1 - 2 | |
| namespace App\Twig\Components; | |
| // ... lines 4 - 6 | |
| use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; | |
| class SearchSite | |
| { | |
| // ... lines 12 - 22 | |
| } |
Esto es exactamente lo que vimos dentro de la clase Button. Hace unos días, mencioné rápidamente que las clases de componentes Twig son servicios, lo que significa que podemos autoconectar otros servicios como VoyageRepository, $voyageRepository:
| // ... lines 1 - 5 | |
| use App\Repository\VoyageRepository; | |
| // ... lines 7 - 8 | |
| class SearchSite | |
| { | |
| public function __construct(private VoyageRepository $voyageRepository) | |
| { | |
| } | |
| // ... lines 15 - 22 | |
| } |
Para proporcionar los datos a la plantilla, crea un nuevo método llamado voyages(). Éste devolverá una matriz... que en realidad será una matriz de Voyage[]. Dentro dereturn $this->voyageRepository->findBySearch(). Es el mismo método que usamos en la página de inicio. Pasa null, un array vacío, y limita a 10 resultados:
| // ... lines 1 - 4 | |
| use App\Entity\Voyage; | |
| // ... lines 6 - 8 | |
| class SearchSite | |
| { | |
| // ... lines 12 - 15 | |
| /** | |
| * @return Voyage[] | |
| */ | |
| public function voyages(): array | |
| { | |
| return $this->voyageRepository->findBySearch(null, [], 10); | |
| } | |
| } |
La consulta de búsqueda aún no es dinámica, pero ahora tenemos un método voyages() que podemos utilizar en la plantilla. Empezaré con un poco de estilo, luego es código Twig normal: {% for voyage in this - ese es nuestro objeto componente -.voyages. Añade endfor, y en el medio, pegaré eso:
| <div {{ attributes }}> | |
| // ... lines 2 - 13 | |
| <div class="text-white py-2 rounded-lg"> | |
| {% for voyage in this.voyages %} | |
| <a href="{{ path('app_voyage_show', { id: voyage.id }) }}" class="flex items-center space-x-4 px-4 p-2 hover:bg-gray-700 cursor-pointer"> | |
| <img | |
| class="h-10 w-10 rounded-full" | |
| src="{{ asset('images/'~voyage.planet.imageFilename) }}" | |
| alt="{{ voyage.planet.name }} planet" | |
| > | |
| <div> | |
| <p class="text-sm font-medium text-white">{{ voyage.purpose }}</p> | |
| <p class="text-xs text-gray-400">{{ voyage.leaveAt|ago }}</p> | |
| </div> | |
| </a> | |
| {% endfor %} | |
| </div> | |
| </div> |
Nada especial: una etiqueta de anclaje, una etiqueta de imagen y algo de información.
Vamos a probarlo. ¡Abre! ¡Genial! Aunque, por supuesto, cuando escribimos, ¡nada se actualiza! Lamentable!
Instalar y actualizar un LiveComponent
Aquí es donde los componentes en vivo resultan útiles. ¡Así que vamos a instalarlo!
composer require "symfony/ux-live-component:^2.0"
Para actualizar nuestro componente Twig a un componente Live, sólo tenemos que hacer dos cosas. Primero, es #[AsLiveComponent]. Y segundo, utilizar DefaultActionTrait:
| // ... lines 1 - 6 | |
| use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; | |
| use Symfony\UX\LiveComponent\DefaultActionTrait; | |
| class SearchSite | |
| { | |
| use DefaultActionTrait; | |
| // ... lines 14 - 25 | |
| } |
Es un detalle interno... pero necesario.
Hasta aquí, nada cambiará. Sigue siendo un componente Twig... y no hemos añadido ninguna superpotencia de componente en vivo.
Añadir un accesorio escribible
Uno de los conceptos clave con un componente vivo es que puedes añadir una propiedad y permitir que el usuario cambie esa propiedad desde el frontend. Por ejemplo, crea un public string $query para representar la cadena de búsqueda:
| // ... lines 1 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 16 | |
| public string $query = ''; | |
| // ... lines 18 - 29 | |
| } |
A continuación, utilízala cuando llamemos al repositorio:
| // ... lines 1 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 16 | |
| public string $query = ''; | |
| // ... lines 18 - 25 | |
| public function voyages(): array | |
| { | |
| return $this->voyageRepository->findBySearch($this->query, [], 10); | |
| } | |
| } |
Para permitir que el usuario modifique esta propiedad, necesitamos darle un atributo:#[LiveProp] con writeable: true:
| // ... lines 1 - 7 | |
| use Symfony\UX\LiveComponent\Attribute\LiveProp; | |
| // ... lines 9 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 15 | |
| (writable: true) | |
| public string $query = ''; | |
| // ... lines 18 - 29 | |
| } |
Por último, para vincular esta propiedad a la entrada -de modo que la propiedad query cambie a medida que el usuario escribe- añade data-model="query":
| <div {{ attributes }}> | |
| <div class="relative"> | |
| // ... lines 3 - 5 | |
| <input | |
| type="search" | |
| data-model="query" | |
| // ... lines 9 - 11 | |
| /> | |
| </div> | |
| // ... lines 14 - 30 | |
| </div> |
¡Ya está! Comprueba el resultado. Empezamos con todo, pero cuando tecleamos... ¡filtra! Incluso tiene depuración incorporada.
Entre bastidores, hace una petición AJAX, rellena la propiedad query con esta cadena, vuelve a renderizar la plantilla Twig y la coloca justo aquí.
Y, mira, es sólo PHP, así que esto es fácil. Si no es $this->query, devuelve un array vacío:
| // ... lines 1 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 25 | |
| public function voyages(): array | |
| { | |
| if (!$this->query) { | |
| return []; | |
| } | |
| // ... lines 31 - 32 | |
| } | |
| } |
Y en SearchSite.html.twig, añade una declaración if alrededor de esto: if this.voyages no está vacío, devuelve eso... con el endif al final:
| <div {{ attributes }}> | |
| // ... lines 2 - 14 | |
| {% if this.voyages is not empty %} | |
| <div class="text-white py-2 rounded-lg"> | |
| {% for voyage in this.voyages %} | |
| // ... lines 18 - 29 | |
| {% endfor %} | |
| </div> | |
| {% endif %} | |
| </div> |
Para los quisquillosos con los detalles, sí, con this.voyages, estamos llamando al método dos veces. Pero hay formas de evitarlo, y mi favorita se llama #[ExposeInTemplate]. No la mostraré, pero es un cambio rápido.
Fijar el modal a la parte superior
Así que, ¡estoy contento! Pero, esto no es perfecto... y yo quiero eso. Una cosa que me molesta es la posición: parece baja cuando está vacía. Y al escribir, salta de un lado a otro. Es el posicionamiento nativo de <dialog>, que normalmente es estupendo, pero no cuando nuestro contenido cambia. Así que, en este caso, vamos a fijar la posición cerca de la parte superior.
En Modal.html.twig, añade una última pieza de flexibilidad a nuestro componente: una prop llamada fixedTop = false:
| {% props | |
| // ... lines 2 - 3 | |
| padding="p-5", | |
| fixedTop=false | |
| %} | |
| // ... lines 7 - 42 |
Entonces, al final de las clases dialog, si fixedTop, renderiza mt-14 para fijar el margen superior. Si no, no hagas nada:
| // ... lines 1 - 6 | |
| <div | |
| // ... lines 8 - 11 | |
| > | |
| // ... lines 13 - 14 | |
| <dialog | |
| class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] {{ allowSmallWidth ? '' : 'md:min-w-[50%] ' }}animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80{{ fixedTop ? ' mt-14' : '' }}" | |
| // ... lines 17 - 18 | |
| > | |
| // ... lines 20 - 33 | |
| </dialog> | |
| // ... lines 35 - 40 | |
| </div> |
En base.html.twig, en el modal... es hora de dividirlo en varias líneas. Entonces pasa a :fixedTop="true":
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding="" :fixedTop="true"> | |
| // ... lines 33 - 43 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 47 - 56 | |
| </div> | |
| // ... lines 58 - 86 | |
| </body> | |
| </html> |
Y ahora, ah. Mucho más bonito y sin más saltos.
Establecer la Búsqueda como Turbo Permanente
¿Y qué más? Es necesario pulsar arriba y abajo en el teclado para recorrer los resultados, aunque eso lo dejaré para otra ocasión. Pero fíjate en esto. Si busco, luego hago clic fuera y navego a otra página, como es lógico, al abrir el modal de búsqueda, está vacío. Sería genial que recordara la búsqueda.
Y podemos hacerlo con un truco de Turbo. En base.html.twig, en el modal, añade data-turbo-permanent:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding="" :fixedTop="true" data-turbo-permanent id="global-search-modal"> | |
| // ... lines 33 - 43 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 47 - 56 | |
| </div> | |
| // ... lines 58 - 86 | |
| </body> | |
| </html> |
Eso le dice a Turbo que mantenga esto en la página cuando navegue. Cuando utilices esto, necesita un id.
Veamos cómo se siente esto. Abre la búsqueda, escribe algo, pulsa fuera, ve a la página principal y ábrela de nuevo. ¡Qué guay!
Abrir la búsqueda con Ctrl+K
Vale, ¡última cosa! Aquí arriba estoy anunciando que abres la búsqueda con un atajo de teclado. ¡Eso es mentira! Pero podemos añadir esto... y, de nuevo, es fácil.
En el modal, añade un data-action. Stimulus tiene soporte incorporado para hacer cosas en keydown. Así que podemos decir keydown., y luego la tecla que queramos, comoK. O en este caso, Ctrl+K.
Si nos detuviéramos ahora, esto sólo se activaría si el modal estuviera enfocado y luego alguien pulsara Ctrl+K. Eso... no va a ocurrir. En lugar de eso, queremos que se abra independientemente de lo que esté enfocado. Queremos una escucha global. Para ello, añade@window.
Cópialo, añade un espacio, pégalo y activa también meta+k. Meta es la tecla comando en un Mac:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal | |
| // ... lines 33 - 37 | |
| data-action="keydown.meta+k@window->modal#open keydown.ctrl+k@window->modal#open" | |
| > | |
| // ... lines 40 - 50 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 54 - 63 | |
| </div> | |
| // ... lines 65 - 93 | |
| </body> | |
| </html> |
¡Hora de probar! Me muevo y... ¡teclado! ¡Me encanta! ¡Listo!
Componente vivo de carga perezosa
Ah, ¡y los Componentes Vivos también se pueden cargar perezosamente mediante AJAX! Observa: añade un atributo defer. Cuando actualicemos, no veremos ninguna diferencia... porque ese componente se oculta al cargar la página de todos modos. Pero en realidad, acaba de cargarse vacío y luego ha hecho inmediatamente una llamada Ajax para cargarse de verdad. Podemos verlo aquí abajo, en la barra de herramientas de depuración web Esta es una forma estupenda de aplazar la carga de algo pesado, para que no ralentice tu página.
No es especialmente útil en nuestro caso porque el componente SearchSite es muy ligero, así que lo eliminaré.
Mañana pasaremos un día más con los Componentes Vivos, esta vez para dotar a un formulario de superpoderes de validación en tiempo real y resolver el viejo y molesto problema de los campos de formulario dinámicos o dependientes.
40 Comments
I got some weird behaviours when using
keydown.ctrl+k@window->modal#open keydown.meta+k@window->modal#openin Ubuntu+ChromeWhen I press CTRL+k, it opens the search modal and Chrome search bar.
When I press META+k (I suppose it also works with the Windows key), it opens the search modal with a letter k already filled in. Changing to other letters happens the same and triggers the Ubuntu key binding. For instance, META+l would lock the session
I added
:preventto both of them to callpreventDefault()to work correctlykeydown.ctrl+k@window->modal#open:prevent keydown.meta+k@window->modal#open:preventhttps://stimulus.hotwired.dev/reference/actions#options
Hey Rcapile,
So did that
:preventhelp you? It seems like you have some global shortcuts for that combination. The other solution would be to change to a key that isn't reserved in your system, but if with:preventit works - even better :) Thanks for sharing this with others!Cheers!
Hello. Long time without an answer for this topic, so a quick one to clarify it :)
I made test with :prevent, and it works well.
Hey Shaan,
Awesome, thanks for confirming it works for you :)
Cheers!
Hi, is there a way to use PagerFanta in live components?
I made a live component witch show a list of items with getItems method.
I have a liveProp $query to filter the list.
But if there are too many items the list will be too long. So i would like to use PagerFanta, but i can't figure out how to use it in a livecomponent.
Hey Gbd,
It should be possible, why not? :) We were talking about Pagerfanta in this tutorial, in this specific chapter: https://symfonycasts.com/screencast/last-stack/pager-sorting - that's how to use Pagerfanta, or you can search more related videos about it on SymfonyCasts: https://symfonycasts.com/search?q=pagerfanta&sort=most relevant&page=1&types=video . Or just read their official docs.
So, in theory, you just need to inject Pagerfanta and Repository in your Live Component. Instead of getting an error of entities you need to return a query builder object from the repository, and you will need to pass that query builder to the Pagerfanta with some other details like page, limit, etc. And Pagerfanta will do the rest for you getting you th limited result.
I hope that helps!
Cheers!
Ok, Pagerfanta will prepare a list of links (like <a href=...&page=2... >) but can normal links be used in Live Component?
I tried and it throws me a MethodNotAllowedHttpException. Do I need to define something in Live Component, or convert links from Pagefranta to some butons/actions?
Hey Nataniel,
There's some misunderstanding I suppose. The MethodNotAllowedHttpException says that the controller that is trying to handle your request does not support the request method, and the solution may depend on the details. If the problem with your custom route - you either should match the method of the request your sending, see https://symfony.com/doc/current/routing.html#matching-http-methods - or send the request with the method that's required by that route.
I hope that helps
Cheers!
So I added a pagefrante to the example with LiveComponent as below:
after these changes, clicking on the page number triggers a download (?😯?) instead of switching to the next page.
Thanks Victor!
Yes, in PageFranata the links are the same as the one which goes from/to the LiveComponent Ajax, but (obviously) GET not POST.
So I now have the idea of replacing the hrefs with actions or somehow replacing all the default PageFranta elements with buttons.
I'm not sure I know how to do this yet though. But I'll try.
I have something like this, but is it the best idea?
And, um, how do I apply this to a full widget?
Hey @Nataniel-Z ,
So you create your own Next/Previous buttons and tied that to the live component - it should work great I think, give it a try. Thankfully, Pagerfanta has some useful methods that you can leverage to see if you need to show Next/Previous.
Are you talking about Symfony Form widget? Yeah, that might be trickier, probably better to keep the current implementation in the template for simplification, but if you want to do that for a form widget - you would need to override the button widget ofthe default form theme (or form theme you want to base on), and use that new custom form theme with overridden widget. And since it's not that easy to pass the data into form widget - you would probably need to code a workaround on the things you have, for example base on some special attributes you're passing in the widget.
I hope that helps!
Cheers!
Great, thank you very much for the confirmation - that's what I thought.
Symfony simplifies so many things that it's sometimes easy to forget that you have to add more yourself :)
Haha, yeah :D
I'm happy to see you nailed that case and it works for you now :)
Cheers!
Hey @Victor,
I hope you can help me:
Live Components: Normal (non-LiveProp) props lose values after re-render
Everything works on the first render, but as soon as I trigger a live action (like handleZoom or handleMaximization), the component re-renders — and all normal props ($url, $title, $alt) lose their values (they become null).
This makes it difficult to mix normal props (for static context data) with live behavior (like toggling booleans) — since the static data disappears after any LiveAction.
Is this the expected behavior, or am I missing a recommended pattern for handling static props that shouldn’t change but need to persist after live re-renders?
Thanks a lot — this feature is super powerful, and I just want to make sure I’m following best practices!
Best wishes,
Zerewan
Hey Zerewan,
Yes, that’s expected behavior - you’ve run into one of the key gotchas of Symfony UX Live Components :)
Non-LiveProps are not preserved between re-renders, they are only passed on the initial render from Twig. After a LiveAction or re-render, LiveComponent restores only
#[LiveProp]properties from the frontend payload. So yes, you need to mark those fields as#[LiveProp]too to persist values between requests.Cheers!
Hi i'm a bit confused now whats the diffrence between LiveComponents, TwigComponents, Turbo Frames/ Stream and Stimulus Controller or where are the benefits over one or the other??
For me they have too much similarities and i have a hard time deciding what to take (e.g. for a modal, for a table to reload after an action etc).
Hey @michitheonlyone ,
Yeah, it's easy to get lost in the beginning with that. :) I think watching this course from beginning to end should explain the difference well, but in case you missed it, let's recap one more time.
LiveComponents is mostly the same as TwigComponents but with dynamic JS actions because they work with some JS code that makes it possible to update content dynamically without a full page refresh. If we only talk about TwigComponents - they are not dynamic, i.e. no helpful JS code, so with them, we only talking about full page refresh.
So TwigComponents' purpose is a simple way to encapsulate reusable UI logic with Twig templates. While LiveComponents' (Symfony UX) purpose is to combine server-side rendering with dynamic client-side updates that help to avoid full page refresh out of the box. I.e. LiveComponents already have some built-in JS code that helps to achieve it. The only different between them in PHP code is that you add
#[AsLiveComponent]to the TwigComponent to make it LiveComponent - so easy! That's why most of the time people use LiveComponents instead of TwigComponents because they give you more advantages with JS behaviour. That's why, unless you don't need dynamic JS / UX improvements, just always use LiveComponents instead of TwigCompnents. And the benefit for LiveComponents is that all the JS is already written for you by Symfony, you just leverage it by using special syntax in the template. And yes, LiveComponents uses Stimulus controllers behind the scene, but that's already a thord-party written code, you just use it with 0 need to write your own JS code.But if you need more custom control over JS code, i.e. when you want (or need) to write a custom JS code - that's where Stimulus controllers come into action. And we have several courses about Stimulus to see how to write them and where those are useful, so I would recommend you to check them out: https://symfonycasts.com/screencast/stimulus
Basically, with Stimulus you can write your own JS code but in Stimulus way, and because you're completely responsible for writing the JS code - there's basically no limitation, you can do whatever you want with it. And finally Turbo Frames / Streams is another tool that allows you to move your application into the next level: give it a single page application feel, i.e. when you click links load the new pages in the background using AJAX requests and dynamically replace the content. Once again, to know what you can do with it - I would recommend you to watch a separate course where it all is explained well: https://symfonycasts.com/screencast/turbo
I hope this clarifies things for you well now. But mostly, watching more related tutorials should explain it even better where and how you can leverage those tools.
Cheers!
Hi Victor
Thank you very much! You cleared out the picture for me. As always in programming: - There isn't just one way of doing it ;-)
Hey @michitheonlyone ,
Indeed, there are a lot of ways of doing things in programming... but Michi is the only one :p
I'm glad to hear it helped you ;)
Cheers!
Hi Victor it's me again ;-) i'm still a little bit confused as i'm playing around LiveComponents and Turbo Streams i sense some similarities there can you explain what is the benefit of using one over the other or when to use what? My scenario: i have 2 entity tables side by side (or in 2 tabs) with a modal form for add and edit as well as independent sorting and pagination (pagerfanta) is it better to use a LiveComponent (for each entity list) structure or rather turbo streams/ frames for each?
btw the sorting and pagination state should be stored in the url and i'm still not sure how to do this
Hey @michitheonlyone ,
If you can do something with LiveComponents - you should go with them :) The feature of LiveComponents is that they have already written JS for you, so you don't need to write any JS code with them, you just leverage the JS code written by the contributors. From the PHP dev point of view, it should sounds more familiar and easier. You just write PHP code and LiveComponents core do the JS work for you.
With the Turbo Streams - that's something that allow you to dynamically update the page with some response.
In short, there might be some overlaps, but they have slightly different approaches. IMO in your case better to use LiveComponents as you probably want to do apply some changes to the table data on a specific event, e.g. sorting, paginating, etc. But for simple operations like adding a new row you just added to the table - it might be also possible to use Turbo Streams. But to be consistent and simplify the code, I think you can just re-render the same template with LiveTemplates when you added a new entry, and the table will reflect the change.
Yeah, for pagination and sorting it's recommended to us query parameters, but I'm not sure you can do it easily with LiveComponents. Unfortunately, I haven't done it myself yet, but you can find some docs about it here: https://symfony.com/bundles/ux-live-component/current/index.html#changing-the-url-when-a-liveprop-changes
I hope this helps!
Cheers!
Sorry if this question is not related to the current lesson but isn't strange that
id="planet-card-{{ voyage-planet-id }}" is present in two files,homepage.html.twigand_card.html.twig?I've just checked in "finish" directory.
The problem is that in homepage there are rfew epeated ID's and this is not good plus I get the error :"Uncaught (in promise) Error: The response (200) did not contain the expected <turbo-frame id="planet-card-1"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload."
Maybe it is my mistake but the problem disappear if I change ID name in
home.page.html.twigHey @Fedale
Do you have repeated IDs in your database? Or what do you mean?
When you want to refresh the content of a
turbo-frameyou need to return anotherturbo-frameelement in your HTML response with the same ID attribute as the one you want to refresh/replace. So, having twoturbo-frameswith the same ID in different files it's okCheers!
Hey Ryan,
I have a Live Component that includes a form within a modal. This form submits to a save method within the Live Component. On my page, I have two columns:
The first column contains a Live Component used for searching through different Categories, Subcategories, and Groups to find an Item. When an Item is selected, it opens a modal, which is another Live Component.
The second column displays everything that has been added to the database from the modal. This second column is wrapped in a Turbo Frame.
The challenge I'm facing is that the original Live Component in column one is loaded in a Turbo Frame. When there is a form success from a modal, it doesn't remember the contents of that original Turbo Frame and empties it. If I remove the Live Component in column one from the Turbo Frame, it retains its state and remembers the previously selected Category, Subcategory, and Group.
I am trying to find a way to return a specific Turbo Frame update in my LiveComponent save method to only refresh the relevant part of the page, thus keeping the Turbo Frame in column one in its original state. I initially thought I could achieve this with Turbo Streams, but it seems that the approach you taught for a normal Symfony controller doesn't work directly within a LiveComponent. How can I address this issue and selectively update Turbo Frames within a Live Component?
Hey @Brandon!
Wow, this is super interesting! Though, tbh, despite your detailed description, it's complex enough that I don't have my mind wrapped around everything.
However, might this be what you're looking for? https://symfonycasts.com/blog/redirect-turbo-frame
Basically:
In
save()of yourLiveAction, instead of redirecting (which you are maybe or maybe not doing), allow the template to re-render, but with a<turbo-stream>inside that targets the frame you want and replaces it with a new version of itself with the correct URL.Let me know if that helps. Or if not, you can give even more info and I'll do my best :).
Cheers!
Ryan,
I read through the blog post and it seems close but I want to explain my situation a little more. LiveComponent #1 located in a Turbo Frame in column 1, lets you select a Category, Subcategory, and Group to find a specific item. Next to the item is a button that when clicked opens a modal which is LiveComponent #2. A lot happens with calculations in this LiveComponent, so I decided to make a 'save' method in the LiveComponent rather than doing it in a controller. So after the save method, I currently am redirecting, back to the 'index' page that houses LiveComponent #1 and a second Turbo Frame that displays every item that has been added. After success on the save method, I only really want to have the second Turbo Frame update, or refresh or replace, but yes, this is what the end of my save method looks like currently:
I have been kind of struggling with what to return, and it seems like I've tried everything. So with what the blog post says, I would add
To what page, #1 LiveComponent doesn't seem right as it is only a dynamic form for filtering to a certain item, #2 LiveComponent that has the modal and where the save method is called seems right, but it doesn't really have direct access to the page that has the Turbo Frame in column 2 that I want updated though, or would it go on the index page that contains the two Turbo Frames, and where LiveComponent #1 is called?
Hey @Brandon!
I think I understand! So let's make a few assumptions:
A) Let's assume that
takeoff_circuitis the route for the main page you're loading. Like, if the user typed the URL to this route in their browser, it would load the whole page - the page with the multiple live components and turbo frames.B) Let's also assume that the 2nd turbo frame - the one that shows the results - looks like this:
So, when #2 LiveComponent saves (the modal), yes, I'd include something like this in the template of your live component:
Notice the
srcistakeoff_circuit. The flow will be:A) The existing
turbo-frameis removed from the page, and this new one is addedB) Because it has a
src, it will make an AJAX request totakeoff_circuit. That will return the entire page, but Turbo will only grab the content from theitem-listframe on that page, then put it onto the new page.The only weird thing will be that the
item-listframe will be emptied for just a moment. I can think of 2 ideas to deal with this:1) Perhaps you don't need a
turbo-framefor theitem-listat all. Perhaps you can just return a stream from your #2 live component that updates this element with the new content - something like:Where this template is a partial that renders the
<div id="item-list">and has theforinside to actually render the content.2) If you do need a frame, you could also create a custom stream action called, for example,
refresh-frame. Then, with the JS you use for that custom stream action, you find the target<turbo-frame>element and set itssrcattribute to thetakeoff_circuitURL and also call.refresh()on it if it's already set - references https://turbo.hotwired.dev/reference/frames#functions and https://marcoroth.dev/posts/guide-to-custom-turbo-stream-actionsCheers!
Ryan,
This is awesome thank you for the reply, I appreciate all the hard work. The last few days I decided to see if I could make the entire page into one big LiveComponent, and I was successful, so now I'm not using Frames or Streams for this. I do have some repeated code which I don't like, but I love LiveComponents. I'll give your suggestion a try and see if I can eliminate my repeated code. I know you touched on LiveComponents with 30 Days of LAST stack, but I'd love to see an entire tutorial on them showing all of the different features.
Hey @Brandon!
Cool!
Live Components had its first stable release (checks watch) yesterday :). That's what I was waiting for before considering a tutorial. It's in the works!
Cheers!
Hello, following the course, from what you see, voyages is placed inside the anonymous component twig, maybe i miss something but i dont know
Neither the property "voyages" nor one of the methods "voyages()", "getvoyages()"/"isvoyages()"/"hasvoyages()" or "__call()" exist and have public access in class "Symfony\UX\TwigComponent\AnonymousComponent".<br />fixed, finaly i create my twigcomponent with the comand make:twig-component with select AsLiveComponent, also i run debug:twig-component to see how to look the component
Hey @sansxd!
Glad you figured it out - and sorry for the slow reply! In this tutorial, I wasn't using an anonymous component, but rather one backed with a class - https://symfonycasts.com/screencast/last-stack/live-components#codeblock-f055aa7e33 - and that is what you probably got when you used
make:twig-component:).Cheers!
Hi, I'm using a Live Component with a Live Action that ends with
return $this->redirectToRoute('my-route');Is there a way to load this new page (only the changing part) in a turbo-frame ?
Thanks for your answer
Cyril
Hey @Cyril
yes, the response just needs to return HTML wrapped in a turbo-frame element with the same ID as the one you want to replace
Cheers!
I tried but it doesn’t work. The live component is in the turbo-frame, the live action is redirecting to the same route (reloading the same page via the controller). So the turbo-frame element is in the returned HTML. But the entire page is reloaded from scratch, not only the changes in the turbo-frame. I tried to had a specific header Turbo-Frame in the redirectToRoute but it doen’t work…
Hey @Cyril!
That's an interesting situation! Internally, when you redirect from
LiveAction, LiveComponents sees that and checks to see ifTurbois installed. If it is, it does aTurbo.visit()instead of a real, full site redirect.So that works great. But you want something different: not a page navigation, but a frame navigation. I'm not sure that it makes sense to add this feature specifically to LiveComponents, but there is a solution:
A) In your
LiveAction, instead of redirecting, set some flag (i.e. property on your class) that indicates that you're in this "success" situation.B) In your template, anywhere inside your root component element, add something like this:
That should do it! When this
turbo-streampops onto the page, it'll replace your existingturbo-framewith this new one. That new element will instantly activate, see itssrcattribute, and make an Ajax request to fetch that page. It'll then use its normal behavior of only loading the matching frame from that page into the frame.Let me know if this works! I love combining these tools :)
Cheers!
Thanks Ryan! Your solution is working well but, you're right :
My mistake! I was wrong in thinking that normal redirection didn't work... Looking at the Profiler, I can see that only Ajax requests are made.
My problem seems to be somewhere else: after the LiveAction, the page scrolls up at the top. That's why I thought it was fully recharging!
Usually, Turbo reloads the page without any move, which gives a nice impression of fluidity. Here, it doesn't have this behavior.
Your solution works the same way: the page scrolls at the top :-(
Any idea?
Hey @Cyril!
Ah! So I can explain this:
With a normal Turbo navigation (i.e. NOT inside a
<turbo-frame>) Turbo actually DOES always scroll to the top. To prevent that, you need to be navigating in a frame. In Turbo 8 (released today, so I haven't played with it yet), you may be able to preserve the scroll position by adding:to your page. Note: this will only work for what Turbo 8 calls a "page refresh": when the navigation you're going to is exactly the same as the current URL.
Cheers!
Ok, I'll try with turbo 8. Thank you very much for taking the time to reply to me after your live stream ;-)
Cyril
"Houston: no signs of life"
Start the conversation!