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>
561 lines
17 KiB
Python
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();
|
|
});
|
|
})();
|
|
""")
|