Add registry selection + expandable affected animals

Registry improvements:
- Add checkbox column for selecting animals in the table
- Add selection toolbar with count display
- Add Actions dropdown (Move, Add Tag, Update Attributes, Record Outcome)
- Selection persists across infinite scroll via JavaScript
- Navigate to action page with filter=animal_id:X|Y|Z for selected animals

Event detail improvements:
- Show more animal details: sex (M/F/?), life stage, location name
- Add "Show all X animals" button when >20 animals affected
- HTMX endpoint to load full list on demand
- Separate affected_animals_list component for HTMX swaps

🤖 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-05 16:03:25 +00:00
parent ad1f91098b
commit abb1c87e6c
3 changed files with 380 additions and 35 deletions

View File

@@ -247,29 +247,60 @@ def event_log_index(request: Request, htmx):
)
def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
def get_event_animals(db: Any, event_id: str, limit: int | None = None) -> list[dict[str, Any]]:
"""Get animals affected by an event with display info.
Args:
db: Database connection.
event_id: Event ID to look up animals for.
limit: Maximum number of animals to return (None for all).
Returns:
List of animal dicts with id, nickname, species_name.
List of animal dicts with id, nickname, species_name, sex, life_stage, location_name.
"""
rows = db.execute(
"""
SELECT ar.animal_id, ar.nickname, s.name as species_name
query = """
SELECT ar.animal_id, ar.nickname, s.name as species_name,
ar.sex, ar.life_stage, l.name as location_name
FROM event_animals ea
JOIN animal_registry ar ON ar.animal_id = ea.animal_id
JOIN species s ON s.code = ar.species_code
LEFT JOIN locations l ON l.id = ar.location_id
WHERE ea.event_id = ?
ORDER BY ar.nickname NULLS LAST, ar.animal_id
""",
(event_id,),
).fetchall()
"""
if limit:
query += f" LIMIT {limit}"
return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows]
rows = db.execute(query, (event_id,)).fetchall()
return [
{
"id": row[0],
"nickname": row[1],
"species_name": row[2],
"sex": row[3],
"life_stage": row[4],
"location_name": row[5],
}
for row in rows
]
def get_event_animal_count(db: Any, event_id: str) -> int:
"""Get count of animals affected by an event.
Args:
db: Database connection.
event_id: Event ID to count animals for.
Returns:
Total number of animals affected.
"""
row = db.execute(
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
(event_id,),
).fetchone()
return row[0] if row else 0
@ar("/events/{event_id}")
@@ -292,8 +323,9 @@ def event_detail(request: Request, event_id: str, htmx):
# Check if tombstoned
is_tombstoned = event_store.is_tombstoned(event_id)
# Get affected animals
affected_animals = get_event_animals(db, event_id)
# Get affected animals (limited to first 20 for performance)
affected_animals = get_event_animals(db, event_id, limit=20)
total_animal_count = get_event_animal_count(db, event_id)
# Get location names if entity_refs has location IDs
location_names = {}
@@ -317,7 +349,9 @@ def event_detail(request: Request, event_id: str, htmx):
user_role = auth.role if auth else None
# Build the panel
panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
panel = event_detail_panel(
event, affected_animals, total_animal_count, is_tombstoned, location_names, user_role
)
# HTMX request (slide-over) → return just panel
if htmx.request:
@@ -327,6 +361,24 @@ def event_detail(request: Request, event_id: str, htmx):
return render_page(request, panel, title=f"Event {event.id}")
@ar("/events/{event_id}/animals")
def event_animals_all(request: Request, event_id: str):
"""GET /events/{event_id}/animals - Get all affected animals for an event.
This endpoint is used via HTMX to load the full list when user clicks "Show all".
"""
from animaltrack.web.templates.event_detail import affected_animals_list
db = request.app.state.db
# Get all animals (no limit)
animals = get_event_animals(db, event_id)
total_count = len(animals)
# Return just the list component for HTMX swap
return affected_animals_list(animals, total_count, expanded=True)
@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).

View File

