Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add explanations regarding form submissions and the data-turbo attribute for Flash Messages #2486

Open
wants to merge 2 commits into
base: 2.x
Choose a base branch
from

Conversation

DocFX
Copy link

@DocFX DocFX commented Jan 6, 2025

Added info for form submission inputs

Q A
Bug fix? no
New feature? no
Issues
License MIT

Just added some info regarding how not to lose Flash Messages with Turbo Drive prefetching resources on hover/load.

Added info for form submission inputs
@carsonbot carsonbot added the Status: Needs Review Needs to be reviewed label Jan 6, 2025
@Kocal Kocal added the Turbo label Jan 17, 2025

.. code-block:: html

<button data-turbo="false">Submit form</button>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit hesitant here... Turbo is not supposed to prefetch forms, only links right ?

Could you clarify to me in what scenario flash messages would be lost?

Like what URL is the user / what does he do / where is he redirected on / where does he end up beeing on ?

And then at what moment Turbo currently interfer with this process ?

If we can isolate this, maybe we can also add some safe guards to avoid it ?

Copy link
Author

@DocFX DocFX Jan 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK that's what it did out of the box to me.

When you hover a submit / button, it tries to submit the form to see what's behind it, you can see it in your browser network log. So that empties the flashmessages as they're parsed on destination in a Twig template.

The fix is exactly extracted from the documentation here: https://turbo.hotwired.dev/reference/attributes and worked like a charm.

data-turbo="false" disables Turbo Drive on links and forms including descendants. To reenable when an ancestor has opted out, use data-turbo="true". Be careful: when Turbo Drive is disabled, browsers treat link clicks as normal, but native adapters may exit the app.

Let me paste an example.

Here's the action:

#[Route('/organize', name: 'organize')]
    #[IsGranted('ROLE_ORGANIZER')]
    public function organize(Request $request, EntityManagerInterface $entityManager, TranslatorInterface $translator): Response
    {
        /** @var User $user */
        $user = $this->getUser();

        if($user->getStatus() !== UserStatusEnum::validated) {
            $this->createAccessDeniedException($translator->trans('global.denied.invalidstatus'));
        }

        $event = new Event();
        $event->setStatus(EventStatusEnum::Open);

        if(! empty($user->getTimezone())) {
            $event->setTimezone($user->getTimezone());
        }

        if(! empty($user->getCountry())) {
            $event->setCountry($user->getCountry());
        }

        $form = $this->createForm(EventType::class, $event);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $event->setCreatedBy($user);
            $event->setUpdatedBy($user);
            $event->setAddress(strip_tags($event->getAddress()));
            $event->setDescription(strip_tags($event->getDescription(), '<a><br><ul><ol><li><h2><h3><h4><h5><h6><table><tbody><tr><td><th><strong><em><hr><pre><address><blockquote><s>'));
            $entityManager->persist($event);
            $entityManager->flush();

            $this->addFlash('success', $translator->trans('front.nav.myworkshops.created.success'));

            return $this->redirectToRoute('front_user_workshops', ['status' => 'organizer'], Response::HTTP_SEE_OTHER);
        }

        return $this->render(
            'front/event/organize.html.twig',
            [
                'form' => $form->createView(),
            ],
            new Response(null, $form->isSubmitted() && ! $form->isValid() ? 422 : 200)
        );
    }

Here's its template:

{% extends 'front/front_layout.html.twig' %}

{% block title %}
    {% if event is defined and event is not empty %}
        {{ 'front.nav.eventedit.title'|trans({'date': event.happeningAt|format_datetime}) }}
    {% else %}
        {{ 'front.nav.organize.title'|trans }}
    {% endif %}
{% endblock %}

{% block main_layout %}
    <div class="main-row theme1 transluent-row">
        <div class="main-column main-content most-opquage-column">
            <h1>
                {% if event is defined and event is not empty %}
                    {{ 'front.nav.eventedit.title'|trans({'date': event.happeningAt|format_datetime}) }}
                {% else %}
                    {{ 'front.nav.organize.title'|trans }}
                {% endif %}
            </h1>
            {{ include('front/account/_account_menu.html.twig') }}
            {% if event is defined and event is not empty %}
                <hr>
                {{ include('front/account/_events_menu.html.twig') }}
            {% endif %}
        </div>
    </div>

    <div class="main-row spacer-row"></div>

    {% if form is defined %}
        <div class="main-row theme1 unique-row transluent-row">
            <div class="main-column main-content most-opquage-column">
                <div class="w-full lg:w-1/2">
                    <div class="form-container">
                        {{ form_start(form) }}

                        <fieldset class="mb-4">
                            <legend>{{ 'front.event.yourworkshop'|trans }}</legend>
                            {{ form_row(form.workshop) }}
                            {{ form_row(form.maxParticipants) }}
                        </fieldset>

                        <fieldset class="mb-4">
                            <legend>{{ 'front.event.yourtime'|trans }}</legend>
                            {{ form_row(form.happeningAt) }}
                            {{ form_row(form.timezone) }}
                        </fieldset>

                        <fieldset class="mb-4">
                            <legend>{{ 'front.event.yourlocation'|trans }}</legend>
                            {{ form_row(form.address) }}
                            {{ form_row(form.country) }}
                        </fieldset>

                        <fieldset class="mb-4">
                            <legend>{{ 'front.event.youradditionalinfo'|trans }}</legend>
                            {{ form_row(form.description) }}
                        </fieldset>

                        <fieldset class="mb-4">
                            <legend>{{ 'front.event.yourstatus'|trans }}</legend>
                            {{ form_row(form.status) }}
                            {{ form_rest(form) }}
                        </fieldset>

                        <p>{{ 'global.mandatoryfields.text'|trans }}</p>
                        <input type="submit" data-turbo="false" class="btn btn-orange" value="{{ 'global.savebutton.label'|trans }}" title="{{ 'global.savebutton.title'|trans }}">
                        <a class="btn btn-gray ml-4" href="{{ url('front_user_account') }}" title="{{ 'global.back'|trans|raw|escape('html_attr') }}">{{ 'global.back'|trans }}</a>
                        {% if event is defined and event.id is defined and event.id is not null %}
                            {% if event.status.value is same as('Open') or event.status.value is same as('Draft') %}
                                <a class="btn btn-gray ml-4" href="{{ url('front_event_organizer_lock', {'id': event.id}) }}" title="{{ 'event.organizerlock.title'|trans|raw|escape('html_attr') }}">{{ 'event.organizerlock.label'|trans }}</a>
                            {% endif %}
                            {% if (event.status.value is same as('Open') or event.status.value is same as('Ready')) and event.remindersSent is same as(false) and event.lastRemindedAt is null %}
                                <a class="btn btn-gray ml-4" href="{{ url('front_event_organizer_send_reminders', {'id': event.id}) }}" title="{{ 'event.organizerremind.title'|trans|raw|escape('html_attr') }}">{{ 'event.organizerremind.label'|trans }}</a>
                            {% endif %}
                            <a class="btn btn-gray ml-4" href="{{ url('front_event_organizer_cancel', {'id': event.id}) }}" title="{{ 'event.organizercancel.title'|trans|raw|escape('html_attr') }}">{{ 'event.organizercancel.label'|trans }}</a>
                        {% endif %}
                        {{ form_end(form) }}
                    </div>
                </div>
            </div>
        </div>
    {% endif %}

{% endblock %}

The base template with messages bag extraction:

