feat: event detail page styling and deleted events indicator
- Fix event detail page for direct navigation by using FastHTML's idiomatic htmx parameter instead of manual header check - Add custom toaster.py with HTML support using NotStr to render clickable links in toast messages - Add hx_preserve to toast container to survive HTMX body swaps - Add is_deleted column to event_log_by_location table - Update event_log projection revert() to set is_deleted flag instead of deleting rows - Add strikethrough styling for deleted events in event log 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
migrations/0010-addeventlogisdeleted.sql
Normal file
4
migrations/0010-addeventlogisdeleted.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- ABOUTME: Migration 0010 - add_event_log_is_deleted
|
||||
-- ABOUTME: Adds is_deleted flag to event_log_by_location for deleted event visibility.
|
||||
|
||||
ALTER TABLE event_log_by_location ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -64,9 +64,9 @@ class EventLogProjection(Projection):
|
||||
)
|
||||
|
||||
def revert(self, event: Event) -> None:
|
||||
"""Revert event by removing entry from event_log_by_location."""
|
||||
"""Revert event by marking entry as deleted in event_log_by_location."""
|
||||
self.db.execute(
|
||||
"DELETE FROM event_log_by_location WHERE event_id = ?",
|
||||
"UPDATE event_log_by_location SET is_deleted = 1 WHERE event_id = ?",
|
||||
(event.id,),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fasthtml.common import Beforeware, Meta, fast_app, setup_toasts
|
||||
from fasthtml.common import Beforeware, Meta, fast_app
|
||||
from monsterui.all import Theme
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
@@ -34,6 +34,7 @@ from animaltrack.web.routes import (
|
||||
products_router,
|
||||
registry_router,
|
||||
)
|
||||
from animaltrack.web.toaster import setup_toasts_with_html
|
||||
|
||||
# Default static directory relative to this module
|
||||
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
@@ -148,8 +149,8 @@ def create_app(
|
||||
app.state.settings = settings
|
||||
app.state.db = db
|
||||
|
||||
# Setup toast notifications with 5 second duration
|
||||
setup_toasts(app, duration=5000)
|
||||
# Setup toast notifications with 5 second duration (custom version supports HTML)
|
||||
setup_toasts_with_html(app, duration=5000)
|
||||
|
||||
# Register exception handlers for auth errors
|
||||
async def authentication_error_handler(request, exc):
|
||||
|
||||
@@ -6,9 +6,9 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import APIRouter, to_xml
|
||||
from fasthtml.common import APIRouter
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from animaltrack.events.delete import delete_event
|
||||
from animaltrack.events.dependencies import find_dependent_events
|
||||
@@ -49,7 +49,7 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str,
|
||||
"""
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT event_id, location_id, ts_utc, type, actor, summary
|
||||
SELECT event_id, location_id, ts_utc, type, actor, summary, is_deleted
|
||||
FROM event_log_by_location
|
||||
WHERE location_id = ?
|
||||
ORDER BY ts_utc DESC
|
||||
@@ -68,6 +68,7 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str,
|
||||
"type": row[3],
|
||||
"actor": row[4],
|
||||
"summary": json.loads(row[5]),
|
||||
"is_deleted": bool(row[6]),
|
||||
}
|
||||
)
|
||||
return events
|
||||
@@ -94,10 +95,12 @@ def get_all_events(
|
||||
if event_type:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, ts_utc, type, actor, payload
|
||||
FROM events
|
||||
WHERE type = ?
|
||||
ORDER BY ts_utc DESC
|
||||
SELECT e.id, e.ts_utc, e.type, e.actor, e.payload,
|
||||
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||
FROM events e
|
||||
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||
WHERE e.type = ?
|
||||
ORDER BY e.ts_utc DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(event_type, limit),
|
||||
@@ -105,9 +108,11 @@ def get_all_events(
|
||||
else:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, ts_utc, type, actor, payload
|
||||
FROM events
|
||||
ORDER BY ts_utc DESC
|
||||
SELECT e.id, e.ts_utc, e.type, e.actor, e.payload,
|
||||
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||
FROM events e
|
||||
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||
ORDER BY e.ts_utc DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
@@ -126,6 +131,7 @@ def get_all_events(
|
||||
"type": row[2],
|
||||
"actor": row[3],
|
||||
"summary": summary,
|
||||
"is_deleted": bool(row[5]),
|
||||
}
|
||||
)
|
||||
return events
|
||||
@@ -188,7 +194,7 @@ def _build_summary_from_payload(event_type: str, payload: dict[str, Any]) -> dic
|
||||
|
||||
|
||||
@ar("/event-log")
|
||||
def event_log_index(request: Request):
|
||||
def event_log_index(request: Request, htmx):
|
||||
"""GET /event-log - Event log for all events or filtered by location/type."""
|
||||
db = request.app.state.db
|
||||
|
||||
@@ -228,12 +234,9 @@ def event_log_index(request: Request):
|
||||
if event_type:
|
||||
events = [e for e in events if e["type"] == event_type]
|
||||
|
||||
# Check if HTMX request
|
||||
is_htmx = request.headers.get("HX-Request") == "true"
|
||||
|
||||
if is_htmx:
|
||||
# Return partial - just the event list
|
||||
return HTMLResponse(content=to_xml(event_log_list(events)))
|
||||
# HTMX request → return partial (just the event list)
|
||||
if htmx.request:
|
||||
return event_log_list(events)
|
||||
|
||||
# Full page render
|
||||
return render_page(
|
||||
@@ -270,8 +273,8 @@ def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
@ar("/events/{event_id}")
|
||||
def event_detail(request: Request, event_id: str):
|
||||
"""GET /events/{event_id} - Event detail panel for slide-over."""
|
||||
def event_detail(request: Request, event_id: str, htmx):
|
||||
"""GET /events/{event_id} - Event detail panel for slide-over or full page."""
|
||||
db = request.app.state.db
|
||||
|
||||
# Get event from store
|
||||
@@ -279,10 +282,12 @@ def event_detail(request: Request, event_id: str):
|
||||
event = event_store.get_event(event_id)
|
||||
|
||||
if event is None:
|
||||
return HTMLResponse(
|
||||
content="<div class='p-4 text-red-400'>Event not found</div>",
|
||||
status_code=404,
|
||||
)
|
||||
from fasthtml.common import Div
|
||||
|
||||
error_content = Div("Event not found", cls="p-4 text-red-400")
|
||||
if htmx.request:
|
||||
return error_content
|
||||
return render_page(request, error_content, title="Event Not Found")
|
||||
|
||||
# Check if tombstoned
|
||||
is_tombstoned = event_store.is_tombstoned(event_id)
|
||||
@@ -311,12 +316,15 @@ def event_detail(request: Request, event_id: str):
|
||||
auth = request.scope.get("auth")
|
||||
user_role = auth.role if auth else None
|
||||
|
||||
# Return slide-over panel HTML
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
|
||||
),
|
||||
)
|
||||
# Build the panel
|
||||
panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
|
||||
|
||||
# HTMX request (slide-over) → return just panel
|
||||
if htmx.request:
|
||||
return panel
|
||||
|
||||
# Direct navigation → wrap in full page layout
|
||||
return render_page(request, panel, title=f"Event {event.id}")
|
||||
|
||||
|
||||
@ar("/events/{event_id}/delete", methods=["POST"])
|
||||
|
||||
@@ -136,13 +136,15 @@ def page(
|
||||
# Main content with responsive padding/margin
|
||||
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
|
||||
# md:ml-60 to offset for desktop sidebar
|
||||
# hx-boost enables AJAX for all descendant forms/links
|
||||
# hx-boost enables AJAX for all descendant links/forms
|
||||
Div(
|
||||
Container(content),
|
||||
hx_boost="true",
|
||||
hx_target="body",
|
||||
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||
),
|
||||
# Toast container with hx-preserve to survive body swaps for OOB toast injection
|
||||
Div(id="fh-toast-container", hx_preserve=True),
|
||||
# Mobile bottom nav
|
||||
BottomNav(active_id=active_nav),
|
||||
)
|
||||
|
||||
@@ -92,20 +92,26 @@ def event_log_item(
|
||||
ts_utc: int,
|
||||
actor: str,
|
||||
summary: dict[str, Any],
|
||||
is_deleted: bool = False,
|
||||
) -> Any:
|
||||
"""Render a single event log item."""
|
||||
badge_cls = event_type_badge_class(event_type)
|
||||
summary_text = format_event_summary(event_type, summary)
|
||||
time_str = format_timestamp(ts_utc)
|
||||
|
||||
# Add strikethrough styling for deleted events
|
||||
deleted_cls = "line-through opacity-50" if is_deleted else ""
|
||||
|
||||
return Li(
|
||||
Div(
|
||||
Span(event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls}"),
|
||||
Span(time_str, cls="text-xs text-stone-500 ml-2"),
|
||||
Span(
|
||||
event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls} {deleted_cls}"
|
||||
),
|
||||
Span(time_str, cls=f"text-xs text-stone-500 ml-2 {deleted_cls}"),
|
||||
cls="flex items-center gap-2 mb-1",
|
||||
),
|
||||
P(summary_text, cls="text-sm text-stone-700"),
|
||||
P(f"by {actor}", cls="text-xs text-stone-400"),
|
||||
P(summary_text, cls=f"text-sm text-stone-700 {deleted_cls}"),
|
||||
P(f"by {actor}", cls=f"text-xs text-stone-400 {deleted_cls}"),
|
||||
cls="py-3 border-b border-stone-200 last:border-0 cursor-pointer hover:bg-stone-50",
|
||||
hx_get=f"/events/{event_id}",
|
||||
hx_target="#event-panel-content",
|
||||
@@ -128,6 +134,7 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
|
||||
ts_utc=e["ts_utc"],
|
||||
actor=e["actor"],
|
||||
summary=e["summary"],
|
||||
is_deleted=e.get("is_deleted", False),
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
|
||||
73
src/animaltrack/web/toaster.py
Normal file
73
src/animaltrack/web/toaster.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# ABOUTME: Custom toast system that supports HTML content in messages.
|
||||
# ABOUTME: Extends FastHTML's toaster to use NotStr for unescaped HTML rendering.
|
||||
|
||||
from fasthtml.common import Button, Div, NotStr, Span
|
||||
|
||||
# Use FastHTML's toast session key and container ID for compatibility
|
||||
TOAST_CONTAINER_ID = "fh-toast-container"
|
||||
TOAST_SESSION_KEY = "toasts"
|
||||
|
||||
|
||||
def Toast(message: str, typ: str = "info", dismiss: bool = False, duration: int = 5000): # noqa: N802
|
||||
"""Create a toast notification element with HTML support.
|
||||
|
||||
Unlike FastHTML's Toast, this version uses NotStr to allow HTML in messages.
|
||||
PascalCase follows FastHTML's component naming convention.
|
||||
"""
|
||||
x_btn = (
|
||||
Button(
|
||||
"x",
|
||||
cls="fh-toast-dismiss",
|
||||
onclick="htmx.remove(this?.parentElement);",
|
||||
)
|
||||
if dismiss
|
||||
else None
|
||||
)
|
||||
# Use NotStr to prevent HTML escaping in the message
|
||||
return Div(
|
||||
Span(NotStr(message)),
|
||||
x_btn,
|
||||
cls=f"fh-toast fh-toast-{typ}",
|
||||
hx_on_transitionend=f"setTimeout(() => this?.remove(), {duration});",
|
||||
)
|
||||
|
||||
|
||||
def render_toasts(sess):
|
||||
"""Render queued toasts from session with HTML support."""
|
||||
toasts = [
|
||||
Toast(msg, typ, dismiss, sess["toast_duration"])
|
||||
for msg, typ, dismiss in sess.pop(TOAST_SESSION_KEY, [])
|
||||
]
|
||||
return Div(*toasts, id=TOAST_CONTAINER_ID, hx_swap_oob=f"beforeend:#{TOAST_CONTAINER_ID}")
|
||||
|
||||
|
||||
def toast_after(resp, req, sess):
|
||||
"""After-response hook to inject toast HTML into the response."""
|
||||
from fastcore.xml import FT
|
||||
from fasthtml.core import FtResponse
|
||||
|
||||
if TOAST_SESSION_KEY in sess and (not resp or isinstance(resp, (tuple, FT, FtResponse))):
|
||||
sess["toast_duration"] = req.app.state.toast_duration
|
||||
req.injects.append(render_toasts(sess))
|
||||
|
||||
|
||||
def setup_toasts_with_html(app, duration: int = 5000):
|
||||
"""Setup toast system with HTML support.
|
||||
|
||||
This replaces FastHTML's default toast_after with our custom version
|
||||
that renders HTML content without escaping.
|
||||
"""
|
||||
from fasthtml.common import Script, Style
|
||||
from fasthtml.toaster import js, toast_css
|
||||
from fasthtml.toaster import toast_after as default_toast_after
|
||||
|
||||
app.state.toast_duration = duration
|
||||
|
||||
# Add CSS and JS (same as FastHTML's setup_toasts)
|
||||
app.hdrs += [Style(toast_css), Script(js)]
|
||||
|
||||
# Remove FastHTML's default toast_after if present, add our custom one
|
||||
if default_toast_after in app.after:
|
||||
app.after.remove(default_toast_after)
|
||||
|
||||
app.after.append(toast_after)
|
||||
@@ -405,19 +405,23 @@ class TestEventLogProjectionRevert:
|
||||
event = make_product_collected_event(event_id, location_id, animal_ids)
|
||||
projection.apply(event)
|
||||
|
||||
# Verify row exists
|
||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
||||
assert count == 1
|
||||
# Verify row exists and is not deleted
|
||||
row = seeded_db.execute(
|
||||
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?", (event_id,)
|
||||
).fetchone()
|
||||
assert row[0] == 0
|
||||
|
||||
# Revert
|
||||
projection.revert(event)
|
||||
|
||||
# Verify row removed
|
||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
||||
assert count == 0
|
||||
# Verify row is marked as deleted (not removed)
|
||||
row = seeded_db.execute(
|
||||
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?", (event_id,)
|
||||
).fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
def test_revert_only_affects_specific_event(self, seeded_db):
|
||||
"""Revert only removes the specific event log entry."""
|
||||
"""Revert only marks the specific event log entry as deleted."""
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
@@ -439,16 +443,23 @@ class TestEventLogProjectionRevert:
|
||||
)
|
||||
projection.apply(event2)
|
||||
|
||||
# Verify both exist
|
||||
# Verify both exist and are not deleted
|
||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
# Revert only event1
|
||||
projection.revert(event1)
|
||||
|
||||
# Event2 should still exist
|
||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
||||
assert count == 1
|
||||
# Event1 should be marked as deleted
|
||||
row = seeded_db.execute(
|
||||
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?",
|
||||
("01ARZ3NDEKTSV4RRFFQ69G5001",),
|
||||
).fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
row = seeded_db.execute("SELECT event_id FROM event_log_by_location").fetchone()
|
||||
assert row[0] == "01ARZ3NDEKTSV4RRFFQ69G5002"
|
||||
# Event2 should still be active (not deleted)
|
||||
row = seeded_db.execute(
|
||||
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?",
|
||||
("01ARZ3NDEKTSV4RRFFQ69G5002",),
|
||||
).fetchone()
|
||||
assert row[0] == 0
|
||||
|
||||
Reference in New Issue
Block a user