Flask, HTMX e alertas
HTMX é uma biblioteca poderosa para adicionar interatividade em aplicações tradicionais. Ao adicionar atributos em elementos, permite a manipulação do DOM com requisições contendo fragmentos HTML, event triggers e até WebSockets. Inspirado pelo artigo do Benoît Blanchon sobre como utilizar HTMX com o framework de messages do Django, resolvi adaptar essa lógica para o Flask, utilizando triggers e a função utilitária flash
.
Boilerplate
Uma das vantagens (e desvantagens) do Flask é que podemos criar tudo em um único arquivo app.py
, facilitando
esse breve tutorial. Os passos abaixo foram reproduzidos com Python 3.12:
python -m venv .venv --upgrade-deps
source .venv/bin/activate
echo "Flask==3.0.3" >> requirements.txt
pip install -r requirements.txt
Crie o arquivo principal app.py
e faça o boilerplate de uma aplicação Flask:
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
Crie um arquivo templates/index.html
:
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask + HTMX + flash</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<section>
<h1>Olá mundo!</h1>
</section>
<div id="alerts"></div>
</body>
</html>
Adicione uma estilização básica em static/css/style.css
:
body {
font-family: system-ui, sans-serif;
max-width: 1024px;
min-height: 100vh;
margin: 0 auto;
background-color: rgb(249 250 251);
display: grid;
place-items: center;
}
section {
padding: 40px 20px;
width: 480px;
text-align: center;
}
h1 {
font-size: 48px;
margin: 0 0 20px 0;
}
button {
padding: 10px 20px;
outline: none;
color: #fff;
background-color: rgb(29 78 216);
cursor: pointer;
font-family: inherit;
border: none;
border-radius: 6px;
font-size: 1em;
font-weight: 600;
margin: 10px 0;
}
button:hover {
background-color: rgb(30 64 175);
}
E um caloroso olá do nosso arquivo static/js/main.js
:
console.log('Olá mundo 👋️')
No fim dessa etapa, a estrutura do projeto deve ser:
.
├── app.py
├── static
│ ├── js
│ │ └── main.js
│ └── css
│ └── style.css
└── templates
└── index.html
Inicie o servidor de desenvolvimento com flask run --debug
, acesse http://localhost:5000
e garanta que a aplicação está funcionando corretamente.
HTMX
Para trabalhar com a biblioteca, precisamos importar ela no nosso templates/index.html
:
<!-- dentro da tag <head> -->
<script src="https://unpkg.com/htmx.org@2.0.1" integrity="sha384-QWGpdj554B4ETpJJC9z+ZHJcA/i59TyjxEPXiiUgN2WmTyV5OEZWCD6gQhgkdpB/" crossorigin="anonymous"></script>
<!-- </head> -->
E podemos fazer um protótipo de um alerta, retornando-o como HTML em uma chamada
à um endpoint. Adicione no arquivo app.py
:
import random
...
@app.route('/alert')
def alert():
level = random.choice(['info', 'error', 'success', 'warning'])
return f'''
<div class="alert is-{level}">
<strong>{level.upper()}</strong> Esse é um alerta vindo do Flask!
</div>'''
E a chamada no arquivo templates/index.html
:
<body>
<section>
<h1>Olá mundo</h1>
<button hx-get="{{ url_for('alert') }}" hx-target="#alerts">
Criar um alerta aleatório
</button>
</section>
<div id="alerts"></div>
<body>
Também vamos adicionar uma estilização para os alertas em static/css/style.css
:
/** estilos anteriores **/
#alerts {
position: fixed;
z-index: 10;
left: 0;
right: 0;
top: 5px;
max-width: 480px;
margin: 0 auto;
}
.is-info {
--alert-color: rgb(30 64 175);
--alert-background-color: rgb(239 246 255);
}
.is-error {
--alert-color: rgb(153 27 27);
--alert-background-color: rgb(254 242 242);
}
.is-warning {
--alert-color: rgb(133 77 14);
--alert-background-color: rgb(254 252 232);
}
.is-success {
--alert-color: rgb(22 101 52);
--alert-background-color: rgb(240 253 244);
}
.alert {
margin: 10px 0;
background-color: var(--alert-background-color);
color: var(--alert-color);
border: 2px dashed var(--alert-color);
border-radius: 10px;
padding: 22px 24px;
max-width: 480px;
}
Ao clicar no botão já teremos nosso alerta aleatório aparecendo no canto superior da tela. Apesar de ser próximo do que queremos, ainda não é interativo o suficiente:
- Temos que construir o HTML no servidor
- O alerta está fixo no DOM
Para adicionarmos mais interatividade, precisamos de um pouco de JavaScript e dos Event Triggers do HTMX.
JS e Event Triggers
Ao invés de enviarmos o HTML do servidor, podemos criar ele no lado do cliente com JavaScript ao ouvir um evento no corpo
do documento. A lógica em static/js/main.js
será:
class Alert {
#ALERT_DURATION = 3000
constructor(category, message) {
this.category = category
this.message = message
}
#createAlertElement() {
const alert = document.createElement('div')
alert.className = `alert is-${this.category}`
alert.textContent = this.message
return alert
}
show(queryContainer = '#alerts') {
const alertsContainer = document.querySelector(queryContainer)
if (!alertsContainer) {
console.error('Não encontrou o container de alerts!')
return
}
const alert = this.#createAlertElement()
alertsContainer.appendChild(alert)
setTimeout(() => alert.remove(), this.#ALERT_DURATION)
}
}
document.body.addEventListener('alerts', (event) => {
const alerts = event.detail.value
alerts.forEach(([category, message]) => {
const alert = new Alert(category, message)
alert.show()
})
})
E no lado do servidor, em app.py
nós vamos criar um trigger para o HTMX:
import random
import json
from flask import Flask, render_template, make_response
...
@app.route('/alert')
def alert():
level = random.choice(['info', 'error', 'success', 'warning'])
response = make_response()
response.headers['HX-Trigger'] = json.dumps({
'alerts': [('info', 'Esse é um alerta vindo do HX-Trigger!')]
})
return response
A última modificação é retirar o atributo hx-target
e adicionarmos o atributo
hx-swap: none
, visto que a resposta do servidor sempre será vazia e não queremos
fazer o swap do conteúdo.
<!-- dentro de <section> -->
<button hx-get="{{ url_for('alert') }}" hx-swap="none">
Criar um alerta aleatório
</button>
Flash e Middlewares no Flask
Com essa lógica, toda vez que precisarmos de uma notificação no cliente basta enviar uma resposta com o trigger do HTMX, mas o processo pode se tornar repetitivo.
Por sorte, o Flask
vem com uma função utilitária para mostrar alertas ao usuário, o flash
. Essa função
salva a mensagem e a categoria dela dentro do cookie de sessão, mas podemos criar um “middleware” na resposta do
Flask para coletar os dados salvos pelo flash
e transformarmos em um trigger para o HTMX.
No arquivo app.py
, adicione:
import random
import json
from flask import Flask, render_template, make_response, session, flash
app = Flask(__name__)
app.secret_key = '<super-secret>'
@app.after_request
def flash_to_htmx_trigger(response):
"""Middleware que converte flashes para HX-Trigger."""
flashes = session.pop('_flashes') if '_flashes' in session else []
if not flashes:
return response
trigger_content = {'alerts': flashes}
response.headers['HX-Trigger'] = json.dumps(trigger_content)
return response
...
@app.route('/alert')
def alert():
level = random.choice(['info', 'error', 'success', 'warning'])
flash('Esse é um alerta vindo do HX-Trigger!', level)
# No caso só queremos o alerta, mas temos que retornar um corpo vazio para
# ser uma função de rota válida. Agora 'flash' pode ser chamado em qualquer
# rota e um alerta sempre vai ser criado com a mensagem.
return ''
Evitando problemas futuros
A funcionalidade está quase pronta, mas precisamos melhorar o nosso middleware. O que acontece se durante uma
outra função de rota adicionarmos um outro trigger à resposta? O middleware vai sobrescrever o header HX-Request
e
um bug vai surgir na sua aplicação.
O header HX-Trigger
pode receber uma string (que pode ser dividida por vírgulas) ou um objeto. Vamos modificar
nossa função flash_to_htmx_trigger
para levar isso em conta. Em app.py
:
@app.after_request
def flash_to_htmx_trigger(response):
"""Middleware que converte flashes para HX-Trigger."""
flashes = session.pop('_flashes') if '_flashes' in session else []
if not flashes:
return response
trigger_content = {'alerts': flashes}
if hx_header := response.headers.get('HX-Trigger'):
try:
existing_trigger_content = json.loads(hx_header)
except json.JSONDecodeError:
# É uma string, criar objeto vazio tendo ela como chave.
for key in hx_header.split(','):
trigger_content[key.strip()] = {}
else:
# É um objeto, fazer o merge com o nosso.
if isinstance(existing_trigger_content, list):
raise TypeError('HX-Trigger does not support array')
trigger_content |= existing_trigger_content
response.headers['HX-Trigger'] = json.dumps(trigger_content)
return response
Com isso temos nosso alertas dinâmicos! Ainda há espaço para melhorias:
- O que acontece se tivermos muitos alertas?
- Como adicionar uma animação na remoção do alerta?
- Como tratar requisições falhas com HTMX que deveriam retornar uma resposta com o trigger?
E claro, testes unitários!
Os códigos podem ser acessados no repositório no Github.