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:
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
""")
|
||||
|
||||
Reference in New Issue
Block a user