miguelgrinberg / turbo-flask

Integration of Hotwire's Turbo library with Flask.
MIT License
301 stars 35 forks source link

Flask `flash` not working with Turbo #5

Closed janpeterka closed 2 years ago

janpeterka commented 3 years ago

I stumbled upon flash not working when using turbo.

controller:

    @route("/delete/<id>", methods=["POST"])
    def delete(self, id):
        if self.measurement.is_used:
            flash("You can not delete this!")
            return redirect(url_for("MeasurementsView:index"))

If I remove turbo from page head, flash is working. If turbo is in, it's not (rest of the code is working no problem). I can imagine replacing flash mechanism with turbo-stream in future (it would be neat actually), but not sure what to do now.

If I understand correctly, it's because turbo turns my redirect to fetch, and flash is not rerendered. So I need to force flash element reloading. Not sure how tho.

(Also, the flash message is rendered next time page is "properly" reloaded.)

Thanks for your help!

miguelgrinberg commented 3 years ago

This is an indirect result of how turbo frames work. When you click a link or submit a form only the turbo frame in which this was done is updated. The rest of the page is not. One option that you have is to include the area that display the flashes in the turbo frame. Or stop using turbo frames altogether. In the case of a response to a form submission you can also use a turbo stream that includes updates in all the areas of the page.

janpeterka commented 3 years ago

Understand that. So, at least for now I made some helper code to solve this (only working for cases with form+POST). I put it here, so it may help someone dealing with this later :)

# helpers/turbo_flash.py
def turbo_flash(message):
    from app import turbo
    from flask import render_template as template

    if turbo.can_stream():
        return turbo.stream(
            turbo.replace(
                template("base/_turbo_flashing.html.j2", message=message, category="error"),
                target="turbo-flash",
            )
        )
    else:
        return False

using template

{# base/_turbo_flashing.html.j2#}
<turbo-frame id=turbo-flash>    
  <div class="turbo-flash container alert alert-light" role="alert">
    <ul>
      <li style="" class='{{ category }}'>{{ message }}</li>
    </ul>
  </div>
</turbo-frame>

like this

# some controller

if turbo.can_stream():
                return turbo_flash("Message I want flashed")

and in template, where I use turbo (so normal flask flashing is not working)

{% block flashing %}
    {% include('base/_turbo_flashing.html.j2') %}
{% endblock %}

and styling for fadeout animation:

.turbo-flash{
     animation:fadeout0.5s 1;
    -webkit-animation:fadeout 0.5s 1;
    animation-fill-mode: forwards;

    animation-delay:2s;
    -webkit-animation-delay:1s; /* Safari and Chrome */
    -webkit-animation-fill-mode: forwards;

} 

@keyframes fadeout{
    from {opacity :1;}
    to {opacity :0;}
}

@-webkit-keyframes fadeout{
    from {opacity :1;}
    to {opacity :0;}
}
janpeterka commented 3 years ago

I was thinking how this could be more automatic, here's some thought:

Could every turbo.stream automatically check for messages with get_flashed_messages, and if there are messages, it would replace flashing part of page (set in config, with some reasonable defaults like FLASHING_TEMPLATE='flashing.j2' and FLASHING_ID="flashing", and delete messages from session.

miguelgrinberg commented 3 years ago

@janmpeterka this is something that I think would be hard to generalize, but in your own application you can add an after_request handler that checks for a turbo stream response and in that case appends the rendered alert as an additional section in the stream.

janpeterka commented 3 years ago

Thanks for answer! You are right, it doesn't make much sense to add this here. I didn't think of after_request, that sounds like a great way to solve this :) I will add some code here for reference.

tnaescher commented 3 years ago

I will add some code here for reference.

@janmpeterka Did you already find time to add the code? Looking at the same problem right now and would be interested in seeing how you worked around the problem! Thank you :)

janpeterka commented 3 years ago

@tnaescher I tried that, but for some reason I wasn't able to make it run correctly :( No time now (exams ahead), I will probably look into it later (~ in two weeks).

Here's my WIP code:

@application.after_request
def flash_if_turbo(response):
    from app import turbo
    from flask import render_template as template
    from flask import session, get_flashed_messages

    # if turbo
    if response.headers["Content-Type"] == "text/vnd.turbo-stream.html; charset=utf-8":
        messages = get_flashed_messages(with_categories=True)
        session.pop("_flashes", None)

        if messages:
            flash_turbo_response = turbo.replace(
                template("base/_flashing.html.j2", messages=messages), target="flashing"
            )
            response.response.append(flash_turbo_response)

    return response

I also stumbled on a thing I don't understand - elements in response.response list (turbo streams) are sometimes string and sometimes bytes type. Not sure if it matters, just weird for me.

janpeterka commented 2 years ago

Ok, here is my (hopefully) working code, if anyone wants to solve this in their applications.

So, in my controller, I want to call flash. In my code, I do this:

    @route("/toggle-reaction", methods=["POST"])
    def toggle_reaction():
       (...)

        turbo_flash("Reaction was noted.", "success")

       # return either turbo.stream or normal response

The important new part here is my function turbo_flash. How does it work?

def turbo_flash(message, category=None):
    if turbo.can_stream():
        save_flash_to_session(message, category)
    else:
        flash(message, category)

So, it checks if turbo is active, and if not, it changes it to normal flash, nothing interesting there.
But if turbo is applicable, we instead save our message to session like this:

def save_flash_to_session(message, category):
    session["turbo_flash_message"] = message
    session["turbo_flash_message_category"] = category

Easy, right?

Ok, we have our message in session, what now? We will check on the end of each request if there's some flash message in our session:


@application.after_request
def flash_if_message(response):
    from flask import session, flash
    from app.helpers.turbo_flash import turbo_flash_partial, remove_flash_from_session

   # we try to load our message from session..
    message = session.get("turbo_flash_message", None)
    category = session.get("turbo_flash_message_category", "info")

    if message:
       # we check if our response is turbo (meaning we used return turbo.stream in controller) 
        if _is_turbo_response(response):
           # if it is, we append html to our response. It replaces our typical flash template (shown later)
            turbo_flash = turbo_flash_partial(message, category)
            remove_flash_from_session()
            response.response.append(turbo_flash)

        else:
           # if it is not (we used redirect in controller for example), we turn it into normal flash.
            flash(message, category)
            remove_flash_from_session()

    return response

def _is_turbo_response(response) -> bool:
    return (
        response.headers["Content-Type"] == "text/vnd.turbo-stream.html; charset=utf-8"
    )

def turbo_flash_partial(message, category):
    return turbo.replace(
        template("base/_flashing.html.j2", messages=[(category, message)]),
        target="flashes",
    )

So,

Note: I don't guarantee it working in all cases. For example, if you split your page with turbo-frames, it might be possible that your flashing area is not replaced (if you dont target _top). This probably might be solved easily, but as I was working for solution on my app, I didn't solve that.


Here are complete codes, I didn't include some parts to simplify my thought process and workflow:

# app.helpers.turbo_flash.py

from flask import session, flash
from flask import render_template as template

from app import turbo

def turbo_flash(message, category=None):
    if turbo.can_stream():
        save_flash_to_session(message, category)
    else:
        flash(message, category)

def turbo_flash_partial(message, category):
    return turbo.replace(
        template("base/_flashing.html.j2", messages=[(category, message)]),
        target="flashes",
    )

def save_flash_to_session(message, category):
    session["turbo_flash_message"] = message
    session["turbo_flash_message_category"] = category

def remove_flash_from_session():
    session.pop("turbo_flash_message")
    session.pop("turbo_flash_message_category")

def is_flash_in_session() -> bool:
    return "turbo_flash_message" in session
# myapplication.py

@application.after_request
def flash_if_message(response):
    from flask import session, flash
    from app.helpers.turbo_flash import turbo_flash_partial, remove_flash_from_session

    message = session.get("turbo_flash_message", None)
    category = session.get("turbo_flash_message_category", "info")

    if message:
        if _is_turbo_response(response):
            turbo_flash = turbo_flash_partial(message, category)
            remove_flash_from_session()
            response.response.append(turbo_flash)

        else:
            flash(message, category)
            remove_flash_from_session()

    return response

def _is_turbo_response(response) -> bool:
    return (
        response.headers["Content-Type"] == "text/vnd.turbo-stream.html; charset=utf-8"
    )
<!-- base/_flashing.html.j2 -->

<div id="flashes" class="flashes container alert alert-light mt-5 animate-fadeout" role="alert">
  <ul id="flashes_list">
  {% for category, message in messages %}
    <li class='{{ category }}'> {{ message }} </li>
  {% endfor %}
  </ul>
</div>
EpicNoxious commented 2 years ago

Ok, so I am facing an issue on this flash even after trying everything, still my app isn't working. Maybe I am making a silly mistake here. I am making a Sign Up Sign In using Flask and MongoDB. Please help me out. Thanks in advance :)

main.py

from flask import Flask, render_template, request, flash, session
from pymongo import MongoClient
from forms import SignUp, SignIn
from turbo_flask import Turbo

app = Flask(__name__)
turbo = Turbo(app)
app.secret_key = 'Login System'
cluster = "mongodb://localhost:27017"
client = MongoClient(cluster)
db = client['practice']
login = db.login

def turbo_flash(message, category=None):
    if turbo.can_stream():
        save_flash_to_session(message, category)
    else:
        flash(message, category)

def turbo_flash_partial(message, category):
    return turbo.replace(
        render_template("base/_flashing.html.j2", messages=[(category, message)]),
        target="flashes",
    )

def save_flash_to_session(message, category):
    session["turbo_flash_message"] = message
    session["turbo_flash_message_category"] = category

def remove_flash_from_session():
    session.pop("turbo_flash_message")
    session.pop("turbo_flash_message_category")

def is_flash_in_session() -> bool:
    return "turbo_flash_message" in session

@app.after_request
def flash_if_message(response):

    message = session.get("turbo_flash_message", None)
    category = session.get("turbo_flash_message_category", "info")

    if message:
        if _is_turbo_response(response):
            turbo_flash = turbo_flash_partial(message, category)
            remove_flash_from_session()
            response.response.append(turbo_flash)

        else:
            flash(message, category)
            remove_flash_from_session()

    return response

def _is_turbo_response(response) -> bool:
    return (
        response.headers["Content-Type"] == "text/vnd.turbo-stream.html; charset=utf-8"
    )

@app.route("/", methods=['GET', 'POST'])
def index():
    signup = SignUp()
    signin = SignIn()
    if signup.signup.data and signup.validate():
        print('Sign Up')
        name = request.form['name']
        email = request.form['email']
        password = request.form['password']
        confirm_password = request.form['confirm_password']

        if password != confirm_password:
            turbo_flash("Passwords don't match")
        else:
            dict = {'name': name, 'email': email, 'password': password, 'confirm_password': confirm_password}
            login.insert_one(dict)
            turbo_flash("User Added")

    if signin.signin.data and signin.validate():
        print('Sign In')
        email = request.form['email']
        password = request.form['password']
        data = login.find_one({'email': email})
        if data is None:
            turbo_flash("No such email exist")
        elif password != data['password']:
            turbo_flash('Password is wrong', "success")
        else:
            turbo_flash("User Logged In")

    return render_template("sign_up_in.html", signup=signup, signin=signin)

if __name__ == "__main__":
    app.run(use_reloader=True)

forms.py

from flask_wtf import FlaskForm
from wtforms.validators import DataRequired, Email
from wtforms import StringField, SubmitField, PasswordField

class SignUp(FlaskForm):
    name = StringField("Name", validators=[DataRequired()])
    email = StringField("Email", validators=[DataRequired(), Email()])
    password = PasswordField("Password", validators=[DataRequired()])
    confirm_password = PasswordField("Confirm Password", validators=[DataRequired()])
    signup = SubmitField("Sign Up")

class SignIn(FlaskForm):
    email = StringField("Email", validators=[DataRequired(), Email()])
    password = PasswordField("Password", validators=[DataRequired()])
    signin = SubmitField("Sign In")

sign_up_in.html

<!DOCTYPE html>
<html lang="en">
<head>