<!DOCTYPE html>
<html lang="{{ app.locale }}">
    {% apply spaceless %}
        <head>
            <meta charset="UTF-8">
            <title>{% block title %}Welcome!{% endblock %} | The Earth System Fresco</title>
            <link rel="preconnect" href="https://fonts.googleapis.com">
            <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
            <link href="https://fonts.googleapis.com/css2?family=Oswald:[email protected]&display=swap" rel="stylesheet">
            <link rel="shortcut icon" href="{{ asset('metadata/favicon.ico') }}">
            <link rel="icon" type="image/png" href="{{ asset('metadata/favicon-48x48.png') }}" sizes="48x48">
            <link rel="apple-touch-icon" sizes="180x180" href="{{ asset('metadata/apple-touch-icon.png') }}">
            <link rel="manifest" href="{{ asset('metadata/site.webmanifest') }}">
            <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
            {% block stylesheets %}
            {% endblock %}
            {% block javascripts %}
                {% block importmap %}{{ importmap('app') }}{% endblock %}
            {% endblock %}
            {% block seohead %}
            {% endblock %}
            {% block link_canonical %}
                {% if app.request.attributes.get('_route') is defined and app.request.attributes.get('_route') is not empty %}
                    <link rel="canonical" href="{{ url(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')) }}">
                {% endif %}
            {% endblock %}

            {% block og_meta -%}
                {% if app.request.attributes.get('_route') is defined and app.request.attributes.get('_route') is not empty %}
                    <meta property="og:url" content="{{ url(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')) }}">
                {% endif %}
                <meta property="og:site_name" content="{{ globals_website_name|escape }}">
                <meta property="og:type" content="website">
                <meta property="og:image:type" content="image/jpeg">
                <meta name="twitter:card" content="summary_large_image">
                <meta name="twitter:creator" content="@innersonics">
            {%- endblock %}
            {% block og_specific %}
                <meta property="og:title" content="{{ website_title|default(globals_website_name)|escape }}">
                <meta property="og:description" content="{{ globals_website_description }}">
                <meta property="og:image" content="{{ absolute_url(asset('build/images/logo-large-' ~ app.locale ~ '.png')) }}">
                <meta property="og:image:width" content="888">
                <meta property="og:image:height" content="672">
                <meta name="twitter:title" content="{{ website_title|default(globals_website_name)|escape }}">
                <meta property="twitter:description" content="{{ globals_website_description }}">
                <meta property="twitter:image" content="{{ absolute_url(asset('build/images/logo-large-' ~ app.locale ~ '.png')) }}">
            {% endblock %}

            {% block schema_org -%}
                <script type="application/ld+json">
                    {
                        "@context": "https://www.schema.org",
                        "@type": "Service",
                        "name": "{{ globals_website_name }}",
                    "description": "{{ globals_website_subtitle }}",
                    "address": {
                        "@type": "PostalAddress",
                        "addressCountry": "France"
                    }
                }
                </script>
            {%- endblock %}
        </head>
        <body>
            <a tabindex="0" id="skipcontent" href="#maincontent" title="{{ 'global.skipcontent.label'|trans }}">{{ 'global.skipcontent.label'|trans }}</a>
            <main id="swup">

                {% block menu %}{% endblock %}

                <div class="main-row spacer-row"></div>

                {% block messages %}
                    {% if app.flashes is defined and app.session.flashbag.peekAll()|length > 0 and app.request.xmlHttpRequest is same as(false) and app.request.headers.get('x-turbo-request-id') is empty %}
                        <div class="spaceless-row">
                            <div class="main-column">
                                <div class="messages">
                                    {% for label, messages in app.flashes %}
                                        {% for message in messages %}
                                            <div class="flash-message flash-{{ label }}">
                                                {{ message|raw }}
                                            </div>
                                        {% endfor %}
                                    {% endfor %}
                                </div>
                            </div>
                        </div>

                        <div class="main-row spacer-row"></div>
                    {% endif %}
                {% endblock %}

                <div id="maincontent">
                    {% block main_layout %}
                        <div class="main-row theme1 unique-row">
                            <div class="main-column main-content">
                                {% block main_content %}
                                    {% block body %}{% endblock %}
                                {% endblock %}
                            </div>
                        </div>
                    {% endblock %}
                </div>

                <div class="main-row spacer-row"></div>

                {% block footer %}
                    <div class="spaceless-row theme2">
                        <div class="main-column no-shadow">
                            <footer class="flex flex-col items-center justify-center w-full">
                                <div class="menu footer w-full">
                                    {{ front_menu('footer') }}
                                </div>
                                <div class="footer-text text-sm w-full lg:w-2/3">
                                    {{ include('_custom_block.html.twig', {'block_key': 'footer.' ~  app.locale|default('en')}) }}
                                    <br>
                                    <a class="mr-2 lg:mr-8" href="https://checklists.opquast.com/en/web-quality-assurance/" title="WCAG 2AA compliance (new window)">
                                        <img height="30" class="inline-block" src="{{ asset('images/wcag2AA.png') }}" alt="WCAG 2AA compliance logo">
                                    </a>
                                    <a class="mr-2 lg:mr-8" href="https://www.w3.org/WAI/standards-guidelines/wcag/" title="OpQuast compliance (new window)">
                                        <img height="30" class="inline-block" src="{{ asset('images/opquast.svg') }}" alt="OpQuast compliance logo">
                                    </a>
                                    <a class="mr-2 lg:mr-8" href="https://validator.w3.org/check?uri=https%3A%2F%2Fworkshops.theearthsystemfresco.org%2F&charset=%28detect+automatically%29&doctype=Inline&group=0" title="W3C HTML5 compliance (new window)">
                                        <img height="30" class="inline-block" src="{{ asset('images/html5-valid.png') }}" alt="W3C HTML5 compliance logo">
                                    </a>
                                    <a class="mr-2 lg:mr-8" href="https://www.theearthsystemfresco.org" title="The Earth System Fresco (new window)">
                                        <img height="30" class="inline-block" src="{{ asset('images/logo-large-' ~ app.locale ~ '.png') }}" alt="The Earth System Fresco Logo - {{ app.locale|upper }}">
                                    </a>
                                    <a href="https://jigsaw.w3.org/css-validator/validator?uri=https%3A%2F%2Fworkshops.theearthsystemfresco.org%2F&profile=css3svg&usermedium=all&warning=1&vextwarning=&lang=fr" title="W3C CSS compliance (new window)">
                                        <img height="30" class="inline-block" src="{{ asset('images/valid-css.png') }}" alt="W3C CSS compliance logo">
                                    </a>
                                </div>
                            </footer>
                        </div>
                    </div>
                {% endblock %}

            </main>
        </body>
    {% endapply %}
