Add toast notification system
Add more toast types
This commit is contained in:
@@ -291,3 +291,26 @@ def PurchasePrice(purchase) -> str:
|
|||||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||||
wrapped_classes="underline decoration-dotted",
|
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],
|
||||||
|
)
|
||||||
|
|||||||
@@ -225,3 +225,9 @@ textarea:disabled {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.toast-container {
|
||||||
|
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.timezone import now as django_timezone_now
|
from django.utils.timezone import now as django_timezone_now
|
||||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
|
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)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
setattr(game, "status", payload.status)
|
setattr(game, "status", payload.status)
|
||||||
game.save()
|
game.save()
|
||||||
|
messages.success(request, "Status updated")
|
||||||
return 204, None
|
return 204, None
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ def list_playevents(request):
|
|||||||
@playevent_router.post("/", response={201: PlayEventOut})
|
@playevent_router.post("/", response={201: PlayEventOut})
|
||||||
def create_playevent(request, payload: PlayEventIn):
|
def create_playevent(request, payload: PlayEventIn):
|
||||||
playevent = PlayEvent.objects.create(**payload.dict())
|
playevent = PlayEvent.objects.create(**payload.dict())
|
||||||
|
messages.success(request, "Game played!")
|
||||||
return playevent
|
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 = get_object_or_404(Session, id=session_id)
|
||||||
session.device_id = payload.device_id
|
session.device_id = payload.device_id
|
||||||
session.save()
|
session.save()
|
||||||
|
messages.success(request, "Device updated")
|
||||||
return 204, None
|
return 204, None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -30,6 +30,15 @@
|
|||||||
--color-orange-800: oklch(47% 0.157 37.304);
|
--color-orange-800: oklch(47% 0.157 37.304);
|
||||||
--color-orange-900: oklch(40.8% 0.123 38.172);
|
--color-orange-900: oklch(40.8% 0.123 38.172);
|
||||||
--color-orange-950: oklch(26.6% 0.079 36.259);
|
--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-50: oklch(98.7% 0.026 102.212);
|
||||||
--color-yellow-100: oklch(97.3% 0.071 103.193);
|
--color-yellow-100: oklch(97.3% 0.071 103.193);
|
||||||
--color-yellow-200: oklch(94.5% 0.129 101.54);
|
--color-yellow-200: oklch(94.5% 0.129 101.54);
|
||||||
@@ -458,6 +467,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.pointer-events-auto {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
.pointer-events-none {
|
.pointer-events-none {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -916,6 +928,9 @@
|
|||||||
.mt-0 {
|
.mt-0 {
|
||||||
margin-top: calc(var(--spacing) * 0);
|
margin-top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.mt-0\.5 {
|
||||||
|
margin-top: calc(var(--spacing) * 0.5);
|
||||||
|
}
|
||||||
.mt-1 {
|
.mt-1 {
|
||||||
margin-top: calc(var(--spacing) * 1);
|
margin-top: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@@ -1547,6 +1562,9 @@
|
|||||||
.w-64 {
|
.w-64 {
|
||||||
width: calc(var(--spacing) * 64);
|
width: calc(var(--spacing) * 64);
|
||||||
}
|
}
|
||||||
|
.w-72 {
|
||||||
|
width: calc(var(--spacing) * 72);
|
||||||
|
}
|
||||||
.w-80 {
|
.w-80 {
|
||||||
width: calc(var(--spacing) * 80);
|
width: calc(var(--spacing) * 80);
|
||||||
}
|
}
|
||||||
@@ -1631,6 +1649,9 @@
|
|||||||
.flex-shrink {
|
.flex-shrink {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.-translate-x-full {
|
.-translate-x-full {
|
||||||
--tw-translate-x: -100%;
|
--tw-translate-x: -100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1639,6 +1660,10 @@
|
|||||||
--tw-translate-x: calc(var(--spacing) * 0);
|
--tw-translate-x: calc(var(--spacing) * 0);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
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 {
|
.translate-x-full {
|
||||||
--tw-translate-x: 100%;
|
--tw-translate-x: 100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1941,6 +1966,12 @@
|
|||||||
.border-accent {
|
.border-accent {
|
||||||
border-color: var(--color-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-brand {
|
||||||
border-color: var(--color-brand);
|
border-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
@@ -1959,9 +1990,15 @@
|
|||||||
.border-gray-300 {
|
.border-gray-300 {
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
}
|
}
|
||||||
|
.border-green-200 {
|
||||||
|
border-color: var(--color-green-200);
|
||||||
|
}
|
||||||
.border-purple-200 {
|
.border-purple-200 {
|
||||||
border-color: var(--color-purple-200);
|
border-color: var(--color-purple-200);
|
||||||
}
|
}
|
||||||
|
.border-red-200 {
|
||||||
|
border-color: var(--color-red-200);
|
||||||
|
}
|
||||||
.border-transparent {
|
.border-transparent {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
@@ -1996,12 +2033,18 @@
|
|||||||
background-color: var(--color-neutral-secondary-medium);
|
background-color: var(--color-neutral-secondary-medium);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-amber-50 {
|
||||||
|
background-color: var(--color-amber-50);
|
||||||
|
}
|
||||||
.bg-black\/70 {
|
.bg-black\/70 {
|
||||||
background-color: color-mix(in srgb, #000 70%, transparent);
|
background-color: color-mix(in srgb, #000 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-black) 70%, transparent);
|
background-color: color-mix(in oklab, var(--color-black) 70%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-blue-50 {
|
||||||
|
background-color: var(--color-blue-50);
|
||||||
|
}
|
||||||
.bg-blue-100 {
|
.bg-blue-100 {
|
||||||
background-color: var(--color-blue-100);
|
background-color: var(--color-blue-100);
|
||||||
}
|
}
|
||||||
@@ -2041,6 +2084,9 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-gray-900) 50%, transparent);
|
background-color: color-mix(in oklab, var(--color-gray-900) 50%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-green-50 {
|
||||||
|
background-color: var(--color-green-50);
|
||||||
|
}
|
||||||
.bg-green-500 {
|
.bg-green-500 {
|
||||||
background-color: var(--color-green-500);
|
background-color: var(--color-green-500);
|
||||||
}
|
}
|
||||||
@@ -2071,6 +2117,9 @@
|
|||||||
.bg-purple-500 {
|
.bg-purple-500 {
|
||||||
background-color: var(--color-purple-500);
|
background-color: var(--color-purple-500);
|
||||||
}
|
}
|
||||||
|
.bg-red-50 {
|
||||||
|
background-color: var(--color-red-50);
|
||||||
|
}
|
||||||
.bg-red-500 {
|
.bg-red-500 {
|
||||||
background-color: var(--color-red-500);
|
background-color: var(--color-red-500);
|
||||||
}
|
}
|
||||||
@@ -2454,9 +2503,24 @@
|
|||||||
color: var(--color-heading);
|
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 {
|
.text-black {
|
||||||
color: var(--color-black);
|
color: var(--color-black);
|
||||||
}
|
}
|
||||||
|
.text-blue-400 {
|
||||||
|
color: var(--color-blue-400);
|
||||||
|
}
|
||||||
|
.text-blue-500 {
|
||||||
|
color: var(--color-blue-500);
|
||||||
|
}
|
||||||
.text-blue-600 {
|
.text-blue-600 {
|
||||||
color: var(--color-blue-600);
|
color: var(--color-blue-600);
|
||||||
}
|
}
|
||||||
@@ -2487,17 +2551,32 @@
|
|||||||
.text-gray-700 {
|
.text-gray-700 {
|
||||||
color: var(--color-gray-700);
|
color: var(--color-gray-700);
|
||||||
}
|
}
|
||||||
|
.text-gray-800 {
|
||||||
|
color: var(--color-gray-800);
|
||||||
|
}
|
||||||
.text-gray-900 {
|
.text-gray-900 {
|
||||||
color: var(--color-gray-900);
|
color: var(--color-gray-900);
|
||||||
}
|
}
|
||||||
.text-green-600 {
|
.text-green-400 {
|
||||||
color: var(--color-green-600);
|
color: var(--color-green-400);
|
||||||
|
}
|
||||||
|
.text-green-500 {
|
||||||
|
color: var(--color-green-500);
|
||||||
|
}
|
||||||
|
.text-green-800 {
|
||||||
|
color: var(--color-green-800);
|
||||||
}
|
}
|
||||||
.text-heading {
|
.text-heading {
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
.text-red-600 {
|
.text-red-400 {
|
||||||
color: var(--color-red-600);
|
color: var(--color-red-400);
|
||||||
|
}
|
||||||
|
.text-red-500 {
|
||||||
|
color: var(--color-red-500);
|
||||||
|
}
|
||||||
|
.text-red-800 {
|
||||||
|
color: var(--color-red-800);
|
||||||
}
|
}
|
||||||
.text-slate-300 {
|
.text-slate-300 {
|
||||||
color: var(--color-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));
|
--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);
|
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 {
|
.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));
|
--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);
|
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);
|
color: var(--color-body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.last\:mb-0 {
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.odd\:bg-white {
|
.odd\:bg-white {
|
||||||
&:nth-child(odd) {
|
&:nth-child(odd) {
|
||||||
background-color: var(--color-white);
|
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\:text-blue-700 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-gray-700 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-heading {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-white {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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 {
|
.dark\:border-gray-500 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
border-color: var(--color-gray-500);
|
border-color: var(--color-gray-500);
|
||||||
@@ -3235,11 +3368,26 @@
|
|||||||
border-color: var(--color-gray-700);
|
border-color: var(--color-gray-700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:border-green-700 {
|
||||||
|
&:is(.dark *) {
|
||||||
|
border-color: var(--color-green-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:border-purple-600 {
|
.dark\:border-purple-600 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
border-color: var(--color-purple-600);
|
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 {
|
.dark\:bg-blue-200 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
background-color: var(--color-blue-200);
|
background-color: var(--color-blue-200);
|
||||||
@@ -3250,6 +3398,11 @@
|
|||||||
background-color: var(--color-blue-600);
|
background-color: var(--color-blue-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:bg-blue-900 {
|
||||||
|
&:is(.dark *) {
|
||||||
|
background-color: var(--color-blue-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:bg-gray-600 {
|
.dark\:bg-gray-600 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
background-color: var(--color-gray-600);
|
background-color: var(--color-gray-600);
|
||||||
@@ -3299,6 +3452,11 @@
|
|||||||
background-color: var(--color-green-600);
|
background-color: var(--color-green-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:bg-green-900 {
|
||||||
|
&:is(.dark *) {
|
||||||
|
background-color: var(--color-green-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:bg-purple-800 {
|
.dark\:bg-purple-800 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
background-color: var(--color-purple-800);
|
background-color: var(--color-purple-800);
|
||||||
@@ -3309,6 +3467,31 @@
|
|||||||
background-color: var(--color-red-600);
|
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 {
|
.dark\:text-blue-800 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
color: var(--color-blue-800);
|
color: var(--color-blue-800);
|
||||||
@@ -3339,14 +3522,24 @@
|
|||||||
color: var(--color-gray-600);
|
color: var(--color-gray-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dark\:text-green-400 {
|
.dark\:text-green-200 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
color: var(--color-green-400);
|
color: var(--color-green-200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dark\:text-red-400 {
|
.dark\:text-green-500 {
|
||||||
&:is(.dark *) {
|
&: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 {
|
.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 {
|
.dark\:hover\:text-white {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -4042,6 +4280,18 @@ form input:disabled, select:disabled, textarea:disabled {
|
|||||||
justify-content: space-between;
|
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 {
|
@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 {
|
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;
|
appearance: none;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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 }}"
|
<a href="{{ href }}"
|
||||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||||
{% if hx_target %}hx-target="{{ hx_target }}"{% 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">
|
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
||||||
{% if color == "gray" %}
|
{% if color == "gray" %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Timetracker - {{ title }}</title>
|
<title>Timetracker - {{ title }}</title>
|
||||||
<script src="{% static 'js/htmx.min.js' %}"></script>
|
<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 %}
|
{% django_htmx_script %}
|
||||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
||||||
@@ -26,6 +31,13 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-indicator="#indicator" class="bg-neutral-primary">
|
<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"
|
<img id="indicator"
|
||||||
src="{% static 'icons/loading.png' %}"
|
src="{% static 'icons/loading.png' %}"
|
||||||
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
||||||
@@ -95,5 +107,105 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,16 +8,21 @@
|
|||||||
this.status = newStatus;
|
this.status = newStatus;
|
||||||
this.status_display = newStatusDisplay;
|
this.status_display = newStatusDisplay;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
fetch(`/api/games/{{ game.id }}/status`, {
|
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||||
|
fetchWithHtmxTriggers(`/api/games/{{ game.id }}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
'X-CSRFToken': '{{ csrf_token }}'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ status: newStatus })
|
body: JSON.stringify({ status: newStatus })
|
||||||
}).then(() => {
|
})
|
||||||
|
.then(() => {
|
||||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error('Failed to update status');
|
||||||
|
})
|
||||||
.finally(() => this.saving = false);
|
.finally(() => this.saving = false);
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@@ -41,5 +46,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="saving" style="display: none;">Saving...</div>
|
|
||||||
</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 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="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="">
|
<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">
|
<p class="dark:text-white text-center mt-5">
|
||||||
Are you sure you want to mark this purchase as refunded?
|
Are you sure you want to mark this purchase as refunded?
|
||||||
</p>
|
</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 %}
|
{% csrf_token %}
|
||||||
<div class="mt-5 text-center">
|
<div class="mt-5 text-center">
|
||||||
<label class="flex flex-row items-center justify-center align-baseline gap-5">
|
<label class="flex flex-row items-center justify-center align-baseline gap-5">
|
||||||
|
|||||||
@@ -6,15 +6,12 @@
|
|||||||
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
|
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
|
||||||
open: false,
|
open: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
error: '',
|
|
||||||
success: false,
|
|
||||||
setDevice(newDeviceId, newDeviceName) {
|
setDevice(newDeviceId, newDeviceName) {
|
||||||
this.deviceId = newDeviceId;
|
this.deviceId = newDeviceId;
|
||||||
this.deviceName = newDeviceName;
|
this.deviceName = newDeviceName;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.error = '';
|
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||||
this.success = false;
|
fetchWithHtmxTriggers(`/api/session/{{ session.id }}/device`, {
|
||||||
fetch(`/api/session/{{ session.id }}/device`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -22,15 +19,13 @@
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ device_id: newDeviceId })
|
body: JSON.stringify({ device_id: newDeviceId })
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then((res) => {
|
||||||
this.success = true;
|
|
||||||
this.error = '';
|
|
||||||
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
this.error = 'Failed to update device';
|
|
||||||
this.deviceName = this.originalDeviceName;
|
this.deviceName = this.originalDeviceName;
|
||||||
this.deviceId = this.originalDeviceId;
|
this.deviceId = this.originalDeviceId;
|
||||||
|
console.error('Failed to update device');
|
||||||
})
|
})
|
||||||
.finally(() => this.saving = false);
|
.finally(() => this.saving = false);
|
||||||
}
|
}
|
||||||
@@ -51,6 +46,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -94,7 +94,16 @@
|
|||||||
<script>
|
<script>
|
||||||
function createPlayEvent() {
|
function createPlayEvent() {
|
||||||
this.played++;
|
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>
|
</script>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+68
-55
@@ -1,5 +1,6 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.http import (
|
from django.http import (
|
||||||
@@ -20,6 +21,62 @@ from games.models import Game, Purchase
|
|||||||
from games.views.general import use_custom_redirect
|
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
|
@login_required
|
||||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
context: dict[Any, Any] = {}
|
||||||
@@ -54,57 +111,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
"Created",
|
"Created",
|
||||||
"Actions",
|
"Actions",
|
||||||
],
|
],
|
||||||
"rows": [
|
"rows": [_render_purchase_row(purchase) for purchase in purchases],
|
||||||
[
|
|
||||||
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
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
return render(request, "list_purchases.html", context)
|
||||||
@@ -192,7 +199,6 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
def refund_purchase_confirmation(
|
def refund_purchase_confirmation(
|
||||||
request: HttpRequest, purchase_id: int
|
request: HttpRequest, purchase_id: int
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
# purchase = get_object_or_404(Purchase, id=purchase_id)
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"partials/refund_purchase_confirmation.html",
|
"partials/refund_purchase_confirmation.html",
|
||||||
@@ -212,9 +218,16 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
|
|
||||||
purchase.refund()
|
purchase.refund()
|
||||||
|
|
||||||
response = HttpResponse(status=204)
|
messages.success(request, "Purchase refunded")
|
||||||
response["HX-Redirect"] = reverse("list_purchases")
|
row_data = _render_purchase_row(purchase)
|
||||||
return response
|
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
|
@login_required
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -70,6 +70,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"django_htmx.middleware.HtmxMiddleware",
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
|
"games.htmx_middleware.HTMXMessagesMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user