feat: add event detail slide-over, fix toasts, and checkbox selection

Three major features implemented:

1. Event Detail Slide-Over Panel
   - Click timeline events to view details in slide-over
   - New /events/{event_id} route and event_detail.py template
   - Type-specific payload rendering for all event types

2. Toast System Refactor
   - Switch from custom addEventListener to FastHTML's add_toast()
   - Replace HX-Trigger headers with session-based toasts
   - Add event links in toast messages
   - Replace addEventListener with hx_on_* in templates

3. Checkbox Selection for Animal Subsets
   - New animal_select.py component with checkbox list
   - New /api/compute-hash and /api/selection-preview endpoints
   - Add subset_mode support to SelectionContext validation
   - Update 5 forms: outcome, move, tag-add, tag-end, attrs
   - Users can select specific animals from filtered results

🤖 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-01 19:10:57 +00:00
parent 25a91c3322
commit 3937d675ba
19 changed files with 1420 additions and 360 deletions

View File

@@ -107,6 +107,68 @@ class AnimalRepository:
last_event_utc=row[13], last_event_utc=row[13],
) )
def get_by_ids(self, animal_ids: list[str]) -> list[AnimalListItem]:
"""Get multiple animals by ID with display info.
Args:
animal_ids: List of animal IDs to look up.
Returns:
List of AnimalListItem with display info. Order matches input IDs
for animals that exist.
"""
if not animal_ids:
return []
placeholders = ",".join("?" * len(animal_ids))
query = f"""
SELECT
ar.animal_id,
ar.species_code,
ar.sex,
ar.life_stage,
ar.status,
ar.location_id,
l.name as location_name,
ar.nickname,
ar.identified,
ar.last_event_utc,
COALESCE(
(SELECT json_group_array(tag)
FROM animal_tag_intervals ati
WHERE ati.animal_id = ar.animal_id
AND ati.end_utc IS NULL),
'[]'
) as tags
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE ar.animal_id IN ({placeholders})
"""
rows = self.db.execute(query, animal_ids).fetchall()
# Build lookup dict
items_by_id = {}
for row in rows:
tags_json = row[10]
tags = json.loads(tags_json) if tags_json else []
items_by_id[row[0]] = AnimalListItem(
animal_id=row[0],
species_code=row[1],
sex=row[2],
life_stage=row[3],
status=row[4],
location_id=row[5],
location_name=row[6],
nickname=row[7],
identified=bool(row[8]),
last_event_utc=row[9],
tags=tags,
)
# Return in original order, filtering out non-existent IDs
return [items_by_id[aid] for aid in animal_ids if aid in items_by_id]
def list_animals( def list_animals(
self, self,
filter_str: str = "", filter_str: str = "",

View File

@@ -23,6 +23,8 @@ class SelectionContext:
from_location_id: str | None # For move operations (included in hash) from_location_id: str | None # For move operations (included in hash)
confirmed: bool = False # Override on mismatch confirmed: bool = False # Override on mismatch
resolver_version: str = "v1" # Fixed version string resolver_version: str = "v1" # Fixed version string
subset_mode: bool = False # True when user selected a subset via checkboxes
selected_ids: list[str] | None = None # Subset of IDs selected by user
@dataclass @dataclass
@@ -101,6 +103,10 @@ def validate_selection(
confirmed=True. Returns valid=False with diff if mismatch and not confirmed=True. Returns valid=False with diff if mismatch and not
confirmed. confirmed.
In subset_mode, validates that selected_ids are a valid subset of
the filter resolution. Hash is computed from selected_ids, not the
full resolution.
Args: Args:
db: Database connection. db: Database connection.
context: SelectionContext with client's filter, IDs, and hash. context: SelectionContext with client's filter, IDs, and hash.
@@ -112,6 +118,11 @@ def validate_selection(
filter_ast = parse_filter(context.filter) filter_ast = parse_filter(context.filter)
resolution = resolve_filter(db, filter_ast, context.ts_utc) resolution = resolve_filter(db, filter_ast, context.ts_utc)
if context.subset_mode and context.selected_ids is not None:
# Subset mode: validate that all selected IDs are in the resolved set
return _validate_subset(db, context, resolution.animal_ids)
# Standard mode: compare full resolution hashes
# Compute server's hash (including from_location_id if provided) # Compute server's hash (including from_location_id if provided)
server_hash = compute_roster_hash( server_hash = compute_roster_hash(
resolution.animal_ids, resolution.animal_ids,
@@ -147,3 +158,71 @@ def validate_selection(
roster_hash=server_hash, roster_hash=server_hash,
diff=diff, diff=diff,
) )
def _validate_subset(
db: Any,
context: SelectionContext,
resolved_ids: list[str],
) -> SelectionValidationResult:
"""Validate subset selection against filter resolution.
Checks that all selected IDs are in the resolved set (still match filter).
IDs that no longer match are reported in the diff.
Args:
db: Database connection.
context: SelectionContext with subset_mode=True and selected_ids.
resolved_ids: IDs from resolving the filter at ts_utc.
Returns:
SelectionValidationResult with validation status.
"""
selected_ids = context.selected_ids or []
resolved_set = set(resolved_ids)
selected_set = set(selected_ids)
# Find selected IDs that no longer match the filter
invalid_ids = selected_set - resolved_set
if not invalid_ids:
# All selected IDs are valid - compute hash from selected IDs
subset_hash = compute_roster_hash(selected_ids, context.from_location_id)
# Verify hash matches what client sent
if subset_hash == context.roster_hash:
return SelectionValidationResult(
valid=True,
resolved_ids=selected_ids,
roster_hash=context.roster_hash,
diff=None,
)
# Some selected IDs are no longer valid, or hash mismatch
# Compute diff: removed = invalid_ids, added = none
diff = SelectionDiff(
added=[],
removed=sorted(invalid_ids),
server_count=len(resolved_ids),
client_count=len(selected_ids),
)
if context.confirmed and not invalid_ids:
# Client confirmed, and all IDs are still valid
return SelectionValidationResult(
valid=True,
resolved_ids=selected_ids,
roster_hash=context.roster_hash,
diff=diff,
)
# Invalid - return with valid selected IDs (those that still match)
valid_selected = [sid for sid in selected_ids if sid in resolved_set]
new_hash = compute_roster_hash(valid_selected, context.from_location_id)
return SelectionValidationResult(
valid=False,
resolved_ids=valid_selected,
roster_hash=new_hash,
diff=diff,
)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from fasthtml.common import Beforeware, Meta, fast_app from fasthtml.common import Beforeware, Meta, fast_app, setup_toasts
from monsterui.all import Theme from monsterui.all import Theme
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.requests import Request from starlette.requests import Request
@@ -22,6 +22,7 @@ from animaltrack.web.middleware import (
from animaltrack.web.routes import ( from animaltrack.web.routes import (
actions_router, actions_router,
animals_router, animals_router,
api_router,
eggs_router, eggs_router,
events_router, events_router,
feed_router, feed_router,
@@ -143,6 +144,9 @@ def create_app(
app.state.settings = settings app.state.settings = settings
app.state.db = db app.state.db = db
# Setup toast notifications with 5 second duration
setup_toasts(app, duration=5000)
# Register exception handlers for auth errors # Register exception handlers for auth errors
async def authentication_error_handler(request, exc): async def authentication_error_handler(request, exc):
return PlainTextResponse(str(exc) or "Authentication required", status_code=401) return PlainTextResponse(str(exc) or "Authentication required", status_code=401)
@@ -157,6 +161,7 @@ def create_app(
health_router.to_app(app) health_router.to_app(app)
actions_router.to_app(app) actions_router.to_app(app)
animals_router.to_app(app) animals_router.to_app(app)
api_router.to_app(app)
eggs_router.to_app(app) eggs_router.to_app(app)
events_router.to_app(app) events_router.to_app(app)
feed_router.to_app(app) feed_router.to_app(app)

View File

@@ -3,6 +3,7 @@
from animaltrack.web.routes.actions import ar as actions_router from animaltrack.web.routes.actions import ar as actions_router
from animaltrack.web.routes.animals import ar as animals_router from animaltrack.web.routes.animals import ar as animals_router
from animaltrack.web.routes.api import ar as api_router
from animaltrack.web.routes.eggs import ar as eggs_router from animaltrack.web.routes.eggs import ar as eggs_router
from animaltrack.web.routes.events import ar as events_router from animaltrack.web.routes.events import ar as events_router
from animaltrack.web.routes.feed import ar as feed_router from animaltrack.web.routes.feed import ar as feed_router
@@ -15,6 +16,7 @@ from animaltrack.web.routes.registry import ar as registry_router
__all__ = [ __all__ = [
"actions_router", "actions_router",
"animals_router", "animals_router",
"api_router",
"eggs_router", "eggs_router",
"events_router", "events_router",
"feed_router", "feed_router",

View File

@@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json
import time import time
from typing import Any from typing import Any
from fasthtml.common import APIRouter, to_xml from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
@@ -119,7 +118,7 @@ def cohort_index(request: Request):
@ar("/actions/animal-cohort", methods=["POST"]) @ar("/actions/animal-cohort", methods=["POST"])
async def animal_cohort(request: Request): async def animal_cohort(request: Request, session):
"""POST /actions/animal-cohort - Create a new animal cohort.""" """POST /actions/animal-cohort - Create a new animal cohort."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -198,8 +197,16 @@ async def animal_cohort(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_cohort_error(request, locations, species_list, str(e), form) return _render_cohort_error(request, locations, species_list, str(e), form)
# Add success toast with link to event
animal_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Created {animal_count} {species}(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -210,19 +217,6 @@ async def animal_cohort(request: Request):
), ),
) )
# Add toast trigger header
animal_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Created {animal_count} {species}(s)",
"type": "success",
}
}
)
return response
def _render_cohort_error( def _render_cohort_error(
request: Request, request: Request,
@@ -280,7 +274,7 @@ def hatch_index(request: Request):
@ar("/actions/hatch-recorded", methods=["POST"]) @ar("/actions/hatch-recorded", methods=["POST"])
async def hatch_recorded(request: Request): async def hatch_recorded(request: Request, session):
"""POST /actions/hatch-recorded - Record a hatch event.""" """POST /actions/hatch-recorded - Record a hatch event."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -346,8 +340,16 @@ async def hatch_recorded(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_hatch_error(request, locations, species_list, str(e), form) return _render_hatch_error(request, locations, species_list, str(e), form)
# Add success toast with link to event
animal_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Recorded {animal_count} hatchling(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -358,19 +360,6 @@ async def hatch_recorded(request: Request):
), ),
) )
# Add toast trigger header
animal_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {animal_count} hatchling(s)",
"type": "success",
}
}
)
return response
def _render_hatch_error( def _render_hatch_error(
request: Request, request: Request,
@@ -547,6 +536,7 @@ def tag_add_index(request: Request):
ts_utc = int(time.time() * 1000) ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = [] resolved_ids: list[str] = []
roster_hash = "" roster_hash = ""
animals = []
if filter_str: if filter_str:
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
@@ -555,6 +545,9 @@ def tag_add_index(request: Request):
if resolved_ids: if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None) roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
return render_page( return render_page(
request, request,
@@ -564,6 +557,7 @@ def tag_add_index(request: Request):
roster_hash=roster_hash, roster_hash=roster_hash,
ts_utc=ts_utc, ts_utc=ts_utc,
resolved_count=len(resolved_ids), resolved_count=len(resolved_ids),
animals=animals,
), ),
title="Add Tag - AnimalTrack", title="Add Tag - AnimalTrack",
active_nav=None, active_nav=None,
@@ -571,7 +565,7 @@ def tag_add_index(request: Request):
@ar("/actions/animal-tag-add", methods=["POST"]) @ar("/actions/animal-tag-add", methods=["POST"])
async def animal_tag_add(request: Request): async def animal_tag_add(request: Request, session):
"""POST /actions/animal-tag-add - Add tag to animals.""" """POST /actions/animal-tag-add - Add tag to animals."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -589,12 +583,22 @@ async def animal_tag_add(request: Request):
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to tag
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: tag required # Validation: tag required
if not tag: if not tag:
return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag") return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag")
# Validation: must have animals # Validation: must have animals
if not resolved_ids: if not ids_for_validation:
return _render_tag_add_error_form(request, db, filter_str, "No animals selected") return _render_tag_add_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation # Build selection context for validation
@@ -605,6 +609,8 @@ async def animal_tag_add(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
from_location_id=None, from_location_id=None,
confirmed=confirmed, confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
) )
# Validate selection (check for concurrent changes) # Validate selection (check for concurrent changes)
@@ -631,14 +637,26 @@ async def animal_tag_add(request: Request):
status_code=409, status_code=409,
) )
# When confirmed, re-resolve to get current server IDs # Determine which IDs to use for the update
if confirmed: if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_tag = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_tag = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc) current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts) current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_tag = current_resolution.animal_ids ids_to_tag = current_resolution.animal_ids
else: else:
ids_to_tag = resolved_ids ids_to_tag = list(resolved_ids)
# Check we still have animals # Check we still have animals
if not ids_to_tag: if not ids_to_tag:
@@ -667,8 +685,16 @@ async def animal_tag_add(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_tag_add_error_form(request, db, filter_str, str(e)) return _render_tag_add_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
actually_tagged = event.entity_refs.get("actually_tagged", [])
add_toast(
session,
f"Tagged {len(actually_tagged)} animal(s) as '{tag}'. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -679,19 +705,6 @@ async def animal_tag_add(request: Request):
), ),
) )
# Add toast trigger header
actually_tagged = event.entity_refs.get("actually_tagged", [])
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Tagged {len(actually_tagged)} animal(s) as '{tag}'",
"type": "success",
}
}
)
return response
def _render_tag_add_error_form(request, db, filter_str, error_message): def _render_tag_add_error_form(request, db, filter_str, error_message):
"""Render tag add form with error message.""" """Render tag add form with error message."""
@@ -774,6 +787,7 @@ def tag_end_index(request: Request):
resolved_ids: list[str] = [] resolved_ids: list[str] = []
roster_hash = "" roster_hash = ""
active_tags: list[str] = [] active_tags: list[str] = []
animals = []
if filter_str: if filter_str:
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
@@ -783,6 +797,9 @@ def tag_end_index(request: Request):
if resolved_ids: if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None) roster_hash = compute_roster_hash(resolved_ids, None)
active_tags = _get_active_tags_for_animals(db, resolved_ids) active_tags = _get_active_tags_for_animals(db, resolved_ids)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
return render_page( return render_page(
request, request,
@@ -793,6 +810,7 @@ def tag_end_index(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
resolved_count=len(resolved_ids), resolved_count=len(resolved_ids),
active_tags=active_tags, active_tags=active_tags,
animals=animals,
), ),
title="End Tag - AnimalTrack", title="End Tag - AnimalTrack",
active_nav=None, active_nav=None,
@@ -800,7 +818,7 @@ def tag_end_index(request: Request):
@ar("/actions/animal-tag-end", methods=["POST"]) @ar("/actions/animal-tag-end", methods=["POST"])
async def animal_tag_end(request: Request): async def animal_tag_end(request: Request, session):
"""POST /actions/animal-tag-end - End tag on animals.""" """POST /actions/animal-tag-end - End tag on animals."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -818,12 +836,22 @@ async def animal_tag_end(request: Request):
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to untag
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: tag required # Validation: tag required
if not tag: if not tag:
return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end") return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end")
# Validation: must have animals # Validation: must have animals
if not resolved_ids: if not ids_for_validation:
return _render_tag_end_error_form(request, db, filter_str, "No animals selected") return _render_tag_end_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation # Build selection context for validation
@@ -834,6 +862,8 @@ async def animal_tag_end(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
from_location_id=None, from_location_id=None,
confirmed=confirmed, confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
) )
# Validate selection (check for concurrent changes) # Validate selection (check for concurrent changes)
@@ -860,14 +890,26 @@ async def animal_tag_end(request: Request):
status_code=409, status_code=409,
) )
# When confirmed, re-resolve to get current server IDs # Determine which IDs to use for the update
if confirmed: if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_untag = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_untag = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc) current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts) current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_untag = current_resolution.animal_ids ids_to_untag = current_resolution.animal_ids
else: else:
ids_to_untag = resolved_ids ids_to_untag = list(resolved_ids)
# Check we still have animals # Check we still have animals
if not ids_to_untag: if not ids_to_untag:
@@ -896,8 +938,16 @@ async def animal_tag_end(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_tag_end_error_form(request, db, filter_str, str(e)) return _render_tag_end_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
actually_ended = event.entity_refs.get("actually_ended", [])
add_toast(
session,
f"Ended tag '{tag}' on {len(actually_ended)} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -908,19 +958,6 @@ async def animal_tag_end(request: Request):
), ),
) )
# Add toast trigger header
actually_ended = event.entity_refs.get("actually_ended", [])
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Ended tag '{tag}' on {len(actually_ended)} animal(s)",
"type": "success",
}
}
)
return response
def _render_tag_end_error_form(request, db, filter_str, error_message): def _render_tag_end_error_form(request, db, filter_str, error_message):
"""Render tag end form with error message.""" """Render tag end form with error message."""
@@ -977,6 +1014,7 @@ def attrs_index(request: Request):
ts_utc = int(time.time() * 1000) ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = [] resolved_ids: list[str] = []
roster_hash = "" roster_hash = ""
animals = []
if filter_str: if filter_str:
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
@@ -985,6 +1023,9 @@ def attrs_index(request: Request):
if resolved_ids: if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None) roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
return render_page( return render_page(
request, request,
@@ -994,6 +1035,7 @@ def attrs_index(request: Request):
roster_hash=roster_hash, roster_hash=roster_hash,
ts_utc=ts_utc, ts_utc=ts_utc,
resolved_count=len(resolved_ids), resolved_count=len(resolved_ids),
animals=animals,
), ),
title="Update Attributes - AnimalTrack", title="Update Attributes - AnimalTrack",
active_nav=None, active_nav=None,
@@ -1001,7 +1043,7 @@ def attrs_index(request: Request):
@ar("/actions/animal-attrs", methods=["POST"]) @ar("/actions/animal-attrs", methods=["POST"])
async def animal_attrs(request: Request): async def animal_attrs(request: Request, session):
"""POST /actions/animal-attrs - Update attributes on animals.""" """POST /actions/animal-attrs - Update attributes on animals."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -1021,6 +1063,16 @@ async def animal_attrs(request: Request):
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to update
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: at least one attribute required # Validation: at least one attribute required
if not sex and not life_stage and not repro_status: if not sex and not life_stage and not repro_status:
return _render_attrs_error_form( return _render_attrs_error_form(
@@ -1028,7 +1080,7 @@ async def animal_attrs(request: Request):
) )
# Validation: must have animals # Validation: must have animals
if not resolved_ids: if not ids_for_validation:
return _render_attrs_error_form(request, db, filter_str, "No animals selected") return _render_attrs_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation # Build selection context for validation
@@ -1039,6 +1091,8 @@ async def animal_attrs(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
from_location_id=None, from_location_id=None,
confirmed=confirmed, confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
) )
# Validate selection (check for concurrent changes) # Validate selection (check for concurrent changes)
@@ -1067,14 +1121,26 @@ async def animal_attrs(request: Request):
status_code=409, status_code=409,
) )
# When confirmed, re-resolve to get current server IDs # Determine which IDs to use for the update
if confirmed: if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_update = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_update = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc) current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts) current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids ids_to_update = current_resolution.animal_ids
else: else:
ids_to_update = resolved_ids ids_to_update = list(resolved_ids)
# Check we still have animals # Check we still have animals
if not ids_to_update: if not ids_to_update:
@@ -1108,8 +1174,16 @@ async def animal_attrs(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_attrs_error_form(request, db, filter_str, str(e)) return _render_attrs_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
updated_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Updated attributes on {updated_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -1120,19 +1194,6 @@ async def animal_attrs(request: Request):
), ),
) )
# Add toast trigger header
updated_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Updated attributes on {updated_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_attrs_error_form(request, db, filter_str, error_message): def _render_attrs_error_form(request, db, filter_str, error_message):
"""Render attributes form with error message.""" """Render attributes form with error message."""
@@ -1186,6 +1247,7 @@ def outcome_index(request: Request):
ts_utc = int(time.time() * 1000) ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = [] resolved_ids: list[str] = []
roster_hash = "" roster_hash = ""
animals = []
if filter_str: if filter_str:
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
@@ -1194,6 +1256,9 @@ def outcome_index(request: Request):
if resolved_ids: if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None) roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get active products for yield items dropdown # Get active products for yield items dropdown
product_repo = ProductRepository(db) product_repo = ProductRepository(db)
@@ -1208,6 +1273,7 @@ def outcome_index(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
resolved_count=len(resolved_ids), resolved_count=len(resolved_ids),
products=products, products=products,
animals=animals,
), ),
title="Record Outcome - AnimalTrack", title="Record Outcome - AnimalTrack",
active_nav=None, active_nav=None,
@@ -1215,7 +1281,7 @@ def outcome_index(request: Request):
@ar("/actions/animal-outcome", methods=["POST"]) @ar("/actions/animal-outcome", methods=["POST"])
async def animal_outcome(request: Request): async def animal_outcome(request: Request, session):
"""POST /actions/animal-outcome - Record outcome for animals.""" """POST /actions/animal-outcome - Record outcome for animals."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -1256,6 +1322,16 @@ async def animal_outcome(request: Request):
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to update
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: outcome required # Validation: outcome required
if not outcome_str: if not outcome_str:
return _render_outcome_error_form(request, db, filter_str, "Please select an outcome") return _render_outcome_error_form(request, db, filter_str, "Please select an outcome")
@@ -1269,7 +1345,7 @@ async def animal_outcome(request: Request):
) )
# Validation: must have animals # Validation: must have animals
if not resolved_ids: if not ids_for_validation:
return _render_outcome_error_form(request, db, filter_str, "No animals selected") return _render_outcome_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation # Build selection context for validation
@@ -1280,6 +1356,8 @@ async def animal_outcome(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
from_location_id=None, from_location_id=None,
confirmed=confirmed, confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
) )
# Validate selection (check for concurrent changes) # Validate selection (check for concurrent changes)
@@ -1311,14 +1389,26 @@ async def animal_outcome(request: Request):
status_code=409, status_code=409,
) )
# When confirmed, re-resolve to get current server IDs # Determine which IDs to use for the update
if confirmed: if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_update = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_update = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc) current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts) current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids ids_to_update = current_resolution.animal_ids
else: else:
ids_to_update = resolved_ids ids_to_update = list(resolved_ids)
# Check we still have animals # Check we still have animals
if not ids_to_update: if not ids_to_update:
@@ -1366,11 +1456,19 @@ async def animal_outcome(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_outcome_error_form(request, db, filter_str, str(e)) return _render_outcome_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
outcome_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Recorded {outcome_str} for {outcome_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
product_repo = ProductRepository(db) product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active] products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -1388,19 +1486,6 @@ async def animal_outcome(request: Request):
), ),
) )
# Add toast trigger header
outcome_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {outcome_str} for {outcome_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_outcome_error_form(request, db, filter_str, error_message): def _render_outcome_error_form(request, db, filter_str, error_message):
"""Render outcome form with error message.""" """Render outcome form with error message."""
@@ -1485,7 +1570,7 @@ async def status_correct_index(req: Request):
@ar("/actions/animal-status-correct", methods=["POST"]) @ar("/actions/animal-status-correct", methods=["POST"])
@require_role(UserRole.ADMIN) @require_role(UserRole.ADMIN)
async def animal_status_correct(req: Request): async def animal_status_correct(req: Request, session):
"""POST /actions/animal-status-correct - Correct status of animals (admin-only).""" """POST /actions/animal-status-correct - Correct status of animals (admin-only)."""
db = req.app.state.db db = req.app.state.db
form = await req.form() form = await req.form()
@@ -1598,8 +1683,16 @@ async def animal_status_correct(req: Request):
except ValidationError as e: except ValidationError as e:
return _render_status_correct_error_form(req, db, filter_str, str(e)) return _render_status_correct_error_form(req, db, filter_str, str(e))
# Add success toast with link to event
corrected_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Corrected status to {new_status_str} for {corrected_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form # Success: re-render fresh form
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
req, req,
@@ -1616,19 +1709,6 @@ async def animal_status_correct(req: Request):
), ),
) )
# Add toast trigger header
corrected_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Corrected status to {new_status_str} for {corrected_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_status_correct_error_form(request, db, filter_str, error_message): def _render_status_correct_error_form(request, db, filter_str, error_message):
"""Render status correct form with error message.""" """Render status correct form with error message."""

View File

@@ -0,0 +1,84 @@
# ABOUTME: API routes for HTMX partial updates.
# ABOUTME: Provides endpoints for selection preview and hash computation.
from __future__ import annotations
import time
from fasthtml.common import APIRouter
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.web.templates.animal_select import animal_checkbox_list
# APIRouter for multi-file route organization
ar = APIRouter()
@ar("/api/compute-hash", methods=["POST"])
async def compute_hash(request: Request):
"""POST /api/compute-hash - Compute roster hash for selected IDs.
Expects JSON body with:
- selected_ids: list of animal IDs
- from_location_id: optional location ID
Returns JSON with:
- roster_hash: computed hash string
"""
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
selected_ids = body.get("selected_ids", [])
from_location_id = body.get("from_location_id") or None
roster_hash = compute_roster_hash(selected_ids, from_location_id)
return JSONResponse({"roster_hash": roster_hash})
@ar("/api/selection-preview")
def selection_preview(request: Request):
"""GET /api/selection-preview - Get animal checkbox list for filter.
Query params:
- filter: DSL filter string
- selected_ids: optional comma-separated IDs to pre-select
Returns HTML partial with checkbox list.
"""
db = request.app.state.db
filter_str = request.query_params.get("filter", "")
selected_param = request.query_params.get("selected_ids", "")
# Parse pre-selected IDs if provided
selected_ids = None
if selected_param:
selected_ids = [s.strip() for s in selected_param.split(",") if s.strip()]
# Resolve filter to get animals
ts_utc = int(time.time() * 1000)
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
if not resolution.animal_ids:
return HTMLResponse(
content="<p class='text-stone-500 text-sm'>No animals match this filter</p>"
)
# Get animal details
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolution.animal_ids)
# If no pre-selection, default to all selected
if selected_ids is None:
selected_ids = [a.animal_id for a in animals]
# Render checkbox list
from fasthtml.common import to_xml
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))