</html>

If you remove the data-turbo="false" attribute/value, upon hovering the button, there's an async query that's sent, and browser cache does not seem to be used afterwards, so I got no flashmessage. Adding that attribute, as documented, fixed the problem.

(note that I still left the weird code: and app.request.xmlHttpRequest is same as(false) and app.request.headers.get('x-turbo-request-id') is empty in the Twig condition, which should be useless now I found the fix, but doesn't hurt.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well.. let's separate two things here: Turbo prefetch on hover and ... the rest :)

Turbo Prefetch

afaik, the prefetch is a mecanism Turbo uses only for internal links, and for buttons in form that uses GET method -- forms that should not have effects according to the HTTP spec.

And this behaviour can be disabled easily with data-turbo-prefetch="false".

See https://turbo.hotwired.dev/reference/attributes#data-attributes

Capture d’écran 2025-01-28 à 02 48 13

... the rest

That said, i confirm I reproduce the behaviour with a form redirection, but this has nothing to do with hovering something (it does the same thing even when you do not hover), it's related to the way Turbo navigates / handle HTTP responses.

THat's why your suggestion makes me hesistant:

  • I don't want to freighten user with wordings that could be missinterpreted ("Turbo submit your forms!")
  • I'd rather not advice them to disable entirely Turbo on a form for a problem of Flash message.

Do you think there is any other method to use flash messages when Turbo is enabled ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have yet to find such a solution (apart from adding a query string, but then any further refresh on the target page would retrigger the message).

Let's just delay that PR until someone finds a better idea. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Needs Review Needs to be reviewed Turbo
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants