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 SubscribeLog back in... and head to any product page. Thanks to the work that we did earlier, when we submit the review form, the opacity does go lower while the frame is loading. You can see this fairly well on the button... but it is still a bit subtle. So here's an idea: what if we also disabled this submit button while the frame was loading? That would give us an even better loading indicator and, as a bonus, it would help prevent double submits. The best part? We can make this happen for every form on our site by leveraging an event that Turbo dispatches.
In your editor, open up assets/turbo/turbo-helper.js
. Anywhere in the constructor, listen to a new event: document.addEventListener('turbo:submit-start')
. Pass this an arrow function with an event argument. Inside, let's console.log()
the string submit-start
and also the event object.
... lines 1 - 8 | |
document.addEventListener('turbo:submit-start', (event) => { | |
console.log('submit-start', event); | |
}) | |
... lines 14 - 91 |
Turbo triggers this turbo:submit-start
event whenever any form is submitted with turbo, whether it's inside of a Turbo frame or just a normal form that Turbo Drive is handling.
Let's go see if this works. Move over, refresh, submit, and go check the console. There it is!
Now some Turbo events have a detail
key inside them with extra info. And this is one of those events. This formSubmission
key holds all kinds of information about the form submit that's about to start. Most importantly, for us, it has a submitter
key set to the button that triggered the submit. That's this button right here!
This is awesome because we can use that to add a disabled
attribute! The path to this is detail.formSubmission.submitter
.
Head back to our code and replace the log with event.detail.formSubmission.submitter
. Add the disabled
attribute with .toggleAttribute('disabled', true)
.
... lines 1 - 8 | |
document.addEventListener('turbo:submit-start', (event) => { | |
event.detail.formSubmission.submitter.toggleAttribute('disabled', true); | |
}) | |
... lines 14 - 91 |
When you use toggleAttribute
with a second argument of true
, it means:
I want you to add this attribute... but I don't need it to be
disabled="something"
. I just need thedisabled
attribute.
Let's try that. Refresh the page... and then inspect the button element. Watch it when I click. Yes! Perfect! For just a moment, it had a disabled
attribute, which made it even more obvious that it was loading. And, we can't click to submit it twice.
Behind the scenes, our code added the disabled
attribute. Then, when the frame finished loading, the entire contents of the frame were replaced with a new, non-disabled form to give us the exact effect we want.
Scroll up, log out, then go to the registration form. This form does not live in a Turbo frame. But it still gets the new submit behavior! Yup, with just a few lines of code, every form on our site just got a little fancier.
But... there is one... super edge case. If you submitted the form and navigated away from the page while the form was still submitting, that would cause Turbo to take a snapshot of the page with the disabled button. If the user then clicked back on their browser, the button would still be disabled.
This is probably such a rare edge case that... maybe we don't care. But let's code for it.
Back in turbo-helper.js
, create a new variable: const submitter =
. Copy the event.detail
line from below, paste here, and just use submitter
below.
We're doing this so we can also give this button a new class: submitter.classList.add('turbo-submit-disabled')
.
... lines 1 - 9 | |
document.addEventListener('turbo:submit-start', (event) => { | |
const submitter = event.detail.formSubmission.submitter; | |
submitter.toggleAttribute('disabled', true); | |
submitter.classList.add('turbo-submit-disabled'); | |
}) | |
... lines 17 - 101 |
This class doesn't do anything and doesn't have any CSS attached to it. I just invented it as a way to mark that this button was disabled because of our loading logic.
Why is that helpful? Above this, we're listening to turbo:before-cache
. This is called right before Turbo takes a snapshot of the page. We can use the turbo-submit-disabled
class to find the disabled button and remove that attribute.
But let's not put the logic here: let's call a new function: this.reenableSubmitButtons()
.
Copy that method name, scroll all the way to the bottom and paste to create it. Inside, use document.querySelectorAll()
to find any element with the turbo-submit-disabled
class that we added. Foreach over this, pass a callback with a button
argument, and then say: button.toggleAttribute('disabled', false)
. Fully clean things up by removing the class: button.classList.remove('turbo-submit-disabled')
.
... lines 1 - 4 | |
document.addEventListener('turbo:before-cache', () => { | |
this.closeModal(); | |
this.closeSweetalert(); | |
this.reenableSubmitButtons(); | |
}); | |
... lines 10 - 90 | |
reenableSubmitButtons() { | |
document.querySelectorAll('.turbo-submit-disabled').forEach((button) => { | |
button.toggleAttribute('disabled', false); | |
button.classList.remove('turbo-submit-disabled'); | |
}); | |
} | |
... lines 98 - 101 |
It's pretty hard to actually repeat the edge case we just fixed... but let's at least make sure we didn't break anything. Submit the form. Yup! That still looks great!
Next: there's another place that we can leverage a Turbo Frame to do something cool. While viewing a product, if we're an admin, it would be awesome to be able to click an "edit" button that would Ajax load the "product form" right into this space. So... let's do it!
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
"doctrine/annotations": "^1.0", // 1.13.1
"doctrine/doctrine-bundle": "^2.2", // 2.3.2
"doctrine/orm": "^2.8", // 2.9.1
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"sensio/framework-extra-bundle": "^6.1", // v6.1.4
"symfony/asset": "5.3.*", // v5.3.0-RC1
"symfony/console": "5.3.*", // v5.3.0-RC1
"symfony/dotenv": "5.3.*", // v5.3.0-RC1
"symfony/flex": "^1.3.1", // v1.18.5
"symfony/form": "5.3.*", // v5.3.0-RC1
"symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
"symfony/property-access": "5.3.*", // v5.3.0-RC1
"symfony/property-info": "5.3.*", // v5.3.0-RC1
"symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
"symfony/runtime": "5.3.*", // v5.3.0-RC1
"symfony/security-bundle": "5.3.*", // v5.3.0-RC1
"symfony/serializer": "5.3.*", // v5.3.0-RC1
"symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
"symfony/ux-chartjs": "^1.1", // v1.3.0
"symfony/ux-turbo": "^1.3", // v1.3.0
"symfony/ux-turbo-mercure": "^1.3", // v1.3.0
"symfony/validator": "5.3.*", // v5.3.0-RC1
"symfony/webpack-encore-bundle": "^1.9", // v1.11.2
"symfony/yaml": "5.3.*", // v5.3.0-RC1
"twig/extra-bundle": "^2.12|^3.0", // v3.3.1
"twig/intl-extra": "^3.2", // v3.3.0
"twig/string-extra": "^3.3", // v3.3.1
"twig/twig": "^2.12|^3.0" // v3.3.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
"symfony/debug-bundle": "^5.2", // v5.3.0-RC1
"symfony/maker-bundle": "^1.27", // v1.31.1
"symfony/monolog-bundle": "^3.0", // v3.7.0
"symfony/stopwatch": "^5.2", // v5.3.0-RC1
"symfony/var-dumper": "^5.2", // v5.3.0-RC1
"symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
"zenstruck/foundry": "^1.10" // v1.10.0
}
}
// package.json
{
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.13.13
"@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
"@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
"@popperjs/core": "^2.9.1", // 2.9.2
"@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
"@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
"@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
"@symfony/webpack-encore": "^1.0.0", // 1.3.0
"bootstrap": "^5.0.0-beta2", // 5.0.1
"chart.js": "^2.9.4",
"core-js": "^3.0.0", // 3.13.0
"jquery": "^3.6.0", // 3.6.0
"react": "^17.0.1", // 17.0.2
"react-dom": "^17.0.1", // 17.0.2
"regenerator-runtime": "^0.13.2", // 0.13.7
"stimulus": "^2.0.0", // 2.0.0
"stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
"stimulus-use": "^0.24.0-1", // 0.24.0-2
"sweetalert2": "^11.0.8", // 11.0.12
"webpack-bundle-analyzer": "^4.4.0", // 4.4.2
"webpack-notifier": "^1.6.0" // 1.13.0
}
}