fix: checkbox selection bug and add event log improvements

- Fix checkbox selection not working: remove duplicate subset_mode hidden
  fields from 5 form templates and add resolved_ids to checkbox component
- Add all-events view to event log (shows events without location like
  AnimalOutcome, FeedPurchased, ProductSold)
- Add event type filter dropdown alongside location filter
- Make event log items clickable to open event detail slide-over
- Add event delete UI with confirmation dialog (admin only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 20:03:34 +00:00
parent 1c836c6f7d
commit 9cd890b936
8 changed files with 435 additions and 48 deletions

View File

@@ -8,9 +8,17 @@ from typing import Any
from fasthtml.common import APIRouter, to_xml from fasthtml.common import APIRouter, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse, JSONResponse
from animaltrack.events.delete import delete_event
from animaltrack.events.dependencies import find_dependent_events
from animaltrack.events.exceptions import (
DependentEventsError,
EventNotFoundError,
EventTombstonedError,
)
from animaltrack.events.store import EventStore from animaltrack.events.store import EventStore
from animaltrack.models.reference import UserRole
from animaltrack.repositories.locations import LocationRepository from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page
@@ -58,40 +66,166 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str,
return events return events
def get_all_events(
db: Any,
event_type: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Get all events from the events table.
This queries the main events table directly, so it includes
events that are not location-scoped (AnimalOutcome, FeedPurchased, etc.).
Args:
db: Database connection.
event_type: Optional event type filter.
limit: Maximum number of events to return.
Returns:
List of event entries, newest first.
"""
if event_type:
rows = db.execute(
"""
SELECT id, ts_utc, type, actor, payload
FROM events
WHERE type = ?
ORDER BY ts_utc DESC
LIMIT ?
""",
(event_type, limit),
).fetchall()
else:
rows = db.execute(
"""
SELECT id, ts_utc, type, actor, payload
FROM events
ORDER BY ts_utc DESC
LIMIT ?
""",
(limit,),
).fetchall()
events = []
for row in rows:
payload = json.loads(row[4])
# Build a summary from the payload
summary = _build_summary_from_payload(row[2], payload)
events.append(
{
"event_id": row[0],
"location_id": None, # Not always present
"ts_utc": row[1],
"type": row[2],
"actor": row[3],
"summary": summary,
}
)
return events
def _build_summary_from_payload(event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Build a display summary from event payload."""
if event_type == "AnimalCohortCreated":
return {
"species": payload.get("species"),
"count": payload.get("count"),
"origin": payload.get("origin"),
}
if event_type == "AnimalMoved":
return {"animal_count": len(payload.get("resolved_ids", []))}
if event_type == "AnimalTagged":
return {
"tag": payload.get("tag"),
"animal_count": len(payload.get("resolved_ids", [])),
}
if event_type == "AnimalTagEnded":
return {
"tag": payload.get("tag"),
"animal_count": len(payload.get("resolved_ids", [])),
}
if event_type == "AnimalOutcome":
return {
"outcome": payload.get("outcome"),
"animal_count": len(payload.get("resolved_ids", [])),
}
if event_type == "ProductCollected":
return {
"product_code": payload.get("product_code"),
"quantity": payload.get("quantity"),
}
if event_type == "FeedGiven":
kg = payload.get("quantity_grams", 0) / 1000 if payload.get("quantity_grams") else 0
return {
"feed_type_code": payload.get("feed_code"),
"amount_kg": round(kg, 3),
}
if event_type == "FeedPurchased":
kg = payload.get("quantity_grams", 0) / 1000 if payload.get("quantity_grams") else 0
return {
"feed_code": payload.get("feed_code"),
"amount_kg": round(kg, 3),
}
if event_type == "ProductSold":
return {
"product_code": payload.get("product_code"),
"quantity": payload.get("quantity"),
}
if event_type == "HatchRecorded":
return {
"species": payload.get("species"),
"hatched_live": payload.get("hatched_live"),
}
# Fallback
return {"event_type": event_type}
@ar("/event-log") @ar("/event-log")
def event_log_index(request: Request): def event_log_index(request: Request):
"""GET /event-log - Event log for a location.""" """GET /event-log - Event log for all events or filtered by location/type."""
db = request.app.state.db db = request.app.state.db
# Get username for user defaults lookup # Get username for user defaults lookup
auth = request.scope.get("auth") auth = request.scope.get("auth")
username = auth.username if auth else None username = auth.username if auth else None
# Get location_id from query params # Get filter params from query
location_id = request.query_params.get("location_id") location_id = request.query_params.get("location_id", "")
event_type = request.query_params.get("event_type", "")
# If no query param, try user defaults # "all" means show all events (no location filter)
if not location_id and username: show_all = location_id == "all" or location_id == ""
# If no query param and not explicitly "all", try user defaults
if not location_id and not event_type and username:
defaults = UserDefaultsRepository(db).get(username, "event_log") defaults = UserDefaultsRepository(db).get(username, "event_log")
if defaults: if defaults and defaults.location_id:
location_id = defaults.location_id location_id = defaults.location_id
show_all = False
# Get all locations for selector # Get all locations for selector
location_repo = LocationRepository(db) location_repo = LocationRepository(db)
locations = location_repo.list_active() locations = location_repo.list_active()
# Find location name if we have a location_id # Find location name if we have a specific location_id
location_name = None location_name = None
if location_id: if location_id and location_id != "all":
for loc in locations: for loc in locations:
if loc.id == location_id: if loc.id == location_id:
location_name = loc.name location_name = loc.name
break break
# Get event log if we have a valid location # Get events based on filter
events = [] events = []
if location_id and location_name: if show_all or not location_id:
# Show all events (from main events table)
events = get_all_events(db, event_type=event_type or None)
elif location_id and location_name:
# Show events for specific location
events = get_event_log(db, location_id) events = get_event_log(db, location_id)
# Filter by event type if specified
if event_type:
events = [e for e in events if e["type"] == event_type]
# Check if HTMX request # Check if HTMX request
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = request.headers.get("HX-Request") == "true"
@@ -103,7 +237,7 @@ def event_log_index(request: Request):
# Full page render # Full page render
return render_page( return render_page(
request, request,
event_log_panel(events, locations, location_id), event_log_panel(events, locations, location_id, event_type),
title="Event Log - AnimalTrack", title="Event Log - AnimalTrack",
active_nav="event_log", active_nav="event_log",
) )
@@ -172,7 +306,94 @@ def event_detail(request: Request, event_id: str):
if loc: if loc:
location_names[loc_id] = loc.name location_names[loc_id] = loc.name
# Get user role for delete button visibility
auth = request.scope.get("auth")
user_role = auth.role if auth else None
# Return slide-over panel HTML # Return slide-over panel HTML
return HTMLResponse( return HTMLResponse(
content=to_xml(event_detail_panel(event, affected_animals, is_tombstoned, location_names)), content=to_xml(
event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
),
)
@ar("/events/{event_id}/delete", methods=["POST"])
async def event_delete(request: Request, event_id: str):
"""POST /events/{event_id}/delete - Delete an event (admin only).
Form params:
- reason: Optional reason for deletion
- cascade: Whether to delete dependent events too
Returns JSON response indicating success or error.
"""
db = request.app.state.db
# Check authorization
auth = request.scope.get("auth")
if not auth:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if auth.role != UserRole.admin:
return JSONResponse({"error": "Admin role required"}, status_code=403)
# Parse form data
form = await request.form()
reason = form.get("reason", "")
cascade = form.get("cascade", "false") == "true"
# Get event store and registry
event_store = EventStore(db)
registry = request.app.state.registry
try:
# Check for dependent events first
dependents = find_dependent_events(db, event_store, event_id)
if dependents and not cascade:
return JSONResponse(
{
"error": "has_dependents",
"dependent_count": len(dependents),
"message": f"This event has {len(dependents)} dependent event(s). "
"Delete them first or enable cascade delete.",
},
status_code=409,
)
# Perform the deletion
deleted_ids = delete_event(
db=db,
event_store=event_store,
event_id=event_id,
actor=auth.username,
role="admin",
cascade=cascade,
reason=reason or None,
registry=registry,
)
return JSONResponse(
{
"success": True,
"deleted_count": len(deleted_ids),
"deleted_ids": deleted_ids,
}
)
except EventNotFoundError:
return JSONResponse({"error": "Event not found"}, status_code=404)
except EventTombstonedError:
return JSONResponse({"error": "Event has already been deleted"}, status_code=409)
except DependentEventsError as e:
return JSONResponse(
{
"error": "has_dependents",
"dependent_count": len(e.dependents),
"message": str(e),
},
status_code=409,
) )

View File

@@ -568,14 +568,12 @@ def tag_add_form(
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting # Wrapped in a container with ID for HTMX targeting
selection_content = None selection_content = None
subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_content = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
) )
subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
selection_content = Div( selection_content = Div(
P( P(
@@ -641,7 +639,6 @@ def tag_add_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Add Tag", type="submit", cls=ButtonT.primary), Button("Add Tag", type="submit", cls=ButtonT.primary),
@@ -781,14 +778,12 @@ def tag_end_form(
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting # Wrapped in a container with ID for HTMX targeting
selection_content = None selection_content = None
subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_content = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
) )
subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
selection_content = Div( selection_content = Div(
P( P(
@@ -864,7 +859,6 @@ def tag_end_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags), Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
@@ -1000,14 +994,12 @@ def attrs_form(
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting # Wrapped in a container with ID for HTMX targeting
selection_content = None selection_content = None
subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_content = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
) )
subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
selection_content = Div( selection_content = Div(
P( P(
@@ -1111,7 +1103,6 @@ def attrs_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Update Attributes", type="submit", cls=ButtonT.primary), Button("Update Attributes", type="submit", cls=ButtonT.primary),
@@ -1257,14 +1248,12 @@ def outcome_form(
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting # Wrapped in a container with ID for HTMX targeting
selection_content = None selection_content = None
subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_content = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
) )
subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
# Fallback to simple count display # Fallback to simple count display
selection_content = Div( selection_content = Div(
@@ -1408,7 +1397,6 @@ def outcome_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Outcome", type="submit", cls=ButtonT.destructive), Button("Record Outcome", type="submit", cls=ButtonT.destructive),

View File

@@ -83,12 +83,19 @@ def animal_checkbox_list(
cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2", cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2",
) )
# Hidden fields for all resolved IDs (needed for validation)
resolved_id_fields = [
Input(type="hidden", name="resolved_ids", value=a.animal_id) for a in animals
]
return Div( return Div(
count_display, count_display,
Div( Div(
*items, *items,
cls="max-h-64 overflow-y-auto", cls="max-h-64 overflow-y-auto",
), ),
# Hidden fields for resolved_ids (all animals from filter resolution)
*resolved_id_fields,
# Hidden field to indicate subset selection mode # Hidden field to indicate subset selection mode
Input(type="hidden", name="subset_mode", value="true"), Input(type="hidden", name="subset_mode", value="true"),
# Hidden field for roster_hash - will be updated via JS # Hidden field for roster_hash - will be updated via JS

View File

@@ -60,7 +60,8 @@ def EventSlideOverScript(): # noqa: N802
// HTMX event: after loading event content, open the panel // HTMX event: after loading event content, open the panel
document.body.addEventListener('htmx:afterSwap', function(evt) { document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'event-slide-over') { if (evt.detail.target.id === 'event-slide-over' ||
evt.detail.target.id === 'event-panel-content') {
openEventPanel(); openEventPanel();
} }
}); });

View File

@@ -4,9 +4,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from fasthtml.common import H3, A, Button, Div, Li, P, Span, Ul from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul
from animaltrack.models.events import Event from animaltrack.models.events import Event
from animaltrack.models.reference import UserRole
def format_timestamp(ts_utc: int) -> str: def format_timestamp(ts_utc: int) -> str:
@@ -20,6 +21,7 @@ def event_detail_panel(
affected_animals: list[dict[str, Any]], affected_animals: list[dict[str, Any]],
is_tombstoned: bool = False, is_tombstoned: bool = False,
location_names: dict[str, str] | None = None, location_names: dict[str, str] | None = None,
user_role: UserRole | None = None,
) -> Div: ) -> Div:
"""Event detail slide-over panel content. """Event detail slide-over panel content.
@@ -28,6 +30,7 @@ def event_detail_panel(
affected_animals: List of animals affected by this event. affected_animals: List of animals affected by this event.
is_tombstoned: Whether the event has been deleted. is_tombstoned: Whether the event has been deleted.
location_names: Map of location IDs to names. location_names: Map of location IDs to names.
user_role: User's role for delete button visibility.
Returns: Returns:
Div containing the panel content. Div containing the panel content.
@@ -66,6 +69,8 @@ def event_detail_panel(
entity_refs_section(event.entity_refs, location_names), entity_refs_section(event.entity_refs, location_names),
# Affected animals # Affected animals
affected_animals_section(affected_animals), affected_animals_section(affected_animals),
# Delete button (admin only, not for tombstoned events)
delete_section(event.id) if user_role == UserRole.admin and not is_tombstoned else None,
id="event-panel-content", id="event-panel-content",
cls="bg-[#141413] h-full overflow-y-auto", cls="bg-[#141413] h-full overflow-y-auto",
) )
@@ -320,3 +325,109 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
Ul(*animal_items, cls="space-y-1"), Ul(*animal_items, cls="space-y-1"),
cls="p-4", cls="p-4",
) )
def delete_section(event_id: str) -> Div:
"""Section with delete button and confirmation dialog for admins."""
return Div(
# Delete button
Button(
"Delete Event",
type="button",
onclick="showDeleteConfirm()",
cls="w-full bg-red-800 hover:bg-red-700 text-white font-medium py-2 px-4 rounded",
),
# Confirmation dialog (hidden by default)
Div(
Div(
P(
"Are you sure you want to delete this event?",
cls="text-stone-200 font-medium mb-2",
),
P(
"This will reverse its effects on projections.",
cls="text-stone-400 text-sm mb-4",
),
# Delete status message
Div(
id="delete-status",
cls="text-sm mb-4",
),
# Buttons
Div(
Button(
"Cancel",
type="button",
onclick="hideDeleteConfirm()",
cls="bg-stone-700 hover:bg-stone-600 text-white font-medium py-2 px-4 rounded mr-2",
),
Button(
"Delete",
type="button",
onclick=f"deleteEvent('{event_id}')",
id="confirm-delete-btn",
cls="bg-red-700 hover:bg-red-600 text-white font-medium py-2 px-4 rounded",
),
cls="flex justify-end",
),
cls="bg-stone-800 p-4 rounded-lg",
),
id="delete-confirm-dialog",
cls="hidden",
),
# JavaScript for delete functionality
delete_script(),
cls="p-4 border-t border-stone-700",
)
def delete_script() -> Script:
"""JavaScript for delete confirmation dialog."""
return Script("""
function showDeleteConfirm() {
document.getElementById('delete-confirm-dialog').classList.remove('hidden');
}
function hideDeleteConfirm() {
document.getElementById('delete-confirm-dialog').classList.add('hidden');
document.getElementById('delete-status').innerHTML = '';
}
async function deleteEvent(eventId) {
const statusEl = document.getElementById('delete-status');
const deleteBtn = document.getElementById('confirm-delete-btn');
statusEl.innerHTML = '<span class="text-amber-400">Deleting...</span>';
deleteBtn.disabled = true;
try {
const response = await fetch('/events/' + eventId + '/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'reason=Deleted via UI'
});
const data = await response.json();
if (response.ok) {
statusEl.innerHTML = '<span class="text-green-400">Event deleted successfully!</span>';
// Close panel and refresh page after brief delay
setTimeout(function() {
closeEventPanel();
window.location.reload();
}, 1000);
} else if (data.error === 'has_dependents') {
statusEl.innerHTML = '<span class="text-red-400">' + data.message + '</span>';
deleteBtn.disabled = false;
} else {
statusEl.innerHTML = '<span class="text-red-400">Error: ' + (data.error || 'Unknown error') + '</span>';
deleteBtn.disabled = false;
}
} catch (err) {
statusEl.innerHTML = '<span class="text-red-400">Error: ' + err.message + '</span>';
deleteBtn.disabled = false;
}
}
""")

View File

@@ -55,6 +55,16 @@ def format_event_summary(event_type: str, summary: dict[str, Any]) -> str:
count = summary.get("animal_count", 0) count = summary.get("animal_count", 0)
return f"{outcome.capitalize()}: {count} animal(s)" return f"{outcome.capitalize()}: {count} animal(s)"
if event_type == "FeedPurchased":
feed_code = summary.get("feed_code", "feed")
amount = summary.get("amount_kg", 0)
return f"Purchased {amount}kg {feed_code}"
if event_type == "ProductSold":
product = summary.get("product_code", "product")
qty = summary.get("quantity", 0)
return f"Sold {qty} {product}"
# Fallback # Fallback
return event_type return event_type
@@ -65,11 +75,13 @@ def event_type_badge_class(event_type: str) -> str:
"ProductCollected": "bg-amber-100 text-amber-800", "ProductCollected": "bg-amber-100 text-amber-800",
"AnimalCohortCreated": "bg-green-100 text-green-800", "AnimalCohortCreated": "bg-green-100 text-green-800",
"FeedGiven": "bg-blue-100 text-blue-800", "FeedGiven": "bg-blue-100 text-blue-800",
"FeedPurchased": "bg-cyan-100 text-cyan-800",
"AnimalMoved": "bg-purple-100 text-purple-800", "AnimalMoved": "bg-purple-100 text-purple-800",
"HatchRecorded": "bg-pink-100 text-pink-800", "HatchRecorded": "bg-pink-100 text-pink-800",
"AnimalTagged": "bg-indigo-100 text-indigo-800", "AnimalTagged": "bg-indigo-100 text-indigo-800",
"AnimalTagEnded": "bg-slate-100 text-slate-800", "AnimalTagEnded": "bg-slate-100 text-slate-800",
"AnimalOutcome": "bg-red-100 text-red-800", "AnimalOutcome": "bg-red-100 text-red-800",
"ProductSold": "bg-emerald-100 text-emerald-800",
} }
return type_colors.get(event_type, "bg-gray-100 text-gray-800") return type_colors.get(event_type, "bg-gray-100 text-gray-800")
@@ -94,7 +106,10 @@ def event_log_item(
), ),
P(summary_text, cls="text-sm text-stone-700"), P(summary_text, cls="text-sm text-stone-700"),
P(f"by {actor}", cls="text-xs text-stone-400"), P(f"by {actor}", cls="text-xs text-stone-400"),
cls="py-3 border-b border-stone-200 last:border-0", 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",
hx_swap="innerHTML",
) )
@@ -102,7 +117,7 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
"""Render the event log list.""" """Render the event log list."""
if not events: if not events:
return Div( return Div(
P("No events recorded at this location yet.", cls="text-stone-500 text-sm"), P("No events found matching the current filters.", cls="text-stone-500 text-sm"),
cls="p-4 text-center", cls="p-4 text-center",
) )
@@ -120,9 +135,12 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
return Ul(*items, cls="divide-y divide-stone-200") return Ul(*items, cls="divide-y divide-stone-200")
def location_selector(locations: list[Any], selected_location_id: str | None) -> Any: def location_selector(
locations: list[Any], selected_location_id: str | None, selected_event_type: str = ""
) -> Any:
"""Render location selector dropdown.""" """Render location selector dropdown."""
options = [Option("Select a location...", value="", selected=not selected_location_id)] is_all = selected_location_id in ("", "all", None)
options = [Option("All locations", value="all", selected=is_all)]
for loc in locations: for loc in locations:
options.append(Option(loc.name, value=loc.id, selected=loc.id == selected_location_id)) options.append(Option(loc.name, value=loc.id, selected=loc.id == selected_location_id))
@@ -131,25 +149,70 @@ def location_selector(locations: list[Any], selected_location_id: str | None) ->
Select( Select(
*options, *options,
name="location_id", name="location_id",
id="location-filter",
cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm " cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm "
"focus:border-amber-500 focus:ring-amber-500 sm:text-sm", "focus:border-amber-500 focus:ring-amber-500 sm:text-sm",
hx_get="/event-log", hx_get="/event-log",
hx_trigger="change", hx_trigger="change",
hx_target="#event-log-content", hx_target="#event-log-content",
hx_swap="innerHTML", hx_swap="innerHTML",
hx_include="this", hx_include="#event-type-filter",
), ),
cls="mb-4 max-w-xs", cls="max-w-xs",
)
# List of known event types for the filter dropdown
EVENT_TYPES = [
"AnimalCohortCreated",
"AnimalMoved",
"AnimalOutcome",
"AnimalTagEnded",
"AnimalTagged",
"FeedGiven",
"FeedPurchased",
"HatchRecorded",
"ProductCollected",
"ProductSold",
]
def event_type_selector(selected_event_type: str = "") -> Any:
"""Render event type filter dropdown."""
options = [Option("All types", value="", selected=not selected_event_type)]
for event_type in EVENT_TYPES:
options.append(
Option(event_type, value=event_type, selected=event_type == selected_event_type)
)
return Div(
Label("Event Type", cls="text-sm font-medium text-stone-700"),
Select(
*options,
name="event_type",
id="event-type-filter",
cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm "
"focus:border-amber-500 focus:ring-amber-500 sm:text-sm",
hx_get="/event-log",
hx_trigger="change",
hx_target="#event-log-content",
hx_swap="innerHTML",
hx_include="#location-filter",
),
cls="max-w-xs",
) )
def event_log_panel( def event_log_panel(
events: list[dict[str, Any]], locations: list[Any], selected_location_id: str | None events: list[dict[str, Any]],
locations: list[Any],
selected_location_id: str | None,
selected_event_type: str = "",
) -> Any: ) -> Any:
"""Render the full event log panel.""" """Render the full event log panel."""
# Find location name for header # Find location name for header
location_name = None location_name = None
if selected_location_id: if selected_location_id and selected_location_id not in ("", "all"):
for loc in locations: for loc in locations:
if loc.id == selected_location_id: if loc.id == selected_location_id:
location_name = loc.name location_name = loc.name
@@ -159,14 +222,13 @@ def event_log_panel(
return Div( return Div(
H3(header_text, cls="text-lg font-semibold mb-4"), H3(header_text, cls="text-lg font-semibold mb-4"),
location_selector(locations, selected_location_id),
Div( Div(
event_log_list(events) location_selector(locations, selected_location_id, selected_event_type),
if selected_location_id event_type_selector(selected_event_type),
else P( cls="flex gap-4 mb-4",
"Select a location to view events.",
cls="text-stone-500 text-sm text-center py-4",
), ),
Div(
event_log_list(events),
id="event-log-content", id="event-log-content",
), ),
cls="bg-white rounded-lg shadow p-4", cls="bg-white rounded-lg shadow p-4",

View File

@@ -68,7 +68,6 @@ def move_form(
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting # Wrapped in a container with ID for HTMX targeting
selection_content = None selection_content = None
subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
location_info = f" from {from_location_name}" if from_location_name else "" location_info = f" from {from_location_name}" if from_location_name else ""
@@ -76,7 +75,6 @@ def move_form(
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"), P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
) )
subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else "" location_info = f" from {from_location_name}" if from_location_name else ""
selection_content = Div( selection_content = Div(
@@ -145,7 +143,6 @@ def move_form(
Hidden(name="from_location_id", value=from_location_id or ""), Hidden(name="from_location_id", value=from_location_id or ""),
Hidden(name="resolver_version", value="v1"), Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary), Button("Move Animals", type="submit", cls=ButtonT.primary),

View File

@@ -108,11 +108,11 @@ class TestEventLogRoute:
"""Tests for GET /event-log route.""" """Tests for GET /event-log route."""
def test_event_log_without_location_shows_selector(self, client): def test_event_log_without_location_shows_selector(self, client):
"""Event log without location_id shows location selector.""" """Event log without location_id shows location selector with 'All locations' default."""
response = client.get("/event-log") response = client.get("/event-log")
assert response.status_code == 200 assert response.status_code == 200
# Should show location selector prompt # Should show location selector with "All locations" option
assert "Select a location" in response.text assert "All locations" in response.text
def test_event_log_returns_empty_for_new_location(self, client, valid_location_id): def test_event_log_returns_empty_for_new_location(self, client, valid_location_id):
"""Event log returns empty state for location with no events.""" """Event log returns empty state for location with no events."""