View File

@@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json
import time import time
from typing import Any from typing import Any
from fasthtml.common import APIRouter, to_xml from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
@@ -112,7 +111,7 @@ def egg_index(request: Request):
@ar("/actions/product-collected", methods=["POST"]) @ar("/actions/product-collected", methods=["POST"])
async def product_collected(request: Request): async def product_collected(request: Request, session):
"""POST /actions/product-collected - Record egg collection.""" """POST /actions/product-collected - Record egg collection."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -181,7 +180,7 @@ async def product_collected(request: Request):
# Collect product # Collect product
try: try:
product_service.collect_product( event = product_service.collect_product(
payload=payload, payload=payload,
ts_utc=ts_utc, ts_utc=ts_utc,
actor=actor, actor=actor,
@@ -202,8 +201,15 @@ async def product_collected(request: Request):
) )
) )
# Add success toast with link to event
add_toast(
session,
f"Recorded {quantity} eggs. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with location sticking, qty cleared # Success: re-render form with location sticking, qty cleared
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -221,16 +227,9 @@ async def product_collected(request: Request):
), ),
) )
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{"showToast": {"message": f"Recorded {quantity} eggs", "type": "success"}}
)
return response
@ar("/actions/product-sold", methods=["POST"]) @ar("/actions/product-sold", methods=["POST"])
async def product_sold(request: Request): async def product_sold(request: Request, session):
"""POST /actions/product-sold - Record product sale (from Eggs page Sell tab).""" """POST /actions/product-sold - Record product sale (from Eggs page Sell tab)."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -303,7 +302,7 @@ async def product_sold(request: Request):
# Sell product # Sell product
try: try:
product_service.sell_product( event = product_service.sell_product(
payload=payload, payload=payload,
ts_utc=ts_utc, ts_utc=ts_utc,
actor=actor, actor=actor,
@@ -313,8 +312,15 @@ async def product_sold(request: Request):
except ValidationError as e: except ValidationError as e:
return _render_sell_error(request, locations, products, product_code, str(e)) return _render_sell_error(request, locations, products, product_code, str(e))
# Add success toast with link to event
add_toast(
session,
f"Recorded sale of {quantity} {product_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with product sticking # Success: re-render form with product sticking
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -332,13 +338,6 @@ async def product_sold(request: Request):
), ),
) )
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{"showToast": {"message": f"Recorded sale of {quantity} {product_code}", "type": "success"}}
)
return response
def _render_harvest_error(request, locations, products, selected_location_id, error_message): def _render_harvest_error(request, locations, products, selected_location_id, error_message):
"""Render harvest form with error message. """Render harvest form with error message.

View File

@@ -1,5 +1,5 @@
# ABOUTME: Routes for event log functionality. # ABOUTME: Routes for event log and event detail functionality.
# ABOUTME: Handles GET /event-log for viewing location event history. # ABOUTME: Handles GET /event-log for location event history and GET /events/{id} for event details.
from __future__ import annotations from __future__ import annotations
@@ -10,9 +10,11 @@ from fasthtml.common import APIRouter, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from animaltrack.events.store import EventStore
from animaltrack.repositories.locations import LocationRepository from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page
from animaltrack.web.templates.event_detail import event_detail_panel
from animaltrack.web.templates.events import event_log_list, event_log_panel from animaltrack.web.templates.events import event_log_list, event_log_panel
# APIRouter for multi-file route organization # APIRouter for multi-file route organization
@@ -105,3 +107,72 @@ def event_log_index(request: Request):
title="Event Log - AnimalTrack", title="Event Log - AnimalTrack",
active_nav="event_log", active_nav="event_log",
) )
def get_event_animals(db: Any, event_id: str) -> 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.
Returns:
List of animal dicts with id, nickname, species_name.
"""
rows = db.execute(
"""
SELECT ar.id, ar.nickname, s.name as species_name
FROM event_animals ea
JOIN animal_registry ar ON ar.id = ea.animal_id
JOIN species s ON s.code = ar.species_code
WHERE ea.event_id = ?
ORDER BY ar.nickname NULLS LAST, ar.id
""",
(event_id,),
).fetchall()
return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows]
@ar("/events/{event_id}")
def event_detail(request: Request, event_id: str):
"""GET /events/{event_id} - Event detail panel for slide-over."""
db = request.app.state.db
# Get event from store
event_store = EventStore(db)
event = event_store.get_event(event_id)
if event is None:
return HTMLResponse(
content="<div class='p-4 text-red-400'>Event not found</div>",
status_code=404,
)
# Check if tombstoned
is_tombstoned = event_store.is_tombstoned(event_id)
# Get affected animals
affected_animals = get_event_animals(db, event_id)
# Get location names if entity_refs has location IDs
location_names = {}
location_ids = []
if "location_id" in event.entity_refs:
location_ids.append(event.entity_refs["location_id"])
if "from_location_id" in event.entity_refs:
location_ids.append(event.entity_refs["from_location_id"])
if "to_location_id" in event.entity_refs:
location_ids.append(event.entity_refs["to_location_id"])
if location_ids:
loc_repo = LocationRepository(db)
for loc_id in location_ids:
loc = loc_repo.get(loc_id)
if loc:
location_names[loc_id] = loc.name
# Return slide-over panel HTML
return HTMLResponse(
content=to_xml(event_detail_panel(event, affected_animals, is_tombstoned, location_names)),
)

View File

@@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json
import time import time
from typing import Any from typing import Any
from fasthtml.common import APIRouter from fasthtml.common import APIRouter, add_toast
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
@@ -109,7 +108,7 @@ def feed_index(request: Request):
@ar("/actions/feed-given", methods=["POST"]) @ar("/actions/feed-given", methods=["POST"])
async def feed_given(request: Request): async def feed_given(request: Request, session):
"""POST /actions/feed-given - Record feed given.""" """POST /actions/feed-given - Record feed given."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -202,7 +201,7 @@ async def feed_given(request: Request):
# Give feed # Give feed
try: try:
feed_service.give_feed( event = feed_service.give_feed(
payload=payload, payload=payload,
ts_utc=ts_utc, ts_utc=ts_utc,
actor=actor, actor=actor,
@@ -238,8 +237,15 @@ async def feed_given(request: Request):
) )
) )
# Add success toast with link to event
add_toast(
session,
f"Recorded {amount_kg}kg {feed_type_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with location/type sticking, amount reset # Success: re-render form with location/type sticking, amount reset
response = HTMLResponse( return HTMLResponse(
content=str( content=str(
render_page( render_page(
request, request,
@@ -260,21 +266,9 @@ async def feed_given(request: Request):
), ),
) )
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {amount_kg}kg {feed_type_code}",
"type": "success",
}
}
)
return response
@ar("/actions/feed-purchased", methods=["POST"]) @ar("/actions/feed-purchased", methods=["POST"])
async def feed_purchased(request: Request): async def feed_purchased(request: Request, session):
"""POST /actions/feed-purchased - Record feed purchase.""" """POST /actions/feed-purchased - Record feed purchase."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -384,7 +378,7 @@ async def feed_purchased(request: Request):
# Purchase feed # Purchase feed
try: try:
feed_service.purchase_feed( event = feed_service.purchase_feed(
payload=payload, payload=payload,
ts_utc=ts_utc, ts_utc=ts_utc,
actor=actor, actor=actor,
@@ -402,8 +396,15 @@ async def feed_purchased(request: Request):
# Calculate total for toast # Calculate total for toast
total_kg = bag_size_kg * bags_count total_kg = bag_size_kg * bags_count
# Add success toast with link to event
add_toast(
session,
f"Purchased {total_kg}kg {feed_type_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with fields cleared # Success: re-render form with fields cleared
response = HTMLResponse( return HTMLResponse(
content=str( content=str(
render_page( render_page(
request, request,
@@ -420,18 +421,6 @@ async def feed_purchased(request: Request):
), ),
) )
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Purchased {total_kg}kg {feed_type_code}",
"type": "success",
}
}
)
return response
def _render_give_error( def _render_give_error(
request, request,

View File

@@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json
import time import time
from typing import Any from typing import Any
from fasthtml.common import APIRouter, to_xml from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
@@ -17,6 +16,7 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection from animaltrack.projections.intervals import IntervalProjection
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository from animaltrack.repositories.locations import LocationRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection from animaltrack.selection.validation import SelectionContext, validate_selection
@@ -100,6 +100,7 @@ def move_index(request: Request):
roster_hash = "" roster_hash = ""
from_location_id = None from_location_id = None
from_location_name = None from_location_name = None
animals = []
if filter_str or not request.query_params: if filter_str or not request.query_params:
# If no filter, default to empty (show all alive animals) # If no filter, default to empty (show all alive animals)
@@ -110,6 +111,9 @@ def move_index(request: Request):
if resolved_ids: if resolved_ids:
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc) from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id) roster_hash = compute_roster_hash(resolved_ids, from_location_id)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
return render_page( return render_page(
request, request,
@@ -123,6 +127,7 @@ def move_index(request: Request):
resolved_count=len(resolved_ids), resolved_count=len(resolved_ids),
from_location_name=from_location_name, from_location_name=from_location_name,
action=animal_move, action=animal_move,
animals=animals,
), ),
title="Move - AnimalTrack", title="Move - AnimalTrack",
active_nav="move", active_nav="move",
@@ -130,7 +135,7 @@ def move_index(request: Request):
@ar("/actions/animal-move", methods=["POST"]) @ar("/actions/animal-move", methods=["POST"])
async def animal_move(request: Request): async def animal_move(request: Request, session):
"""POST /actions/animal-move - Move animals to new location.""" """POST /actions/animal-move - Move animals to new location."""
db = request.app.state.db db = request.app.state.db
form = await request.form() form = await request.form()
@@ -149,6 +154,16 @@ async def animal_move(request: Request):
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to move
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Get locations for potential re-render # Get locations for potential re-render
locations = LocationRepository(db).list_active() locations = LocationRepository(db).list_active()
@@ -157,7 +172,7 @@ async def animal_move(request: Request):
return _render_error_form(request, db, locations, filter_str, "Please select a destination") return _render_error_form(request, db, locations, filter_str, "Please select a destination")
# Validation: must have animals # Validation: must have animals
if not resolved_ids: if not ids_for_validation:
return _render_error_form(request, db, locations, filter_str, "No animals selected to move") return _render_error_form(request, db, locations, filter_str, "No animals selected to move")
# Validation: destination must be different from source # Validation: destination must be different from source
@@ -186,6 +201,8 @@ async def animal_move(request: Request):
ts_utc=ts_utc, ts_utc=ts_utc,
from_location_id=from_location_id, from_location_id=from_location_id,
confirmed=confirmed, confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
) )
# Validate selection (check for concurrent changes) # Validate selection (check for concurrent changes)
@@ -215,11 +232,22 @@ async def animal_move(request: Request):
status_code=409, status_code=409,
) )
# When confirmed, re-resolve to get current server IDs (per spec: "server re-resolves") # Determine which IDs to use for the move
if confirmed: if subset_mode and selected_ids:
# Re-resolve the filter at current timestamp to get animals still matching # In subset mode, use the selected IDs from checkboxes
# Use max of current time and form's ts_utc to ensure we resolve at least if confirmed:
# as late as the submission - important when moves happened after client's resolution # When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_move = [sid for sid in selected_ids if sid in current_set]
# Update from_location_id based on filtered selected IDs
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
else:
ids_to_move = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc) current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts) current_resolution = resolve_filter(db, filter_ast, current_ts)
@@ -227,7 +255,7 @@ async def animal_move(request: Request):
# Update from_location_id based on current resolution # Update from_location_id based on current resolution
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts) from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
else: else:
ids_to_move = resolved_ids ids_to_move = list(resolved_ids)
# Check we still have animals to move after validation # Check we still have animals to move after validation
if not ids_to_move: if not ids_to_move:
@@ -257,14 +285,21 @@ async def animal_move(request: Request):
# Move animals # Move animals
try: try:
animal_service.move_animals( event = animal_service.move_animals(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move" payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
) )
except ValidationError as e: except ValidationError as e:
return _render_error_form(request, db, locations, filter_str, str(e)) return _render_error_form(request, db, locations, filter_str, str(e))
# Add success toast with link to event
add_toast(
session,
f"Moved {len(ids_to_move)} animals to {dest_location.name}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form (nothing sticks per spec) # Success: re-render fresh form (nothing sticks per spec)
response = HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
request, request,
@@ -278,18 +313,6 @@ async def animal_move(request: Request):
), ),
) )
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Moved {len(ids_to_move)} animals to {dest_location.name}",
"type": "success",
}
}
)
return response
def _render_error_form(request, db, locations, filter_str, error_message): def _render_error_form(request, db, locations, filter_str, error_message):
"""Render form with error message. """Render form with error message.

View File

@@ -4,7 +4,7 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Script, Span from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Span
from monsterui.all import ( from monsterui.all import (
Alert, Alert,
AlertT, AlertT,
@@ -45,7 +45,6 @@ def event_datetime_field(
Returns: Returns:
Div containing the datetime picker with toggle functionality. Div containing the datetime picker with toggle functionality.
""" """
toggle_id = f"{field_id}_toggle"
picker_id = f"{field_id}_picker" picker_id = f"{field_id}_picker"
input_id = f"{field_id}_input" input_id = f"{field_id}_input"
@@ -54,38 +53,31 @@ def event_datetime_field(
picker_style = "display: block;" if has_initial else "display: none;" picker_style = "display: block;" if has_initial else "display: none;"
toggle_text = "Use current time" if has_initial else "Set custom date" toggle_text = "Use current time" if has_initial else "Set custom date"
# JavaScript for toggle and conversion # Inline JavaScript for toggle click handler
script = f""" toggle_onclick = f"""
(function() {{
var toggle = document.getElementById('{toggle_id}');
var picker = document.getElementById('{picker_id}'); var picker = document.getElementById('{picker_id}');
var input = document.getElementById('{input_id}'); var input = document.getElementById('{input_id}');
var tsField = document.querySelector('input[name="ts_utc"]'); var tsField = document.querySelector('input[name="ts_utc"]');
if (picker.style.display === 'none') {{
picker.style.display = 'block';
this.textContent = 'Use current time';
}} else {{
picker.style.display = 'none';
this.textContent = 'Set custom date';
input.value = '';
if (tsField) tsField.value = '0';
}}
"""
if (!toggle || !picker || !input) return; # Inline JavaScript for input change handler
input_onchange = """
toggle.addEventListener('click', function(e) {{ var tsField = document.querySelector('input[name="ts_utc"]');
e.preventDefault(); if (tsField && this.value) {
if (picker.style.display === 'none') {{ var date = new Date(this.value);
picker.style.display = 'block'; tsField.value = date.getTime().toString();
toggle.textContent = 'Use current time'; } else if (tsField) {
}} else {{ tsField.value = '0';
picker.style.display = 'none'; }
toggle.textContent = 'Set custom date';
input.value = '';
if (tsField) tsField.value = '0';
}}
}});
input.addEventListener('change', function() {{
if (tsField && input.value) {{
var date = new Date(input.value);
tsField.value = date.getTime().toString();
}} else if (tsField) {{
tsField.value = '0';
}}
}});
}})();
""" """
return Div( return Div(
@@ -96,8 +88,8 @@ def event_datetime_field(
" - ", " - ",
Span( Span(
toggle_text, toggle_text,
id=toggle_id,
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline", cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
hx_on_click=toggle_onclick,
), ),
cls="text-sm", cls="text-sm",
), ),
@@ -108,6 +100,7 @@ def event_datetime_field(
type="datetime-local", type="datetime-local",
value=initial_value, value=initial_value,
cls="uk-input w-full mt-2", cls="uk-input w-full mt-2",
hx_on_change=input_onchange,
), ),
P( P(
"Select date/time for this event (leave empty for current time)", "Select date/time for this event (leave empty for current time)",
@@ -119,7 +112,6 @@ def event_datetime_field(
cls="mt-1", cls="mt-1",
), ),
Hidden(name="ts_utc", value=initial_ts), Hidden(name="ts_utc", value=initial_ts),
Script(script),
cls="space-y-1", cls="space-y-1",
) )
@@ -544,6 +536,7 @@ def tag_add_form(
resolved_count: int = 0, resolved_count: int = 0,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-add", action: Callable[..., Any] | str = "/actions/animal-tag-add",
animals: list | None = None,
) -> Form: ) -> Form:
"""Create the Add Tag form. """Create the Add Tag form.
@@ -555,22 +548,36 @@ def tag_add_form(
resolved_count: Number of resolved animals. resolved_count: Number of resolved animals.
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns: Returns:
Form component for adding tags to animals. Form component for adding tags to animals.
""" """
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None: if resolved_ids is None:
resolved_ids = [] resolved_ids = []
if animals is None:
animals = []
# Error display component # Error display component
error_component = None error_component = None
if error: if error:
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection preview component # Selection component - show checkboxes if animals provided and > 1
selection_preview = None selection_component = None
if resolved_count > 0: subset_mode = False
selection_preview = Div( if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
@@ -579,7 +586,7 @@ def tag_add_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
) )
elif filter_str: elif filter_str:
selection_preview = Div( selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
) )
@@ -601,8 +608,8 @@ def tag_add_form(
value=filter_str, value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck', placeholder='e.g., location:"Strip 1" species:duck',
), ),
# Selection preview # Selection component (checkboxes or simple count)
selection_preview, selection_component,
# Tag input # Tag input
LabelInput( LabelInput(
"Tag", "Tag",
@@ -623,6 +630,7 @@ def tag_add_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Add Tag", type="submit", cls=ButtonT.primary), Button("Add Tag", type="submit", cls=ButtonT.primary),
@@ -727,6 +735,7 @@ def tag_end_form(
active_tags: list[str] | None = None, active_tags: list[str] | None = None,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-end", action: Callable[..., Any] | str = "/actions/animal-tag-end",
animals: list | None = None,
) -> Form: ) -> Form:
"""Create the End Tag form. """Create the End Tag form.
@@ -739,24 +748,38 @@ def tag_end_form(
active_tags: List of tags active on selected animals. active_tags: List of tags active on selected animals.
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns: Returns:
Form component for ending tags on animals. Form component for ending tags on animals.
""" """
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None: if resolved_ids is None:
resolved_ids = [] resolved_ids = []
if active_tags is None: if active_tags is None:
active_tags = [] active_tags = []
if animals is None:
animals = []
# Error display component # Error display component
error_component = None error_component = None
if error: if error:
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection preview component # Selection component - show checkboxes if animals provided and > 1
selection_preview = None selection_component = None
if resolved_count > 0: subset_mode = False
selection_preview = Div( if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
@@ -765,7 +788,7 @@ def tag_end_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
) )
elif filter_str: elif filter_str:
selection_preview = Div( selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
) )
@@ -792,8 +815,8 @@ def tag_end_form(
value=filter_str, value=filter_str,
placeholder="e.g., tag:layer-birds species:duck", placeholder="e.g., tag:layer-birds species:duck",
), ),
# Selection preview # Selection component (checkboxes or simple count)
selection_preview, selection_component,
# Tag dropdown # Tag dropdown
LabelSelect( LabelSelect(
*tag_options, *tag_options,
@@ -819,6 +842,7 @@ def tag_end_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags), Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
@@ -922,6 +946,7 @@ def attrs_form(
resolved_count: int = 0, resolved_count: int = 0,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-attrs", action: Callable[..., Any] | str = "/actions/animal-attrs",
animals: list | None = None,
) -> Form: ) -> Form:
"""Create the Update Attributes form. """Create the Update Attributes form.
@@ -933,22 +958,36 @@ def attrs_form(
resolved_count: Number of resolved animals. resolved_count: Number of resolved animals.
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns: Returns:
Form component for updating animal attributes. Form component for updating animal attributes.
""" """
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None: if resolved_ids is None:
resolved_ids = [] resolved_ids = []
if animals is None:
animals = []
# Error display component # Error display component
error_component = None error_component = None
if error: if error:
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection preview component # Selection component - show checkboxes if animals provided and > 1
selection_preview = None selection_component = None
if resolved_count > 0: subset_mode = False
selection_preview = Div( if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
@@ -957,7 +996,7 @@ def attrs_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
) )
elif filter_str: elif filter_str:
selection_preview = Div( selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
) )
@@ -1005,8 +1044,8 @@ def attrs_form(
value=filter_str, value=filter_str,
placeholder="e.g., species:duck life_stage:juvenile", placeholder="e.g., species:duck life_stage:juvenile",
), ),
# Selection preview # Selection component (checkboxes or simple count)
selection_preview, selection_component,
# Attribute dropdowns # Attribute dropdowns
LabelSelect( LabelSelect(
*sex_options, *sex_options,
@@ -1039,6 +1078,7 @@ def attrs_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Update Attributes", type="submit", cls=ButtonT.primary), Button("Update Attributes", type="submit", cls=ButtonT.primary),
@@ -1149,6 +1189,7 @@ def outcome_form(
products: list[tuple[str, str]] | None = None, products: list[tuple[str, str]] | None = None,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-outcome", action: Callable[..., Any] | str = "/actions/animal-outcome",
animals: list | None = None,
) -> Form: ) -> Form:
"""Create the Record Outcome form. """Create the Record Outcome form.
@@ -1161,24 +1202,39 @@ def outcome_form(
products: List of (code, name) tuples for product dropdown. products: List of (code, name) tuples for product dropdown.
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns: Returns:
Form component for recording animal outcomes. Form component for recording animal outcomes.
""" """
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None: if resolved_ids is None:
resolved_ids = [] resolved_ids = []
if products is None: if products is None:
products = [] products = []
if animals is None:
animals = []
# Error display component # Error display component
error_component = None error_component = None
if error: if error:
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection preview component # Selection component - show checkboxes if animals provided and > 1
selection_preview = None selection_component = None
if resolved_count > 0: subset_mode = False
selection_preview = Div( if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
# Fallback to simple count display
selection_component = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
@@ -1187,7 +1243,7 @@ def outcome_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
) )
elif filter_str: elif filter_str:
selection_preview = Div( selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
) )
@@ -1267,7 +1323,7 @@ def outcome_form(
return Form( return Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"), H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component, error_component,
selection_preview, selection_component,
# Filter field # Filter field
LabelInput( LabelInput(
label="Filter (DSL)", label="Filter (DSL)",
@@ -1307,6 +1363,7 @@ def outcome_form(
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Outcome", type="submit", cls=ButtonT.destructive), Button("Record Outcome", type="submit", cls=ButtonT.destructive),

View File

@@ -223,7 +223,7 @@ def animal_timeline_list(timeline: list[TimelineEvent]) -> Ul:
def timeline_event_item(event: TimelineEvent) -> Li: def timeline_event_item(event: TimelineEvent) -> Li:
"""Single timeline event item.""" """Single timeline event item - clickable to view details."""
badge_cls = event_type_badge_class(event.event_type) badge_cls = event_type_badge_class(event.event_type)
summary_text = format_timeline_summary(event.event_type, event.summary) summary_text = format_timeline_summary(event.event_type, event.summary)
time_str = format_timestamp(event.ts_utc) time_str = format_timestamp(event.ts_utc)
@@ -236,7 +236,11 @@ def timeline_event_item(event: TimelineEvent) -> Li:
), ),
P(summary_text, cls="text-sm text-stone-300"), P(summary_text, cls="text-sm text-stone-300"),
P(f"by {event.actor}", cls="text-xs text-stone-500"), P(f"by {event.actor}", cls="text-xs text-stone-500"),
cls="py-3 border-b border-stone-700 last:border-0", cls="py-3 border-b border-stone-700 last:border-0 cursor-pointer "
"hover:bg-stone-800/50 -mx-2 px-2 rounded transition-colors",
hx_get=f"/events/{event.event_id}",
hx_target="#event-slide-over",
hx_swap="innerHTML",
) )

View File

@@ -0,0 +1,192 @@
# ABOUTME: Animal selection component with checkboxes.
# ABOUTME: Renders a list of animals with checkboxes for subset selection.
from fasthtml.common import Div, Input, Label, P, Span
from animaltrack.repositories.animals import AnimalListItem
def animal_checkbox_list(
animals: list[AnimalListItem],
selected_ids: list[str] | None = None,
max_display: int = 50,
) -> Div:
"""Render a list of animals with checkboxes for selection.
Args:
animals: List of animals to display.
selected_ids: IDs that should be pre-checked. If None, all are checked.
max_display: Maximum animals to show before truncating.
Returns:
Div containing the checkbox list.
"""
if selected_ids is None:
selected_ids = [a.animal_id for a in animals]
selected_set = set(selected_ids)
items = []
for animal in animals[:max_display]:
is_checked = animal.animal_id in selected_set
display_name = animal.nickname or animal.animal_id[:8] + "..."
items.append(
Label(
Input(
type="checkbox",
name="selected_ids",
value=animal.animal_id,
checked=is_checked,
cls="uk-checkbox mr-2",
hx_on_change="updateSelectionCount()",
),
Span(display_name, cls="text-stone-200"),
Span(
f" ({animal.species_code}, {animal.location_name})",
cls="text-stone-500 text-sm",
),
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",
)
)
# Show truncation message if needed
if len(animals) > max_display:
remaining = len(animals) - max_display
items.append(
P(
f"... and {remaining} more",
cls="text-stone-500 text-sm pl-6 py-1",
)
)
# Selection count display
count_display = Div(
Span(
f"{len(selected_ids)} of {len(animals)} selected",
id="selection-count-text",
cls="text-stone-400 text-sm",
),
Div(
Span(
"Select all",
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm mr-3",
hx_on_click="selectAllAnimals(true)",
),
Span(
"Select none",
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm",
hx_on_click="selectAllAnimals(false)",
),
cls="flex gap-2",
),
cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2",
)
return Div(
count_display,
Div(
*items,
cls="max-h-64 overflow-y-auto",
),
# Hidden field for roster_hash - will be updated via JS
Input(type="hidden", name="roster_hash", id="roster-hash-field"),
# Script for selection management
selection_script(len(animals)),
id="animal-selection-list",
cls="border border-stone-700 rounded-lg p-3 bg-stone-900/50",
)
def selection_script(total_count: int) -> Div:
"""JavaScript for managing selection state.
Args:
total_count: Total number of animals in the list.
Returns:
Script element with selection management code.
"""
from fasthtml.common import Script
return Script(f"""
function updateSelectionCount() {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]');
var checked = Array.from(checkboxes).filter(cb => cb.checked).length;
var countText = document.getElementById('selection-count-text');
if (countText) {{
countText.textContent = checked + ' of {total_count} selected';
}}
// Trigger hash recomputation if needed
computeSelectionHash();
}}
function selectAllAnimals(selectAll) {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]');
checkboxes.forEach(function(cb) {{
cb.checked = selectAll;
}});
updateSelectionCount();
}}
function getSelectedIds() {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}}
function computeSelectionHash() {{
// Get selected IDs and compute hash via API
var selectedIds = getSelectedIds();
var fromLocationId = document.querySelector('input[name="from_location_id"]');
var fromLoc = fromLocationId ? fromLocationId.value : '';
fetch('/api/compute-hash', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
selected_ids: selectedIds,
from_location_id: fromLoc
}})
}})
.then(response => response.json())
.then(data => {{
var hashField = document.getElementById('roster-hash-field');
if (hashField) {{
hashField.value = data.roster_hash;
}}
}});
}}
// Initialize hash on load
document.addEventListener('DOMContentLoaded', function() {{
computeSelectionHash();
}});
""")
def selection_summary(count: int, total: int) -> Div:
"""Render a selection summary when all animals are selected.
Shows "X animals selected" with option to customize.
Args:
count: Number of selected animals.
total: Total animals matching filter.
Returns:
Div with selection summary.
"""
return Div(
P(
f"{count} animals selected",
cls="text-stone-300",
),
Span(
"Customize selection",
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm",
hx_get="", # Will be set by caller
hx_target="#selection-container",
hx_swap="innerHTML",
),
cls="flex justify-between items-center py-2",
)

View File

@@ -1,7 +1,7 @@
# ABOUTME: Base HTML template for AnimalTrack pages. # ABOUTME: Base HTML template for AnimalTrack pages.
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav. # ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
from fasthtml.common import Container, Div, Script, Title from fasthtml.common import Container, Div, Script, Style, Title
from starlette.requests import Request from starlette.requests import Request
from animaltrack.models.reference import UserRole from animaltrack.models.reference import UserRole
@@ -14,65 +14,84 @@ from animaltrack.web.templates.sidebar import (
) )
def toast_container(): def EventSlideOverStyles(): # noqa: N802
"""Create a toast container for displaying notifications. """CSS styles for event detail slide-over panel."""
return Style("""
/* Event slide-over panel - slides from right */
#event-slide-over {
transform: translateX(100%);
transition: transform 0.3s ease-out;
}
This container holds toast notifications that appear in the top-right corner. #event-slide-over.open {
Toasts are triggered via HTMX events (HX-Trigger header with showToast). transform: translateX(0);
""" }
/* Backdrop overlay for event panel */
#event-backdrop {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-out;
}
#event-backdrop.open {
opacity: 1;
pointer-events: auto;
}
""")
def EventSlideOverScript(): # noqa: N802
"""JavaScript for event slide-over panel open/close behavior."""
return Script("""
function openEventPanel() {
document.getElementById('event-slide-over').classList.add('open');
document.getElementById('event-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
// Focus the panel for keyboard events
document.getElementById('event-slide-over').focus();
}
function closeEventPanel() {
document.getElementById('event-slide-over').classList.remove('open');
document.getElementById('event-backdrop').classList.remove('open');
document.body.style.overflow = '';
}
// HTMX event: after loading event content, open the panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'event-slide-over') {
openEventPanel();
}
});
""")
def EventSlideOver(): # noqa: N802
"""Event detail slide-over panel container."""
return Div( return Div(
id="toast-container", # Backdrop
cls="toast toast-end toast-top z-50", Div(
id="event-backdrop",
cls="fixed inset-0 bg-black/60 z-40",
hx_on_click="closeEventPanel()",
),
# Slide-over panel
Div(
# Content loaded via HTMX
Div(
id="event-panel-content",
cls="h-full",
),
id="event-slide-over",
cls="fixed top-0 right-0 bottom-0 w-96 max-w-full bg-[#141413] z-50 "
"shadow-2xl border-l border-stone-700 overflow-hidden",
tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
),
) )
def toast_script():
"""JavaScript to handle showToast events from HTMX.
Listens for the showToast event and creates toast notifications
that auto-dismiss after 5 seconds.
"""
script = """
document.body.addEventListener('showToast', function(evt) {
var message = evt.detail.message || 'Action completed';
var type = evt.detail.type || 'success';
// Create alert element with appropriate styling
var alertClass = 'alert shadow-lg mb-2 ';
if (type === 'success') {
alertClass += 'alert-success';
} else if (type === 'error') {
alertClass += 'alert-error';
} else if (type === 'warning') {
alertClass += 'alert-warning';
} else {
alertClass += 'alert-info';
}
var toast = document.createElement('div');
toast.className = alertClass;
toast.innerHTML = '<span>' + message + '</span>';
var container = document.getElementById('toast-container');
if (container) {
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.5s';
setTimeout(function() {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 500);
}, 5000);
}
});
"""
return Script(script)
def page( def page(
content, content,
title: str = "AnimalTrack", title: str = "AnimalTrack",
@@ -88,7 +107,7 @@ def page(
- Desktop sidebar (hidden on mobile) - Desktop sidebar (hidden on mobile)
- Mobile bottom nav (hidden on desktop) - Mobile bottom nav (hidden on desktop)
- Mobile menu drawer - Mobile menu drawer
- Toast container for notifications - Event detail slide-over panel
Args: Args:
content: Page content (FT components) content: Page content (FT components)
@@ -104,12 +123,15 @@ def page(
Title(title), Title(title),
BottomNavStyles(), BottomNavStyles(),
SidebarStyles(), SidebarStyles(),
toast_script(), EventSlideOverStyles(),
SidebarScript(), SidebarScript(),
EventSlideOverScript(),
# Desktop sidebar # Desktop sidebar
Sidebar(active_nav=active_nav, user_role=user_role, username=username), Sidebar(active_nav=active_nav, user_role=user_role, username=username),
# Mobile menu drawer # Mobile menu drawer
MenuDrawer(user_role=user_role), MenuDrawer(user_role=user_role),
# Event detail slide-over panel
EventSlideOver(),
# Main content with responsive padding/margin # Main content with responsive padding/margin
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav) # pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
# md:ml-60 to offset for desktop sidebar # md:ml-60 to offset for desktop sidebar
@@ -120,7 +142,6 @@ def page(
hx_target="body", hx_target="body",
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100", cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
), ),
toast_container(),
# Mobile bottom nav # Mobile bottom nav
BottomNav(active_id=active_nav), BottomNav(active_id=active_nav),
) )

View File

@@ -0,0 +1,322 @@
# ABOUTME: Template for event detail slide-over panel.
# ABOUTME: Renders event information, payload details, and affected animals.
from datetime import UTC, datetime
from typing import Any
from fasthtml.common import H3, A, Button, Div, Li, P, Span, Ul
from animaltrack.models.events import Event
def format_timestamp(ts_utc: int) -> str:
"""Format timestamp for display."""
dt = datetime.fromtimestamp(ts_utc / 1000, tz=UTC)
return dt.strftime("%Y-%m-%d %H:%M:%S")
def event_detail_panel(
event: Event,
affected_animals: list[dict[str, Any]],
is_tombstoned: bool = False,
location_names: dict[str, str] | None = None,
) -> Div:
"""Event detail slide-over panel content.
Args:
event: The event to display.
affected_animals: List of animals affected by this event.
is_tombstoned: Whether the event has been deleted.
location_names: Map of location IDs to names.
Returns:
Div containing the panel content.
"""
if location_names is None:
location_names = {}
return Div(
# Header with close button
Div(
Div(
event_type_badge(event.type),
Span(format_timestamp(event.ts_utc), cls="text-sm text-stone-400 ml-2"),
cls="flex items-center",
),
Button(
"×",
hx_on_click="closeEventPanel()",
cls="text-2xl text-stone-400 hover:text-stone-200 p-2 -mr-2",
type="button",
),
cls="flex items-center justify-between p-4 border-b border-stone-700",
),
# Tombstone warning
tombstone_warning() if is_tombstoned else None,
# Event metadata
Div(
metadata_item("Event ID", event.id),
metadata_item("Actor", event.actor),
metadata_item("Version", str(event.version)),
cls="p-4 space-y-2 border-b border-stone-700",
),
# Payload details
payload_section(event.type, event.payload, location_names),
# Entity references
entity_refs_section(event.entity_refs, location_names),
# Affected animals
affected_animals_section(affected_animals),
id="event-panel-content",
cls="bg-[#141413] h-full overflow-y-auto",
)
def event_type_badge(event_type: str) -> Span:
"""Badge for event type with color coding."""
type_colors = {
"AnimalCohortCreated": "bg-green-900/50 text-green-300",
"AnimalMoved": "bg-purple-900/50 text-purple-300",
"AnimalAttributesUpdated": "bg-blue-900/50 text-blue-300",
"AnimalTagged": "bg-indigo-900/50 text-indigo-300",
"AnimalTagEnded": "bg-slate-700 text-slate-300",
"ProductCollected": "bg-amber-900/50 text-amber-300",
"HatchRecorded": "bg-pink-900/50 text-pink-300",
"AnimalOutcome": "bg-red-900/50 text-red-300",
"AnimalPromoted": "bg-cyan-900/50 text-cyan-300",
"AnimalMerged": "bg-slate-600 text-slate-300",
"AnimalStatusCorrected": "bg-orange-900/50 text-orange-300",
"FeedPurchased": "bg-lime-900/50 text-lime-300",
"FeedGiven": "bg-teal-900/50 text-teal-300",
"ProductSold": "bg-yellow-900/50 text-yellow-300",
}
color_cls = type_colors.get(event_type, "bg-gray-700 text-gray-300")
return Span(event_type, cls=f"text-sm font-medium px-2 py-1 rounded {color_cls}")
def tombstone_warning() -> Div:
"""Warning that event has been deleted."""
return Div(
P(
"⚠ This event has been deleted.",
cls="text-sm text-red-400",
),
cls="p-4 bg-red-900/20 border-b border-red-800",
)
def metadata_item(label: str, value: str) -> Div:
"""Single metadata key-value item."""
return Div(
Span(label + ":", cls="text-stone-500 text-sm"),
Span(value, cls="text-stone-300 text-sm ml-2 font-mono"),
cls="flex",
)
def payload_section(
event_type: str,
payload: dict[str, Any],
location_names: dict[str, str],
) -> Div:
"""Section showing event payload details."""
items = render_payload_items(event_type, payload, location_names)
if not items:
return Div()
return Div(
H3("Details", cls="text-sm font-semibold text-stone-400 mb-2"),
*items,
cls="p-4 border-b border-stone-700",
)
def render_payload_items(
event_type: str,
payload: dict[str, Any],
location_names: dict[str, str],
) -> list:
"""Render payload items based on event type."""
items = []
if event_type == "AnimalCohortCreated":
if "species" in payload:
items.append(payload_item("Species", payload["species"]))
if "count" in payload:
items.append(payload_item("Count", str(payload["count"])))
if "origin" in payload:
items.append(payload_item("Origin", payload["origin"]))
if "sex" in payload:
items.append(payload_item("Sex", payload["sex"]))
if "life_stage" in payload:
items.append(payload_item("Life Stage", payload["life_stage"]))
elif event_type == "AnimalMoved":
from_loc = payload.get("from_location_id", "")
to_loc = payload.get("to_location_id", "")
from_name = location_names.get(from_loc, from_loc[:8] + "..." if from_loc else "")
to_name = location_names.get(to_loc, to_loc[:8] + "..." if to_loc else "")
if from_name:
items.append(payload_item("From", from_name))
if to_name:
items.append(payload_item("To", to_name))
elif event_type == "AnimalTagged":
if "tag" in payload:
items.append(payload_item("Tag", payload["tag"]))
elif event_type == "AnimalTagEnded":
if "tag" in payload:
items.append(payload_item("Tag", payload["tag"]))
if "reason" in payload:
items.append(payload_item("Reason", payload["reason"]))
elif event_type == "ProductCollected":
if "product_code" in payload:
items.append(payload_item("Product", payload["product_code"]))
if "quantity" in payload:
items.append(payload_item("Quantity", str(payload["quantity"])))
elif event_type == "AnimalOutcome":
if "outcome" in payload:
items.append(payload_item("Outcome", payload["outcome"]))
if "reason" in payload:
items.append(payload_item("Reason", payload["reason"]))
elif event_type == "AnimalPromoted":
if "nickname" in payload:
items.append(payload_item("Nickname", payload["nickname"]))
elif event_type == "AnimalStatusCorrected":
if "old_status" in payload:
items.append(payload_item("Old Status", payload["old_status"]))
if "new_status" in payload:
items.append(payload_item("New Status", payload["new_status"]))
if "reason" in payload:
items.append(payload_item("Reason", payload["reason"]))
elif event_type == "FeedPurchased":
if "feed_code" in payload:
items.append(payload_item("Feed", payload["feed_code"]))
if "quantity_grams" in payload:
kg = payload["quantity_grams"] / 1000
items.append(payload_item("Quantity", f"{kg:.3f} kg"))
if "cost_cents" in payload:
cost = payload["cost_cents"] / 100
items.append(payload_item("Cost", f"${cost:.2f}"))
elif event_type == "FeedGiven":
if "feed_code" in payload:
items.append(payload_item("Feed", payload["feed_code"]))
if "quantity_grams" in payload:
kg = payload["quantity_grams"] / 1000
items.append(payload_item("Quantity", f"{kg:.3f} kg"))
elif event_type == "ProductSold":
if "product_code" in payload:
items.append(payload_item("Product", payload["product_code"]))
if "quantity" in payload:
items.append(payload_item("Quantity", str(payload["quantity"])))
if "price_cents" in payload:
price = payload["price_cents"] / 100
items.append(payload_item("Price", f"${price:.2f}"))
elif event_type == "HatchRecorded":
if "clutch_size" in payload:
items.append(payload_item("Clutch Size", str(payload["clutch_size"])))
if "hatch_count" in payload:
items.append(payload_item("Hatched", str(payload["hatch_count"])))
# Fallback: show raw payload for unknown types
if not items and payload:
for key, value in payload.items():
if not key.startswith("_"):
items.append(payload_item(key, str(value)))
return items
def payload_item(label: str, value: str) -> Div:
"""Single payload item."""
return Div(
Span(label + ":", cls="text-stone-500 text-sm min-w-[100px]"),
Span(value, cls="text-stone-300 text-sm"),
cls="flex gap-2",
)
def entity_refs_section(
entity_refs: dict[str, Any],
location_names: dict[str, str],
) -> Div:
"""Section showing entity references."""
if not entity_refs:
return Div()
items = []
for key, value in entity_refs.items():
# Skip animal_ids - shown in affected animals section
if key == "animal_ids":
continue
display_value = value
# Resolve location names
if key.endswith("_location_id") or key == "location_id":
display_value = location_names.get(value, value[:8] + "..." if value else "")
if isinstance(value, list):
display_value = f"{len(value)} items"
elif isinstance(value, str) and len(value) > 20:
display_value = value[:8] + "..."
items.append(payload_item(key.replace("_", " ").title(), str(display_value)))
if not items:
return Div()
return Div(
H3("References", cls="text-sm font-semibold text-stone-400 mb-2"),
*items,
cls="p-4 border-b border-stone-700",
)
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
"""Section showing affected animals."""
if not animals:
return Div()
animal_items = []
for animal in animals[:20]: # Limit display
display_name = animal.get("nickname") or animal["id"][:8] + "..."
animal_items.append(
Li(
A(
Span(display_name, cls="text-amber-500 hover:underline"),
Span(
f" ({animal.get('species_name', '')})",
cls="text-stone-500 text-xs",
),
href=f"/animals/{animal['id']}",
),
cls="py-1",
)
)
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",
)
)
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",
)

View File

@@ -24,6 +24,7 @@ def move_form(
from_location_name: str | None = None, from_location_name: str | None = None,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-move", action: Callable[..., Any] | str = "/actions/animal-move",
animals: list | None = None,
) -> Form: ) -> Form:
"""Create the Move Animals form. """Create the Move Animals form.
@@ -38,12 +39,17 @@ def move_form(
from_location_name: Name of source location for display. from_location_name: Name of source location for display.
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns: Returns:
Form component for moving animals. Form component for moving animals.
""" """
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None: if resolved_ids is None:
resolved_ids = [] resolved_ids = []
if animals is None:
animals = []
# Build destination location options (exclude from_location if set) # Build destination location options (exclude from_location if set)
location_options = [Option("Select destination...", value="", disabled=True, selected=True)] location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
@@ -59,11 +65,21 @@ def move_form(
cls=AlertT.warning, cls=AlertT.warning,
) )
# Selection preview component # Selection component - show checkboxes if animals provided and > 1
selection_preview = None selection_component = None
if resolved_count > 0: subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
location_info = f" from {from_location_name}" if from_location_name else "" location_info = f" from {from_location_name}" if from_location_name else ""
selection_preview = Div( selection_component = Div(
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else ""
selection_component = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
f" animals selected{location_info}", f" animals selected{location_info}",
@@ -72,7 +88,7 @@ def move_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
) )
elif filter_str: elif filter_str:
selection_preview = Div( selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
) )
@@ -94,8 +110,8 @@ def move_form(
value=filter_str, value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck', placeholder='e.g., location:"Strip 1" species:duck',
), ),
# Selection preview # Selection component (checkboxes or simple count)
selection_preview, selection_component,
# Destination dropdown # Destination dropdown
LabelSelect( LabelSelect(
*location_options, *location_options,
@@ -118,6 +134,7 @@ def move_form(
Hidden(name="from_location_id", value=from_location_id or ""), Hidden(name="from_location_id", value=from_location_id or ""),
Hidden(name="resolver_version", value="v1"), Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary), Button("Move Animals", type="submit", cls=ButtonT.primary),

View File

@@ -77,6 +77,8 @@ def SidebarScript(): # noqa: N802
document.getElementById('menu-drawer').classList.add('open'); document.getElementById('menu-drawer').classList.add('open');
document.getElementById('menu-backdrop').classList.add('open'); document.getElementById('menu-backdrop').classList.add('open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Focus the drawer for keyboard events
document.getElementById('menu-drawer').focus();
} }
function closeMenuDrawer() { function closeMenuDrawer() {
@@ -84,13 +86,6 @@ def SidebarScript(): # noqa: N802
document.getElementById('menu-backdrop').classList.remove('open'); document.getElementById('menu-backdrop').classList.remove('open');
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
// Close on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeMenuDrawer();
}
});
""") """)
@@ -255,7 +250,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
Div( Div(
id="menu-backdrop", id="menu-backdrop",
cls="fixed inset-0 bg-black/60 z-40", cls="fixed inset-0 bg-black/60 z-40",
onclick="closeMenuDrawer()", hx_on_click="closeMenuDrawer()",
), ),
# Drawer panel # Drawer panel
Div( Div(
@@ -264,7 +259,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"), Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"),
Button( Button(
_close_icon(), _close_icon(),
onclick="closeMenuDrawer()", hx_on_click="closeMenuDrawer()",
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors", cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
type="button", type="button",
), ),
@@ -277,6 +272,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
), ),
id="menu-drawer", id="menu-drawer",
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl", cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
), ),
cls="md:hidden", cls="md:hidden",
) )

View File

@@ -149,7 +149,7 @@ class TestCohortCreationSuccess:
assert count_after == count_before + 3 assert count_after == count_before + 3
def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id): def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id):
"""Successful cohort creation returns HX-Trigger with toast.""" """Successful cohort creation stores toast in session."""
resp = client.post( resp = client.post(
"/actions/animal-cohort", "/actions/animal-cohort",
data={ data={
@@ -164,8 +164,20 @@ class TestCohortCreationSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers # Toast is stored in session cookie (FastHTML's add_toast mechanism)
assert "showToast" in resp.headers["HX-Trigger"] # The session cookie contains base64-encoded toast data with "toasts" key
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
# Base64 decode contains toast message (eyJ0b2FzdHMi... = {"toasts"...)
import base64
# Extract base64 portion from cookie value
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
# FastHTML uses itsdangerous, so format is base64.timestamp.signature
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Created 2 duck" in decoded
class TestCohortCreationValidation: class TestCohortCreationValidation:
@@ -363,7 +375,7 @@ class TestHatchRecordingSuccess:
assert count_at_nursery >= 3 assert count_at_nursery >= 3
def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id): def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id):
"""Successful hatch recording returns HX-Trigger with toast.""" """Successful hatch recording stores toast in session."""
resp = client.post( resp = client.post(
"/actions/hatch-recorded", "/actions/hatch-recorded",
data={ data={
@@ -375,8 +387,16 @@ class TestHatchRecordingSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers # Toast is stored in session cookie (FastHTML's add_toast mechanism)
assert "showToast" in resp.headers["HX-Trigger"] assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
import base64
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Recorded 2 hatchling" in decoded
class TestHatchRecordingValidation: class TestHatchRecordingValidation:
@@ -709,7 +729,8 @@ class TestTagAddSuccess:
assert tag_count >= len(animals_for_tagging) assert tag_count >= len(animals_for_tagging)
def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging): def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful tag add returns HX-Trigger with toast.""" """Successful tag add stores toast in session."""
import base64
import time import time
from animaltrack.selection import compute_roster_hash from animaltrack.selection import compute_roster_hash
@@ -730,8 +751,14 @@ class TestTagAddSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers # Toast is stored in session cookie
assert "showToast" in resp.headers["HX-Trigger"] assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Tagged" in decoded and "test-tag-toast" in decoded
class TestTagAddValidation: class TestTagAddValidation:
@@ -898,7 +925,8 @@ class TestTagEndSuccess:
assert open_after == 0 assert open_after == 0
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals): def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
"""Successful tag end returns HX-Trigger with toast.""" """Successful tag end stores toast in session."""
import base64
import time import time
from animaltrack.selection import compute_roster_hash from animaltrack.selection import compute_roster_hash
@@ -919,8 +947,14 @@ class TestTagEndSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers # Toast is stored in session cookie
assert "showToast" in resp.headers["HX-Trigger"] assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Ended tag" in decoded and "test-end-tag" in decoded
class TestTagEndValidation: class TestTagEndValidation:
@@ -1069,7 +1103,8 @@ class TestAttrsSuccess:
assert adult_count == len(animals_for_tagging) assert adult_count == len(animals_for_tagging)
def test_attrs_success_returns_toast(self, client, seeded_db, animals_for_tagging): def test_attrs_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful attrs update returns HX-Trigger with toast.""" """Successful attrs update stores toast in session."""
import base64
import time import time
from animaltrack.selection import compute_roster_hash from animaltrack.selection import compute_roster_hash
@@ -1090,8 +1125,14 @@ class TestAttrsSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers # Toast is stored in session cookie
assert "showToast" in resp.headers["HX-Trigger"] assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Updated attributes" in decoded
class TestAttrsValidation: class TestAttrsValidation:
@@ -1239,7 +1280,8 @@ class TestOutcomeSuccess:
assert harvested_count == len(animals_for_tagging) assert harvested_count == len(animals_for_tagging)
def test_outcome_success_returns_toast(self, client, seeded_db, animals_for_tagging): def test_outcome_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful outcome recording returns HX-Trigger with toast.""" """Successful outcome recording stores toast in session."""
import base64
import time import time
from animaltrack.selection import compute_roster_hash from animaltrack.selection import compute_roster_hash
@@ -1260,8 +1302,14 @@ class TestOutcomeSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers # Toast is stored in session cookie
assert "showToast" in resp.headers["HX-Trigger"] assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Recorded sold" in decoded
class TestOutcomeValidation: class TestOutcomeValidation:

View File

@@ -198,7 +198,7 @@ class TestMoveAnimalSuccess:
location_strip2_id, location_strip2_id,
ducks_at_strip1, ducks_at_strip1,
): ):
"""Successful move returns HX-Trigger with toast.""" """Successful move returns session cookie with toast."""
ts_utc = int(time.time() * 1000) ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"' filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str) filter_ast = parse_filter(filter_str)
@@ -219,8 +219,16 @@ class TestMoveAnimalSuccess:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "HX-Trigger" in resp.headers assert "set-cookie" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"] session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
# Base64 decode contains toast message
import base64
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Moved 5 animals to Strip 2" in decoded
def test_move_success_resets_form( def test_move_success_resets_form(
self, self,