Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 17 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ---


Expand Down
17 changes: 11 additions & 6 deletions routers/core/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
61 changes: 11 additions & 50 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<div class="d-flex">' +
'<div class="toast-body">' + message + '</div>' +
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
'</div>';
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
Expand Down
13 changes: 13 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,26 @@
order and waits for DOM parsing to complete. -->
<script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js"></script>
<script defer src="{{ url_for('static', path='js/app.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="min-vh-100 d-flex flex-column">
<div id="toast-container"
class="toast-container position-fixed bottom-0 end-0 p-3"
hx-ext="remove-me"
aria-live="polite">
{% set flash = request.state.flash %}
{% if flash %}
<div class="toast align-items-center text-bg-{{ flash.level|default('success') }} border-0 show"
role="alert" aria-atomic="true" remove-me="5s">
<div class="d-flex">
<div class="toast-body">{{ flash.message }}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endif %}
</div>

<header>
Expand Down
9 changes: 1 addition & 8 deletions templates/base/partials/header.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{% from 'base/macros/logo.html' import render_logo %}
{% from 'base/macros/silhouette.html' import render_silhouette %}


<header class="navbar navbar-expand-lg navbar-light bg-light" hx-boost="true">
Expand Down Expand Up @@ -35,13 +34,7 @@
<ul class="navbar-nav ms-auto mb-lg-0 d-none d-lg-flex">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<button class="profile-button btn p-0 border-0 bg-transparent">
{% if user.avatar %}
<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%;">
{% else %}
{{ render_silhouette() }}
{% endif %}
</button>
{% include 'base/partials/navbar_avatar.html' %}
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="{{ url_for('read_profile') }}">Profile</a></li>
Expand Down
9 changes: 9 additions & 0 deletions templates/base/partials/navbar_avatar.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{# Navbar avatar button — swappable via OOB when avatar changes. #}
{% from 'base/macros/silhouette.html' import render_silhouette %}
<button id="navbar-avatar" class="profile-button btn p-0 border-0 bg-transparent">
{% if user.avatar %}
<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%;">
{% else %}
{{ render_silhouette() }}
{% endif %}
</button>
9 changes: 9 additions & 0 deletions templates/base/partials/navbar_avatar_oob.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{# OOB swap for the navbar avatar after avatar update. #}
{% from 'base/macros/silhouette.html' import render_silhouette %}
<button id="navbar-avatar" hx-swap-oob="true" class="profile-button btn p-0 border-0 bg-transparent">
{% if user.avatar %}
<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%;">
{% else %}
{{ render_silhouette() }}
{% endif %}
</button>
3 changes: 2 additions & 1 deletion templates/base/partials/toast.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
<div id="toast-container"
class="toast-container position-fixed bottom-0 end-0 p-3"
hx-swap-oob="true"
hx-ext="remove-me"
aria-live="polite">
<div class="toast align-items-center text-bg-{{ level|default('danger') }} border-0 show"
role="alert" aria-atomic="true">
role="alert" aria-atomic="true" remove-me="5s">
<div class="d-flex">
<div class="toast-body">{{ message }}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
Expand Down
24 changes: 20 additions & 4 deletions templates/users/partials/profile_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@
</form>
</div>
<script>
function _clientToast(message, level) {
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.setAttribute('remove-me', '5s');
wrapper.innerHTML =
'<div class="d-flex">' +
'<div class="toast-body">' + message + '</div>' +
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
'</div>';
container.appendChild(wrapper);
htmx.process(wrapper);
}

document.getElementById('avatar_file').addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
Expand All @@ -48,13 +64,13 @@

var fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSizeMB) {
showToast('File size must be less than ' + maxSizeMB + 'MB', 'danger');
_clientToast('File size must be less than ' + maxSizeMB + 'MB', 'danger');
this.value = '';
return;
}

if (allowedFormats.indexOf(file.type) === -1) {
showToast('File format must be one of: ' + allowedFormats.join(', '), 'danger');
_clientToast('File format must be one of: ' + allowedFormats.join(', '), 'danger');
this.value = '';
return;
}
Expand All @@ -65,12 +81,12 @@
img.onload = function() {
URL.revokeObjectURL(this.src);
if (this.width < minDim || this.height < minDim) {
showToast('Image dimensions must be at least ' + minDim + 'x' + minDim + ' pixels', 'danger');
_clientToast('Image dimensions must be at least ' + minDim + 'x' + minDim + ' pixels', 'danger');
input.value = '';
return;
}
if (this.width > maxDim || this.height > maxDim) {
showToast('Image dimensions must not exceed ' + maxDim + 'x' + maxDim + ' pixels', 'danger');
_clientToast('Image dimensions must not exceed ' + maxDim + 'x' + maxDim + ' pixels', 'danger');
input.value = '';
return;
}
Expand Down
Loading
Loading