<meta charset="UTF-8">
<title>Sign Up OR Sign In</title>
<style>
    .turbo-flash{
     animation:fadeout0.5s 1;
    -webkit-animation:fadeout 0.5s 1;
    animation-fill-mode: forwards;

    animation-delay:2s;
    -webkit-animation-delay:1s; /* Safari and Chrome */
    -webkit-animation-fill-mode: forwards;

}

@keyframes fadeout{
    from {opacity :1;}
    to {opacity :0;}
}

@-webkit-keyframes fadeout{
    from {opacity :1;}
    to {opacity :0;}
}
</style>

{{ turbo() }}

</head>
<body>

<div id="flashes" class="flashes container alert alert-light mt-5 animate-fadeout" style="width: 100px; height: 100px" role="alert">
  <ul id="flashes_list">
  {% for category, message in messages %}
    <li class='{{ category }}'> {{ message }} </li>
  {% endfor %}
  </ul>
</div>
<h1>Sign Up Form</h1>
<form name="signup_form" method="POST">
    <div>
        {{ signup.csrf_token }}
        {{ signup.name.label.text }}: {{ signup.name }}
        {{ signup.email.label.text }}: {{ signup.email }}
        {{ signup.password.label.text }}: {{ signup.password }}
        {{ signup.confirm_password.label.text }}: {{ signup.confirm_password }}
        {{ signup.signup }}
    </div>
</form>

<br>
<br>

<h1>Sign In Form</h1>
<form name="signin_form" method="POST">
    <div>
        {{ signin.csrf_token }}
        {{ signin.email.label.text }}: {{ signin.email }}
        {{ signin.password.label.text }}: {{ signin.password }}
        {{ signin.signin }}
    </div>
</form>

</body>
</html>
miguelgrinberg commented 2 years ago

I have now added a small example in this repo that shows how to flash a message in a turbo-stream response. It is actually simpler then the code shown above. https://github.com/miguelgrinberg/turbo-flask/tree/main/examples/flash

janpeterka commented 2 years ago

nice, thank you! but this still doesn't solve flashing with turbo-frame.

I went another route for that - I created Stimulus controller that is attached to body and on frame-render/stream-render calls /flashing endpoint that loads messages. its somewhat cumbersome, but I didn't find better way how to solve this for all possible ways page changes.

i will add code for reference later, cannot do that now.

miguelgrinberg commented 2 years ago

Correct, this solution is for turbo-stream. To solve for turbo-frame, include the flash area in your frame.

I went another route for that - I created Stimulus controller that is attached to body and on frame-render/stream-render calls /flashing endpoint that loads messages. its somewhat cumbersome, but I didn't find better way how to solve this for all possible ways page changes.

This sounds way more complicated than making your frame large enough to contain the part of the page that includes your flash.

janpeterka commented 2 years ago

sure, you can always target whole page with _top, but it sort of defeats a purpose of turbo-frames, doesn't it? and you have to explicitly call _top ewerywhere.

or you can just hook a bit of logic tu frame-render to always re-render flashing. I don't like that there needs to be separate logic for normal load, frame load and stream, but still it seems like possibly the most clean solution.

miguelgrinberg commented 2 years ago

sure, you can always target whole page with _top, but it sort of defeats a purpose of turbo-frames, doesn't it? and you have to explicitly call _top ewerywhere.

That's not what I'm saying. If you have a turbo frame for the content area of your page, then including the alerts inside this frame will resolve the issue completely, without you having to do anything differently.

If including the alerts in the turbo frame isn't feasible, then the solution I'm showing in the example linked above, which pushes the update directly to the alert section is also fairly straightforward.

What case(s) aren't covered by the two solutions I proposed? The whole point of turbo.js is avoiding the use of JS, so why would you resort to a JS solution when there are no-JS solution available to you?

janpeterka commented 2 years ago

sorry, didn't realize you are suggesting including flash in my frame. that's possible, but as I want to have one place with flashes, not what I'm looking for.

your example works only with Streams, doesn't it? I can use Push, but then I have to depend on WebSockets.

of course, if I can avoid writing JS, I will 😅 (even though Stimulus is pretty neat).