Data Tables with Turbo Frames
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 SubscribeOur data tables-like setup is working. And it's almost awesome. What I don't love is how it jumps around. Every time we click a link, it jumps back to the top of the page. Boo.
That's because Turbo is reloading the full page. And when it does that, it scrolls to the top... because that's usually what we want! But not this time. I want our data table to work like a little application: where the content changes without moving around.
Turbo 8 Morphing?
There are two great ways to solve this. In Turbo 8 - which is not released yet, it's Beta 1 at the time of recording this - there's a new feature called page refreshes that leverages morphing. Without nerding out too much - and I want to - when navigating to the same page - like our search form, column sorting and pagination links all do - we can tell Turbo to only update the content on the page that changed... and to preserve the scroll position. So, keep an eye out for that.
Adding a Turbo Frame
The second way - which works fantastically today - is to surround this entire table with a <turbo-frame>. In homepage.html.twig, find the table. Here it is: this div represents the table. Above it, add <turbo-frame id="voyage-list">. Indent this div... and also these pagination links: we want those to be inside the Turbo frame so that when we click on them, they advance the frame & update:
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 38 - 55 | |
| <turbo-frame id="voyage-list"> | |
| <div class="bg-gray-800 p-4 rounded"> | |
| <table class="w-full text-white"> | |
| // ... lines 59 - 120 | |
| </table> | |
| </div> | |
| <div class="flex items-center mt-6 space-x-4"> | |
| // ... lines 124 - 132 | |
| </div> | |
| </turbo-frame> | |
| </section> | |
| </div> | |
| {% endblock %} |
Let's try this. Zap that search clear. And oh... beautiful. Look at that! Everything moves within the frame. Try pagination. That too! All of our links point back to the homepage... and the homepage, of course, has this frame.
But remember: now that this table lives inside a Turbo frame, if we have any links inside, they'll stop working. Again, to fix that, on each link, add data-turbo-frame="_top". Or to be more conservative, go up to the new <turbo-frame> and add target="_top". If you do that, you'll also need to find the sorting and pagination links that should navigate the frame and add data-turbo-frame="voyage-list".
But I'll remove those... because we don't have any links in the table.
Targeting the Search on the Form
At this point, the pagination and sorting links work perfectly! But... the search? The search is still a full page reload. That makes sense! I didn't put that inside the frame. Why? Because, if we had, when we typed and the frame reloaded, it would have also reloaded the search box... which would still reset my cursor position. So we don't want the form to reload.
Can we... keep this outside of the frame but target the frame when the form submits? We can! Up on the form element that submits, add data-turbo-frame="voyage-list":
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| <form | |
| // ... lines 39 - 42 | |
| data-turbo-frame="voyage-list" | |
| > | |
| // ... lines 45 - 55 | |
| </form> | |
| // ... lines 57 - 135 | |
| </section> | |
| </div> | |
| {% endblock %} |
Isn't that cool? Now when we refresh: watch. It's perfect! The table loads, but I keep my input focus. This is gorgeous.
Adding a Loading Screen
And now we have time to make things extra fancy! What about a loading indicator on the table while it's navigating? To make this obvious, go to our controller and add a sleep() for one second:
| // ... lines 1 - 14 | |
| class MainController extends AbstractController | |
| { | |
| ('/', name: 'app_homepage') | |
| public function homepage( | |
| // ... lines 19 - 25 | |
| ): Response | |
| { | |
| // ... lines 28 - 29 | |
| sleep(1); | |
| // ... lines 31 - 43 | |
| } | |
| } |
Now... it's slow... and when we click or search, we don't even getting any feedback that the site is doing anything.
How can we add a loading indicator? This is a spot where Turbo has our back. So here's the <turbo-frame> element. Watch the attributes at the end when I navigate. Did you see that? Turbo added an aria-busy="true" attribute while it was loading. That's there for accessibility, but it's also something that we can leverage within Tailwind!
Over on that <turbo-frame> element, here it is, say class="" with aria-busy:opacity-50.
This special syntax says that, if this element has an aria-busy attribute, apply the opacity-50. Add one more aria-busy: with blur-sm to blur the background. And for extra points, include transition-all so that the opacity and blur transition instead of happening abruptly:
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 38 - 56 | |
| <turbo-frame id="voyage-list" class="aria-busy:opacity-50 aria-busy:blur-sm transition-all"> | |
| // ... lines 58 - 134 | |
| </turbo-frame> | |
| </section> | |
| </div> | |
| {% endblock %} |
Tip
For an even nicer effect, you can also change the opacity & blur only if loading
takes longer than, for example, 700ms. Do that by adding an aria-busy:delay-700 class.
Refresh that and watch. Oh, that's lovely! And it all happens thanks to 3 CSS classes. And, though I love watching that, in MainController, remove the sleep.
Advancing the Frame
Is this mission accomplished? Nearly. There's one gigantic, horrible problem... with an easy solution. When we search or sort or paginate, the URL doesn't change. That's the default behavior of Turbo frames: when they navigate, they don't update the URL. However, we can tell Turbo that we want this. On the Turbo Frame, add data-turbo-action="advance":
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 38 - 56 | |
| <turbo-frame id="voyage-list" data-turbo-action="advance" class="aria-busy:opacity-50 aria-busy:blur-sm transition-all"> | |
| // ... lines 58 - 134 | |
| </turbo-frame> | |
| </section> | |
| </div> | |
| {% endblock %} |
Advance means that it will update the URL and advance the browser history so that if we hit the "Back" button, it'll go the previous URL. You can also use replace to change the URL, but without adding to the history.
Watch: this time when we search... the URL updates! And as we navigate to page two or three... it updates... and we can hit back, back, and forward, forward.
We now have a truly fantastic data tables setup... entirely written without any custom JavaScript inside of Twig and Symfony. What a time to be alive.
The final teensy problem is this "30 results". As we search, that never changes: it's stuck on whatever number was there when the original page loaded. That's because this lives outside the Turbo frame. The easiest fix would be to move it into the frame... but I don't want it there! Visually, I want it up here!
We're going to leave that for now. But we'll fix it in a few days with Turbo Streams.
Tomorrow, we're going to dive into a brand-new browser feature! It's called View Transitions, and it'll let us apply visual transitions to any navigation.
19 Comments
Hi ,
Thanks for video.
I don't see any error and I don't see any affects (4:50) - page loading styles.
Just in case I copied homepage.html.twig from your script.
Turbo - works , I don't see any errors in browser console.
How to investigate issue?
BR.
Hey @Ruslan
That's a complex question, these effects depend on the browser, and probably some of them will not work at all. Probably if you share what browser you are using, I will be able to help more.
Cheers.
What is the status on the morphing functionality? I have the latest turbo version but adding the meta tags in the head as per the documentation doesn't seem to do anything
Hey @Nick-F!
Morphing is alive and well on Turbo 8, though I admit that I don't have any real experience with it yet (not because it's bad or anything, I've just not been available!). If you have the
metatags, I can't think of a reason why you wouldn't see the morphing functionality... which I realize isn't much help, but maybe it'll give you more confidence to try again.Cheers and good luck!
Hello.. When I add
data-turbo-frame="voyage-list"to the <form> element (~3:22 in the video), I don't see any change when searching. It still does a full-page reload and the cursor resets to the beginning of the <input> field. I have triple checked everything, but don't know where to look. Any ideas?Hey @CDesign!
Sorry for my VERY slow reply! I would check a few things:
A) Is Turbo running otherwise - like do you see no page reloads during normal navigation? My guess is yes, but we need to check.
B) Watch your browser console closely. Do you see any errors? It's possible that the form is submitting into the frame... but then some error (e.g. a 500 error) is causing the response to NOT contain the matching frame... and then perhaps (I can't remember if it actually does this), the entire page is refreshing.
If you're still having the issue and this doesn't help, let me know!
Cheers!
Hey !
Thanks for the work !
A little suggestion : During this video you said that the table doesn't contain links. However, we have the planet links in the turbo-frame
planet-card-{id}.I don't know it's possible, but it could be cool to know : How can I force a page reload on a link who's come from another turbo-frame in the actual turbo-frame ?
PS : I don't remember, it can be myself who's added the planet link :D.
Hey Adn,
Hm, maybe you add it yourself :p Anyway, if you want to reload content in the specific frame - I believe you need something like this:
I.e.
data-turbo-action="replace"should do the trick, you just need to point that link to specific frame, and specific URL of course. It might be so that it will work with empty href though, i.e. it will be pointed to the same URL you're currently on, but I didn't try it :)I hope this helps!
Cheers!
Thanks for your reply !
My bad, a
target="_top"is enough...It still new for me ! Sorry for the inconvenience ! :D
just for clarification for others
in file _card.html.twig
Hey Adn,
Ah, ok, no problem :) Yeah, it's easy to stuck in new stack ;) thanks for sharing your final solution with others!
Cheers!
Hello!
I noticed when the sleep was removed, we see the quick flash when updating the table. Is there a way to say "only add aria-busy after waiting 300ms"?
Hey @kbond!
After talking with you on Slack, you basically figured this out on your own... then told ME. Yes! It's awesome! We can delay the CSS transition so it only happens if the page load is particularly slow. All in CSS:
I LIKE that
Thanks for this great tutorial!
Adding data-turbo-action="advance" makes the page jump back to the top of the page again when searching, clicking on the sorting or paging links. Removing it fixes that again. I thought I missed something, but copied your exact code and it still does it. Has something changed since?
Also, the planet popovers do have a link... which now have "content missing". they should have a data-turbo-frame="_top" I guess?
Using stimulus '3.2.2 and turbo 7.3.0
Update: the issue with the page jumping to top after using data-turbo-action="advance" happens in Brave browser, not on Firefox, Chrome or Edge.
Hey @escobarcampos!
Ah, fascinating! It works in every other browser? I would not have expected that detail to be browser-dependent...
Hello! I have a few questions about this "homemade datatable" :)
Requests are submitted via Ajax using Turbo Drive (if I understood correctly). But if our application is not ready to accommodate Turbo Drive (since all our Javascript must use Stimulus), what should we do?
This question follows on from the 1st. Do you think it is possible to shift all this logic into a LiveComponent? As well as the method that will return the results? I mean, can we in LiveComponent create a LiveAction that we can set the #[MapQueryParameter] to in order to make this all work?
This question follows on from the second. If it is possible to put all the logic in LiveComponent, if we don't use Turbo Drive (but we can use Turbo Frame since it doesn't require Turbo Drive, if I understood correctly. Or maybe there is be alternatives to make all of this work the same way without ever using Turbo), do you think it is possible to create a trait (like ComponentWithFormTrait but something like "ComponentWithDataTableTrait"), and this trait would define all the logic and we would have some abstract methods to define such as the targeted entity, the repository method to use, the sort columns, etc.. And everything would be done automatically?
Hey @Fabrice!
Sorry for the slow reply! But I'm happy to chat about this topic!
For this example of data tables, the Ajax is submitted via Turbo Frames - we put a frame around the
<table>. So, this only means that any JavaScript behavior inside the frame needs to be written in Stimulus. The rest of your site should be ok without it. So, you would disable Turbo Drive (like we talk about in the Drive chapter), but then use frames for this. It is possible that using theadvancemight not play nicely with a disabled Turbo drive... I'm not sure. It's worth a try!Yup! You'll need this PR - https://github.com/symfony/ux/pull/1230 - in order for the URL on the page to change, but I'll merge that really soon. And you might not even need a
LiveActionI would bind the search input to aLivePropmodel, then the navigation links would trigger a model change for apagemodel - e.g. changing it from 2 to 3.Yea! Definitely! That's a really cool idea. More broadly, I'd love to see a PHP library we could use to help build the "data table" and the sort column links, etc. But, anyway, if you built this all inside of a LiveComponent, indeed, I think there would be a lot of boilerplate code. You could have the
queryas aLiveProp(writable:true), a$page = 1as aLiveProp(writable: true)and also 2 for sort direction and sort column. You could have one abstract method - e.g.getQueryBuilder()- where you would use the$queryprop to create the query builder for that entity. Then a method - e.g.getResults()in your trait - that calls this method, sets the orderBy on it, initializes the pagination and returns it. You would usethis.resultsin your template to loop over. Finally, you might have some sort of helpers to help build the pagination links and column sorting headers.If you try this out, I'd love to know how it goes. It feels generic enough that it could at least be something shared publicly.
Cheers!
Thank you for your reply ! So I will try all this soon when I have time, and post the result to you!
thank you !
"Houston: no signs of life"
Start the conversation!