Give messages an icon, a bold title and soft styling

Each level now renders as a daisyUI soft alert: an icon, a bold heading and the
message text.

Django messages carry a level and a string -- there is no title field -- so the
heading comes from the level ("Done", "Careful", "Something went wrong"), and a
call site that wants a specific one passes it as extra_tags:

    messages.success(request, f"{club} is live.", extra_tags="Club created")

The lookup is keyed on level_tag, not tags. `tags` is extra_tags and level_tag
joined, so the old `message.tags == "error"` test would have stopped matching the
moment any message carried a custom title, and every alert would have quietly
rendered as blue info.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:35:06 +02:00
parent 1a2bf257da
commit e44933330d
4 changed files with 74 additions and 6 deletions

View File

@@ -16,6 +16,36 @@ WIDGET_CLASSES = (
DEFAULT_WIDGET_CLASS = "input input-bordered w-full"
#: Icon, default heading and daisyUI colour per message level.
MESSAGE_ALERTS = {
"debug": ("bug", "Debug", "alert-info"),
"info": ("info", "Heads up", "alert-info"),
"success": ("circle-check", "Done", "alert-success"),
"warning": ("triangle-alert", "Careful", "alert-warning"),
"error": ("circle-x", "Something went wrong", "alert-error"),
}
DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"]
@register.filter
def as_alert(message):
"""Presentation for one Django message: icon, bold title, body, colour.
Django messages carry a level and a string — there is no title field — so the
title comes from the level, and a call site that wants a specific one passes it
as ``extra_tags``::
messages.success(request, f"{club} is live.", extra_tags="Club created")
Keyed on ``level_tag``, never ``tags``: ``tags`` is extra_tags and level_tag
joined, so a message carrying a custom title would stop matching its own level
and quietly render as info.
"""
icon, title, css = MESSAGE_ALERTS.get(message.level_tag, DEFAULT_MESSAGE_ALERT)
return {"icon": icon, "title": message.extra_tags or title, "body": message.message, "css": css}
@register.filter
def daisy(field):
"""Render a bound form field with the right daisyUI classes."""

View File

@@ -2,7 +2,9 @@ from decimal import Decimal
from allauth.mfa.models import Authenticator
from django import forms
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.storage.base import Message
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.urls import reverse
@@ -17,7 +19,7 @@ from teams.models import Position, Team, TeamMembership
from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
from .templatetags.ui import daisy
from .templatetags.ui import as_alert, daisy
User = get_user_model()
Flag = get_waffle_flag_model()
@@ -423,3 +425,33 @@ class FeatureViewTests(ControlPanelTestBase):
self.assertContains(response, "On for all clubs")
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
class MessageAlertTests(TestCase):
def alert(self, level, text, extra_tags=None):
return as_alert(Message(level, text, extra_tags=extra_tags))
def test_each_level_gets_its_own_icon_title_and_colour(self):
self.assertEqual(self.alert(messages.SUCCESS, "Saved.")["icon"], "circle-check")
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning")
self.assertEqual(self.alert(messages.ERROR, "Boom.")["title"], "Something went wrong")
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info")
def test_extra_tags_override_the_title(self):
alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created")
self.assertEqual(alert["title"], "Club created")
self.assertEqual(alert["body"], "Ajax United is live.")
self.assertEqual(alert["css"], "alert-success") # a custom title must not change the level
def test_an_unknown_level_falls_back_to_info(self):
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info")
class MessageRenderingTests(ControlPanelTestBase):
def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self):
response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
self.assertContains(response, "alert alert-soft alert-warning")
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False)
self.assertContains(response, "<svg") # the lucide icon

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
{% load lucide static %}
{% load lucide static ui %}
{% comment %}
The page skeleton, with no branding of its own. `_platform_base.html` dresses it
@@ -68,9 +68,15 @@
{% if messages %}
<div class="mx-auto mt-4 w-full space-y-2 px-4">
{% for message in messages %}
<div class="alert {% if message.tags == 'error' %}alert-error{% elif message.tags == 'warning' %}alert-warning{% elif message.tags == 'success' %}alert-success{% else %}alert-info{% endif %}">
<span>{{ message }}</span>
</div>
{% with alert=message|as_alert %}
<div class="alert alert-soft {{ alert.css }}" role="alert">
{% lucide alert.icon size=20 %}
<div>
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}