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.
|
"""Get animals affected by an event with display info.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connection.
|
db: Database connection.
|
||||||
event_id: Event ID to look up animals for.
|
event_id: Event ID to look up animals for.
|
||||||
|
limit: Maximum number of animals to return (None for all).
|
||||||
|
|
||||||
Returns:
|
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(
|
query = """
|
||||||
"""
|
SELECT ar.animal_id, ar.nickname, s.name as species_name,
|
||||||
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
|
FROM event_animals ea
|
||||||
JOIN animal_registry ar ON ar.animal_id = ea.animal_id
|
JOIN animal_registry ar ON ar.animal_id = ea.animal_id
|
||||||
JOIN species s ON s.code = ar.species_code
|
JOIN species s ON s.code = ar.species_code
|
||||||
|
LEFT JOIN locations l ON l.id = ar.location_id
|
||||||
WHERE ea.event_id = ?
|
WHERE ea.event_id = ?
|
||||||
ORDER BY ar.nickname NULLS LAST, ar.animal_id
|
ORDER BY ar.nickname NULLS LAST, ar.animal_id
|
||||||
""",
|
"""
|
||||||
(event_id,),
|
if limit:
|
||||||
).fetchall()
|
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}")
|
@ar("/events/{event_id}")
|
||||||
@@ -292,8 +323,9 @@ def event_detail(request: Request, event_id: str, htmx):
|
|||||||
# Check if tombstoned
|
# Check if tombstoned
|
||||||
is_tombstoned = event_store.is_tombstoned(event_id)
|
is_tombstoned = event_store.is_tombstoned(event_id)
|
||||||
|
|
||||||
# Get affected animals
|
# Get affected animals (limited to first 20 for performance)
|
||||||
affected_animals = get_event_animals(db, event_id)
|
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
|
# Get location names if entity_refs has location IDs
|
||||||
location_names = {}
|
location_names = {}
|
||||||
@@ -317,7 +349,9 @@ def event_detail(request: Request, event_id: str, htmx):
|
|||||||
user_role = auth.role if auth else None
|
user_role = auth.role if auth else None
|
||||||
|
|
||||||
# Build the panel
|
# 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
|
# HTMX request (slide-over) → return just panel
|
||||||
if htmx.request:
|
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}")
|
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"])
|
@ar("/events/{event_id}/delete", methods=["POST"])
|
||||||
async def event_delete(request: Request, event_id: str):
|
async def event_delete(request: Request, event_id: str):
|
||||||
"""POST /events/{event_id}/delete - Delete an event (admin only).
|
"""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(
|
def event_detail_panel(
|
||||||
event: Event,
|
event: Event,
|
||||||
affected_animals: list[dict[str, Any]],
|
affected_animals: list[dict[str, Any]],
|
||||||
|
total_animal_count: int = 0,
|
||||||
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,
|
user_role: UserRole | None = None,
|
||||||
@@ -28,7 +29,8 @@ def event_detail_panel(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
event: The event to display.
|
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.
|
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.
|
user_role: User's role for delete button visibility.
|
||||||
@@ -38,6 +40,8 @@ def event_detail_panel(
|
|||||||
"""
|
"""
|
||||||
if location_names is None:
|
if location_names is None:
|
||||||
location_names = {}
|
location_names = {}
|
||||||
|
if total_animal_count == 0:
|
||||||
|
total_animal_count = len(affected_animals)
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
# Header with close button
|
# Header with close button
|
||||||
@@ -69,7 +73,7 @@ def event_detail_panel(
|
|||||||
# Entity references
|
# Entity references
|
||||||
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, total_animal_count, event.id),
|
||||||
# Delete button (admin only, not for tombstoned events)
|
# Delete button (admin only, not for tombstoned events)
|
||||||
delete_section(event.id) if user_role == UserRole.ADMIN and not is_tombstoned else None,
|
delete_section(event.id) if user_role == UserRole.ADMIN and not is_tombstoned else None,
|
||||||
id="event-panel-content",
|
id="event-panel-content",
|
||||||
@@ -355,20 +359,76 @@ def entity_refs_section(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
def affected_animals_section(
|
||||||
"""Section showing affected animals."""
|
animals: list[dict[str, Any]],
|
||||||
if not animals:
|
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()
|
||||||
|
|
||||||
|
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 = []
|
animal_items = []
|
||||||
for animal in animals[:20]: # Limit display
|
for animal in animals:
|
||||||
display_name = format_animal_id(animal["id"], animal.get("nickname"))
|
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(
|
animal_items.append(
|
||||||
Li(
|
Li(
|
||||||
A(
|
A(
|
||||||
Span(display_name, cls="text-amber-500 hover:underline"),
|
Span(display_name, cls="text-amber-500 hover:underline mr-1"),
|
||||||
Span(
|
Span(
|
||||||
f" ({animal.get('species_name', '')})",
|
f"({details_str})",
|
||||||
cls="text-stone-500 text-xs",
|
cls="text-stone-500 text-xs",
|
||||||
),
|
),
|
||||||
href=f"/animals/{animal['id']}",
|
href=f"/animals/{animal['id']}",
|
||||||
@@ -377,22 +437,23 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
more_count = len(animals) - 20
|
# Show "Show all X animals" button if there are more
|
||||||
if more_count > 0:
|
more_count = total_count - len(animals)
|
||||||
animal_items.append(
|
show_all_button = None
|
||||||
Li(
|
if more_count > 0 and not expanded and event_id:
|
||||||
Span(f"... and {more_count} more", cls="text-stone-500 text-sm"),
|
show_all_button = Button(
|
||||||
cls="py-1",
|
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(
|
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"),
|
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 typing import Any
|
||||||
from urllib.parse import urlencode
|
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 monsterui.all import Button, ButtonT, FormLabel, Grid
|
||||||
|
|
||||||
from animaltrack.id_gen import format_animal_id
|
from animaltrack.id_gen import format_animal_id
|
||||||
@@ -43,9 +60,11 @@ def registry_page(
|
|||||||
Grid(
|
Grid(
|
||||||
# Sidebar with facets
|
# Sidebar with facets
|
||||||
facet_sidebar(facets, filter_str, locations, species_list),
|
facet_sidebar(facets, filter_str, locations, species_list),
|
||||||
# Main content - table
|
# Main content - selection toolbar + table
|
||||||
Div(
|
Div(
|
||||||
|
selection_toolbar(),
|
||||||
animal_table(animals, next_cursor, filter_str),
|
animal_table(animals, next_cursor, filter_str),
|
||||||
|
selection_script(),
|
||||||
cls="col-span-3",
|
cls="col-span-3",
|
||||||
),
|
),
|
||||||
cols_sm=1,
|
cols_sm=1,
|
||||||
@@ -203,6 +222,14 @@ def animal_table(
|
|||||||
return Table(
|
return Table(
|
||||||
Thead(
|
Thead(
|
||||||
Tr(
|
Tr(
|
||||||
|
Th(
|
||||||
|
Input(
|
||||||
|
type="checkbox",
|
||||||
|
id="select-all-checkbox",
|
||||||
|
cls="uk-checkbox",
|
||||||
|
),
|
||||||
|
cls="w-8",
|
||||||
|
),
|
||||||
Th("ID", shrink=True),
|
Th("ID", shrink=True),
|
||||||
Th("Species"),
|
Th("Species"),
|
||||||
Th("Sex"),
|
Th("Sex"),
|
||||||
@@ -254,6 +281,13 @@ def animal_row(animal: AnimalListItem) -> Tr:
|
|||||||
tags_str += "..."
|
tags_str += "..."
|
||||||
|
|
||||||
return Tr(
|
return Tr(
|
||||||
|
Td(
|
||||||
|
Input(
|
||||||
|
type="checkbox",
|
||||||
|
cls="uk-checkbox animal-checkbox",
|
||||||
|
data_animal_id=animal.animal_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
Td(
|
Td(
|
||||||
A(
|
A(
|
||||||
display_id,
|
display_id,
|
||||||
@@ -319,10 +353,208 @@ def load_more_sentinel(cursor: str, filter_str: str) -> Tr:
|
|||||||
"Loading more...",
|
"Loading more...",
|
||||||
cls="text-center text-stone-400 py-4",
|
cls="text-center text-stone-400 py-4",
|
||||||
),
|
),
|
||||||
colspan="8",
|
colspan="9", # Updated for checkbox column
|
||||||
),
|
),
|
||||||
hx_get=url,
|
hx_get=url,
|
||||||
hx_trigger="revealed",
|
hx_trigger="revealed",
|
||||||
hx_swap="outerHTML",
|
hx_swap="outerHTML",
|
||||||
id="load-more-sentinel",
|
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