Files
animaltrack/src/animaltrack/web/templates/registry.py
Petru Paler abb1c87e6c 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>
2026-01-05 16:03:25 +00:00

561 lines
17 KiB
Python

# ABOUTME: Templates for Animal Registry view.
# ABOUTME: Table with infinite scroll, facet sidebar, and filter input.
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
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
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
def registry_page(
animals: list[AnimalListItem],
facets: FacetCounts,
filter_str: str = "",
next_cursor: str | None = None,
total_count: int = 0,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Full registry page with filter at top, then sidebar + table.
Args:
animals: List of animals for the current page.
facets: Facet counts for the sidebar.
filter_str: Current filter string.
next_cursor: Cursor for the next page (if more exist).
total_count: Total number of animals matching the filter.
locations: List of locations for facet labels.
species_list: List of species for facet labels.
Returns:
Div component with header, sidebar, and main content.
"""
return Div(
# Filter at top - full width
registry_header(filter_str, total_count),
# Grid with sidebar and table
Grid(
# Sidebar with facets
facet_sidebar(facets, filter_str, locations, species_list),
# Main content - selection toolbar + table
Div(
selection_toolbar(),
animal_table(animals, next_cursor, filter_str),
selection_script(),
cls="col-span-3",
),
cols_sm=1,
cols_md=4,
cls="gap-4",
),
cls="p-4",
)
def registry_header(filter_str: str, total_count: int) -> Div:
"""Header with title, count, and prominent filter.
Args:
filter_str: Current filter string.
total_count: Total number of matching animals.
Returns:
Div with header and filter form.
"""
return Div(
# Top row: Title and count
Div(
H2("Animal Registry", cls="text-xl font-bold"),
Span(f"{total_count} animals", cls="text-sm text-stone-400 ml-3"),
cls="flex items-baseline mb-4",
),
# Filter form - full width, prominent
Form(
# Label above the input row
FormLabel("Filter", _for="filter", cls="mb-2 block"),
Div(
# Filter input - takes most of the width
Input(
id="filter",
name="filter",
value=filter_str,
placeholder='species:duck status:alive location:"Strip 1"',
cls="uk-input flex-1",
),
# Apply button
Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"),
# Clear button (only shown if filter is active)
A(
"Clear",
href="/registry",
cls="px-3 py-2 text-stone-400 hover:text-stone-200",
)
if filter_str
else None,
cls="flex gap-2 items-center",
),
action="/registry",
method="get",
),
cls="mb-6 pb-4 border-b border-stone-700",
)
def facet_sidebar(
facets: FacetCounts,
filter_str: str,
locations: list[Location] | None,
species_list: list[Species] | None,
) -> Div:
"""Sidebar with compact clickable facet counts.
Args:
facets: Facet counts for display.
filter_str: Current filter string.
locations: List of locations for name lookup.
species_list: List of species for name lookup.
Returns:
Div containing facet sections.
"""
location_map = {loc.id: loc.name for loc in (locations or [])}
species_map = {s.code: s.name for s in (species_list or [])}
return Div(
facet_section("Status", facets.by_status, filter_str, "status"),
facet_section("Species", facets.by_species, filter_str, "species", species_map),
facet_section("Sex", facets.by_sex, filter_str, "sex"),
facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"),
facet_section("Location", facets.by_location, filter_str, "location", location_map),
cls="space-y-3",
)
def facet_section(
title: str,
counts: dict[str, int],
filter_str: str,
field: str,
label_map: dict[str, str] | None = None,
) -> Any:
"""Single facet section with compact pill-style items.
Args:
title: Section title.
counts: Dictionary of value -> count.
filter_str: Current filter string.
field: Field name for building filter links.
label_map: Optional mapping from value to display label.
Returns:
Div component with facet pills, or None if no counts.
"""
if not counts:
return None
# Build inline pill items
items = []
for value, count in sorted(counts.items(), key=lambda x: -x[1]):
label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
# Build filter link - append to current filter
if filter_str:
new_filter = f"{filter_str} {field}:{value}"
else:
new_filter = f"{field}:{value}"
href = f"/registry?{urlencode({'filter': new_filter})}"
items.append(
A(
Span(label, cls="text-xs"),
Span(str(count), cls="text-xs text-stone-500 ml-1"),
href=href,
cls="inline-flex items-center px-2 py-1 rounded bg-stone-800 hover:bg-stone-700 mr-1 mb-1",
)
)
return Div(
P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
Div(
*items,
cls="flex flex-wrap",
),
)
def animal_table(
animals: list[AnimalListItem],
next_cursor: str | None,
filter_str: str,
) -> Table:
"""Animal table with infinite scroll.
Args:
animals: List of animals for the current page.
next_cursor: Cursor for the next page (if more exist).
filter_str: Current filter string.
Returns:
Table component with header and body.
"""
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"),
Th("Life Stage"),
Th("Location"),
Th("Tags"),
Th("Last Event"),
Th("Status"),
)
),
Tbody(
*[animal_row(a) for a in animals],
# Sentinel for infinite scroll
load_more_sentinel(next_cursor, filter_str) if next_cursor else None,
id="animal-tbody",
),
cls="uk-table uk-table-sm uk-table-divider uk-table-hover",
)
def animal_row(animal: AnimalListItem) -> Tr:
"""Single table row for an animal.
Args:
animal: Animal data for the row.
Returns:
Tr component for the animal.
"""
# Format last event timestamp
last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC)
last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M")
# Display ID (phonetic encoding or nickname)
display_id = format_animal_id(animal.animal_id, animal.nickname)
# Status badge styling
status_cls = {
"alive": "bg-green-900/50 text-green-300",
"dead": "bg-red-900/50 text-red-300",
"harvested": "bg-amber-900/50 text-amber-300",
"sold": "bg-blue-900/50 text-blue-300",
"merged_into": "bg-slate-700 text-slate-300",
}.get(animal.status, "bg-slate-700 text-slate-300")
# Format tags (show first 3)
tags_str = ", ".join(animal.tags[:3])
if len(animal.tags) > 3:
tags_str += "..."
return Tr(
Td(
Input(
type="checkbox",
cls="uk-checkbox animal-checkbox",
data_animal_id=animal.animal_id,
),
),
Td(
A(
display_id,
href=f"/animals/{animal.animal_id}",
cls="text-amber-500 hover:underline",
),
title=animal.animal_id,
),
Td(animal.species_code),
Td(animal.sex),
Td(animal.life_stage.replace("_", " ")),
Td(animal.location_name),
Td(tags_str, cls="text-xs text-stone-400"),
Td(last_event_str, cls="text-xs text-stone-400"),
Td(
Span(
animal.status,
cls=f"text-xs px-2 py-0.5 rounded {status_cls}",
)
),
)
def animal_table_rows(
animals: list[AnimalListItem],
next_cursor: str | None,
filter_str: str,
) -> list:
"""Just table rows for HTMX append (no wrapper).
Args:
animals: List of animals for the current page.
next_cursor: Cursor for the next page (if more exist).
filter_str: Current filter string.
Returns:
List of Tr components.
"""
rows = [animal_row(a) for a in animals]
if next_cursor:
rows.append(load_more_sentinel(next_cursor, filter_str))
return rows
def load_more_sentinel(cursor: str, filter_str: str) -> Tr:
"""Sentinel row that triggers infinite scroll.
Args:
cursor: Cursor for the next page.
filter_str: Current filter string.
Returns:
Tr component with HTMX attributes for lazy loading.
"""
params = {"cursor": cursor}
if filter_str:
params["filter"] = filter_str
url = f"/registry?{urlencode(params)}"
return Tr(
Td(
Div(
"Loading more...",
cls="text-center text-stone-400 py-4",
),
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();
});
})();
""")