@@ -20,6 +20,7 @@ def format_timestamp(ts_utc: int) -> str:
def event_detail_panel(
event: Event,
affected_animals: list[dict[str, Any]],
total_animal_count: int = 0,
is_tombstoned: bool = False,
location_names: dict[str, str] | None = None,
user_role: UserRole | None = None,
@@ -28,7 +29,8 @@ def event_detail_panel(
Args:
event: The event to display.
affected_animals: List of animals affected by this event.
affected_animals: List of animals affected by this event (may be limited).
total_animal_count: Total number of affected animals.
is_tombstoned: Whether the event has been deleted.
location_names: Map of location IDs to names.
user_role: User's role for delete button visibility.
@@ -38,6 +40,8 @@ def event_detail_panel(
"""
if location_names is None:
location_names = {}
if total_animal_count == 0:
total_animal_count = len(affected_animals)
return Div(
# Header with close button
@@ -69,7 +73,7 @@ def event_detail_panel(
# Entity references
entity_refs_section(event.entity_refs, location_names),
# Affected animals
affected_animals_section(affected_animals),
affected_animals_section(affected_animals, total_animal_count, event.id),
# 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",
@@ -355,20 +359,76 @@ def entity_refs_section(
)
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
"""Section showing affected animals."""
if not animals:
def affected_animals_section(
animals: list[dict[str, Any]],
total_count: int,
event_id: str,
) -> Div:
"""Section showing affected animals with expandable list.
Args:
animals: List of animals to display (may be limited).
total_count: Total number of affected animals.
event_id: Event ID for "Show all" button.
Returns:
Div containing the affected animals section.
"""
if not animals and total_count == 0:
return Div()
return Div(
H3(
f"Affected Animals ({total_count})",
cls="text-sm font-semibold text-stone-400 mb-2",
),
affected_animals_list(animals, total_count, event_id=event_id),
cls="p-4",
id="affected-animals-section",
)
def affected_animals_list(
animals: list[dict[str, Any]],
total_count: int,
event_id: str | None = None,
expanded: bool = False,
) -> Div:
"""List of affected animals with optional expand button.
Args:
animals: List of animals to display.
total_count: Total number of affected animals.
event_id: Event ID for "Show all" button (None if expanded).
expanded: Whether showing full list.
Returns:
Div containing the animal list.
"""
animal_items = []
for animal in animals[:20]: # Limit display
for animal in animals:
display_name = format_animal_id(animal["id"], animal.get("nickname"))
# Build details string: sex abbreviation, life stage, location
sex_abbr = {"male": "M", "female": "F", "unknown": "?"}.get(
animal.get("sex", "unknown"), "?"
)
life_stage = animal.get("life_stage", "").replace("_", " ")
location = animal.get("location_name", "")
details_parts = [sex_abbr]
if life_stage:
details_parts.append(life_stage)
if location:
details_parts.append(location)
details_str = ", ".join(details_parts)
animal_items.append(
Li(
A(
Span(display_name, cls="text-amber-500 hover:underline"),
Span(display_name, cls="text-amber-500 hover:underline mr-1"),
Span(
f" ({animal.get('species_name', '')})",
f"({details_str})",
cls="text-stone-500 text-xs",
),
href=f"/animals/{animal['id']}",
@@ -377,22 +437,23 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
)
)
more_count = len(animals) - 20
if more_count > 0:
animal_items.append(
Li(
Span(f"... and {more_count} more", cls="text-stone-500 text-sm"),
cls="py-1",
)
# Show "Show all X animals" button if there are more
more_count = total_count - len(animals)
show_all_button = None
if more_count > 0 and not expanded and event_id:
show_all_button = Button(
f"Show all {total_count} animals",
hx_get=f"/events/{event_id}/animals",
hx_target="#affected-animals-list",
hx_swap="outerHTML",
cls="mt-2 text-sm text-amber-500 hover:text-amber-400 hover:underline",
type="button",
)
return Div(
H3(
f"Affected Animals ({len(animals)})",
cls="text-sm font-semibold text-stone-400 mb-2",
),
Ul(*animal_items, cls="space-y-1"),
cls="p-4",
show_all_button,
id="affected-animals-list",
)

View File

@@ -5,7 +5,24 @@ from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
from fasthtml.common import H2, A, Div, Form, Input, P, Span, Table, Tbody, Td, Th, Thead, Tr
from fasthtml.common import (
H2,
A,
Div,
Form,
Input,
Li,
P,
Script,
Span,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
Ul,
)
from monsterui.all import Button, ButtonT, FormLabel, Grid
from animaltrack.id_gen import format_animal_id
@@ -43,9 +60,11 @@ def registry_page(
Grid(
# Sidebar with facets
facet_sidebar(facets, filter_str, locations, species_list),
# Main content - table
# Main content - selection toolbar + table
Div(
selection_toolbar(),
animal_table(animals, next_cursor, filter_str),
selection_script(),
cls="col-span-3",
),
cols_sm=1,
@@ -203,6 +222,14 @@ def animal_table(
return Table(
Thead(
Tr(
Th(
Input(
type="checkbox",
id="select-all-checkbox",
cls="uk-checkbox",
),
cls="w-8",
),
Th("ID", shrink=True),
Th("Species"),
Th("Sex"),
@@ -254,6 +281,13 @@ def animal_row(animal: AnimalListItem) -> Tr:
tags_str += "..."
return Tr(
Td(
Input(
type="checkbox",
cls="uk-checkbox animal-checkbox",
data_animal_id=animal.animal_id,
),
),
Td(
A(
display_id,
@@ -319,10 +353,208 @@ def load_more_sentinel(cursor: str, filter_str: str) -> Tr:
"Loading more...",
cls="text-center text-stone-400 py-4",
),
colspan="8",
colspan="9", # Updated for checkbox column
),
hx_get=url,
hx_trigger="revealed",
hx_swap="outerHTML",
id="load-more-sentinel",
)
def selection_toolbar() -> Div:
"""Toolbar for bulk actions on selected animals.
Returns:
Div with selection count and actions dropdown.
"""
return Div(
# Left side: selection info and controls
Div(
Span("0 selected", id="selection-count", cls="text-sm text-stone-400"),
A(
"Select all",
href="#",
id="select-all-btn",
cls="text-sm text-amber-500 hover:underline ml-3",
),
A(
"Clear",
href="#",
id="clear-selection-btn",
cls="text-sm text-stone-400 hover:text-stone-200 ml-3 hidden",
),
cls="flex items-center",
),
# Right side: actions dropdown
Div(
Button(
"Actions",
id="actions-btn",
cls=f"{ButtonT.default} px-4",
disabled=True,
),
Div(
Ul(
Li(
A(
"Move",
href="#",
data_action="move",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
Li(
A(
"Add Tag",
href="#",
data_action="tag-add",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
Li(
A(
"Update Attributes",
href="#",
data_action="attrs",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
Li(
A(
"Record Outcome",
href="#",
data_action="outcome",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
cls="uk-nav uk-dropdown-nav",
),
uk_dropdown="mode: click; pos: bottom-right",
cls="uk-dropdown",
),
cls="uk-inline",
),
cls="flex justify-between items-center mb-4 py-2 px-3 bg-stone-800/50 rounded",
id="selection-toolbar",
)
def selection_script() -> Script:
"""JavaScript for handling animal selection.
Returns:
Script element with selection logic.
"""
return Script("""
(function() {
const selectedIds = new Set();
function updateUI() {
const count = selectedIds.size;
document.getElementById('selection-count').textContent = count + ' selected';
const actionsBtn = document.getElementById('actions-btn');
actionsBtn.disabled = count === 0;
const clearBtn = document.getElementById('clear-selection-btn');
if (count > 0) {
clearBtn.classList.remove('hidden');
} else {
clearBtn.classList.add('hidden');
}
// Update all checkboxes to reflect state
document.querySelectorAll('.animal-checkbox').forEach(cb => {
cb.checked = selectedIds.has(cb.dataset.animalId);
});
// Update header checkbox
const headerCb = document.getElementById('select-all-checkbox');
const allCheckboxes = document.querySelectorAll('.animal-checkbox');
if (allCheckboxes.length > 0) {
const allChecked = Array.from(allCheckboxes).every(cb => selectedIds.has(cb.dataset.animalId));
const someChecked = selectedIds.size > 0;
headerCb.checked = allChecked;
headerCb.indeterminate = someChecked && !allChecked;
}
}
// Handle individual checkbox changes
document.addEventListener('change', function(e) {
if (e.target.classList.contains('animal-checkbox')) {
const animalId = e.target.dataset.animalId;
if (e.target.checked) {
selectedIds.add(animalId);
} else {
selectedIds.delete(animalId);
}
updateUI();
}
});
// Handle header checkbox (select all visible)
document.getElementById('select-all-checkbox').addEventListener('change', function(e) {
document.querySelectorAll('.animal-checkbox').forEach(cb => {
if (e.target.checked) {
selectedIds.add(cb.dataset.animalId);
} else {
selectedIds.delete(cb.dataset.animalId);
}
});
updateUI();
});
// Select all button
document.getElementById('select-all-btn').addEventListener('click', function(e) {
e.preventDefault();
document.querySelectorAll('.animal-checkbox').forEach(cb => {
selectedIds.add(cb.dataset.animalId);
});
updateUI();
});
// Clear selection button
document.getElementById('clear-selection-btn').addEventListener('click', function(e) {
e.preventDefault();
selectedIds.clear();
updateUI();
});
// Handle action clicks
document.querySelectorAll('[data-action]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
if (selectedIds.size === 0) return;
const action = this.dataset.action;
const filter = 'animal_id:' + Array.from(selectedIds).join('|');
let url;
switch(action) {
case 'move':
url = '/move?filter=' + encodeURIComponent(filter);
break;
case 'tag-add':
url = '/actions/tag-add?filter=' + encodeURIComponent(filter);
break;
case 'attrs':
url = '/actions/attrs?filter=' + encodeURIComponent(filter);
break;
case 'outcome':
url = '/actions/outcome?filter=' + encodeURIComponent(filter);
break;
}
if (url) {
window.location.href = url;
}
});
});
// Re-run updateUI after HTMX swaps (for infinite scroll)
document.body.addEventListener('htmx:afterSwap', function(e) {
// After new rows are loaded, restore checkbox states
updateUI();
});
})();
""")