diff --git a/.claude/projects/-home-chriscarrollsmith-Documents-Software-Websites-fastapi-jinja2-postgres-webapp/memory/MEMORY.md b/.claude/projects/-home-chriscarrollsmith-Documents-Software-Websites-fastapi-jinja2-postgres-webapp/memory/MEMORY.md new file mode 100644 index 0000000..8268c36 --- /dev/null +++ b/.claude/projects/-home-chriscarrollsmith-Documents-Software-Websites-fastapi-jinja2-postgres-webapp/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [feedback_red_green_testing.md](feedback_red_green_testing.md) — Always write a failing test before fixing a bug diff --git a/.claude/projects/-home-chriscarrollsmith-Documents-Software-Websites-fastapi-jinja2-postgres-webapp/memory/feedback_red_green_testing.md b/.claude/projects/-home-chriscarrollsmith-Documents-Software-Websites-fastapi-jinja2-postgres-webapp/memory/feedback_red_green_testing.md new file mode 100644 index 0000000..876a53f --- /dev/null +++ b/.claude/projects/-home-chriscarrollsmith-Documents-Software-Websites-fastapi-jinja2-postgres-webapp/memory/feedback_red_green_testing.md @@ -0,0 +1,11 @@ +--- +name: Red-green testing discipline +description: Always write a failing test (red phase) before applying a bug fix so the fix is validated +type: feedback +--- + +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. + +**Why:** The user expects disciplined red-green-refactor workflow. A test that only exists after the fix could be passing for the wrong reason. + +**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. diff --git a/main.py b/main.py index 375d9a3..3741b53 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ require_unauthenticated_client ) from utils.core.auth import COOKIE_SECURE -from utils.core.htmx import is_htmx_request, toast_response +from utils.core.htmx import is_htmx_request, toast_response, get_flash_cookie, FLASH_COOKIE_NAME from exceptions.http_exceptions import ( AlreadyAuthenticatedError, AuthenticationError, @@ -47,6 +47,22 @@ async def lifespan(app: FastAPI): templates = Jinja2Templates(directory="templates") +# --- Flash cookie middleware --- +# Reads the flash cookie into request.state so templates can render it +# server-side, then clears the cookie on the response. + + +@app.middleware("http") +async def flash_cookie_middleware(request: Request, call_next): + flash = get_flash_cookie(request) + request.state.flash = flash + response = await call_next(request) + if flash: + response.delete_cookie(FLASH_COOKIE_NAME, path="/") + return response + + + # --- Include Routers --- diff --git a/routers/core/user.py b/routers/core/user.py index 6d6e77c..e47ec9e 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -116,6 +116,7 @@ async def update_profile( # Handle avatar update if avatar_changed: + assert avatar_file is not None avatar_data = await avatar_file.read() avatar_content_type = avatar_file.content_type @@ -141,17 +142,21 @@ async def update_profile( session.refresh(user) if is_htmx_request(request): - if avatar_changed: - # Avatar affects the navbar, which is outside the swap target. - # Tell HTMX to do a full page refresh so everything updates. - response = Response(status_code=200) - response.headers["HX-Refresh"] = "true" - return response response = templates.TemplateResponse( request, "users/partials/profile_display.html", {"user": user}, ) + if avatar_changed: + # Avatar also appears in the navbar — append an OOB swap for it. + navbar_html = bytes(templates.TemplateResponse( + request, + "base/partials/navbar_avatar_oob.html", + {"user": user}, + ).body).decode() + original = bytes(response.body).decode() + response.body = (original + navbar_html).encode() + response.headers["content-length"] = str(len(response.body)) return append_toast(response, request, templates, "Profile updated successfully.") return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) diff --git a/static/js/app.js b/static/js/app.js index 2b60d96..8d66d4d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3,58 +3,19 @@ // re-processed during htmx hx-boost body swaps, which means the event // listeners registered here persist across page navigations. -function showToast(message, level) { - level = level || 'success'; - var container = document.getElementById('toast-container'); - var wrapper = document.createElement('div'); - wrapper.className = 'toast align-items-center text-bg-' + level + ' border-0 show'; - wrapper.setAttribute('role', 'alert'); - wrapper.setAttribute('aria-atomic', 'true'); - wrapper.innerHTML = - '
' + - '
' + message + '
' + - '' + - '
'; - container.appendChild(wrapper); - setTimeout(function() { wrapper.remove(); }, 5000); -} - -// For HTMX error responses, extract and apply OOB swaps (toasts) without -// touching the main target. We parse the response HTML, find elements with -// hx-swap-oob, and swap them in manually via htmx.process(). -document.body.addEventListener('htmx:beforeSwap', function(evt) { - if (evt.detail.xhr.status >= 400) { - evt.detail.shouldSwap = false; - evt.detail.isError = false; - var responseText = evt.detail.xhr.responseText; - if (responseText) { - var doc = new DOMParser().parseFromString(responseText, 'text/html'); - var oobElements = doc.querySelectorAll('[hx-swap-oob]'); - oobElements.forEach(function(el) { - var targetId = el.getAttribute('id'); - if (targetId) { - var existing = document.getElementById(targetId); - if (existing) { - existing.replaceWith(el); - htmx.process(el); - } - } - }); - } +// Configure HTMX to process response bodies on error status codes so that +// OOB-swapped toasts are applied. swapOverride:'none' ensures the main +// target is left untouched while OOB elements are still processed. +document.body.addEventListener('htmx:configRequest', function() { + if (!htmx.config.responseHandling.find(function(r) { return r.code === '400'; })) { + htmx.config.responseHandling = [ + { code: '204', swap: false }, + { code: '[23]..', swap: true }, + { code: '[45]..', swap: true, error: false, swapOverride: 'none' }, + ]; } -}); +}, { once: true }); -// Read flash cookie on page load -(function() { - var raw = document.cookie.split('; ').find(function(c) { return c.startsWith('flash_message='); }); - if (!raw) return; - var value = decodeURIComponent(raw.split('=').slice(1).join('=')); - document.cookie = 'flash_message=; Max-Age=0; path=/'; - try { - var flash = JSON.parse(value); - if (flash && flash.message) showToast(flash.message, flash.level); - } catch(e) {} -})(); // Global handler: when a server response includes HX-Trigger: modalDismiss, // clean up any Bootstrap modal backdrop left behind by OOB swaps that diff --git a/templates/base.html b/templates/base.html index 49e1fbf..cfdcfae 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,13 +21,26 @@ order and waits for DOM parsing to complete. --> + {% block extra_head %}{% endblock %}
+ {% set flash = request.state.flash %} + {% if flash %} + + {% endif %}
diff --git a/templates/base/partials/header.html b/templates/base/partials/header.html index 7509bef..b16c7e5 100644 --- a/templates/base/partials/header.html +++ b/templates/base/partials/header.html @@ -1,5 +1,4 @@ {% from 'base/macros/logo.html' import render_logo %} -{% from 'base/macros/silhouette.html' import render_silhouette %}