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:
|
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(
|
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,),
|
(event.id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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 monsterui.all import Theme
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
@@ -34,6 +34,7 @@ from animaltrack.web.routes import (
|
|||||||
products_router,
|
products_router,
|
||||||
registry_router,
|
registry_router,
|
||||||
)
|
)
|
||||||
|
from animaltrack.web.toaster import setup_toasts_with_html
|
||||||
|
|
||||||
# Default static directory relative to this module
|
# Default static directory relative to this module
|
||||||
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
|
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||||
@@ -148,8 +149,8 @@ def create_app(
|
|||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.db = db
|
app.state.db = db
|
||||||
|
|
||||||
# Setup toast notifications with 5 second duration
|
# Setup toast notifications with 5 second duration (custom version supports HTML)
|
||||||
setup_toasts(app, duration=5000)
|
setup_toasts_with_html(app, duration=5000)
|
||||||
|
|
||||||
# Register exception handlers for auth errors
|
# Register exception handlers for auth errors
|
||||||
async def authentication_error_handler(request, exc):
|
async def authentication_error_handler(request, exc):
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import APIRouter, to_xml
|
from fasthtml.common import APIRouter
|
||||||
from starlette.requests import Request
|
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.delete import delete_event
|
||||||
from animaltrack.events.dependencies import find_dependent_events
|
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(
|
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
|
FROM event_log_by_location
|
||||||
WHERE location_id = ?
|
WHERE location_id = ?
|
||||||
ORDER BY ts_utc DESC
|
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],
|
"type": row[3],
|
||||||
"actor": row[4],
|
"actor": row[4],
|
||||||
"summary": json.loads(row[5]),
|
"summary": json.loads(row[5]),
|
||||||
|
"is_deleted": bool(row[6]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return events
|
return events
|
||||||
@@ -94,10 +95,12 @@ def get_all_events(
|
|||||||
if event_type:
|
if event_type:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, ts_utc, type, actor, payload
|
SELECT e.id, e.ts_utc, e.type, e.actor, e.payload,
|
||||||
FROM events
|
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||||
WHERE type = ?
|
FROM events e
|
||||||
ORDER BY ts_utc DESC
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
WHERE e.type = ?
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(event_type, limit),
|
(event_type, limit),
|
||||||
@@ -105,9 +108,11 @@ def get_all_events(
|
|||||||
else:
|
else:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, ts_utc, type, actor, payload
|
SELECT e.id, e.ts_utc, e.type, e.actor, e.payload,
|
||||||
FROM events
|
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||||
ORDER BY ts_utc DESC
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
@@ -126,6 +131,7 @@ def get_all_events(
|
|||||||
"type": row[2],
|
"type": row[2],
|
||||||
"actor": row[3],
|
"actor": row[3],
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
"is_deleted": bool(row[5]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return events
|
return events
|
||||||
@@ -188,7 +194,7 @@ def _build_summary_from_payload(event_type: str, payload: dict[str, Any]) -> dic
|
|||||||
|
|
||||||
|
|
||||||
@ar("/event-log")
|
@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."""
|
"""GET /event-log - Event log for all events or filtered by location/type."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
@@ -228,12 +234,9 @@ def event_log_index(request: Request):
|
|||||||
if event_type:
|
if event_type:
|
||||||
events = [e for e in events if e["type"] == event_type]
|
events = [e for e in events if e["type"] == event_type]
|
||||||
|
|
||||||
# Check if HTMX request
|
# HTMX request → return partial (just the event list)
|
||||||
is_htmx = request.headers.get("HX-Request") == "true"
|
if htmx.request:
|
||||||
|
return event_log_list(events)
|
||||||
if is_htmx:
|
|
||||||
# Return partial - just the event list
|
|
||||||
return HTMLResponse(content=to_xml(event_log_list(events)))
|
|
||||||
|
|
||||||
# Full page render
|
# Full page render
|
||||||
return render_page(
|
return render_page(
|
||||||
@@ -270,8 +273,8 @@ def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
@ar("/events/{event_id}")
|
@ar("/events/{event_id}")
|
||||||
def event_detail(request: Request, event_id: str):
|
def event_detail(request: Request, event_id: str, htmx):
|
||||||
"""GET /events/{event_id} - Event detail panel for slide-over."""
|
"""GET /events/{event_id} - Event detail panel for slide-over or full page."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Get event from store
|
# Get event from store
|
||||||
@@ -279,10 +282,12 @@ def event_detail(request: Request, event_id: str):
|
|||||||
event = event_store.get_event(event_id)
|
event = event_store.get_event(event_id)
|
||||||
|
|
||||||
if event is None:
|
if event is None:
|
||||||
return HTMLResponse(
|
from fasthtml.common import Div
|
||||||
content="<div class='p-4 text-red-400'>Event not found</div>",
|
|
||||||
status_code=404,
|
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
|
# Check if tombstoned
|
||||||
is_tombstoned = event_store.is_tombstoned(event_id)
|
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")
|
auth = request.scope.get("auth")
|
||||||
user_role = auth.role if auth else None
|
user_role = auth.role if auth else None
|
||||||
|
|
||||||
# Return slide-over panel HTML
|
# Build the panel
|
||||||
return HTMLResponse(
|
panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
|
||||||
content=to_xml(
|
|
||||||
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"])
|
@ar("/events/{event_id}/delete", methods=["POST"])
|
||||||
|
|||||||
@@ -136,13 +136,15 @@ def page(
|
|||||||
# Main content with responsive padding/margin
|
# Main content with responsive padding/margin
|
||||||
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
|
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
|
||||||
# md:ml-60 to offset for desktop sidebar
|
# 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(
|
Div(
|
||||||
Container(content),
|
Container(content),
|
||||||
hx_boost="true",
|
hx_boost="true",
|
||||||
hx_target="body",
|
hx_target="body",
|
||||||
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
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
|
# Mobile bottom nav
|
||||||
BottomNav(active_id=active_nav),
|
BottomNav(active_id=active_nav),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,20 +92,26 @@ def event_log_item(
|
|||||||
ts_utc: int,
|
ts_utc: int,
|
||||||
actor: str,
|
actor: str,
|
||||||
summary: dict[str, Any],
|
summary: dict[str, Any],
|
||||||
|
is_deleted: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Render a single event log item."""
|
"""Render a single event log item."""
|
||||||
badge_cls = event_type_badge_class(event_type)
|
badge_cls = event_type_badge_class(event_type)
|
||||||
summary_text = format_event_summary(event_type, summary)
|
summary_text = format_event_summary(event_type, summary)
|
||||||
time_str = format_timestamp(ts_utc)
|
time_str = format_timestamp(ts_utc)
|
||||||
|
|
||||||
|
# Add strikethrough styling for deleted events
|
||||||
|
deleted_cls = "line-through opacity-50" if is_deleted else ""
|
||||||
|
|
||||||
return Li(
|
return Li(
|
||||||
Div(
|
Div(
|
||||||
Span(event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls}"),
|
Span(
|
||||||
Span(time_str, cls="text-xs text-stone-500 ml-2"),
|
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",
|
cls="flex items-center gap-2 mb-1",
|
||||||
),
|
),
|
||||||
P(summary_text, cls="text-sm text-stone-700"),
|
P(summary_text, cls=f"text-sm text-stone-700 {deleted_cls}"),
|
||||||
P(f"by {actor}", cls="text-xs text-stone-400"),
|
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",
|
cls="py-3 border-b border-stone-200 last:border-0 cursor-pointer hover:bg-stone-50",
|
||||||
hx_get=f"/events/{event_id}",
|
hx_get=f"/events/{event_id}",
|
||||||
hx_target="#event-panel-content",
|
hx_target="#event-panel-content",
|
||||||
@@ -128,6 +134,7 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
|
|||||||
ts_utc=e["ts_utc"],
|
ts_utc=e["ts_utc"],
|
||||||
actor=e["actor"],
|
actor=e["actor"],
|
||||||
summary=e["summary"],
|
summary=e["summary"],
|
||||||
|
is_deleted=e.get("is_deleted", False),
|
||||||
)
|
)
|
||||||
for e in events
|
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)
|
event = make_product_collected_event(event_id, location_id, animal_ids)
|
||||||
projection.apply(event)
|
projection.apply(event)
|
||||||
|
|
||||||
# Verify row exists
|
# Verify row exists and is not deleted
|
||||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
row = seeded_db.execute(
|
||||||
assert count == 1
|
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?", (event_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == 0
|
||||||
|
|
||||||
# Revert
|
# Revert
|
||||||
projection.revert(event)
|
projection.revert(event)
|
||||||
|
|
||||||
# Verify row removed
|
# Verify row is marked as deleted (not removed)
|
||||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
row = seeded_db.execute(
|
||||||
assert count == 0
|
"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):
|
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()
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
location_id = row[0]
|
location_id = row[0]
|
||||||
|
|
||||||
@@ -439,16 +443,23 @@ class TestEventLogProjectionRevert:
|
|||||||
)
|
)
|
||||||
projection.apply(event2)
|
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]
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
# Revert only event1
|
# Revert only event1
|
||||||
projection.revert(event1)
|
projection.revert(event1)
|
||||||
|
|
||||||
# Event2 should still exist
|
# Event1 should be marked as deleted
|
||||||
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
row = seeded_db.execute(
|
||||||
assert count == 1
|
"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()
|
# Event2 should still be active (not deleted)
|
||||||
assert row[0] == "01ARZ3NDEKTSV4RRFFQ69G5002"
|
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