Skip to content

Commit c8d9ee2

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 1cef698 + b67ef76 commit c8d9ee2

15 files changed

Lines changed: 334 additions & 76 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Memory Index
2+
3+
- [feedback_red_green_testing.md](feedback_red_green_testing.md) — Always write a failing test before fixing a bug
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
name: Red-green testing discipline
3+
description: Always write a failing test (red phase) before applying a bug fix so the fix is validated
4+
type: feedback
5+
---
6+
7+
Always write a failing test that surfaces the bug before applying the fix. Running tests after a fix without a red-phase test doesn't prove anything.
8+
9+
**Why:** The user expects disciplined red-green-refactor workflow. A test that only exists after the fix could be passing for the wrong reason.
10+
11+
**How to apply:** When fixing a bug, first add or update a test that fails with the current code, confirm it fails, then apply the fix and confirm it passes.

main.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
require_unauthenticated_client
1414
)
1515
from utils.core.auth import COOKIE_SECURE
16-
from utils.core.htmx import is_htmx_request, toast_response
16+
from utils.core.htmx import is_htmx_request, toast_response, get_flash_cookie, FLASH_COOKIE_NAME
1717
from exceptions.http_exceptions import (
1818
AlreadyAuthenticatedError,
1919
AuthenticationError,
@@ -47,6 +47,22 @@ async def lifespan(app: FastAPI):
4747
templates = Jinja2Templates(directory="templates")
4848

4949

50+
# --- Flash cookie middleware ---
51+
# Reads the flash cookie into request.state so templates can render it
52+
# server-side, then clears the cookie on the response.
53+
54+
55+
@app.middleware("http")
56+
async def flash_cookie_middleware(request: Request, call_next):
57+
flash = get_flash_cookie(request)
58+
request.state.flash = flash
59+
response = await call_next(request)
60+
if flash:
61+
response.delete_cookie(FLASH_COOKIE_NAME, path="/")
62+
return response
63+
64+
65+
5066
# --- Include Routers ---
5167

5268

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "0.1.17"
3+
version = "0.1.18"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

routers/core/user.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ async def update_profile(
116116

117117
# Handle avatar update
118118
if avatar_changed:
119+
assert avatar_file is not None
119120
avatar_data = await avatar_file.read()
120121
avatar_content_type = avatar_file.content_type
121122

@@ -141,17 +142,21 @@ async def update_profile(
141142
session.refresh(user)
142143

143144
if is_htmx_request(request):
144-
if avatar_changed:
145-
# Avatar affects the navbar, which is outside the swap target.
146-
# Tell HTMX to do a full page refresh so everything updates.
147-
response = Response(status_code=200)
148-
response.headers["HX-Refresh"] = "true"
149-
return response
150145
response = templates.TemplateResponse(
151146
request,
152147
"users/partials/profile_display.html",
153148
{"user": user},
154149
)
150+
if avatar_changed:
151+
# Avatar also appears in the navbar — append an OOB swap for it.
152+
navbar_html = bytes(templates.TemplateResponse(
153+
request,
154+
"base/partials/navbar_avatar_oob.html",
155+
{"user": user},
156+
).body).decode()
157+
original = bytes(response.body).decode()
158+
response.body = (original + navbar_html).encode()
159+
response.headers["content-length"] = str(len(response.body))
155160
return append_toast(response, request, templates, "Profile updated successfully.")
156161
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
157162

static/js/app.js

Lines changed: 11 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,19 @@
33
// re-processed during htmx hx-boost body swaps, which means the event
44
// listeners registered here persist across page navigations.
55

6-
function showToast(message, level) {
7-
level = level || 'success';
8-
var container = document.getElementById('toast-container');
9-
var wrapper = document.createElement('div');
10-
wrapper.className = 'toast align-items-center text-bg-' + level + ' border-0 show';
11-
wrapper.setAttribute('role', 'alert');
12-
wrapper.setAttribute('aria-atomic', 'true');
13-
wrapper.innerHTML =
14-
'<div class="d-flex">' +
15-
'<div class="toast-body">' + message + '</div>' +
16-
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
17-
'</div>';
18-
container.appendChild(wrapper);
19-
setTimeout(function() { wrapper.remove(); }, 5000);
20-
}
21-
22-
// For HTMX error responses, extract and apply OOB swaps (toasts) without
23-
// touching the main target. We parse the response HTML, find elements with
24-
// hx-swap-oob, and swap them in manually via htmx.process().
25-
document.body.addEventListener('htmx:beforeSwap', function(evt) {
26-
if (evt.detail.xhr.status >= 400) {
27-
evt.detail.shouldSwap = false;
28-
evt.detail.isError = false;
29-
var responseText = evt.detail.xhr.responseText;
30-
if (responseText) {
31-
var doc = new DOMParser().parseFromString(responseText, 'text/html');
32-
var oobElements = doc.querySelectorAll('[hx-swap-oob]');
33-
oobElements.forEach(function(el) {
34-
var targetId = el.getAttribute('id');
35-
if (targetId) {
36-
var existing = document.getElementById(targetId);
37-
if (existing) {
38-
existing.replaceWith(el);
39-
htmx.process(el);
40-
}
41-
}
42-
});
43-
}
6+
// Configure HTMX to process response bodies on error status codes so that
7+
// OOB-swapped toasts are applied. swapOverride:'none' ensures the main
8+
// target is left untouched while OOB elements are still processed.
9+
document.body.addEventListener('htmx:configRequest', function() {
10+
if (!htmx.config.responseHandling.find(function(r) { return r.code === '400'; })) {
11+
htmx.config.responseHandling = [
12+
{ code: '204', swap: false },
13+
{ code: '[23]..', swap: true },
14+
{ code: '[45]..', swap: true, error: false, swapOverride: 'none' },
15+
];
4416
}
45-
});
17+
}, { once: true });
4618

47-
// Read flash cookie on page load
48-
(function() {
49-
var raw = document.cookie.split('; ').find(function(c) { return c.startsWith('flash_message='); });
50-
if (!raw) return;
51-
var value = decodeURIComponent(raw.split('=').slice(1).join('='));
52-
document.cookie = 'flash_message=; Max-Age=0; path=/';
53-
try {
54-
var flash = JSON.parse(value);
55-
if (flash && flash.message) showToast(flash.message, flash.level);
56-
} catch(e) {}
57-
})();
5819

5920
// Global handler: when a server response includes HX-Trigger: modalDismiss,
6021
// clean up any Bootstrap modal backdrop left behind by OOB swaps that

templates/base.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,26 @@
2121
order and waits for DOM parsing to complete. -->
2222
<script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
2323
<script defer src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
24+
<script defer src="https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js"></script>
2425
<script defer src="{{ url_for('static', path='js/app.js') }}"></script>
2526
{% block extra_head %}{% endblock %}
2627
</head>
2728
<body class="min-vh-100 d-flex flex-column">
2829
<div id="toast-container"
2930
class="toast-container position-fixed bottom-0 end-0 p-3"
31+
hx-ext="remove-me"
3032
aria-live="polite">
33+
{% set flash = request.state.flash %}
34+
{% if flash %}
35+
<div class="toast align-items-center text-bg-{{ flash.level|default('success') }} border-0 show"
36+
role="alert" aria-atomic="true" remove-me="5s">
37+
<div class="d-flex">
38+
<div class="toast-body">{{ flash.message }}</div>
39+
<button type="button" class="btn-close btn-close-white me-2 m-auto"
40+
data-bs-dismiss="toast" aria-label="Close"></button>
41+
</div>
42+
</div>
43+
{% endif %}
3144
</div>
3245

3346
<header>

templates/base/partials/header.html

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{% from 'base/macros/logo.html' import render_logo %}
2-
{% from 'base/macros/silhouette.html' import render_silhouette %}
32

43

54
<header class="navbar navbar-expand-lg navbar-light bg-light" hx-boost="true">
@@ -35,13 +34,7 @@
3534
<ul class="navbar-nav ms-auto mb-lg-0 d-none d-lg-flex">
3635
<li class="nav-item dropdown">
3736
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
38-
<button class="profile-button btn p-0 border-0 bg-transparent">
39-
{% if user.avatar %}
40-
<img src="{{ url_for('get_avatar') }}?v={{ range(1000000)|random }}" alt="User Avatar" class="d-inline-block align-top" width="30" height="30" style="border-radius: 50%;">
41-
{% else %}
42-
{{ render_silhouette() }}
43-
{% endif %}
44-
</button>
37+
{% include 'base/partials/navbar_avatar.html' %}
4538
</a>
4639
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
4740
<li><a class="dropdown-item" href="{{ url_for('read_profile') }}">Profile</a></li>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{# Navbar avatar button — swappable via OOB when avatar changes. #}
2+
{% from 'base/macros/silhouette.html' import render_silhouette %}
3+
<button id="navbar-avatar" class="profile-button btn p-0 border-0 bg-transparent">
4+
{% if user.avatar %}
5+
<img src="{{ url_for('get_avatar') }}?v={{ range(1000000)|random }}" alt="User Avatar" class="d-inline-block align-top" width="30" height="30" style="border-radius: 50%;">
6+
{% else %}
7+
{{ render_silhouette() }}
8+
{% endif %}
9+
</button>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{# OOB swap for the navbar avatar after avatar update. #}
2+
{% from 'base/macros/silhouette.html' import render_silhouette %}
3+
<button id="navbar-avatar" hx-swap-oob="true" class="profile-button btn p-0 border-0 bg-transparent">
4+
{% if user.avatar %}
5+
<img src="{{ url_for('get_avatar') }}?v={{ range(1000000)|random }}" alt="User Avatar" class="d-inline-block align-top" width="30" height="30" style="border-radius: 50%;">
6+
{% else %}
7+
{{ render_silhouette() }}
8+
{% endif %}
9+
</button>

0 commit comments

Comments
 (0)