Add toast notification system
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 54s

Add more toast types
This commit is contained in:
2026-05-11 13:22:42 +02:00
parent 4e3b0ddb08
commit f82c61ef1e
18 changed files with 1026 additions and 109 deletions
+23
View File
@@ -291,3 +291,26 @@ def PurchasePrice(purchase) -> str:
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)
def Toast(
message: str = "",
type: str = "info",
attributes: list = [],
):
valid_types = ["success", "error", "info"]
if type not in valid_types:
type = "info"
safe_message = message.replace("\\", "\\\\").replace("`", "\\`")
safe_type = type.replace("\\", "\\\\").replace("`", "\\`")
return Component(
tag_name="div",
attributes=[
("class", "hidden"),
("x-data", "toastStore"),
("x-init", f"addToast(`{safe_message}`, `{safe_type}`)"),
]
+ attributes,
children=[message],
)
+6
View File
@@ -225,3 +225,9 @@ textarea:disabled {
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
}
}
+4
View File
@@ -1,6 +1,7 @@
from datetime import date, datetime
from typing import List
from django.contrib import messages
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
@@ -54,6 +55,7 @@ def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id)
setattr(game, "status", payload.status)
game.save()
messages.success(request, "Status updated")
return 204, None
@@ -65,6 +67,7 @@ def list_playevents(request):
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
messages.success(request, "Game played!")
return playevent
@@ -105,6 +108,7 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi
session = get_object_or_404(Session, id=session_id)
session.device_id = payload.device_id
session.save()
messages.success(request, "Device updated")
return 204, None
+59
View File
@@ -0,0 +1,59 @@
import json
from django.contrib import messages as django_messages
from django.contrib.messages import constants as message_constants
MESSAGE_LEVEL_MAP = {
message_constants.DEBUG: "debug",
message_constants.INFO: "info",
message_constants.SUCCESS: "success",
message_constants.WARNING: "warning",
message_constants.ERROR: "error",
}
class HTMXMessagesMiddleware:
"""
Converts Django messages into HX-Trigger headers so toasts display
automatically without changes to views.
Works for HTMX requests (processed natively by HTMX client),
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
for full-page loads (browsers ignore HX-Trigger).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
# so the message persists in the session for the redirect target page
if "HX-Redirect" in response:
return response
messages = list(django_messages.get_messages(request))
if not messages:
return response
triggers = []
for msg in messages:
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
triggers.append(
{
"message": msg.message,
"type": toast_type,
}
)
if triggers:
# Use last message (most recent) as the primary toast
trigger = triggers[-1]
response["HX-Trigger"] = json.dumps(
{
"show-toast": trigger,
}
)
return response
+258 -8
View File
@@ -30,6 +30,15 @@
--color-orange-800: oklch(47% 0.157 37.304);
--color-orange-900: oklch(40.8% 0.123 38.172);
--color-orange-950: oklch(26.6% 0.079 36.259);
--color-amber-50: oklch(98.7% 0.022 95.277);
--color-amber-200: oklch(92.4% 0.12 95.746);
--color-amber-300: oklch(87.9% 0.169 91.605);
--color-amber-400: oklch(82.8% 0.189 84.429);
--color-amber-500: oklch(76.9% 0.188 70.08);
--color-amber-600: oklch(66.6% 0.179 58.318);
--color-amber-700: oklch(55.5% 0.163 48.998);
--color-amber-800: oklch(47.3% 0.137 46.201);
--color-amber-900: oklch(41.4% 0.112 45.904);
--color-yellow-50: oklch(98.7% 0.026 102.212);
--color-yellow-100: oklch(97.3% 0.071 103.193);
--color-yellow-200: oklch(94.5% 0.129 101.54);
@@ -458,6 +467,9 @@
}
}
@layer utilities {
.pointer-events-auto {
pointer-events: auto;
}
.pointer-events-none {
pointer-events: none;
}
@@ -916,6 +928,9 @@
.mt-0 {
margin-top: calc(var(--spacing) * 0);
}
.mt-0\.5 {
margin-top: calc(var(--spacing) * 0.5);
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
@@ -1547,6 +1562,9 @@
.w-64 {
width: calc(var(--spacing) * 64);
}
.w-72 {
width: calc(var(--spacing) * 72);
}
.w-80 {
width: calc(var(--spacing) * 80);
}
@@ -1631,6 +1649,9 @@
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1639,6 +1660,10 @@
--tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-x-8 {
--tw-translate-x: calc(var(--spacing) * 8);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-x-full {
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1941,6 +1966,12 @@
.border-accent {
border-color: var(--color-accent);
}
.border-amber-200 {
border-color: var(--color-amber-200);
}
.border-blue-200 {
border-color: var(--color-blue-200);
}
.border-brand {
border-color: var(--color-brand);
}
@@ -1959,9 +1990,15 @@
.border-gray-300 {
border-color: var(--color-gray-300);
}
.border-green-200 {
border-color: var(--color-green-200);
}
.border-purple-200 {
border-color: var(--color-purple-200);
}
.border-red-200 {
border-color: var(--color-red-200);
}
.border-transparent {
border-color: transparent;
}
@@ -1996,12 +2033,18 @@
background-color: var(--color-neutral-secondary-medium);
}
}
.bg-amber-50 {
background-color: var(--color-amber-50);
}
.bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-black) 70%, transparent);
}
}
.bg-blue-50 {
background-color: var(--color-blue-50);
}
.bg-blue-100 {
background-color: var(--color-blue-100);
}
@@ -2041,6 +2084,9 @@
background-color: color-mix(in oklab, var(--color-gray-900) 50%, transparent);
}
}
.bg-green-50 {
background-color: var(--color-green-50);
}
.bg-green-500 {
background-color: var(--color-green-500);
}
@@ -2071,6 +2117,9 @@
.bg-purple-500 {
background-color: var(--color-purple-500);
}
.bg-red-50 {
background-color: var(--color-red-50);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
@@ -2454,9 +2503,24 @@
color: var(--color-heading);
}
}
.text-amber-400 {
color: var(--color-amber-400);
}
.text-amber-500 {
color: var(--color-amber-500);
}
.text-amber-800 {
color: var(--color-amber-800);
}
.text-black {
color: var(--color-black);
}
.text-blue-400 {
color: var(--color-blue-400);
}
.text-blue-500 {
color: var(--color-blue-500);
}
.text-blue-600 {
color: var(--color-blue-600);
}
@@ -2487,17 +2551,32 @@
.text-gray-700 {
color: var(--color-gray-700);
}
.text-gray-800 {
color: var(--color-gray-800);
}
.text-gray-900 {
color: var(--color-gray-900);
}
.text-green-600 {
color: var(--color-green-600);
.text-green-400 {
color: var(--color-green-400);
}
.text-green-500 {
color: var(--color-green-500);
}
.text-green-800 {
color: var(--color-green-800);
}
.text-heading {
color: var(--color-heading);
}
.text-red-600 {
color: var(--color-red-600);
.text-red-400 {
color: var(--color-red-400);
}
.text-red-500 {
color: var(--color-red-500);
}
.text-red-800 {
color: var(--color-red-800);
}
.text-slate-300 {
color: var(--color-slate-300);
@@ -2548,6 +2627,10 @@
--tw-shadow: 0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -2712,6 +2795,11 @@
color: var(--color-body);
}
}
.last\:mb-0 {
&:last-child {
margin-bottom: calc(var(--spacing) * 0);
}
}
.odd\:bg-white {
&:nth-child(odd) {
background-color: var(--color-white);
@@ -2834,6 +2922,20 @@
}
}
}
.hover\:text-amber-600 {
&:hover {
@media (hover: hover) {
color: var(--color-amber-600);
}
}
}
.hover\:text-blue-600 {
&:hover {
@media (hover: hover) {
color: var(--color-blue-600);
}
}
}
.hover\:text-blue-700 {
&:hover {
@media (hover: hover) {
@@ -2848,6 +2950,13 @@
}
}
}
.hover\:text-gray-600 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-600);
}
}
}
.hover\:text-gray-700 {
&:hover {
@media (hover: hover) {
@@ -2862,6 +2971,13 @@
}
}
}
.hover\:text-green-600 {
&:hover {
@media (hover: hover) {
color: var(--color-green-600);
}
}
}
.hover\:text-heading {
&:hover {
@media (hover: hover) {
@@ -2869,6 +2985,13 @@
}
}
}
.hover\:text-red-600 {
&:hover {
@media (hover: hover) {
color: var(--color-red-600);
}
}
}
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -3220,6 +3343,16 @@
}
}
}
.dark\:border-amber-700 {
&:is(.dark *) {
border-color: var(--color-amber-700);
}
}
.dark\:border-blue-700 {
&:is(.dark *) {
border-color: var(--color-blue-700);
}
}
.dark\:border-gray-500 {
&:is(.dark *) {
border-color: var(--color-gray-500);
@@ -3235,11 +3368,26 @@
border-color: var(--color-gray-700);
}
}
.dark\:border-green-700 {
&:is(.dark *) {
border-color: var(--color-green-700);
}
}
.dark\:border-purple-600 {
&:is(.dark *) {
border-color: var(--color-purple-600);
}
}
.dark\:border-red-700 {
&:is(.dark *) {
border-color: var(--color-red-700);
}
}
.dark\:bg-amber-900 {
&:is(.dark *) {
background-color: var(--color-amber-900);
}
}
.dark\:bg-blue-200 {
&:is(.dark *) {
background-color: var(--color-blue-200);
@@ -3250,6 +3398,11 @@
background-color: var(--color-blue-600);
}
}
.dark\:bg-blue-900 {
&:is(.dark *) {
background-color: var(--color-blue-900);
}
}
.dark\:bg-gray-600 {
&:is(.dark *) {
background-color: var(--color-gray-600);
@@ -3299,6 +3452,11 @@
background-color: var(--color-green-600);
}
}
.dark\:bg-green-900 {
&:is(.dark *) {
background-color: var(--color-green-900);
}
}
.dark\:bg-purple-800 {
&:is(.dark *) {
background-color: var(--color-purple-800);
@@ -3309,6 +3467,31 @@
background-color: var(--color-red-600);
}
}
.dark\:bg-red-900 {
&:is(.dark *) {
background-color: var(--color-red-900);
}
}
.dark\:text-amber-200 {
&:is(.dark *) {
color: var(--color-amber-200);
}
}
.dark\:text-amber-500 {
&:is(.dark *) {
color: var(--color-amber-500);
}
}
.dark\:text-blue-200 {
&:is(.dark *) {
color: var(--color-blue-200);
}
}
.dark\:text-blue-500 {
&:is(.dark *) {
color: var(--color-blue-500);
}
}
.dark\:text-blue-800 {
&:is(.dark *) {
color: var(--color-blue-800);
@@ -3339,14 +3522,24 @@
color: var(--color-gray-600);
}
}
.dark\:text-green-400 {
.dark\:text-green-200 {
&:is(.dark *) {
color: var(--color-green-400);
color: var(--color-green-200);
}
}
.dark\:text-red-400 {
.dark\:text-green-500 {
&:is(.dark *) {
color: var(--color-red-400);
color: var(--color-green-500);
}
}
.dark\:text-red-200 {
&:is(.dark *) {
color: var(--color-red-200);
}
}
.dark\:text-red-500 {
&:is(.dark *) {
color: var(--color-red-500);
}
}
.dark\:text-slate-300 {
@@ -3471,6 +3664,51 @@
}
}
}
.dark\:hover\:text-amber-300 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-amber-300);
}
}
}
}
.dark\:hover\:text-blue-300 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-300);
}
}
}
}
.dark\:hover\:text-gray-300 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-gray-300);
}
}
}
}
.dark\:hover\:text-green-300 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-green-300);
}
}
}
}
.dark\:hover\:text-red-300 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-red-300);
}
}
}
}
.dark\:hover\:text-white {
&:is(.dark *) {
&:hover {
@@ -4042,6 +4280,18 @@ form input:disabled, select:disabled, textarea:disabled {
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
position: fixed;
right: calc(var(--spacing) * 0);
bottom: calc(var(--spacing) * 0);
z-index: 50;
display: flex;
flex-direction: column;
align-items: flex-end;
padding: calc(var(--spacing) * 4);
}
}
@layer base {
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
appearance: none;
+37
View File
@@ -0,0 +1,37 @@
(function() {
htmx.defineExtension("hx-redirect-toast", {
isInlineSwap: function(swapStyle) {
return swapStyle === "hx-redirect-toast";
},
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
var xhr = htmxConfig.xhr;
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
// Redirect immediately (toast will be shown on the new page)
if (hxRedirect) {
window.location.href = hxRedirect;
}
// Only dispatch HX-Trigger events for toasts when not redirecting
if (!hxRedirect && hxTrigger) {
var triggers = JSON.parse(hxTrigger);
var events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach(function(triggerObj) {
Object.entries(triggerObj).forEach(function(entry) {
var name = entry[0];
var detail = entry[1];
try { detail = JSON.parse(detail); } catch(e) {}
target.dispatchEvent(new CustomEvent(name, {
detail: detail,
bubbles: true,
cancelable: true
}));
});
});
}
// Return null to prevent any DOM swap
return null;
}
});
})();
+1 -1
View File
File diff suppressed because one or more lines are too long
+173
View File
@@ -0,0 +1,173 @@
document.addEventListener("alpine:init", () => {
let idCounter = 0;
console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
Alpine.store("toasts", {
toasts: [],
addToast(message, type) {
console.log("[toast] addToast called:", { message, type });
if (!type) type = "info";
const validTypes = ["success", "error", "info", "warning", "debug"];
if (!validTypes.includes(type)) type = "info";
if (this.toasts.length >= 3) {
console.log("[toast] max 3 toasts reached, removing oldest");
this.toasts.shift();
}
const id = ++idCounter;
console.log("[toast] toast added, count:", this.toasts.length);
this.toasts.push({ id, message, type, visible: true, timer: null, pausedAt: null });
if (type !== "error") {
const toast = this.toasts[this.toasts.length - 1];
const autoDismissDelay = type === "debug" ? 3000 : 5000;
toast.timer = setTimeout(() => {
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s");
this.dismissToast(id);
}, autoDismissDelay);
}
},
dismissToast(id) {
console.log("[toast] dismissToast for id:", id);
const idx = this.toasts.findIndex((t) => t.id === id);
if (idx === -1) { console.log("[toast] toast not found"); return; }
const toast = this.toasts[idx];
if (toast.timer) clearTimeout(toast.timer);
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
console.log("[toast] after dismiss, count:", this.toasts.length);
}, 300);
},
clearToastTimer(id) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.timer) {
console.log("[toast] pause timer for toast id:", id);
clearTimeout(toast.timer);
toast.timer = null;
toast.pausedAt = Date.now();
}
},
resumeToastTimer(id, duration) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.pausedAt && toast.timer === null) {
console.log("[toast] resume timer for toast id:", id);
toast.timer = setTimeout(() => {
this.dismissToast(id);
}, duration);
toast.pausedAt = null;
}
},
});
Alpine.data("toastStore", () => ({
init() {
console.log("[toast] toastStore.init running");
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
window.addEventListener("show-toast", (e) => {
console.log("[toast] show-toast event received:", e.detail);
if (Array.isArray(e.detail)) {
e.detail.forEach((msg) => {
Alpine.store("toasts").addToast(msg.message, msg.type);
});
} else {
Alpine.store("toasts").addToast(e.detail.message, e.detail.type);
}
});
try {
const script = document.getElementById("django-messages");
if (script) {
const msgs = JSON.parse(
script.textContent || script.innerText || "[]"
);
console.log("[toast] django-messages script found:", msgs);
if (Array.isArray(msgs)) {
msgs.forEach((msg) => {
console.log("[toast] loading django-message:", msg);
Alpine.store("toasts").addToast(msg.message, msg.type || "info");
});
}
}
} catch (e) {
console.error("[toast] localStorage restore failed:", e);
// ignore parse errors
}
},
addToast(message, type) {
console.log("[toast] toastStore.addToast delegating:", { message, type });
Alpine.store("toasts").addToast(message, type);
},
dismissToast(id) {
console.log("[toast] toastStore.dismissToast delegating:", id);
Alpine.store("toasts").dismissToast(id);
},
}));
});
function toast(message, type) {
console.log("[toast] toast() called:", { message, type });
const evt = new CustomEvent("show-toast", {
detail: { message, type },
bubbles: true,
});
document.dispatchEvent(evt);
console.log("[toast] CustomEvent dispatched, type:", evt.type);
}
window.toast = toast;
/**
* Wrapper around fetch() that dispatches HTMX HX-Trigger events.
* Use this for any fetch() call that expects HX-Trigger headers
* (e.g., to show toasts via the HTMX middleware).
*
* @todo Migrate these call sites to hx-post + hx-on::after-request
* for HTMX-native toast handling.
*/
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) {
console.log("[fetchWithHtmxTriggers] fetching:", url);
return fetch(url, options).then(async (response) => {
console.log("[fetchWithHtmxTriggers] response status:", response.status);
const htmxTrigger = response.headers.get("HX-Trigger");
console.log("[fetchWithHtmxTriggers] HX-Trigger header:", htmxTrigger);
if (htmxTrigger) {
let triggers;
try {
triggers = JSON.parse(htmxTrigger);
console.log("[fetchWithHtmxTriggers] parsed triggers:", triggers);
} catch {
console.warn("[fetchWithHtmxTriggers] failed to parse HX-Trigger JSON");
return response;
}
// Handle both single object and array of events
const events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach((triggerObj) => {
Object.entries(triggerObj).forEach(([name, detail]) => {
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
let parsedDetail = detail;
try {
parsedDetail = JSON.parse(detail);
} catch {
// keep as string
}
document.dispatchEvent(new CustomEvent(name, {
detail: parsedDetail,
bubbles: true,
}));
});
});
}
return response;
});
};
@@ -2,6 +2,7 @@
<a href="{{ href }}"
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if click %}@click="{{ click }}"{% endif %}
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %}
<button type="button"
+113 -1
View File
@@ -9,6 +9,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {{ title }}</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
<script>
htmx.config.scrollBehavior = 'smooth';
htmx.config.selfRequestsOnly = false;
</script>
<script src="{% static 'js/htmx-redirect-toast.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
@@ -26,6 +31,13 @@
</script>
</head>
<body hx-indicator="#indicator" class="bg-neutral-primary">
<script id="django-messages" type="application/json">
[
{% for message in messages %}
{"message": "{{ message|escapejs }}", "type": "{{ message.tags|default:'info' }}"}{% if not forloop.last %},{% endif %}
{% endfor %}
]
</script>
<img id="indicator"
src="{% static 'icons/loading.png' %}"
class="absolute right-3 top-3 animate-spin htmx-indicator"
@@ -94,6 +106,106 @@
}
});
</script>
<div id="global-modal-container"></div>
<div id="global-modal-container"></div>
<div x-data="toastStore()"
role="region"
aria-label="Notifications"
aria-atomic="true"
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
<div x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-8"
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
tabindex="0"
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
:class="{
'success': toast.type === 'success',
'error': toast.type === 'error',
'info': toast.type === 'info',
'warning': toast.type === 'warning',
'debug': toast.type === 'debug'
}"
@click="dismissToast(toast.id)"
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
@keydown.escape="dismissToast(toast.id)">
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
:class="{
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
}">
<span class="flex-shrink-0 mt-0.5"
:class="{
'text-green-500': toast.type === 'success',
'text-red-500': toast.type === 'error',
'text-blue-500': toast.type === 'info',
'text-amber-500': toast.type === 'warning',
'text-gray-500': toast.type === 'debug'
}">
<template x-if="toast.type === 'success'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="toast.type === 'error'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="toast.type === 'info'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</template>
<template x-if="toast.type === 'warning'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
</svg>
</template>
<template x-if="toast.type === 'debug'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</template>
</span>
<p class="flex-1 text-sm"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-blue-800 dark:text-blue-200': toast.type === 'info',
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
}"
x-text="toast.message"></p>
<button @click.stop="dismissToast(toast.id)"
class="flex-shrink-0"
:class="{
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>
<script src="{% static 'js/toast.js' %}"></script>
</body>
</html>
@@ -8,17 +8,22 @@
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetch(`/api/games/{{ game.id }}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ status: newStatus })
}).then(() => {
document.body.dispatchEvent(new CustomEvent('status-changed'));
})
.finally(() => this.saving = false);
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers(`/api/games/{{ game.id }}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ status: newStatus })
})
.then(() => {
document.body.dispatchEvent(new CustomEvent('status-changed'));
})
.catch(() => {
console.error('Failed to update status');
})
.finally(() => this.saving = false);
}
}"
>
@@ -41,5 +46,4 @@
</div>
</button>
</div>
<div x-show="saving" style="display: none;">Saving...</div>
</div>
@@ -1,11 +1,11 @@
<div id="refund-confirmation-modal" class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative mx-auto p-5 border-accent border w-full max-w-md shadow-lg/50 rounded-md bg-white dark:bg-gray-900">
<div class="">
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Confirm Refund</h3>
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Confirm Refund</h1>
<p class="dark:text-white text-center mt-5">
Are you sure you want to mark this purchase as refunded?
</p>
<form class="" hx-post="{% url 'refund_purchase' purchase_id %}" hx-target="#global-modal-container" hx-swap="innerHTML">
<form class="" hx-post="{% url 'refund_purchase' purchase_id %}" hx-target="#purchase-row-{{ purchase_id }}" hx-swap="outerHTML">
{% csrf_token %}
<div class="mt-5 text-center">
<label class="flex flex-row items-center justify-center align-baseline gap-5">
@@ -6,34 +6,29 @@
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
open: false,
saving: false,
error: '',
success: false,
setDevice(newDeviceId, newDeviceName) {
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
this.error = '';
this.success = false;
fetch(`/api/session/{{ session.id }}/device`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ device_id: newDeviceId })
})
.then(() => {
this.success = true;
this.error = '';
document.body.dispatchEvent(new CustomEvent('device-changed'));
})
.catch(() => {
this.error = 'Failed to update device';
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
})
.finally(() => this.saving = false);
}
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers(`/api/session/{{ session.id }}/device`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ device_id: newDeviceId })
})
.then((res) => {
document.body.dispatchEvent(new CustomEvent('device-changed'));
})
.catch(() => {
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
})
.finally(() => this.saving = false);
}
}"
>
<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">
@@ -51,6 +46,4 @@
</div>
</button>
</div>
<div x-show="success" class="text-xs text-green-600 dark:text-green-400" style="display: none;">Saved</div>
<div x-show="error" x-text="error" class="text-xs text-red-600 dark:text-red-400" style="display: none;"></div>
</div>
+10 -1
View File
@@ -94,7 +94,16 @@
<script>
function createPlayEvent() {
this.played++;
fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'})
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('{% url 'api-1.0.0:create_playevent' %}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/json' },
body: '{"game_id": {{ game.id }}}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
+68 -55
View File
@@ -1,5 +1,6 @@
from typing import Any
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import (
@@ -20,6 +21,62 @@ from games.models import Game, Purchase
from games.views.general import use_custom_redirect
def _render_purchase_buttons(purchase_id, is_refunded):
"""Return button group HTML for a purchase row."""
return render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": "#",
"hx_get": reverse(
"refund_purchase_confirmation",
args=[purchase_id],
),
"hx_target": "#global-modal-container",
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not is_refunded
else {},
{
"href": reverse("edit_purchase", args=[purchase_id]),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse("delete_purchase", args=[purchase_id]),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
},
)
def _render_purchase_row(purchase):
"""Return a row dict for simple-table rendering."""
return {
"row_id": f"purchase-row-{purchase.id}",
"cell_data": [
LinkedPurchase(purchase),
purchase.get_type_display(),
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
purchase.created_at.strftime(dateformat),
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)),
],
}
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
@@ -54,57 +111,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
"Created",
"Actions",
],
"rows": [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": "#",
"hx_get": reverse(
"refund_purchase_confirmation",
args=[purchase.pk],
),
"hx_target": "#global-modal-container",
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not purchase.date_refunded
else {},
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
"rows": [_render_purchase_row(purchase) for purchase in purchases],
},
}
return render(request, "list_purchases.html", context)
@@ -192,7 +199,6 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
def refund_purchase_confirmation(
request: HttpRequest, purchase_id: int
) -> HttpResponse:
# purchase = get_object_or_404(Purchase, id=purchase_id)
return render(
request,
"partials/refund_purchase_confirmation.html",
@@ -212,9 +218,16 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase.refund()
response = HttpResponse(status=204)
response["HX-Redirect"] = reverse("list_purchases")
return response
messages.success(request, "Purchase refunded")
row_data = _render_purchase_row(purchase)
row_html = render_to_string(
"cotton/table_row.html",
{"data": row_data},
)
modal_close = (
'<template id="refund-confirmation-modal" hx-swap-oob="outerHTML"></template>'
)
return HttpResponse(row_html + modal_close, status=200)
@login_required
+111
View File
@@ -0,0 +1,111 @@
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.conf import settings
from django.test import TestCase, Client
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from games.models import Device, Game, Platform, Purchase, Session
from django.contrib.auth.models import User
class MiddlewareIntegrationTest(TestCase):
"""Integration tests for HTMXMessagesMiddleware.
These tests hit real endpoints that use messages.success() to verify
the full chain: API endpoint → messages → middleware → HX-Trigger header.
"""
@staticmethod
def _create_user():
return User.objects.create_user(
username="testuser", password="testpass123"
)
def setUp(self):
self.client = Client()
self.user = self._create_user()
self.client.force_login(self.user)
pl = Platform(name="Test Platform")
pl.save()
self.game = Game(name="Test Game", platform=pl)
self.game.save()
def test_non_htmx_request_with_message_gets_hx_trigger(self):
"""
Regression test: vanilla fetch() requests that set Django messages
must receive HX-Trigger so fetchWithHtmxTriggers can read them.
"""
response = self.client.patch(
f"/api/games/{self.game.id}/status",
data=json.dumps({"status": "played"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 204)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["type"], "success")
def test_session_device_api_endpoint_sends_hx_trigger(self):
"""
Verify the session device API endpoint also produces HX-Trigger.
This is the exact endpoint used by sessiondevice_selector.html.
"""
device = Device(name="Test Device")
device.save()
zt = ZoneInfo(settings.TIME_ZONE)
session = Session(
game=self.game,
device=device,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=zt),
)
session.save()
response = self.client.patch(
f"/api/session/{session.id}/device",
data=json.dumps({"device_id": device.id}),
content_type="application/json",
)
self.assertEqual(response.status_code, 204)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Device updated")
def test_refund_purchase_returns_updated_row_with_hx_trigger(
self,
):
"""
Verify the refund endpoint returns the updated row HTML so the page
swaps it in place without navigating away (preserving URL/query params).
"""
purchase = Purchase.objects.create(
date_purchased=datetime(2023, 1, 1),
platform=Platform.objects.first() or pl,
)
purchase.games.set([self.game])
response = self.client.post(
f"/tracker/purchase/{purchase.id}/refund",
data={"set_abandoned": ""},
)
self.assertEqual(response.status_code, 200)
self.assertNotIn("HX-Redirect", response)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Purchase refunded")
# Verify the row HTML contains the updated row id
body = response.content.decode()
self.assertIn(f'purchase-row-{purchase.id}', body)
# Verify OoO modal close element
self.assertIn('hx-swap-oob', body)
self.assertIn('refund-confirmation-modal', body)
# Verify the purchase is actually refunded
purchase.refresh_from_db()
self.assertIsNotNone(purchase.date_refunded)
+121
View File
@@ -0,0 +1,121 @@
import json
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.contrib.messages import constants as message_constants
from django.contrib.messages.storage.fallback import FallbackStorage
from django.http import HttpRequest, HttpResponse
from django.test import TestCase
from games.htmx_middleware import HTMXMessagesMiddleware
def get_response_ok(request):
return HttpResponse("OK")
class HtmxDetails:
boosted = False
current_url = ""
target_id = ""
class HTMXMessagesMiddlewareTest(TestCase):
def _build_request(self, htmx=True):
"""Build a request with FallbackStorage message backend."""
request = HttpRequest()
request.method = "GET"
request.path = "/test"
request.META = {"SERVER_NAME": "localhost", "SERVER_PORT": "80"}
request.session = {}
storage = FallbackStorage(request)
request._messages = storage
if htmx:
request.htmx = HtmxDetails()
return request
def test_htmx_request_with_messages_sends_hx_trigger(self):
"""HTMX request with messages should include HX-Trigger header."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.SUCCESS, "Item saved")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Item saved")
self.assertEqual(data["show-toast"]["type"], "success")
def test_htmx_request_with_error_message(self):
"""Error messages should map to 'error' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.ERROR, "Something failed")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "error")
def test_htmx_request_with_success_message(self):
"""Success messages should map to 'success' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.SUCCESS, "Saved successfully")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "success")
def test_non_htmx_request_also_sends_hx_trigger(self):
"""Non-HTMX requests should also include HX-Trigger header."""
request = self._build_request(htmx=False)
request._messages.add(message_constants.SUCCESS, "Hello")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Hello")
def test_htmx_request_without_messages_no_hx_trigger(self):
"""HTMX request without messages should not include HX-Trigger header."""
request = self._build_request(htmx=True)
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
self.assertNotIn("HX-Trigger", response)
def test_warning_message_maps_to_warning(self):
"""Warning messages should map to 'warning' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.WARNING, "Warning message")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "warning")
def test_debug_message_maps_to_debug(self):
"""Debug messages should map to 'debug' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.DEBUG, "Debug info")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "debug")
+1
View File
@@ -70,6 +70,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"games.htmx_middleware.HTMXMessagesMiddleware",
]
if DEBUG: